Compare commits

...

7 Commits

Author SHA1 Message Date
evilchili
b09b07d172 make inventories recursive 2024-09-02 12:39:59 -07:00
evilchili
01a4360dca adding containers 2024-08-29 17:10:39 -07:00
evilchili
709b0f5ad0 make character.inventories a dict 2024-08-29 16:51:02 -07:00
evilchili
68a8f4920b move inventory verbs to inventory map from character 2024-08-29 16:41:15 -07:00
evilchili
0eda35b90d ancestry speeds should be configurable at init 2024-08-29 16:14:29 -07:00
evilchili
5c80565264 adding weapons with charges 2024-08-29 15:14:47 -07:00
evilchili
5d9fde949d resolve warnings 2024-08-21 14:14:37 -07:00
9 changed files with 413 additions and 114 deletions

View File

@ -75,8 +75,9 @@ class SQLDatabaseManager:
return base64.urlsafe_b64encode(sha1bytes.digest()).decode("ascii")[:10]
def init(self):
self.session.configure(bind=self.engine)
self.metadata.bind = self.engine
self.session.remove()
self.session.configure(bind=self.engine)
self.metadata.create_all(self.engine)
def dump(self, names: list = []):

View File

@ -1,6 +1,7 @@
from .character import *
from .classes import *
from .constants import *
from .container import *
from .log import *
from .modifiers import *
from .skill import *

View File

