reimplementing inventories

This commit is contained in:
evilchili 2024-09-21 15:59:40 -07:00
parent 926d2fdaf6
commit 1b9ff9b393
9 changed files with 438 additions and 301 deletions

View File

@ -3,7 +3,7 @@ import enum
import nanoid import nanoid
from nanoid_dictionary import human_alphabet from nanoid_dictionary import human_alphabet
from slugify import slugify from slugify import slugify
from sqlalchemy import Column, String from sqlalchemy import Column, String, inspect
from sqlalchemy.orm import DeclarativeBase, MappedAsDataclass from sqlalchemy.orm import DeclarativeBase, MappedAsDataclass
@ -53,6 +53,17 @@ class BaseObject(MappedAsDataclass, DeclarativeBase):
def __repr__(self): def __repr__(self):
return str(dict(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): class EnumField(enum.Enum):
""" """

View File

@ -1,16 +1,16 @@
from dataclasses import dataclass
import itertools import itertools
from collections import defaultdict from collections import defaultdict
from functools import cached_property
from typing import List
from sqlalchemy import ForeignKey, String, Text, UniqueConstraint from sqlalchemy import ForeignKey, String, Text, UniqueConstraint
from sqlalchemy.ext.associationproxy import association_proxy from sqlalchemy.ext.associationproxy import association_proxy
from sqlalchemy.orm import Mapped, mapped_column, relationship 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.base import BaseObject, SlugMixin
from ttfrog.db.schema.classes import CharacterClass, ClassFeature from ttfrog.db.schema.classes import CharacterClass, ClassFeature
from ttfrog.db.schema.constants import DamageType, Defenses from ttfrog.db.schema.constants import DamageType, Defenses, InventoryType
from ttfrog.db.schema.inventory import Inventory, InventoryMap, InventoryType from ttfrog.db.schema.inventory import Inventory, InventoryMixin
from ttfrog.db.schema.modifiers import Modifier, ModifierMixin, Stat from ttfrog.db.schema.modifiers import Modifier, ModifierMixin, Stat
from ttfrog.db.schema.skill import Skill from ttfrog.db.schema.skill import Skill
@ -37,12 +37,6 @@ def skill_creator(fields):
return CharacterSkillMap(**fields) return CharacterSkillMap(**fields)
def inventory_creator(fields):
if isinstance(fields, InventoryMap):
return fields
return InventoryMap(**fields)
def condition_creator(fields): def condition_creator(fields):
if isinstance(fields, CharacterConditionMap): if isinstance(fields, CharacterConditionMap):
return fields return fields
@ -195,6 +189,32 @@ class CharacterConditionMap(BaseObject):
condition = relationship("Condition", lazy="immediate") 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): class Character(BaseObject, SlugMixin, ModifierMixin):
__tablename__ = "character" __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_id: Mapped[int] = mapped_column(ForeignKey("ancestry.id"), nullable=False, default="1")
ancestry: Mapped["Ancestry"] = relationship(uselist=False, default=None) ancestry: Mapped["Ancestry"] = relationship(uselist=False, default=None)
_inventories: Mapped[List["Inventory"]] = relationship( _equipment = relationship(
uselist=True, cascade="all,delete,delete-orphan", lazy="immediate", default_factory=lambda: [] "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") _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") _spell_slots = relationship("SpellSlot", uselist=True, cascade="all,delete,delete-orphan", lazy="immediate")
@cached_property @property
def inventories(self): def equipment(self):
return dict([(inventory.inventory_type, inventory) for inventory in self._inventories]) return self._equipment.inventory
@cached_property @property
def spells(self): def spells(self):
return self.inventories[InventoryType.SPELL] return self._spells.inventory
@property @property
def prepared_spells(self): 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()) return list(hashmap.values())
@property @property
@ -333,10 +360,10 @@ class Character(BaseObject, SlugMixin, ModifierMixin):
merge_modifiers(self.traits) merge_modifiers(self.traits)
merge_modifiers(self.conditions) merge_modifiers(self.conditions)
for mapping in self.equipped_items: for item in self.equipped_items:
for target, mods in mapping.item.modifiers.items(): for target, mods in item.modifiers.items():
for mod in mods: for mod in mods:
if mod.requires_attunement and not mapping.attuned: if mod.requires_attunement and not item.attuned:
continue continue
unified[target].append(mod) unified[target].append(mod)
@ -398,17 +425,13 @@ class Character(BaseObject, SlugMixin, ModifierMixin):
def class_features(self): def class_features(self):
return dict([(mapping.class_feature.name, mapping.option) for mapping in self.character_class_feature_map]) 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 @property
def equipped_items(self): 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 @property
def attuned_items(self): 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): def attune(self, mapping):
if mapping.attuned: if mapping.attuned:
@ -666,6 +689,6 @@ class Character(BaseObject, SlugMixin, ModifierMixin):
): ):
self.add_skill(skill, proficient=False, expert=False) self.add_skill(skill, proficient=False, expert=False)
self._inventories.append(Inventory(inventory_type=InventoryType.EQUIPMENT, character_id=self.id)) self._equipment = CharacterItemInventory(character_id=self.id)
self._inventories.append(Inventory(inventory_type=InventoryType.SPELL, character_id=self.id)) self._spells = CharacterSpellInventory(character_id=self.id)
session.add(self) session.add(self)

