diff --git a/src/ttfrog/db/schema/character.py b/src/ttfrog/db/schema/character.py index 9cb1a0e..504b8d9 100644 --- a/src/ttfrog/db/schema/character.py +++ b/src/ttfrog/db/schema/character.py @@ -8,8 +8,10 @@ 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.modifiers import Modifier, ModifierMixin, Stat +from ttfrog.db.schema.inventory import Inventory, InventoryMap, InventoryType from ttfrog.db.schema.skill import Skill + __all__ = [ "Ancestry", "AncestryTrait", @@ -33,6 +35,12 @@ def skill_creator(fields): return CharacterSkillMap(**fields) +def inventory_creator(fields): + if isinstance(fields, InventoryMap): + return fields + return InventoryMap(**fields) + + def condition_creator(fields): if isinstance(fields, CharacterConditionMap): return fields @@ -219,6 +227,9 @@ class Character(BaseObject, SlugMixin, ModifierMixin): _reactions_per_turn: Mapped[int] = mapped_column( nullable=False, default=1, info={"min": 0, "max": 99, "modifiable": True} ) + _attacks_per_action: Mapped[int] = mapped_column( + nullable=False, default=1, info={"min": 0, "max": 99, "modifiable": True} + ) vision: Mapped[int] = mapped_column(default=None, nullable=True, info={"min": 0, "modifiable": True}) exhaustion: Mapped[int] = mapped_column(nullable=False, default=0, info={"min": 0, "max": 5}) @@ -235,11 +246,14 @@ class Character(BaseObject, SlugMixin, ModifierMixin): conditions = association_proxy("_conditions", "condition", creator=condition_creator) character_class_feature_map = relationship("CharacterClassFeatureMap", cascade="all,delete,delete-orphan") - feature_list = association_proxy("character_class_feature_map", "id", creator=attr_map_creator) + features = association_proxy("character_class_feature_map", "id", creator=attr_map_creator) 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) + _hit_dice = relationship("HitDie", uselist=True, cascade="all,delete,delete-orphan", lazy="immediate") @property @@ -323,6 +337,14 @@ 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 + def equipment(self): + return [inv for inv in self._inventories if inv.inventory_type == InventoryType.EQUIPMENT][0] + + @property + def spells(self): + return [inv for inv in self._inventories if inv.inventory_type == InventoryType.SPELL][0] + def level_in_class(self, charclass): mapping = [mapping for mapping in self.class_map if mapping.character_class_id == charclass.id] if not mapping: @@ -423,7 +445,7 @@ class Character(BaseObject, SlugMixin, ModifierMixin): return False if feature not in mapping.character_class.features_at_level(mapping.level): return False - self.feature_list.append( + self.features.append( CharacterClassFeatureMap( character_id=self.id, class_feature_id=feature.id, @@ -548,3 +570,9 @@ class Character(BaseObject, SlugMixin, ModifierMixin): Skill.name.in_(("strength", "dexterity", "constitution", "intelligence", "wisdom", "charisma")) ): self.add_skill(skill, proficient=False, expert=False) + + for inventory_type in InventoryType: + self._inventories.append( + Inventory(inventory_type=inventory_type, character_id=self.id) + ) + session.add(self) diff --git a/src/ttfrog/db/schema/inventory.py b/src/ttfrog/db/schema/inventory.py new file mode 100644 index 0000000..ee4a297 --- /dev/null +++ b/src/ttfrog/db/schema/inventory.py @@ -0,0 +1,57 @@ +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 + + +class InventoryType(EnumField): + EQUIPMENT = "EQUIPMENT" + SPELL = "SPELL" + + +inventory_type_map = { + InventoryType.EQUIPMENT: [ + ItemType.ITEM, + ItemType.SPELL, + ], + InventoryType.SPELL: [ + ItemType.SPELL + ] +} + + +def inventory_map_creator(fields): + if isinstance(fields, InventoryMap): + return fields + return InventoryMap(**fields) + + +class Inventory(BaseObject): + __tablename__ = "inventory" + __table_args__ = (UniqueConstraint("character_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) + inventory_map = association_proxy("_inventory_map", "id", creator=inventory_map_creator) + + 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)) + + def __iter__(self): + yield from [mapping.item for mapping in self._inventory_map] + + +class InventoryMap(BaseObject): + __tablename__ = "inventory_map" + id: Mapped[int] = mapped_column(init=False, primary_key=True, autoincrement=True) + 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) + + equipped: Mapped[bool] = mapped_column(default=False) + count: Mapped[int] = mapped_column(nullable=False, default=1) diff --git a/src/ttfrog/db/schema/item.py b/src/ttfrog/db/schema/item.py new file mode 100644 index 0000000..49ce2cf --- /dev/null +++ b/src/ttfrog/db/schema/item.py @@ -0,0 +1,37 @@ +from sqlalchemy import ForeignKey, String +from sqlalchemy.orm import Mapped, mapped_column + +from ttfrog.db.base import BaseObject, EnumField +from ttfrog.db.schema.modifiers import ConditionMixin + +__all__ = [ + "Item", + "Spell", +] + + +class ItemType(EnumField): + ITEM = "ITEM" + SPELL = "SPELL" + + +class Item(BaseObject, ConditionMixin): + __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) + consumable: Mapped[bool] = mapped_column(default=False) + item_type: Mapped[ItemType] = mapped_column(default=ItemType.ITEM, nullable=False) + + +class Spell(Item): + __tablename__ = "spell" + __mapper_args__ = { + "polymorphic_identity": ItemType.SPELL + } + id: Mapped[int] = mapped_column(ForeignKey("item.id"), primary_key=True) + level: Mapped[int] = mapped_column(nullable=False, info={"min": 0, "max": 9}, default=0) + concentration: Mapped[bool] = mapped_column(default=False) diff --git a/src/ttfrog/db/schema/modifiers.py b/src/ttfrog/db/schema/modifiers.py index b314f92..5d6da65 100644 --- a/src/ttfrog/db/schema/modifiers.py +++ b/src/ttfrog/db/schema/modifiers.py @@ -282,15 +282,7 @@ class ModifierMixin: raise AttributeError(f"No such attribute on {self.__class__.__name__} object: {attr_name}.") -class Condition(BaseObject): - __tablename__ = "condition" - 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(default="") - - _modifiers = relationship("Modifier", uselist=True, cascade="all,delete,delete-orphan") - _parent_condition_id: Mapped[int] = mapped_column(ForeignKey("condition.id"), nullable=True, default=None) - conditions = relationship("Condition", lazy="immediate", uselist=True) +class ConditionMixin: @property def modifiers(self): @@ -331,6 +323,17 @@ class Condition(BaseObject): self.conditions = [c for c in self.conditions if c != condition] return True + +class Condition(BaseObject, ConditionMixin): + __tablename__ = "condition" + 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(default="") + + _modifiers = relationship("Modifier", uselist=True, cascade="all,delete,delete-orphan") + _parent_condition_id: Mapped[int] = mapped_column(ForeignKey("condition.id"), nullable=True, default=None) + conditions = relationship("Condition", lazy="immediate", uselist=True) + def __str___(self): return self.name diff --git a/test/conftest.py b/test/conftest.py index d5e041e..5936e28 100644 --- a/test/conftest.py +++ b/test/conftest.py @@ -104,3 +104,9 @@ def bootstrap(db): # persist all the records we've created 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 diff --git a/test/test_inventories.py b/test/test_inventories.py new file mode 100644 index 0000000..011fbb8 --- /dev/null +++ b/test/test_inventories.py @@ -0,0 +1,16 @@ +from ttfrog.db.schema.item import Item, ItemType + + +def test_equipment_inventory(db, carl): + with db.transaction(): + # trigger the creation of inventory mappings + db.add_or_update(carl) + + # create an item + ten_foot_pole = Item(name="10ft. Pole", item_type=ItemType.ITEM, consumable=False) + db.add_or_update(ten_foot_pole) + + # add the item to carl's inventory + carl.equipment.add(ten_foot_pole) + db.add_or_update(carl) + assert ten_foot_pole in carl.equipment