@ -1,5 +1,7 @@
import itertools
from collections import defaultdict
from functools import cached_property
from typing import List
from sqlalchemy import ForeignKey, String, Text, UniqueConstraint
from sqlalchemy.ext.associationproxy import association_proxy
@ -9,7 +11,6 @@ from ttfrog.db.base import BaseObject, SlugMixin
from ttfrog.db.schema.classes import CharacterClass, ClassFeature
from ttfrog.db.schema.constants import DamageType, Defenses
from ttfrog.db.schema.inventory import Inventory, InventoryMap, InventoryType
from ttfrog.db.schema.item import ItemType
from ttfrog.db.schema.modifiers import Modifier, ModifierMixin, Stat
from ttfrog.db.schema.skill import Skill
@ -106,9 +107,9 @@ class Ancestry(BaseObject, ModifierMixin):
size: Mapped[str] = mapped_column(nullable=False, default="medium")
speed: Mapped[int] = mapped_column(nullable=False, default=30, info={"min": 0, "max": 99})
fly_speed: Mapped[int] = mapped_column(init=False, nullable=True, info={"min": 0, "max": 99})
climb_speed: Mapped[int] = mapped_column(init=False, nullable=True, info={"min": 0, "max": 99})
swim_speed: Mapped[int] = mapped_column(init=False, nullable=True, info={"min": 0, "max": 99})
fly_speed: Mapped[int] = mapped_column(nullable=True, info={"min": 0, "max": 99}, default=None)
climb_speed: Mapped[int] = mapped_column(nullable=True, info={"min": 0, "max": 99}, default=None)
swim_speed: Mapped[int] = mapped_column(nullable=True, info={"min": 0, "max": 99}, default=None)
_traits = relationship(
"AncestryTraitMap", init=False, uselist=True, cascade="all,delete,delete-orphan", lazy="immediate"
@ -261,15 +262,20 @@ class Character(BaseObject, SlugMixin, ModifierMixin):
ancestry_id: Mapped[int] = mapped_column(ForeignKey("ancestry.id"), nullable=False, default="1")
ancestry: Mapped["Ancestry"] = relationship(uselist=False, default=None)
_inventories = relationship("Inventory", uselist=True, cascade="all,delete,delete-orphan", lazy="immediate")
inventories = association_proxy("_inventories", "id", creator=inventory_creator)
_inventories: Mapped[List["Inventory"]] = relationship(
uselist=True, cascade="all,delete,delete-orphan", lazy="immediate", default_factory=lambda: []
)
_hit_dice = relationship("HitDie", uselist=True, cascade="all,delete,delete-orphan", lazy="immediate")
_spell_slots = relationship("SpellSlot", uselist=True, cascade="all,delete,delete-orphan", lazy="immediate")
@property
@cached_property
def inventories(self):
return dict([(inventory.inventory_type, inventory) for inventory in self._inventories])
@cached_property
def spells(self):
return [inv for inv in self._inventories if inv.inventory_type == InventoryType.SPELL][0]
return self.inventories[InventoryType.SPELL]
@property
def prepared_spells(self):
@ -392,9 +398,9 @@ class Character(BaseObject, SlugMixin, ModifierMixin):
def class_features(self):
return dict([(mapping.class_feature.name, mapping.option) for mapping in self.character_class_feature_map])
@property
@cached_property
def equipment(self):
return [inv for inv in self._inventories if inv.inventory_type == InventoryType.EQUIPMENT][0]
return self.inventories[InventoryType.EQUIPMENT]
@property
def equipped_items(self):
@ -420,47 +426,6 @@ class Character(BaseObject, SlugMixin, ModifierMixin):
mapping.attuned = False
return True
def equip(self, mapping):
if mapping.equipped:
return False
mapping.equipped = True
return True
def unequip(self, mapping):
if not mapping.equipped:
return False
mapping.equipped = False
return True
def prepare(self, mapping):
if mapping.item.item_type != ItemType.SPELL:
return False
if mapping.item.level > 0 and not self.spell_slots_by_level[mapping.item.level]:
return False
return self.equip(mapping)
def unprepare(self, mapping):
if mapping.item.item_type != ItemType.SPELL:
return False
return self.unequip(mapping)
def cast(self, mapping: InventoryMap, level=0):
if not mapping.prepared:
return False
if not level:
level = mapping.item.level
# cantrips
if level == 0:
return True
# expend the spell slot
avail = self.spell_slots_available[level]
if not avail:
return False
avail[0].expended = True
return True
def level_in_class(self, charclass):
mapping = [mapping for mapping in self.class_map if mapping.character_class_id == charclass.id]
if not mapping:

View File

@ -0,0 +1,21 @@
from sqlalchemy import ForeignKey
from sqlalchemy.orm import Mapped, mapped_column, relationship
from ttfrog.db.schema.inventory import Inventory, InventoryType
from ttfrog.db.schema.item import Item, ItemType
__all__ = [
"Container",
]
class Container(Item):
__tablename__ = "container"
__mapper_args__ = {"polymorphic_identity": ItemType.CONTAINER}
id: Mapped[int] = mapped_column(ForeignKey("item.id"), primary_key=True, init=False)
item_type: Mapped[ItemType] = ItemType.CONTAINER
inventory: Mapped["Inventory"] = relationship(
cascade="all,delete,delete-orphan",
lazy="immediate",
default_factory=lambda: Inventory(inventory_type=InventoryType.EQUIPMENT),
)

View File

@ -1,9 +1,10 @@
from typing import List
from sqlalchemy import ForeignKey, UniqueConstraint
from sqlalchemy.ext.associationproxy import association_proxy
from sqlalchemy.orm import Mapped, mapped_column, relationship
from ttfrog.db.base import BaseObject, EnumField
from ttfrog.db.schema.item import Item, ItemType
from ttfrog.db.schema.item import Item, ItemProperty, ItemType
class InventoryType(EnumField):
@ -18,29 +19,62 @@ inventory_type_map = {
ItemType.SHIELD,
ItemType.ITEM,
ItemType.SCROLL,
ItemType.CONTAINER,
],
InventoryType.SPELL: [ItemType.SPELL],
}
def inventory_map_creator(fields):
if isinstance(fields, InventoryMap):
return fields
# if isinstance(fields, InventoryMap):
# return fields
# return InventoryMap(**fields)
return InventoryMap(**fields)
class Inventory(BaseObject):
__tablename__ = "inventory"
__table_args__ = (UniqueConstraint("character_id", "inventory_type"),)
__table_args__ = (UniqueConstraint("character_id", "container_id", "inventory_type"),)
id: Mapped[int] = mapped_column(init=False, primary_key=True, autoincrement=True)
character_id: Mapped[int] = mapped_column(ForeignKey("character.id"))
inventory_type: Mapped[InventoryType] = mapped_column(nullable=False)
_inventory_map = relationship("InventoryMap", lazy="immediate", uselist=True, cascade="all,delete,delete-orphan")
inventory_map = association_proxy("_inventory_map", "id", creator=inventory_map_creator)
item_map: Mapped[List["InventoryMap"]] = relationship(
uselist=True, cascade="all,delete,delete-orphan", lazy="immediate", default_factory=lambda: []
)
character_id: Mapped[int] = mapped_column(ForeignKey("character.id"), nullable=True, default=None)
container_id: Mapped[int] = mapped_column(ForeignKey("item.id"), nullable=True, default=None)
character = relationship("Character", init=False, viewonly=True, lazy="immediate")
container = relationship("Item", init=False, viewonly=True, lazy="immediate")
@property
def items(self):
return [mapping.item for mapping in self.item_map]
@property
def all_items(self):
def inventory_contents(inventory):
for mapping in inventory.item_map:
yield mapping
if mapping.item.item_type == ItemType.CONTAINER:
yield from inventory_contents(mapping.item.inventory)
yield from inventory_contents(self)
@property
def all_item_maps(self):
def inventory_map(inventory):
for mapping in inventory.item_map:
yield mapping
if mapping.item.item_type == ItemType.CONTAINER:
yield from inventory_map(mapping.item.inventory)
yield from inventory_map(self)
def get(self, item):
return [mapping for mapping in self._inventory_map if mapping.item == item]
return self.get_all(item)[0]
def get_all(self, item):
return [mapping for mapping in self.all_item_maps if mapping.item == item]
def add(self, item):
if item.item_type not in inventory_type_map[self.inventory_type]:
@ -48,23 +82,25 @@ class Inventory(BaseObject):
mapping = InventoryMap(inventory_id=self.id, item_id=item.id)
if item.consumable:
mapping.count = item.count
self.inventory_map.append(mapping)
if item.charges:
mapping.charges = [Charge(inventory_map_id=mapping.id) for i in range(item.charges)]
self.item_map.append(mapping)
return mapping
def remove(self, mapping):
if mapping.id not in self.inventory_map:
return False
self.inventory_map.remove(mapping.id)
return True
if mapping in self.item_map:
self.item_map.remove(mapping)
return True
return False
def __contains__(self, obj):
for mapping in self._inventory_map:
if mapping.item == obj:
for item in self.all_items:
if item == obj:
return True
return False
def __iter__(self):
yield from self._inventory_map
yield from self.all_items
class InventoryMap(BaseObject):
@ -81,12 +117,56 @@ class InventoryMap(BaseObject):
always_prepared: Mapped[bool] = mapped_column(default=False)
charges: Mapped[List["Charge"]] = relationship(
uselist=True, cascade="all,delete,delete-orphan", lazy="immediate", default_factory=lambda: []
)
@property
def charges_available(self):
return [charge for charge in self.charges if not charge.expended]
@property
def prepared(self):
if self.item.item_type == ItemType.SPELL:
return self.equipped or self.always_prepared
def use(self, count=1):
def equip(self):
if self.equipped:
return False
self.equipped = True
return True
def unequip(self):
if not self.equipped:
return False
self.equipped = False
return True
def prepare(self):
if self.item.item_type != ItemType.SPELL:
return False
if self.item.level > 0 and not self.inventory.character.spell_slots_by_level[self.item.level]:
return False
return self.equip()
def unprepare(self):
if self.item.item_type != ItemType.SPELL:
return False
return self.unequip()
def use(self, item_property: ItemProperty, charges=None):
if item_property.charge_cost is None:
return True
avail = self.charges_available
if charges is None:
charges = item_property.charge_cost
if len(avail) < charges:
return False
for charge in avail[:charges]:
charge.expended = True
return True
def consume(self, count=1):
if count < 0:
return False
if not self.item.consumable:
@ -98,3 +178,46 @@ class InventoryMap(BaseObject):
self.inventory.remove(self)
return 0
return self.count
def cast(self, level=0):
if self.item.item_type != ItemType.SPELL:
return False
if not self.prepared:
return False
if not level:
level = self.item.level
# cantrips
if level == 0:
return True
# expend the spell slot
avail = self.inventory.character.spell_slots_available[level]
if not avail:
return False
avail[0].expended = True
return True
def attune(self):
if self.attuned:
return False
if not self.item.requires_attunement:
return False
if len(self.inventory.character.attuned_items) >= 3:
return False
self.attuned = True
return True
def unattune(self):
if not self.attuned:
return False
self.attuned = False
return True
class Charge(BaseObject):
__tablename__ = "charge"
id: Mapped[int] = mapped_column(init=False, primary_key=True, autoincrement=True)
inventory_map_id: Mapped[int] = mapped_column(ForeignKey("inventory_map.id"))
expended: Mapped[bool] = mapped_column(nullable=False, default=False)