View File

@ -1,4 +1,5 @@
from enum import StrEnum, auto from enum import StrEnum, auto
from ttfrog.db.base import EnumField
class Conditions(StrEnum): class Conditions(StrEnum):
@ -52,3 +53,8 @@ class Defenses(StrEnum):
resistant = auto() resistant = auto()
immune = auto() immune = auto()
absorbs = auto() absorbs = auto()
class InventoryType(EnumField):
EQUIPMENT = "EQUIPMENT"
SPELL = "SPELL"

View File

@ -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)

View File

@ -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 Mapped
from sqlalchemy.orm import base as sa_base from sqlalchemy.orm import base as sa_base
from sqlalchemy.orm import mapped_column, relationship from sqlalchemy.orm import mapped_column, relationship
from ttfrog.db.base import BaseObject, EnumField from ttfrog.db.base import BaseObject
from ttfrog.db.schema.item import Item, ItemProperty, ItemType from ttfrog.db.schema import prototypes
from ttfrog.db.schema.constants import InventoryType
from ttfrog.db.schema.modifiers import ModifierMixin
class InventoryType(EnumField):
EQUIPMENT = "EQUIPMENT"
SPELL = "SPELL"
inventory_type_map = { inventory_type_map = {
InventoryType.EQUIPMENT: [ InventoryType.EQUIPMENT: [
ItemType.WEAPON, prototypes.ItemType.WEAPON,
ItemType.ARMOR, prototypes.ItemType.ARMOR,
ItemType.SHIELD, prototypes.ItemType.SHIELD,
ItemType.ITEM, prototypes.ItemType.ITEM,
ItemType.SCROLL, prototypes.ItemType.SCROLL,
ItemType.CONTAINER, prototypes.ItemType.CONTAINER,
], ],
InventoryType.SPELL: [ItemType.SPELL], InventoryType.SPELL: [prototypes.ItemType.SPELL],
} }
def inventory_map_creator(fields): def inventory_map_creator(fields):
# if isinstance(fields, InventoryMap): # if isinstance(fields, Item):
# return fields # return fields
# return InventoryMap(**fields) # return Item(**fields)
return InventoryMap(**fields) return Item(**fields)
class InventoryMap(BaseObject): class Inventory(BaseObject):
__tablename__ = "inventory_map" """
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) id: Mapped[int] = mapped_column(init=False, primary_key=True, autoincrement=True)
inventory_id: Mapped[int] = mapped_column(ForeignKey("inventory.id")) inventory_type: Mapped[InventoryType] = mapped_column(nullable=False)
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)
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) equipped: Mapped[bool] = mapped_column(default=False)
attuned: Mapped[bool] = mapped_column(default=False) attuned: Mapped[bool] = mapped_column(default=False)
count: Mapped[int] = mapped_column(nullable=False, default=1) count: Mapped[int] = mapped_column(nullable=False, default=1)
always_prepared: Mapped[bool] = mapped_column(default=False)
charges: Mapped[List["Charge"]] = relationship( charges: Mapped[List["Charge"]] = relationship(
uselist=True, cascade="all,delete,delete-orphan", lazy="immediate", default_factory=lambda: [] 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 @property
def charges_available(self): def charges_available(self):
return [charge for charge in self.charges if not charge.expended] 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): def equip(self):
if self.equipped: if self.equipped:
return False return False
@ -73,19 +264,7 @@ class InventoryMap(BaseObject):
self.equipped = False self.equipped = False
return True return True
def prepare(self): def use(self, item_property: prototypes.ItemProperty, charges=None):
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: if item_property.charge_cost is None:
return True return True
avail = self.charges_available avail = self.charges_available
@ -100,42 +279,22 @@ class InventoryMap(BaseObject):
def consume(self, count=1): def consume(self, count=1):
if count < 0: if count < 0:
return False return False
if not self.item.consumable: if not self.prototype.consumable:
return False return False
if self.count < count: if self.count < count:
return False return False
self.count -= count self.count -= count
if self.count == 0: if self.count == 0:
self.inventory.remove(self) self.container.remove(self)
return 0 return 0
return self.count 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): def attune(self):
if self.attuned: if self.attuned:
return False return False
if not self.item.requires_attunement: if not self.requires_attunement:
return False return False
if len(self.inventory.character.attuned_items) >= 3: if len(self.container.character.attuned_items) >= 3:
return False return False
self.attuned = True self.attuned = True
return True return True
@ -146,101 +305,17 @@ class InventoryMap(BaseObject):
self.attuned = False self.attuned = False
return True return True
def move_to(self, inventory): def move_to(self, target):
if inventory == self.inventory: target_inventory = getattr(target, 'inventory', target)
if self.container == target_inventory:
return False return False
self.inventory.remove(self) self.container.contents.remove(self)
self.inventory = inventory self.container = target_inventory
inventory.item_map.append(self) self.container.id = target_inventory.id
target_inventory.contents.append(self)
return True return True
def __getattr__(self, name: str): def __getattr__(self, name: str):
if name == sa_base.DEFAULT_STATE_ATTR: if name == sa_base.DEFAULT_STATE_ATTR:
raise AttributeError() raise AttributeError()
return getattr(self.item, name) return getattr(self.prototype, 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)

View File

@ -180,7 +180,7 @@ class ModifierMixin:
col = getattr(self.__table__.columns, f"_{attr_name}", None) col = getattr(self.__table__.columns, f"_{attr_name}", None)
if col is None: if col is None:
return None return None
for key in col.info.keys(): for key in getattr(col, "info", {}).keys():
if key.startswith("modifiable"): if key.startswith("modifiable"):
return col return col
return None # pragma: no cover return None # pragma: no cover
@ -282,7 +282,9 @@ class ModifierMixin:
self._get_modifiable_base(col.info.get("modifiable_base", col.name)), self._get_modifiable_base(col.info.get("modifiable_base", col.name)),
modifiable_class=col.info.get("modifiable_class", None), 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: class ConditionMixin:

View File

@ -5,12 +5,20 @@ from sqlalchemy.orm import Mapped, mapped_column, relationship
from ttfrog.db.base import BaseObject, EnumField from ttfrog.db.base import BaseObject, EnumField
from ttfrog.db.schema.classes import CharacterClass 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 from ttfrog.db.schema.modifiers import ModifierMixin
__all__ = [ __all__ = [
"Item", "ItemType",
"Spell", "ItemProperty",
"Rarity",
"RechargeTime",
"Cost",
"BaseItem",
"BaseSpell",
"Armor",
"Shield",
"Weapon",
] ]
@ -22,6 +30,7 @@ ITEM_TYPES = [
"ARMOR", "ARMOR",
"SHIELD", "SHIELD",
"CONTAINER", "CONTAINER",
"SPELLBOOK",
] ]
RECHARGE_TIMES = [ RECHARGE_TIMES = [
@ -50,12 +59,13 @@ def item_property_creator(fields):
return ItemProperty(**fields) return ItemProperty(**fields)
class Item(BaseObject, ModifierMixin): class BaseItem(BaseObject, ModifierMixin):
__tablename__ = "item" __tablename__ = "item_prototype"
__inventory_item_class__ = "Item"
__mapper_args__ = {"polymorphic_identity": ItemType.ITEM, "polymorphic_on": "item_type"} __mapper_args__ = {"polymorphic_identity": ItemType.ITEM, "polymorphic_on": "item_type"}
id: Mapped[int] = mapped_column(init=False, primary_key=True, autoincrement=True) 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) description: Mapped[str] = mapped_column(String, nullable=True, default=None)
item_type: Mapped[ItemType] = mapped_column(default=ItemType.ITEM, nullable=False) 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[int] = mapped_column(ForeignKey("spell.id"), nullable=True, default=None)
# spells: Mapped["Spell"] = relationship(init=False) # 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 @property
def has_charges(self): def has_charges(self):
return self.charges is not None 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} __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 item_type: Mapped[ItemType] = ItemType.SPELL
level: Mapped[int] = mapped_column(nullable=False, info={"min": 0, "max": 9}, default=0) level: Mapped[int] = mapped_column(nullable=False, info={"min": 0, "max": 9}, default=0)
concentration: Mapped[bool] = mapped_column(default=False) concentration: Mapped[bool] = mapped_column(default=False)
class Weapon(Item): class Weapon(BaseItem):
__tablename__ = "weapon" __tablename__ = "weapon"
__mapper_args__ = {"polymorphic_identity": ItemType.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 item_type: Mapped[ItemType] = ItemType.WEAPON
damage_die: Mapped[str] = mapped_column(nullable=False, default="1d6") damage_die: Mapped[str] = mapped_column(nullable=False, default="1d6")
@ -125,17 +142,17 @@ class Weapon(Item):
return self.attack_range > 0 return self.attack_range > 0
class Shield(Item): class Shield(BaseItem):
__tablename__ = "shield" __tablename__ = "shield"
__mapper_args__ = {"polymorphic_identity": ItemType.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 item_type: Mapped[ItemType] = ItemType.SHIELD
class Armor(Item): class Armor(BaseItem):
__tablename__ = "armor" __tablename__ = "armor"
__mapper_args__ = {"polymorphic_identity": ItemType.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 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) name: Mapped[str] = mapped_column(String(collation="NOCASE"), nullable=False, unique=True)
description: Mapped[str] = mapped_column(String, nullable=True, default=None) description: Mapped[str] = mapped_column(String, nullable=True, default=None)
charge_cost: Mapped[int] = mapped_column(nullable=True, info={"min": 1}, 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 # action/reaction/bonus
# modifiers? # modifiers?

View File

@ -1,5 +1,19 @@
from ttfrog.db.schema.container import Container from ttfrog.db.schema import prototypes
from ttfrog.db.schema.item import Item, ItemType, Spell 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): def test_equipment_inventory(db, carl):
@ -8,21 +22,18 @@ def test_equipment_inventory(db, carl):
db.add_or_update(carl) db.add_or_update(carl)
# create some items # create some items
ten_foot_pole = Item(name="10ft. Pole", item_type=ItemType.ITEM, consumable=False) ten_foot_pole = prototypes.BaseItem(name="10ft. Pole", item_type=prototypes.ItemType.ITEM, consumable=False)
fireball = Spell(name="Fireball", level=3, concentration=False) db.add_or_update(ten_foot_pole)
db.add_or_update([ten_foot_pole, fireball])
# add the pole to carl's equipment, and the spell to his spell list. # add the pole to carl's equipment, and the spell to his spell list.
assert carl.equipment.add(ten_foot_pole) assert carl.equipment.add(ten_foot_pole)
assert carl.spells.add(fireball)
# can't mix and match inventory item types # can't mix and match inventory item types
assert not carl.equipment.add(fireball)
assert not carl.spells.add(ten_foot_pole) assert not carl.spells.add(ten_foot_pole)
# add two more 10 foot poles. You can never have too many. # add two more 10 foot poles. You can never have too many.
carl.equipment.add(ten_foot_pole) assert carl.equipment.add(ten_foot_pole)
carl.equipment.add(ten_foot_pole) assert carl.equipment.add(ten_foot_pole)
db.add_or_update(carl) db.add_or_update(carl)
all_carls_poles = carl.equipment.get_all(ten_foot_pole) all_carls_poles = carl.equipment.get_all(ten_foot_pole)
@ -31,8 +42,6 @@ def test_equipment_inventory(db, carl):
# check the "contains" logic # check the "contains" logic
assert ten_foot_pole in carl.equipment assert ten_foot_pole in carl.equipment
assert ten_foot_pole not in carl.spells 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] pole_one = all_carls_poles[0]
@ -42,11 +51,6 @@ def test_equipment_inventory(db, carl):
# can't equip it twice # can't equip it twice
assert not pole_one.equip() 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 # not consumable or attunable
assert not pole_one.consume() assert not pole_one.consume()
assert not pole_one.attune() assert not pole_one.attune()
@ -60,6 +64,7 @@ def test_equipment_inventory(db, carl):
# drop one pole # drop one pole
assert carl.equipment.remove(pole_one) assert carl.equipment.remove(pole_one)
assert ten_foot_pole in carl.equipment assert ten_foot_pole in carl.equipment
return
# drop the remaining poles # drop the remaining poles
assert carl.equipment.remove(all_carls_poles[1]) assert carl.equipment.remove(all_carls_poles[1])
@ -72,10 +77,12 @@ def test_equipment_inventory(db, carl):
def test_inventory_bundles(db, carl): def test_inventory_bundles(db, carl):
with db.transaction(): 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]) db.add_or_update([carl, arrows])
quiver = carl.equipment.add(arrows) quiver = carl.equipment.add(arrows)
db.add_or_update(carl) db.add_or_update([carl, quiver])
assert quiver.container == carl.equipment
# full quiver # full quiver
assert arrows in carl.equipment assert arrows in carl.equipment
@ -98,8 +105,8 @@ def test_inventory_bundles(db, carl):
def test_spell_slots(db, carl, wizard): def test_spell_slots(db, carl, wizard):
with db.transaction(): with db.transaction():
prestidigitation = Spell(name="Prestidigitation", level=0, concentration=False) prestidigitation = prototypes.BaseSpell(name="Prestidigitation", level=0, concentration=False)
fireball = Spell(name="Fireball", level=3, concentration=False) fireball = prototypes.BaseSpell(name="Fireball", level=3, concentration=False)
db.add_or_update([carl, prestidigitation, fireball]) db.add_or_update([carl, prestidigitation, fireball])
carl.spells.add(prestidigitation) carl.spells.add(prestidigitation)
@ -160,45 +167,79 @@ def test_spell_slots(db, carl, wizard):
def test_containers(db, carl): def test_containers(db, carl):
with db.transaction(): with db.transaction():
ten_foot_pole = Item(name="10ft. Pole") ten_foot_pole = prototypes.BaseItem(name="10ft. Pole")
rope = Item(name="50 ft. of Rope", consumable=True, count=50) coil_of_rope = prototypes.BaseItem(name="50 ft. of Rope", consumable=True, count=50)
bag_of_holding = Container(name="Bag of Holding") bag_of_holding = prototypes.BaseItem(name="Bag of Holding", inventory_type=InventoryType.EQUIPMENT)
db.add_or_update([carl, ten_foot_pole, rope, bag_of_holding]) 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 # add some items to the bag of holding
assert bag_of_holding.add(ten_foot_pole) assert pole.move_to(bag)
assert bag_of_holding.add(rope) assert rope.move_to(bag)
db.add_or_update(bag_of_holding)
pole_from_bag = bag_of_holding.get(ten_foot_pole) assert pole.container.id == bag.inventory.id
rope_from_bag = bag_of_holding.get(rope) assert pole.container == bag.inventory
assert pole in bag.inventory
assert pole in bag
assert pole_from_bag.item == ten_foot_pole assert pole not in carl.equipment.contents
assert pole_from_bag in bag_of_holding assert pole.container == bag.inventory
assert pole_from_bag not in carl.equipment
# add the bag of holding to carl's equipment pole_from_bag = bag.inventory.get(ten_foot_pole)
assert carl.equipment.add(bag_of_holding) rope_from_bag = bag.inventory.get(coil_of_rope)
db.add_or_update(bag_of_holding)
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 pole_from_bag in carl.equipment
assert rope_from_bag in carl.equipment assert rope_from_bag in carl.equipment
# test equality of mappings # test equality of mappings
carls_bag = carl.equipment.get(bag_of_holding) carls_bag = carl.equipment.get(bag_of_holding)
carls_pole = carl.equipment.get(ten_foot_pole) 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_pole == pole_from_bag
assert carls_rope == rope_from_bag assert carls_rope == rope_from_bag
# use some rope # use some rope
carls_rope.consume(10) assert carls_rope.consume(10)
assert carls_rope.count == 40 assert carls_rope.count == 40
# move the rope out of the bag of holding, but not the pole # 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.move_to(carl.equipment)
assert carls_rope not in carls_bag assert carls_rope not in carls_bag
assert carls_pole 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 # 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 # 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 # use the rest of the rope
assert carls_rope.consume(40) == 0 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
assert rope_from_bag not in carl.equipment.get(bag_of_holding) assert rope_from_bag not in carl.equipment.get(bag_of_holding)

View File

@ -1,6 +1,7 @@
from ttfrog.db.schema import prototypes
from ttfrog.db.schema.constants import DamageType, Defenses 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.modifiers import Modifier
from ttfrog.db.schema.prototypes import Weapon
def test_weapons(db): def test_weapons(db):
@ -28,7 +29,6 @@ def test_weapons(db):
attack_range=20, attack_range=20,
attack_range_long=60, attack_range_long=60,
) )
db.add_or_update([longbow, dagger]) db.add_or_update([longbow, dagger])
assert longbow.martial assert longbow.martial
@ -42,7 +42,7 @@ def test_weapons(db):
def test_charges(db, carl): def test_charges(db, carl):
with db.transaction(): with db.transaction():
for_the_lulz = ItemProperty( for_the_lulz = prototypes.ItemProperty(
name="For the Lulz", name="For the Lulz",
description=""" description="""
On a hit against a creature with a mouth, spend one charge to force the target to roll a DC 13 Wisdom 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, charge_cost=2,
) )
# from sqlalchemy.orm import relationship dagger_of_lulz = prototypes.Weapon(
# help(relationship)
dagger_of_lulz = Weapon(
name="Dagger of Lulz", name="Dagger of Lulz",
description="This magical dagger has 6 charges. It regains 1d6 charges after a short rest.", description="This magical dagger has 6 charges. It regains 1d6 charges after a short rest.",
damage_die="1d4", damage_die="1d4",
@ -68,9 +65,9 @@ def test_charges(db, carl):
attack_range_long=60, attack_range_long=60,
magical=True, magical=True,
charges=6, charges=6,
recharge_time=RechargeTime.SHORT_REST, recharge_time=prototypes.RechargeTime.SHORT_REST,
recharge_amount="1d6", recharge_amount="1d6",
rarity=Rarity["Very Rare"], rarity=prototypes.Rarity["Very Rare"],
requires_attunement=True, requires_attunement=True,
properties=[for_the_lulz], properties=[for_the_lulz],
) )
@ -100,8 +97,10 @@ def test_charges(db, carl):
def test_nocharges(db, carl): def test_nocharges(db, carl):
smiles = ItemProperty(name="Smile!", description="The target grins for one minute.", charge_cost=None) smiles = prototypes.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]) wand_of_unlimited_smiles = prototypes.BaseItem(
name="Wand of Unlimited Smiles", description="description", properties=[smiles]
)
db.add_or_update(wand_of_unlimited_smiles) db.add_or_update(wand_of_unlimited_smiles)
carl.equipment.add(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): def test_attunement(db, carl):
with db.transaction(): with db.transaction():
helm = Armor( helm = prototypes.Armor(
name="Iron Helm", 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)) 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", name="Shield of Missile Attraction",
description=""" description="""
While holding this shield, you have resistance to damage from ranged weapon attacks. 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 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. 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, requires_attunement=True,
) )