diff --git a/src/ttfrog/db/schema/character.py b/src/ttfrog/db/schema/character.py index b1c528e..766d0bb 100644 --- a/src/ttfrog/db/schema/character.py +++ b/src/ttfrog/db/schema/character.py @@ -1,5 +1,6 @@ import itertools from collections import defaultdict +from typing import List from sqlalchemy import ForeignKey, String, Text, UniqueConstraint from sqlalchemy.ext.associationproxy import association_proxy @@ -261,15 +262,16 @@ 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 def spells(self): - return [inv for inv in self._inventories if inv.inventory_type == InventoryType.SPELL][0] + return [inv for inv in self.inventories if inv.inventory_type == InventoryType.SPELL][0] @property def prepared_spells(self): @@ -394,7 +396,7 @@ class Character(BaseObject, SlugMixin, ModifierMixin): @property def equipment(self): - return [inv for inv in self._inventories if inv.inventory_type == InventoryType.EQUIPMENT][0] + return [inv for inv in self.inventories if inv.inventory_type == InventoryType.EQUIPMENT][0] @property def equipped_items(self): @@ -701,6 +703,6 @@ class Character(BaseObject, SlugMixin, ModifierMixin): ): self.add_skill(skill, proficient=False, expert=False) - self._inventories.append(Inventory(inventory_type=InventoryType.EQUIPMENT, character_id=self.id)) - self._inventories.append(Inventory(inventory_type=InventoryType.SPELL, character_id=self.id)) + self.inventories.append(Inventory(inventory_type=InventoryType.EQUIPMENT, character_id=self.id)) + self.inventories.append(Inventory(inventory_type=InventoryType.SPELL, character_id=self.id)) session.add(self) diff --git a/src/ttfrog/db/schema/inventory.py b/src/ttfrog/db/schema/inventory.py index 1dca61d..f50dbdb 100644 --- a/src/ttfrog/db/schema/inventory.py +++ b/src/ttfrog/db/schema/inventory.py @@ -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): @@ -36,11 +37,15 @@ class Inventory(BaseObject): 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) + items: Mapped[List["InventoryMap"]] = relationship( + uselist=True, cascade="all,delete,delete-orphan", lazy="immediate", default_factory=lambda: [] + ) 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.items if mapping.item == item] def add(self, item): if item.item_type not in inventory_type_map[self.inventory_type]: @@ -48,23 +53,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.items.append(mapping) return mapping def remove(self, mapping): - if mapping.id not in self.inventory_map: + if mapping not in self.items: return False - self.inventory_map.remove(mapping.id) + self.items.remove(mapping) return True def __contains__(self, obj): - for mapping in self._inventory_map: + for mapping in self.items: if mapping.item == obj: return True return False def __iter__(self): - yield from self._inventory_map + yield from self.items class InventoryMap(BaseObject): @@ -81,12 +88,32 @@ 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 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: + charge.expended = True + return True + + def consume(self, count=1): if count < 0: return False if not self.item.consumable: @@ -98,3 +125,10 @@ class InventoryMap(BaseObject): self.inventory.remove(self) return 0 return self.count + + +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) diff --git a/src/ttfrog/db/schema/item.py b/src/ttfrog/db/schema/item.py index 5aa3ab8..d0c3acf 100644 --- a/src/ttfrog/db/schema/item.py +++ b/src/ttfrog/db/schema/item.py @@ -1,3 +1,5 @@ +from typing import List + from sqlalchemy import ForeignKey, String from sqlalchemy.orm import Mapped, mapped_column, relationship @@ -21,16 +23,36 @@ ITEM_TYPES = [ "SHIELD", ] +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,12 +64,24 @@ 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" @@ -83,6 +117,7 @@ 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): @@ -101,3 +136,15 @@ class Armor(Item): __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": 0}, default=None) + item_id: Mapped[int] = mapped_column(ForeignKey("item.id"), default=0) + + # action/reaction/bonus + # modifiers? diff --git a/test/test_inventories.py b/test/test_inventories.py index 08f343b..aadf76c 100644 --- a/test/test_inventories.py +++ b/test/test_inventories.py @@ -24,7 +24,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 @@ -71,17 +71,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 @@ -107,12 +107,12 @@ def test_spell_slots(db, carl, wizard): assert fireball not in carl.prepared_spells # prepare the cantrip - carls_prestidigitation = carl.spells.get(prestidigitation)[0] + carls_prestidigitation = carl.spells.get(prestidigitation) 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_fireball = carl.spells.get(fireball) # can't prepare a 3rd level spell if you don't have 3rd level slots assert carl.spellcaster_level == 1 diff --git a/test/test_items.py b/test/test_items.py index 4fd8cc0..47a8e2b 100644 --- a/test/test_items.py +++ b/test/test_items.py @@ -1,5 +1,5 @@ from ttfrog.db.schema.constants import DamageType, Defenses -from ttfrog.db.schema.item import Armor, Rarity, Shield, Weapon +from ttfrog.db.schema.item import Armor, ItemProperty, Rarity, RechargeTime, Shield, Weapon from ttfrog.db.schema.modifiers import Modifier @@ -40,6 +40,56 @@ 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=1, + ) + + # 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 carl.equip(carls_dagger) + assert carl.attune(carls_dagger) + + 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) + + def test_attunement(db, carl): with db.transaction(): helm = Armor( @@ -77,7 +127,7 @@ 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 @@ -95,7 +145,7 @@ def test_attunement(db, carl): 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.equip(carl.equipment.get(helm)) assert carl.armor_class == 13 assert carl.unattune(carls_shield)