View File

@ -1,3 +1,5 @@
from typing import List
from sqlalchemy import ForeignKey, String
from sqlalchemy.orm import Mapped, mapped_column, relationship
@ -19,18 +21,39 @@ ITEM_TYPES = [
"WEAPON",
"ARMOR",
"SHIELD",
"CONTAINER",
]
RECHARGE_TIMES = [
"short rest",
"long rest",
"dawn",
]
COST_TYPES = ["Action", "Bonus Action", "Reaction"]
RARITY = ["Common", "Uncommon", "Rare", "Very Rare", "Legendary", "Artifact"]
ItemType = EnumField("ItemType", ((k, k) for k in ITEM_TYPES))
Rarity = EnumField("Rarity", ((k, k) for k in RARITY))
RechargeTime = EnumField("RechargeTime", ((k.replace(" ", "_").upper(), k) for k in RECHARGE_TIMES))
Cost = EnumField("Cost", ((k, k) for k in COST_TYPES))
def item_property_creator(fields):
if isinstance(fields, list):
for f in fields:
yield f
elif isinstance(fields, ItemProperty):
return fields
return ItemProperty(**fields)
class Item(BaseObject, ModifierMixin):
__tablename__ = "item"
__mapper_args__ = {"polymorphic_identity": ItemType.ITEM, "polymorphic_on": "item_type"}
id: Mapped[int] = mapped_column(init=False, primary_key=True, autoincrement=True)
name: Mapped[str] = mapped_column(String(collation="NOCASE"), nullable=False, unique=True)
description: Mapped[str] = mapped_column(String, nullable=True, default=None)
@ -42,29 +65,43 @@ class Item(BaseObject, ModifierMixin):
consumable: Mapped[bool] = mapped_column(default=False)
count: Mapped[int] = mapped_column(nullable=False, default=1)
charges: Mapped[int] = mapped_column(nullable=True, info={"min": 0}, default=None)
recharge_time: Mapped[RechargeTime] = mapped_column(default=RechargeTime.LONG_REST)
recharge_amount: Mapped[str] = mapped_column(String(collation="NOCASE"), default="1")
_class_restrictions: Mapped[int] = mapped_column(ForeignKey("character_class.id"), nullable=True, default=None)
class_restrictions: Mapped["CharacterClass"] = relationship(init=False)
properties: Mapped[List["ItemProperty"]] = relationship(
uselist=True, cascade="all,delete,delete-orphan", lazy="immediate", default_factory=lambda: []
)
# _spells: Mapped[int] = mapped_column(ForeignKey("spell.id"), nullable=True, default=None)
# spells: Mapped["Spell"] = relationship(init=False)
@property
def has_charges(self):
return self.charges is not None
class Spell(Item):
__tablename__ = "spell"
__mapper_args__ = {"polymorphic_identity": ItemType.SPELL}
id: Mapped[int] = mapped_column(ForeignKey("item.id"), primary_key=True, init=False)
item_type: Mapped[ItemType] = ItemType.SPELL
level: Mapped[int] = mapped_column(nullable=False, info={"min": 0, "max": 9}, default=0)
concentration: Mapped[bool] = mapped_column(default=False)
item_type: Mapped[ItemType] = mapped_column(default=ItemType.SPELL, init=False)
class Weapon(Item):
__tablename__ = "weapon"
__mapper_args__ = {"polymorphic_identity": ItemType.WEAPON}
id: Mapped[int] = mapped_column(ForeignKey("item.id"), primary_key=True, init=False)
item_type: Mapped[ItemType] = ItemType.WEAPON
damage_die: Mapped[str] = mapped_column(nullable=False, default="1d6")
damage_type: Mapped[DamageType] = mapped_column(nullable=False, default=DamageType.slashing)
item_type: Mapped[ItemType] = mapped_column(default=ItemType.WEAPON)
attack_range: Mapped[int] = mapped_column(nullable=False, info={"min": 0}, default=0)
attack_range_long: Mapped[int] = mapped_column(nullable=True, info={"min": 0}, default=None)
targets: Mapped[int] = mapped_column(nullable=False, info={"min": 1}, default=1)
@ -81,7 +118,34 @@ class Weapon(Item):
versatile: Mapped[bool] = mapped_column(default=False)
silvered: Mapped[bool] = mapped_column(default=False)
adamantine: Mapped[bool] = mapped_column(default=False)
magical: Mapped[bool] = mapped_column(default=False)
@property
def ranged(self):
return self.attack_range > 0
class Shield(Item):
__tablename__ = "shield"
__mapper_args__ = {"polymorphic_identity": ItemType.SHIELD}
id: Mapped[int] = mapped_column(ForeignKey("item.id"), primary_key=True, init=False)
item_type: Mapped[ItemType] = ItemType.SHIELD
class Armor(Item):
__tablename__ = "armor"
__mapper_args__ = {"polymorphic_identity": ItemType.ARMOR}
id: Mapped[int] = mapped_column(ForeignKey("item.id"), primary_key=True, init=False)
item_type: Mapped[ItemType] = ItemType.ARMOR
class ItemProperty(BaseObject):
__tablename__ = "item_property"
id: Mapped[int] = mapped_column(init=False, primary_key=True, autoincrement=True)
name: Mapped[str] = mapped_column(String(collation="NOCASE"), nullable=False, unique=True)
description: Mapped[str] = mapped_column(String, nullable=True, default=None)
charge_cost: Mapped[int] = mapped_column(nullable=True, info={"min": 1}, default=None)
item_id: Mapped[int] = mapped_column(ForeignKey("item.id"), default=0)
# action/reaction/bonus
# modifiers?

