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
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):
"""

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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