diff --git a/src/ttfrog/db/schema/character.py b/src/ttfrog/db/schema/character.py index 504b8d9..cd7257e 100644 --- a/src/ttfrog/db/schema/character.py +++ b/src/ttfrog/db/schema/character.py @@ -341,6 +341,18 @@ class Character(BaseObject, SlugMixin, ModifierMixin): def equipment(self): return [inv for inv in self._inventories if inv.inventory_type == InventoryType.EQUIPMENT][0] + 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 + @property def spells(self): return [inv for inv in self._inventories if inv.inventory_type == InventoryType.SPELL][0] diff --git a/src/ttfrog/db/schema/inventory.py b/src/ttfrog/db/schema/inventory.py index ee4a297..f49ee54 100644 --- a/src/ttfrog/db/schema/inventory.py +++ b/src/ttfrog/db/schema/inventory.py @@ -13,7 +13,7 @@ class InventoryType(EnumField): inventory_type_map = { InventoryType.EQUIPMENT: [ ItemType.ITEM, - ItemType.SPELL, + ItemType.SCROLL, ], InventoryType.SPELL: [ ItemType.SPELL @@ -34,16 +34,47 @@ 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) + _inventory_map = relationship("InventoryMap", lazy="immediate", uselist=True, cascade="all,delete,delete-orphan") inventory_map = association_proxy("_inventory_map", "id", creator=inventory_map_creator) + def get(self, item): + return [mapping for mapping in self._inventory_map if mapping.item == item] + def add(self, item): if item.item_type not in inventory_type_map[self.inventory_type]: return False - self.inventory_map.append(InventoryMap(inventory_id=self.id, item_id=item.id)) + mapping = InventoryMap(inventory_id=self.id, item_id=item.id) + if item.consumable: + mapping.count = item.count + self.inventory_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 + + 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 __contains__(self, obj): + for mapping in self._inventory_map: + if mapping.item == obj: + return True + return False def __iter__(self): - yield from [mapping.item for mapping in self._inventory_map] + yield from self._inventory_map class InventoryMap(BaseObject): @@ -52,6 +83,20 @@ class InventoryMap(BaseObject): inventory_id: Mapped[int] = mapped_column(ForeignKey("inventory.id")) item_id: Mapped[int] = mapped_column(ForeignKey("item.id")) item: Mapped["Item"] = relationship(uselist=False, lazy="immediate", viewonly=True, init=False) + inventory: Mapped["Inventory"] = relationship(uselist=False, viewonly=True, init=False) equipped: Mapped[bool] = mapped_column(default=False) count: Mapped[int] = mapped_column(nullable=False, default=1) + + def use(self, count=1): + if count < 0: + return False + if not self.item.consumable: + return False + if self.count < count: + return False + self.count -= count + if self.count == 0: + self.inventory.remove(self) + return 0 + return self.count diff --git a/src/ttfrog/db/schema/item.py b/src/ttfrog/db/schema/item.py index 49ce2cf..61c71ce 100644 --- a/src/ttfrog/db/schema/item.py +++ b/src/ttfrog/db/schema/item.py @@ -13,6 +13,7 @@ __all__ = [ class ItemType(EnumField): ITEM = "ITEM" SPELL = "SPELL" + SCROLL = "SCROLL" class Item(BaseObject, ConditionMixin): @@ -24,6 +25,7 @@ class Item(BaseObject, ConditionMixin): id: Mapped[int] = mapped_column(init=False, primary_key=True, autoincrement=True) name: Mapped[str] = mapped_column(String(collation="NOCASE"), nullable=False, unique=True) consumable: Mapped[bool] = mapped_column(default=False) + count: Mapped[int] = mapped_column(nullable=False, default=1) item_type: Mapped[ItemType] = mapped_column(default=ItemType.ITEM, nullable=False) @@ -32,6 +34,7 @@ class Spell(Item): __mapper_args__ = { "polymorphic_identity": ItemType.SPELL } - id: Mapped[int] = mapped_column(ForeignKey("item.id"), primary_key=True) + id: Mapped[int] = mapped_column(ForeignKey("item.id"), primary_key=True, init=False) 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) diff --git a/test/conftest.py b/test/conftest.py index 5936e28..da6e671 100644 --- a/test/conftest.py +++ b/test/conftest.py @@ -106,7 +106,13 @@ def bootstrap(db): db.add_or_update([foo, bar]) @pytest.fixture -def carl(db, bootstrap): - tiefling = db.Ancestry.filter_by(name="tiefling").one() - carl = schema.Character(name="Carl", ancestry=tiefling) - return carl +def carl(db, bootstrap, tiefling): + return schema.Character(name="Carl", ancestry=tiefling) + +@pytest.fixture +def tiefling(db, bootstrap): + return db.Ancestry.filter_by(name="tiefling").one() + +@pytest.fixture +def human(db, bootstrap): + return db.Ancestry.filter_by(name="human").one() diff --git a/test/test_inventories.py b/test/test_inventories.py index 011fbb8..68c9e8b 100644 --- a/test/test_inventories.py +++ b/test/test_inventories.py @@ -1,4 +1,4 @@ -from ttfrog.db.schema.item import Item, ItemType +from ttfrog.db.schema.item import Item, Spell, ItemType def test_equipment_inventory(db, carl): @@ -6,11 +6,80 @@ def test_equipment_inventory(db, carl): # trigger the creation of inventory mappings db.add_or_update(carl) - # create an item + # create some items ten_foot_pole = Item(name="10ft. Pole", item_type=ItemType.ITEM, consumable=False) - db.add_or_update(ten_foot_pole) + fireball = Spell(name="Fireball", level=3, concentration=False) + db.add_or_update([ten_foot_pole, fireball]) - # add the item to carl's inventory + # add the pole to carl's equipment, and the spell to his spell list. + assert carl.equipment.add(ten_foot_pole) + assert carl.spells.add(fireball) + + # can't mix and match inventory item types + assert not carl.equipment.add(fireball) + assert not carl.spells.add(ten_foot_pole) + + # add two more 10 foot poles. You can never have too many. + carl.equipment.add(ten_foot_pole) carl.equipment.add(ten_foot_pole) db.add_or_update(carl) + + all_carls_poles = carl.equipment.get(ten_foot_pole) + assert len(all_carls_poles) == 3 + + # check the "contains" logic assert ten_foot_pole in carl.equipment + assert ten_foot_pole not in carl.spells + assert fireball in carl.spells + assert fireball not in carl.equipment + + # equip one pole + assert carl.equip(all_carls_poles[0]) + + # can't equip it twice + assert not carl.equip(all_carls_poles[0]) + + # unequip it + assert carl.unequip(all_carls_poles[0]) + + # can't unequip the unequipped ones + assert not carl.unequip(all_carls_poles[1]) + assert not carl.unequip(all_carls_poles[2]) + + # drop one pole + assert carl.equipment.remove(all_carls_poles[0]) + assert ten_foot_pole in carl.equipment + + # drop the remaining poles + assert carl.equipment.remove(all_carls_poles[1]) + assert carl.equipment.remove(all_carls_poles[2]) + 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]) + + +def test_inventory_bundles(db, carl): + with db.transaction(): + arrows = Item(name="Arrows", item_type=ItemType.ITEM, consumable=True, count=20) + db.add_or_update([carl, arrows]) + quiver = carl.equipment.add(arrows) + db.add_or_update(carl) + + # full quiver + assert arrows in carl.equipment + assert quiver.count == 20 + + # use one + assert quiver.use(1) == 19 + assert quiver.count == 19 + + # cannot use more than you have + assert not quiver.use(20) + + # cannot use a negative amount + assert not quiver.use(-1) + + # consume all remaining arrows + assert quiver.use(19) == 0 + assert arrows not in carl.equipment diff --git a/test/test_schema.py b/test/test_schema.py index c4781e5..02af390 100644 --- a/test/test_schema.py +++ b/test/test_schema.py @@ -205,13 +205,10 @@ def test_ancestries(db, bootstrap): assert grognak.check_modifier(strength, save=True) == 1 -def test_modifiers(db, bootstrap): +def test_modifiers(db, bootstrap, carl, tiefling, human): with db.transaction(): - human = db.Ancestry.filter_by(name="human").one() - tiefling = db.Ancestry.filter_by(name="tiefling").one() # no modifiers; speed is ancestry speed - carl = schema.Character(name="Carl", ancestry=tiefling) marx = schema.Character(name="Marx", ancestry=human) db.add_or_update([carl, marx]) assert carl.speed == carl.ancestry.speed == 30 @@ -335,10 +332,9 @@ def test_modifiers(db, bootstrap): assert not carl.remove_skill(athletics, proficient=True, expert=False, character_class=None) -def test_defenses(db, bootstrap): +def test_defenses(db, bootstrap, tiefling, carl): with db.transaction(): - tiefling = db.Ancestry.filter_by(name="tiefling").one() - carl = schema.Character(name="Carl", ancestry=tiefling) + db.add_or_update(carl) assert carl.resistant(DamageType.fire) carl.apply_damage(5, DamageType.fire) assert carl.hit_points == 8 # half damage @@ -387,13 +383,12 @@ def test_defenses(db, bootstrap): assert carl.hit_points == 8 # half damage -def test_condition_immunity(db, bootstrap): +def test_condition_immunity(db, bootstrap, carl, tiefling): """ Test immunities prevent conditions from being applied """ with db.transaction(): - tiefling = db.Ancestry.filter_by(name="tiefling").one() - carl = schema.Character(name="Carl", ancestry=tiefling) + db.add_or_update(carl) poisoned = schema.Condition(name=DamageType.poison) poison_immunity = schema.Modifier("Poison Immunity", target=DamageType.poison, new_value=Defenses.immune) db.add_or_update([carl, poisoned, poison_immunity]) @@ -423,11 +418,13 @@ def test_condition_immunity(db, bootstrap): assert carl.has_condition(poisoned) -def test_partial_immunities(db, bootstrap): +def test_partial_immunities(db, bootstrap, carl, tiefling): """ Test that individual modifiers applied by a condition can be negated even if not immune to the condition. """ with db.transaction(): + db.add_or_update(carl) + # Create some modifiers and conditions for this test fly = schema.Modifier(target="fly_speed", absolute_value=30, name="Fly Spell") cannot_move = schema.Modifier(name="Cannot Move (Petrified", target="speed", absolute_value=0) @@ -449,10 +446,6 @@ def test_partial_immunities(db, bootstrap): assert petrified.add_condition(incapacitated) db.add_or_update([fly, cannot_move, poisoned, poison_immunity, incapacitated, petrified]) - # hi carl - tiefling = db.Ancestry.filter_by(name="tiefling").one() - carl = schema.Character(name="Carl", ancestry=tiefling) - # carl casts fly! assert carl.fly_speed is None assert carl.add_modifier(fly)