View File

@ -9,7 +9,7 @@ def test_dump_load(db, bootstrap):
# clear the database and reinitialize
db.metadata.drop_all(bind=db.engine)
db.init()
db.metadata.create_all(db.engine)
# load the dump
db.load(data)

View File

@ -1,3 +1,4 @@
from ttfrog.db.schema.container import Container
from ttfrog.db.schema.item import Item, ItemType, Spell
@ -24,7 +25,7 @@ def test_equipment_inventory(db, carl):
carl.equipment.add(ten_foot_pole)
db.add_or_update(carl)
all_carls_poles = carl.equipment.get(ten_foot_pole)
all_carls_poles = carl.equipment.get_all(ten_foot_pole)
assert len(all_carls_poles) == 3
# check the "contains" logic
@ -33,21 +34,31 @@ def test_equipment_inventory(db, carl):
assert fireball in carl.spells
assert fireball not in carl.equipment
pole_one = all_carls_poles[0]
# equip one pole
assert carl.equip(all_carls_poles[0])
assert pole_one.equip()
# can't equip it twice
assert not carl.equip(all_carls_poles[0])
assert not pole_one.equip()
# unequip it
assert carl.unequip(all_carls_poles[0])
# can't prepare or cast an item
assert not pole_one.prepare()
assert not pole_one.unprepare()
assert not pole_one.cast()
# not consumable or attunable
assert not pole_one.consume()
assert not pole_one.attune()
assert pole_one.unequip()
# can't unequip the unequipped ones
assert not carl.unequip(all_carls_poles[1])
assert not carl.unequip(all_carls_poles[2])
assert not all_carls_poles[1].unequip()
assert not all_carls_poles[2].unequip()
# drop one pole
assert carl.equipment.remove(all_carls_poles[0])
assert carl.equipment.remove(pole_one)
assert ten_foot_pole in carl.equipment
# drop the remaining poles
@ -56,7 +67,7 @@ def test_equipment_inventory(db, carl):
assert ten_foot_pole not in carl.equipment
# can't drop what you don't have
assert not carl.equipment.remove(all_carls_poles[0])
assert not carl.equipment.remove(pole_one)
def test_inventory_bundles(db, carl):
@ -71,17 +82,17 @@ def test_inventory_bundles(db, carl):
assert quiver.count == 20
# use one
assert quiver.use(1) == 19
assert quiver.consume(1) == 19
assert quiver.count == 19
# cannot use more than you have
assert not quiver.use(20)
assert not quiver.consume(20)
# cannot use a negative amount
assert not quiver.use(-1)
assert not quiver.consume(-1)
# consume all remaining arrows
assert quiver.use(19) == 0
assert quiver.consume(19) == 0
assert arrows not in carl.equipment
@ -96,8 +107,6 @@ def test_spell_slots(db, carl, wizard):
db.add_or_update(carl)
# verify carl has the spell slots granted by wizard at 1st level
print(carl.levels)
print(carl.spell_slots)
assert len(carl.spell_slots) == 2
assert carl.spell_slots[0].spell_level == 1
assert carl.spell_slots[1].spell_level == 1
@ -109,16 +118,13 @@ def test_spell_slots(db, carl, wizard):
assert fireball not in carl.prepared_spells
# prepare the cantrip
carls_prestidigitation = carl.spells.get(prestidigitation)[0]
assert carl.prepare(carls_prestidigitation)
assert carl.cast(carls_prestidigitation)
# prepare() and cast() require a spell from the spell inventory
carls_fireball = carl.spells.get(fireball)[0]
carls_prestidigitation = carl.spells.get(prestidigitation)
assert carls_prestidigitation.prepare()
assert carls_prestidigitation.cast()
# can't prepare a 3rd level spell if you don't have 3rd level slots
assert carl.spellcaster_level == 1
assert not carl.prepare(carls_fireball)
assert not carl.spells.get(fireball).prepare()
# make carl a 5th level wizard so he gets a 3rd level spell slot
carl.level_up(wizard, num_levels=4)
@ -126,10 +132,11 @@ def test_spell_slots(db, carl, wizard):
assert carl.spellcaster_level == 3
# cast fireball until he's out of 3rd level slots
assert carl.prepare(carls_fireball)
assert carl.cast(carls_fireball)
assert carl.cast(carls_fireball)
assert not carl.cast(carls_fireball)
assert not carl.spells.get(fireball).cast()
assert carl.spells.get(fireball).prepare()
assert carl.spells.get(fireball).cast()
assert carl.spells.get(fireball).cast()
assert not carl.spells.get(fireball).cast()
# level up to 7th level, gaining 1 4th level slot and 1 more 3rd level slot
carl.add_class(wizard)
@ -139,9 +146,41 @@ def test_spell_slots(db, carl, wizard):
assert len(carl.spell_slots_available[3]) == 1
# cast at 4th level
assert carl.cast(carls_fireball, 4)
assert not carl.cast(carls_fireball, 4)
assert carl.spells.get(fireball).cast(level=4)
assert not carl.spells.get(fireball).cast(level=4)
# use the last 3rd level slot
assert carl.cast(carls_fireball)
assert not carl.cast(carls_fireball)
assert carl.spells.get(fireball).cast()
assert not carl.spells.get(fireball).cast()
# unprepare it
assert carl.spells.get(fireball).unprepare()
assert not carl.spells.get(fireball).unprepare()
def test_containers(db, carl):
with db.transaction():
ten_foot_pole = Item(name="10ft. Pole", item_type=ItemType.ITEM, consumable=False)
bag_of_holding = Container(name="Bag of Holding")
db.add_or_update([carl, ten_foot_pole, bag_of_holding])
# add the ten_foot_pole to the bag of holding
assert bag_of_holding.inventory.add(ten_foot_pole)
db.add_or_update(bag_of_holding)
pole_from_bag = bag_of_holding.inventory.get(ten_foot_pole)
assert pole_from_bag
assert pole_from_bag in bag_of_holding.inventory
assert pole_from_bag not in carl.equipment
# add the bag of holding to carl's equipment
assert carl.equipment.add(bag_of_holding)
db.add_or_update(bag_of_holding)
assert pole_from_bag in carl.equipment
# test equality of mappings
carls_bag = carl.equipment.get(bag_of_holding)
carls_pole = carl.equipment.get(ten_foot_pole)
assert carls_pole == pole_from_bag
# remove the pole from the bag
assert carls_bag.item.inventory.remove(pole_from_bag)

