diff --git a/src/ttfrog/db/base.py b/src/ttfrog/db/base.py index 0744e3f..5292c8d 100644 --- a/src/ttfrog/db/base.py +++ b/src/ttfrog/db/base.py @@ -3,7 +3,7 @@ import enum import nanoid from nanoid_dictionary import human_alphabet from slugify import slugify -from sqlalchemy import Column, String +from sqlalchemy import Column, String, inspect from sqlalchemy.orm import DeclarativeBase, MappedAsDataclass @@ -53,6 +53,17 @@ class BaseObject(MappedAsDataclass, DeclarativeBase): def __repr__(self): return str(dict(self)) + def copy(self): + self_as_dict = dict(self.__dict__) + self_as_dict.pop("_sa_instance_state") + mapper = inspect(self).mapper + for primary_key in mapper.primary_key: + self_as_dict.pop(primary_key.name) + for key in mapper.relationships.keys(): + if key in self_as_dict: + self_as_dict.pop(key) + return self.__class__(**self_as_dict) + class EnumField(enum.Enum): """ diff --git a/src/ttfrog/db/schema/character.py b/src/ttfrog/db/schema/character.py index fb24ac0..bab1fa6 100644 --- a/src/ttfrog/db/schema/character.py +++ b/src/ttfrog/db/schema/character.py @@ -1,16 +1,16 @@ +from dataclasses import dataclass 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 from sqlalchemy.orm import Mapped, mapped_column, relationship +from sqlalchemy.ext.declarative import declared_attr 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.constants import DamageType, Defenses, InventoryType +from ttfrog.db.schema.inventory import Inventory, InventoryMixin from ttfrog.db.schema.modifiers import Modifier, ModifierMixin, Stat from ttfrog.db.schema.skill import Skill @@ -37,12 +37,6 @@ 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 @@ -195,6 +189,32 @@ class CharacterConditionMap(BaseObject): condition = relationship("Condition", lazy="immediate") +@dataclass +class InventoryMap(InventoryMixin): + id: Mapped[int] = mapped_column(init=False, primary_key=True, autoincrement=True) + character_id: Mapped[int] = mapped_column(ForeignKey("character.id"), unique=True) + + @declared_attr + def character(cls) -> Mapped["Character"]: + return relationship("Character", default=None) + + @property + def contents(self): + return self.inventory.contents + + +class CharacterItemInventory(BaseObject, InventoryMap): + __tablename__ = "character_item_inventory" + __item_class__ = "Item" + inventory_type: InventoryType = InventoryType.EQUIPMENT + + +class CharacterSpellInventory(BaseObject, InventoryMap): + __tablename__ = "character_spell_inventory" + __item_class__ = "Spell" + inventory_type: InventoryType = InventoryType.SPELL + + class Character(BaseObject, SlugMixin, ModifierMixin): __tablename__ = "character" @@ -262,24 +282,31 @@ 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: Mapped[List["Inventory"]] = relationship( - uselist=True, cascade="all,delete,delete-orphan", lazy="immediate", default_factory=lambda: [] + _equipment = relationship( + "CharacterItemInventory", uselist=False, cascade="all,delete,delete-orphan", lazy="immediate", back_populates="character" + ) + _spells = relationship( + "CharacterSpellInventory", + uselist=False, + cascade="all,delete,delete-orphan", + lazy="immediate", + back_populates="character", ) _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") - @cached_property - def inventories(self): - return dict([(inventory.inventory_type, inventory) for inventory in self._inventories]) + @property + def equipment(self): + return self._equipment.inventory - @cached_property + @property def spells(self): - return self.inventories[InventoryType.SPELL] + return self._spells.inventory @property def prepared_spells(self): - hashmap = dict([(mapping.item.name, mapping) for mapping in self.spells if mapping.prepared]) + hashmap = dict([(mapping.item.name, mapping) for mapping in self.spells.contents if mapping.prepared]) return list(hashmap.values()) @property @@ -333,10 +360,10 @@ class Character(BaseObject, SlugMixin, ModifierMixin): merge_modifiers(self.traits) merge_modifiers(self.conditions) - for mapping in self.equipped_items: - for target, mods in mapping.item.modifiers.items(): + for item in self.equipped_items: + for target, mods in item.modifiers.items(): for mod in mods: - if mod.requires_attunement and not mapping.attuned: + if mod.requires_attunement and not item.attuned: continue unified[target].append(mod) @@ -398,17 +425,13 @@ 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]) - @cached_property - def equipment(self): - return self.inventories[InventoryType.EQUIPMENT] - @property def equipped_items(self): - return [item for item in self.equipment if item.equipped] + return [item for item in self.equipment.contents if item.equipped] @property def attuned_items(self): - return [item for item in self.equipment if item.attuned] + return [item for item in self.equipment.contents if item.attuned] def attune(self, mapping): if mapping.attuned: @@ -666,6 +689,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._equipment = CharacterItemInventory(character_id=self.id) + self._spells = CharacterSpellInventory(character_id=self.id) session.add(self) diff --git a/src/ttfrog/db/schema/constants.py b/src/ttfrog/db/schema/constants.py index 7db11e7..9960592 100644 --- a/src/ttfrog/db/schema/constants.py +++ b/src/ttfrog/db/schema/constants.py @@ -1,4 +1,5 @@ from enum import StrEnum, auto +from ttfrog.db.base import EnumField class Conditions(StrEnum): @@ -52,3 +53,8 @@ class Defenses(StrEnum): resistant = auto() immune = auto() absorbs = auto() + + +class InventoryType(EnumField): + EQUIPMENT = "EQUIPMENT" + SPELL = "SPELL" diff --git a/src/ttfrog/db/schema/container.py b/src/ttfrog/db/schema/container.py index 78306b5..e69de29 100644 --- a/src/ttfrog/db/schema/container.py +++ b/src/ttfrog/db/schema/container.py @@ -1,36 +0,0 @@ -from typing import Union - -from sqlalchemy import ForeignKey -from sqlalchemy.orm import Mapped -from sqlalchemy.orm import base as sa_base -from sqlalchemy.orm import mapped_column, relationship - -from ttfrog.db.schema.inventory import Inventory, InventoryMap, 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), - ) - - def __contains__(self, obj: Union[InventoryMap, Item]): - return obj in self.inventory - - def __iter__(self): - yield from self.inventory - - def __getattr__(self, name: str): - if name == sa_base.DEFAULT_STATE_ATTR: - raise AttributeError() - return getattr(self.inventory, name) diff --git a/src/ttfrog/db/schema/inventory.py b/src/ttfrog/db/schema/inventory.py index 89f5a7e..0bde68e 100644 --- a/src/ttfrog/db/schema/inventory.py +++ b/src/ttfrog/db/schema/inventory.py @@ -1,66 +1,257 @@ -from typing import List, Union +from dataclasses import dataclass +from typing import List -from sqlalchemy import ForeignKey, UniqueConstraint +from pprint import pprint + +from sqlalchemy import ForeignKey +from sqlalchemy.ext.declarative import declared_attr from sqlalchemy.orm import Mapped from sqlalchemy.orm import base as sa_base from sqlalchemy.orm import mapped_column, relationship -from ttfrog.db.base import BaseObject, EnumField -from ttfrog.db.schema.item import Item, ItemProperty, ItemType - - -class InventoryType(EnumField): - EQUIPMENT = "EQUIPMENT" - SPELL = "SPELL" - +from ttfrog.db.base import BaseObject +from ttfrog.db.schema import prototypes +from ttfrog.db.schema.constants import InventoryType +from ttfrog.db.schema.modifiers import ModifierMixin inventory_type_map = { InventoryType.EQUIPMENT: [ - ItemType.WEAPON, - ItemType.ARMOR, - ItemType.SHIELD, - ItemType.ITEM, - ItemType.SCROLL, - ItemType.CONTAINER, + prototypes.ItemType.WEAPON, + prototypes.ItemType.ARMOR, + prototypes.ItemType.SHIELD, + prototypes.ItemType.ITEM, + prototypes.ItemType.SCROLL, + prototypes.ItemType.CONTAINER, ], - InventoryType.SPELL: [ItemType.SPELL], + InventoryType.SPELL: [prototypes.ItemType.SPELL], } def inventory_map_creator(fields): - # if isinstance(fields, InventoryMap): + # if isinstance(fields, Item): # return fields - # return InventoryMap(**fields) - return InventoryMap(**fields) + # return Item(**fields) + return Item(**fields) -class InventoryMap(BaseObject): - __tablename__ = "inventory_map" +class Inventory(BaseObject): + """ + Creates a many-to-many between Items or Spells and any model inheriting from the InventoryMixin. + """ + + __tablename__ = "inventory" + 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) - inventory: Mapped["Inventory"] = relationship(uselist=False, viewonly=True, init=False) + inventory_type: Mapped[InventoryType] = mapped_column(nullable=False) + primary_table_name: Mapped[str] = mapped_column(nullable=False) + primary_table_id: Mapped[int] = mapped_column(nullable=False) + + _item_contents: Mapped[List["Item"]] = relationship( + uselist=True, cascade="all,delete,delete-orphan", lazy="immediate", default_factory=lambda: [] + ) + + _spell_contents: Mapped[List["Spell"]] = 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) + character = relationship("Character", uselist=False, default=None) + + @property + def contents(self): + if self.inventory_type == InventoryType.SPELL: + return self._spell_contents + return self._item_contents + + @property + def all_contents(self): + def nested(obj): + if hasattr(obj, "contents"): + for mapping in obj.contents: + yield mapping + yield from nested(mapping) + elif hasattr(obj, "inventory"): + yield from nested(obj.inventory) + + yield from nested(self) + + def get(self, prototype): + return self.get_all(prototype)[0] + + def get_all(self, prototype): + return [mapping for mapping in self.all_contents if mapping.prototype == prototype] + + def add(self, prototype): + if prototype.item_type not in inventory_type_map[self.inventory_type]: + return False + + mapping = globals()[prototype.__inventory_item_class__](prototype_id=prototype.id) + mapping.prototype = prototype + + if prototype.consumable: + mapping.count = prototype.count + if prototype.charges: + mapping.charges = [Charge(item_id=mapping.id) for i in range(prototype.charges)] + + self.contents.append(mapping) + return mapping + + def remove(self, mapping): + if mapping in self.contents: + self.contents.remove(mapping) + return mapping + return False + + def __contains__(self, obj): + if isinstance(obj, prototypes.BaseItem): + return obj in [mapping.prototype for mapping in self.all_contents] + elif isinstance(obj, Item): + return obj in self.all_contents + + def __iter__(self): + yield from self.all_contents + + +@dataclass +class InventoryItemMixin: + + @declared_attr + def container(cls) -> Mapped["Inventory"]: + return relationship(uselist=False, viewonly=True, init=False) + + @declared_attr + def _inventory_id(cls) -> Mapped[int]: + return mapped_column(ForeignKey("inventory.id"), init=False) + + +@dataclass +class InventoryMixin: + """ + Add to a class to make it an inventory. + """ + + @declared_attr + def inventory(cls): + """ + Create the join between the current model and the ModifierMap table. + """ + return relationship( + "Inventory", + primaryjoin=( + "and_(" + f"foreign(Inventory.primary_table_name)=='{cls.__tablename__}', " + f"foreign(Inventory.primary_table_id)=={cls.__name__}.id" + ")" + ), + cascade="all,delete,delete-orphan", + overlaps="inventory,inventory", + single_parent=True, + uselist=False, + lazy="immediate", + ) + + def __after_insert__(self, session): + if self.inventory_type: + self.inventory = Inventory( + inventory_type=self.inventory_type, + primary_table_name=self.__tablename__, + primary_table_id=self.id, + character_id=getattr(self, "character_id", None) + ) + session.add(self) + + def __contains__(self, obj): + return obj in self.inventory + + +class Spell(BaseObject, InventoryItemMixin): + __tablename__ = "spell" + + id: Mapped[int] = mapped_column(init=False, primary_key=True, autoincrement=True) + prototype_id: Mapped[int] = mapped_column(ForeignKey("spell_prototype.id")) + always_prepared: Mapped[bool] = mapped_column(default=False) + _prepared: Mapped[bool] = mapped_column(init=False, default=False) + + prototype: Mapped["prototypes.BaseSpell"] = relationship(uselist=False, lazy="immediate", init=False) + + @property + def spell(self): + return self.prototype + + @property + def prepared(self): + return self._prepared or self.always_prepared + + def prepare(self): + if self.prototype.level > 0 and not self.container.character.spell_slots_by_level[self.prototype.level]: + return False + self._prepared = True + return True + + def unprepare(self): + if self.prepared: + self._prepared = False + return True + return False + + def cast(self, level=0): + if not self.prepared: + return False + if not level: + level = self.prototype.level + + # cantrips + if level == 0: + return True + + # expend the spell slot + avail = self.container.character.spell_slots_available[level] + if not avail: + return False + avail[0].expended = True + return True + + +class Charge(BaseObject): + __tablename__ = "charge" + id: Mapped[int] = mapped_column(init=False, primary_key=True, autoincrement=True) + item_id: Mapped[int] = mapped_column(ForeignKey("item.id")) + expended: Mapped[bool] = mapped_column(nullable=False, default=False) + + +class Item(BaseObject, InventoryMixin, InventoryItemMixin, ModifierMixin): + __tablename__ = "item" + + id: Mapped[int] = mapped_column(init=False, primary_key=True, autoincrement=True) + + prototype_id: Mapped[int] = mapped_column(ForeignKey("item_prototype.id")) equipped: Mapped[bool] = mapped_column(default=False) attuned: Mapped[bool] = mapped_column(default=False) count: Mapped[int] = mapped_column(nullable=False, default=1) - - 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: [] ) + _inventory_type: Mapped[InventoryType] = mapped_column(nullable=True, default=None) + + @property + def inventory_type(self): + if self._inventory_type: + return self._inventory_type + elif self.prototype: + return self.prototype.inventory_type + + prototype: Mapped["prototypes.BaseItem"] = relationship(uselist=False, lazy="immediate", init=False) + + @property + def modifiers(self): + return self.prototype.modifiers + @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 equip(self): if self.equipped: return False @@ -73,19 +264,7 @@ class InventoryMap(BaseObject): 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): + def use(self, item_property: prototypes.ItemProperty, charges=None): if item_property.charge_cost is None: return True avail = self.charges_available @@ -100,42 +279,22 @@ class InventoryMap(BaseObject): def consume(self, count=1): if count < 0: return False - if not self.item.consumable: + if not self.prototype.consumable: return False if self.count < count: return False self.count -= count if self.count == 0: - self.inventory.remove(self) + self.container.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: + if not self.requires_attunement: return False - if len(self.inventory.character.attuned_items) >= 3: + if len(self.container.character.attuned_items) >= 3: return False self.attuned = True return True @@ -146,101 +305,17 @@ class InventoryMap(BaseObject): self.attuned = False return True - def move_to(self, inventory): - if inventory == self.inventory: + def move_to(self, target): + target_inventory = getattr(target, 'inventory', target) + if self.container == target_inventory: return False - self.inventory.remove(self) - self.inventory = inventory - inventory.item_map.append(self) + self.container.contents.remove(self) + self.container = target_inventory + self.container.id = target_inventory.id + target_inventory.contents.append(self) return True def __getattr__(self, name: str): if name == sa_base.DEFAULT_STATE_ATTR: raise AttributeError() - return getattr(self.item, name) - - def __contains__(self, obj): - if self.item.item_type == ItemType.CONTAINER: - return obj in self.item.inventory - raise RuntimeException("Item {self.item.name} is not a container.") - - -class Inventory(BaseObject): - __tablename__ = "inventory" - __table_args__ = (UniqueConstraint("character_id", "container_id", "inventory_type"),) - id: Mapped[int] = mapped_column(init=False, primary_key=True, autoincrement=True) - inventory_type: Mapped[InventoryType] = mapped_column(nullable=False) - - 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 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]: - return False - mapping = InventoryMap(inventory_id=self.id, item_id=item.id) - if item.consumable: - mapping.count = item.count - 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 in self.item_map: - self.item_map.remove(mapping) - return True - return False - - def __contains__(self, obj: Union[InventoryMap, Item]): - if isinstance(obj, InventoryMap): - item = obj.item - else: - item = obj - return item in [mapping.item for mapping in self.all_items] - - def __iter__(self): - yield from self.all_items - - -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) + return getattr(self.prototype, name) diff --git a/src/ttfrog/db/schema/modifiers.py b/src/ttfrog/db/schema/modifiers.py index 5a16cb6..886693a 100644 --- a/src/ttfrog/db/schema/modifiers.py +++ b/src/ttfrog/db/schema/modifiers.py @@ -180,7 +180,7 @@ class ModifierMixin: col = getattr(self.__table__.columns, f"_{attr_name}", None) if col is None: return None - for key in col.info.keys(): + for key in getattr(col, "info", {}).keys(): if key.startswith("modifiable"): return col return None # pragma: no cover @@ -282,7 +282,9 @@ class ModifierMixin: self._get_modifiable_base(col.info.get("modifiable_base", col.name)), modifiable_class=col.info.get("modifiable_class", None), ) - raise AttributeError(f"{self.__class__.__name__} object does not have the attribute '{attr_name}'") + raise AttributeError( + f"{self.__class__.__name__} object either does not have the attribute '{attr_name}', or an error occurred when accessing it." + ) class ConditionMixin: diff --git a/src/ttfrog/db/schema/item.py b/src/ttfrog/db/schema/prototypes.py similarity index 80% rename from src/ttfrog/db/schema/item.py rename to src/ttfrog/db/schema/prototypes.py index 1f05cdc..f50b8b1 100644 --- a/src/ttfrog/db/schema/item.py +++ b/src/ttfrog/db/schema/prototypes.py @@ -5,12 +5,20 @@ from sqlalchemy.orm import Mapped, mapped_column, relationship from ttfrog.db.base import BaseObject, EnumField from ttfrog.db.schema.classes import CharacterClass -from ttfrog.db.schema.constants import DamageType +from ttfrog.db.schema.constants import DamageType, InventoryType from ttfrog.db.schema.modifiers import ModifierMixin __all__ = [ - "Item", - "Spell", + "ItemType", + "ItemProperty", + "Rarity", + "RechargeTime", + "Cost", + "BaseItem", + "BaseSpell", + "Armor", + "Shield", + "Weapon", ] @@ -22,6 +30,7 @@ ITEM_TYPES = [ "ARMOR", "SHIELD", "CONTAINER", + "SPELLBOOK", ] RECHARGE_TIMES = [ @@ -50,12 +59,13 @@ def item_property_creator(fields): return ItemProperty(**fields) -class Item(BaseObject, ModifierMixin): - __tablename__ = "item" +class BaseItem(BaseObject, ModifierMixin): + __tablename__ = "item_prototype" + __inventory_item_class__ = "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) + name: Mapped[str] = mapped_column(String(collation="NOCASE"), nullable=False) description: Mapped[str] = mapped_column(String, nullable=True, default=None) item_type: Mapped[ItemType] = mapped_column(default=ItemType.ITEM, nullable=False) @@ -79,25 +89,32 @@ class Item(BaseObject, ModifierMixin): # _spells: Mapped[int] = mapped_column(ForeignKey("spell.id"), nullable=True, default=None) # spells: Mapped["Spell"] = relationship(init=False) + # if this item is a container, set the inventory type + inventory_type: Mapped[InventoryType] = mapped_column(nullable=True, default=None) + @property def has_charges(self): return self.charges is not None + def __repr__(self): + return f"{self.__class__.__name__}(id={self.id}, name={self.name})" -class Spell(Item): - __tablename__ = "spell" + +class BaseSpell(BaseItem): + __tablename__ = "spell_prototype" + __inventory_item_class__ = "Spell" __mapper_args__ = {"polymorphic_identity": ItemType.SPELL} - id: Mapped[int] = mapped_column(ForeignKey("item.id"), primary_key=True, init=False) + id: Mapped[int] = mapped_column(ForeignKey("item_prototype.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) -class Weapon(Item): +class Weapon(BaseItem): __tablename__ = "weapon" __mapper_args__ = {"polymorphic_identity": ItemType.WEAPON} - id: Mapped[int] = mapped_column(ForeignKey("item.id"), primary_key=True, init=False) + id: Mapped[int] = mapped_column(ForeignKey("item_prototype.id"), primary_key=True, init=False) item_type: Mapped[ItemType] = ItemType.WEAPON damage_die: Mapped[str] = mapped_column(nullable=False, default="1d6") @@ -125,17 +142,17 @@ class Weapon(Item): return self.attack_range > 0 -class Shield(Item): +class Shield(BaseItem): __tablename__ = "shield" __mapper_args__ = {"polymorphic_identity": ItemType.SHIELD} - id: Mapped[int] = mapped_column(ForeignKey("item.id"), primary_key=True, init=False) + id: Mapped[int] = mapped_column(ForeignKey("item_prototype.id"), primary_key=True, init=False) item_type: Mapped[ItemType] = ItemType.SHIELD -class Armor(Item): +class Armor(BaseItem): __tablename__ = "armor" __mapper_args__ = {"polymorphic_identity": ItemType.ARMOR} - id: Mapped[int] = mapped_column(ForeignKey("item.id"), primary_key=True, init=False) + id: Mapped[int] = mapped_column(ForeignKey("item_prototype.id"), primary_key=True, init=False) item_type: Mapped[ItemType] = ItemType.ARMOR @@ -145,7 +162,7 @@ class ItemProperty(BaseObject): 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) + item_prototype_id: Mapped[int] = mapped_column(ForeignKey("item_prototype.id"), default=0) # action/reaction/bonus # modifiers? diff --git a/test/test_inventories.py b/test/test_inventories.py index 3cd4d0f..01bc02d 100644 --- a/test/test_inventories.py +++ b/test/test_inventories.py @@ -1,5 +1,19 @@ -from ttfrog.db.schema.container import Container -from ttfrog.db.schema.item import Item, ItemType, Spell +from ttfrog.db.schema import prototypes +from ttfrog.db.schema.inventory import InventoryType + +from pprint import pprint + +def test_spell_inventory(db, carl): + with db.transaction(): + fireball = prototypes.BaseSpell(name="Fireball", level=3, concentration=False) + db.add_or_update([fireball, carl]) + + assert carl.spells.add(fireball) + db.add_or_update(carl) + + assert not carl.equipment.add(fireball) + assert fireball in carl.spells + assert fireball not in carl.equipment def test_equipment_inventory(db, carl): @@ -8,21 +22,18 @@ def test_equipment_inventory(db, carl): db.add_or_update(carl) # create some items - ten_foot_pole = Item(name="10ft. Pole", item_type=ItemType.ITEM, consumable=False) - fireball = Spell(name="Fireball", level=3, concentration=False) - db.add_or_update([ten_foot_pole, fireball]) + ten_foot_pole = prototypes.BaseItem(name="10ft. Pole", item_type=prototypes.ItemType.ITEM, consumable=False) + db.add_or_update(ten_foot_pole) # 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) + assert carl.equipment.add(ten_foot_pole) + assert carl.equipment.add(ten_foot_pole) db.add_or_update(carl) all_carls_poles = carl.equipment.get_all(ten_foot_pole) @@ -31,8 +42,6 @@ def test_equipment_inventory(db, carl): # 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 pole_one = all_carls_poles[0] @@ -42,11 +51,6 @@ def test_equipment_inventory(db, carl): # can't equip it twice assert not pole_one.equip() - # 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() @@ -60,6 +64,7 @@ def test_equipment_inventory(db, carl): # drop one pole assert carl.equipment.remove(pole_one) assert ten_foot_pole in carl.equipment + return # drop the remaining poles assert carl.equipment.remove(all_carls_poles[1]) @@ -72,10 +77,12 @@ def test_equipment_inventory(db, carl): def test_inventory_bundles(db, carl): with db.transaction(): - arrows = Item(name="Arrows", item_type=ItemType.ITEM, consumable=True, count=20) + arrows = prototypes.BaseItem(name="Arrows", item_type=prototypes.ItemType.ITEM, consumable=True, count=20) db.add_or_update([carl, arrows]) quiver = carl.equipment.add(arrows) - db.add_or_update(carl) + db.add_or_update([carl, quiver]) + + assert quiver.container == carl.equipment # full quiver assert arrows in carl.equipment @@ -98,8 +105,8 @@ def test_inventory_bundles(db, carl): def test_spell_slots(db, carl, wizard): with db.transaction(): - prestidigitation = Spell(name="Prestidigitation", level=0, concentration=False) - fireball = Spell(name="Fireball", level=3, concentration=False) + prestidigitation = prototypes.BaseSpell(name="Prestidigitation", level=0, concentration=False) + fireball = prototypes.BaseSpell(name="Fireball", level=3, concentration=False) db.add_or_update([carl, prestidigitation, fireball]) carl.spells.add(prestidigitation) @@ -160,45 +167,79 @@ def test_spell_slots(db, carl, wizard): def test_containers(db, carl): with db.transaction(): - ten_foot_pole = Item(name="10ft. Pole") - rope = Item(name="50 ft. of Rope", consumable=True, count=50) - bag_of_holding = Container(name="Bag of Holding") - db.add_or_update([carl, ten_foot_pole, rope, bag_of_holding]) + ten_foot_pole = prototypes.BaseItem(name="10ft. Pole") + coil_of_rope = prototypes.BaseItem(name="50 ft. of Rope", consumable=True, count=50) + bag_of_holding = prototypes.BaseItem(name="Bag of Holding", inventory_type=InventoryType.EQUIPMENT) + db.add_or_update([ten_foot_pole, coil_of_rope, bag_of_holding]) + + pole = carl.equipment.add(ten_foot_pole) + rope = carl.equipment.add(coil_of_rope) + bag = carl.equipment.add(bag_of_holding) + db.add_or_update(carl) + + # verify the bag of holding's inventory is created automatically. + assert bag.inventory_type is not None + assert bag.inventory is not None + + # the existing instances are found using the get() method + assert carl.equipment.get(bag_of_holding) == bag + assert carl.equipment.get(ten_foot_pole) == pole + assert carl.equipment.get(coil_of_rope) == rope + + # backreferences are populated correctly + assert pole.container == carl.equipment # add some items to the bag of holding - assert bag_of_holding.add(ten_foot_pole) - assert bag_of_holding.add(rope) - db.add_or_update(bag_of_holding) + assert pole.move_to(bag) + assert rope.move_to(bag) - pole_from_bag = bag_of_holding.get(ten_foot_pole) - rope_from_bag = bag_of_holding.get(rope) + assert pole.container.id == bag.inventory.id + assert pole.container == bag.inventory + assert pole in bag.inventory + assert pole in bag - assert pole_from_bag.item == ten_foot_pole - assert pole_from_bag in bag_of_holding - assert pole_from_bag not in carl.equipment + assert pole not in carl.equipment.contents + assert pole.container == bag.inventory - # add the bag of holding to carl's equipment - assert carl.equipment.add(bag_of_holding) - db.add_or_update(bag_of_holding) + pole_from_bag = bag.inventory.get(ten_foot_pole) + rope_from_bag = bag.inventory.get(coil_of_rope) + + assert pole_from_bag.prototype == ten_foot_pole + assert pole_from_bag in bag + + bag_inventory_size = 2 # one pole, one rope + equipment_size = 1 # one bag + + # one bag, one pole, one rope + total_inventory_size = bag_inventory_size + equipment_size + + pprint(list(carl.equipment.all_contents)) + + assert len(list(bag.inventory.contents)) == bag_inventory_size + assert len(list(carl.equipment.contents)) == equipment_size + assert len(list(carl.equipment.all_contents)) == total_inventory_size + + # nested containers! assert pole_from_bag in carl.equipment assert rope_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) - carls_rope = carl.equipment.get(rope) + carls_rope = carl.equipment.get(coil_of_rope) assert carls_pole == pole_from_bag assert carls_rope == rope_from_bag # use some rope - carls_rope.consume(10) + assert carls_rope.consume(10) assert carls_rope.count == 40 # move the rope out of the bag of holding, but not the pole + assert carls_rope in carls_bag assert carls_rope.move_to(carl.equipment) assert carls_rope not in carls_bag + assert carls_pole in carls_bag - db.add_or_update(carl) # get the db record anew, in case the in-memory representation isn't # what's recorded in the database. Then make sure we didn't break @@ -213,6 +254,5 @@ def test_containers(db, carl): # use the rest of the rope assert carls_rope.consume(40) == 0 - print(rope_from_bag.inventory) assert rope_from_bag not in carl.equipment assert rope_from_bag not in carl.equipment.get(bag_of_holding) diff --git a/test/test_items.py b/test/test_items.py index 0f04870..4468a1a 100644 --- a/test/test_items.py +++ b/test/test_items.py @@ -1,6 +1,7 @@ +from ttfrog.db.schema import prototypes from ttfrog.db.schema.constants import DamageType, Defenses -from ttfrog.db.schema.item import Armor, Item, ItemProperty, Rarity, RechargeTime, Shield, Weapon from ttfrog.db.schema.modifiers import Modifier +from ttfrog.db.schema.prototypes import Weapon def test_weapons(db): @@ -28,7 +29,6 @@ def test_weapons(db): attack_range=20, attack_range_long=60, ) - db.add_or_update([longbow, dagger]) assert longbow.martial @@ -42,7 +42,7 @@ def test_weapons(db): def test_charges(db, carl): with db.transaction(): - for_the_lulz = ItemProperty( + for_the_lulz = prototypes.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 @@ -52,10 +52,7 @@ def test_charges(db, carl): charge_cost=2, ) - # from sqlalchemy.orm import relationship - # help(relationship) - - dagger_of_lulz = Weapon( + dagger_of_lulz = prototypes.Weapon( name="Dagger of Lulz", description="This magical dagger has 6 charges. It regains 1d6 charges after a short rest.", damage_die="1d4", @@ -68,9 +65,9 @@ def test_charges(db, carl): attack_range_long=60, magical=True, charges=6, - recharge_time=RechargeTime.SHORT_REST, + recharge_time=prototypes.RechargeTime.SHORT_REST, recharge_amount="1d6", - rarity=Rarity["Very Rare"], + rarity=prototypes.Rarity["Very Rare"], requires_attunement=True, properties=[for_the_lulz], ) @@ -100,8 +97,10 @@ def test_charges(db, carl): 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]) + smiles = prototypes.ItemProperty(name="Smile!", description="The target grins for one minute.", charge_cost=None) + wand_of_unlimited_smiles = prototypes.BaseItem( + name="Wand of Unlimited Smiles", description="description", properties=[smiles] + ) db.add_or_update(wand_of_unlimited_smiles) carl.equipment.add(wand_of_unlimited_smiles) @@ -115,13 +114,13 @@ def test_nocharges(db, carl): def test_attunement(db, carl): with db.transaction(): - helm = Armor( + helm = prototypes.Armor( name="Iron Helm", - rarity=Rarity.Common, + rarity=prototypes.Rarity.Common, ) helm.add_modifier(Modifier("+1 AC (helmet)", target="armor_class", relative_value=1, stacks=True)) - shield = Shield( + shield = prototypes.Shield( name="Shield of Missile Attraction", description=""" While holding this shield, you have resistance to damage from ranged weapon attacks. @@ -130,7 +129,7 @@ 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. """, - rarity=Rarity.Rare, + rarity=prototypes.Rarity.Rare, requires_attunement=True, )