View File

@ -1,5 +1,5 @@
from ttfrog.db.schema.constants import DamageType, Defenses
from ttfrog.db.schema.item import Item, ItemType, Rarity, Weapon
from ttfrog.db.schema.item import Armor, Item, ItemProperty, Rarity, RechargeTime, Shield, Weapon
from ttfrog.db.schema.modifiers import Modifier
@ -40,16 +40,88 @@ def test_weapons(db):
assert dagger.melee
def test_charges(db, carl):
with db.transaction():
for_the_lulz = ItemProperty(
name="For the Lulz",
description="""
On a hit against a creature with a mouth, spend one charge to force the target to roll a DC 13 Wisdom
saving throw. On a failure, the target is forced to grin for one minute. While grinning, the target
cannot speak. The target can repeat the saving throw at the start of their turn."
""",
charge_cost=2,
)
# from sqlalchemy.orm import relationship
# help(relationship)
dagger_of_lulz = Weapon(
name="Dagger of Lulz",
description="This magical dagger has 6 charges. It regains 1d6 charges after a short rest.",
damage_die="1d4",
damage_type=DamageType.slashing,
melee=True,
finesse=True,
light=True,
thrown=True,
attack_range=20,
attack_range_long=60,
magical=True,
charges=6,
recharge_time=RechargeTime.SHORT_REST,
recharge_amount="1d6",
rarity=Rarity["Very Rare"],
requires_attunement=True,
properties=[for_the_lulz],
)
db.add_or_update([carl, dagger_of_lulz])
assert for_the_lulz in dagger_of_lulz.properties
assert carl.equipment.add(dagger_of_lulz)
db.add_or_update(carl)
carls_dagger = carl.equipment.get(dagger_of_lulz)
assert carls_dagger.equip()
assert carls_dagger.attune()
assert len(carls_dagger.charges) == dagger_of_lulz.charges == 6
assert len(carls_dagger.charges_available) == dagger_of_lulz.charges == 6
assert carls_dagger.use(for_the_lulz)
assert len(carls_dagger.charges_available) == 4
# use the remaining charges
assert carls_dagger.use(for_the_lulz)
assert carls_dagger.use(for_the_lulz)
# all out of charges
assert len(carls_dagger.charges_available) == 0
assert not carls_dagger.use(for_the_lulz)
def test_nocharges(db, carl):
smiles = ItemProperty(name="Smile!", description="The target grins for one minute.", charge_cost=None)
wand_of_unlimited_smiles = Item(name="Wand of Unlimited Smiles", description="description", properties=[smiles])
db.add_or_update(wand_of_unlimited_smiles)
carl.equipment.add(wand_of_unlimited_smiles)
db.add_or_update(carl)
# no charges means you can use it at will
assert carl.equipment.get(wand_of_unlimited_smiles).use(smiles)
assert carl.equipment.get(wand_of_unlimited_smiles).use(smiles)
assert carl.equipment.get(wand_of_unlimited_smiles).use(smiles)
def test_attunement(db, carl):
with db.transaction():
helm = Item(
helm = Armor(
name="Iron Helm",
item_type=ItemType.ARMOR,
rarity=Rarity.Common,
)
helm.add_modifier(Modifier("+1 AC (helmet)", target="armor_class", relative_value=1, stacks=True))
shield = Item(
shield = Shield(
name="Shield of Missile Attraction",
description="""
While holding this shield, you have resistance to damage from ranged weapon attacks.
@ -58,7 +130,6 @@ def test_attunement(db, carl):
or similar magic. Removing the shield fails to end the curse on you. Whenever a ranged weapon attack is made
against a target within 10 feet of you, the curse causes you to become the target instead.
""",
item_type=ItemType.SHIELD,
rarity=Rarity.Rare,
requires_attunement=True,
)
@ -79,29 +150,43 @@ def test_attunement(db, carl):
assert carl.equipment.add(helm)
db.add_or_update(carl)
carls_shield = carl.equipment.get(shield)[0]
carls_shield = carl.equipment.get(shield)
assert carl.armor_class == 10
assert len(carl.attuned_items) == 0
carl.equip(carls_shield)
carls_shield.equip()
assert plus_two_ac in carl.modifiers["armor_class"]
assert ranged_resistance not in carl.modifiers[DamageType.ranged_weapon_attacks]
assert carl.armor_class == 12
assert carls_shield not in carl.attuned_items
carl.attune(carls_shield)
assert carls_shield.attune()
assert not carls_shield.attune()
assert carl.armor_class == 12
assert plus_two_ac in carl.modifiers["armor_class"]
assert ranged_resistance in carl.modifiers[DamageType.ranged_weapon_attacks]
assert carls_shield in carl.attuned_items
assert carl.equip(carl.equipment.get(helm)[0])
assert carl.equipment.get(helm).equip()
assert carl.armor_class == 13
assert carl.unattune(carls_shield)
assert carls_shield.unattune()
assert not carls_shield.unattune()
assert carl.armor_class == 13
assert ranged_resistance not in carl.modifiers[DamageType.ranged_weapon_attacks]
assert carl.unequip(carls_shield)
assert carls_shield.unequip()
assert carl.armor_class == 11
# can only attune 3 items
assert carl.equipment.add(shield)
assert carl.equipment.add(shield)
assert carl.equipment.add(shield)
db.add_or_update(carl)
assert carl.equipment.get_all(shield)[0].attune()
assert carl.equipment.get_all(shield)[1].attune()
assert carl.equipment.get_all(shield)[2].attune()
assert len(carl.attuned_items) == 3
assert not carl.equipment.get_all(shield)[3].attune()