Compare commits

..

No commits in common. "9bece1550d5e29adf810b757ad4930d3c7257535" and "926d2fdaf6addd34217d0df73100898cbc26b372" have entirely different histories.

9 changed files with 300 additions and 436 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, inspect
from sqlalchemy import Column, String
from sqlalchemy.orm import DeclarativeBase, MappedAsDataclass
@ -53,17 +53,6 @@ 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 @@
import itertools
from collections import defaultdict
from dataclasses import dataclass
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.ext.declarative import declared_attr
from sqlalchemy.orm import Mapped, mapped_column, relationship
from ttfrog.db.base import BaseObject, SlugMixin
from ttfrog.db.schema.classes import CharacterClass, ClassFeature
from ttfrog.db.schema.constants import DamageType, Defenses, InventoryType
from ttfrog.db.schema.inventory import InventoryMixin
from ttfrog.db.schema.constants import DamageType, Defenses
from ttfrog.db.schema.inventory import Inventory, InventoryMap, InventoryType
from ttfrog.db.schema.modifiers import Modifier, ModifierMixin, Stat
from ttfrog.db.schema.skill import Skill
@ -37,6 +37,12 @@ def skill_creator(fields):
return CharacterSkillMap(**fields)
def inventory_creator(fields):
if isinstance(fields, InventoryMap):
return fields
return InventoryMap(**fields)
def condition_creator(fields):
if isinstance(fields, CharacterConditionMap):
return fields
@ -189,32 +195,6 @@ 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"
@ -282,35 +262,24 @@ 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)
_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",
_inventories: Mapped[List["Inventory"]] = relationship(
uselist=True, cascade="all,delete,delete-orphan", lazy="immediate", default_factory=lambda: []
)
_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")
@property
def equipment(self):
return self._equipment.inventory
@cached_property
def inventories(self):
return dict([(inventory.inventory_type, inventory) for inventory in self._inventories])
@property
@cached_property
def spells(self):
return self._spells.inventory
return self.inventories[InventoryType.SPELL]
@property
def prepared_spells(self):
hashmap = dict([(mapping.item.name, mapping) for mapping in self.spells.contents if mapping.prepared])
hashmap = dict([(mapping.item.name, mapping) for mapping in self.spells if mapping.prepared])
return list(hashmap.values())
@property
@ -364,10 +333,10 @@ class Character(BaseObject, SlugMixin, ModifierMixin):
merge_modifiers(self.traits)
merge_modifiers(self.conditions)
for item in self.equipped_items:
for target, mods in item.modifiers.items():
for mapping in self.equipped_items:
for target, mods in mapping.item.modifiers.items():
for mod in mods:
if mod.requires_attunement and not item.attuned:
if mod.requires_attunement and not mapping.attuned:
continue
unified[target].append(mod)
@ -429,13 +398,17 @@ 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.contents if item.equipped]
return [item for item in self.equipment if item.equipped]
@property
def attuned_items(self):
return [item for item in self.equipment.contents if item.attuned]
return [item for item in self.equipment if item.attuned]
def attune(self, mapping):
if mapping.attuned:
@ -693,6 +666,6 @@ class Character(BaseObject, SlugMixin, ModifierMixin):
):
self.add_skill(skill, proficient=False, expert=False)
self._equipment = CharacterItemInventory(character_id=self.id)
self._spells = CharacterSpellInventory(character_id=self.id)
self._inventories.append(Inventory(inventory_type=InventoryType.EQUIPMENT, character_id=self.id))
self._inventories.append(Inventory(inventory_type=InventoryType.SPELL, character_id=self.id))
session.add(self)

View File

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

View File

@ -0,0 +1,36 @@
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,254 +1,66 @@
from dataclasses import dataclass
from typing import List
from typing import List, Union
from sqlalchemy import ForeignKey
from sqlalchemy.ext.declarative import declared_attr
from sqlalchemy import ForeignKey, UniqueConstraint
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
from ttfrog.db.schema import prototypes
from ttfrog.db.schema.constants import InventoryType
from ttfrog.db.schema.modifiers import ModifierMixin
from ttfrog.db.base import BaseObject, EnumField
from ttfrog.db.schema.item import Item, ItemProperty, ItemType
class InventoryType(EnumField):
EQUIPMENT = "EQUIPMENT"
SPELL = "SPELL"
inventory_type_map = {
InventoryType.EQUIPMENT: [
prototypes.ItemType.WEAPON,
prototypes.ItemType.ARMOR,
prototypes.ItemType.SHIELD,
prototypes.ItemType.ITEM,
prototypes.ItemType.SCROLL,
prototypes.ItemType.CONTAINER,
ItemType.WEAPON,
ItemType.ARMOR,
ItemType.SHIELD,
ItemType.ITEM,
ItemType.SCROLL,
ItemType.CONTAINER,
],
InventoryType.SPELL: [prototypes.ItemType.SPELL],
InventoryType.SPELL: [ItemType.SPELL],
}
def inventory_map_creator(fields):
# if isinstance(fields, Item):
# if isinstance(fields, InventoryMap):
# return fields
# return Item(**fields)
return Item(**fields)
# return InventoryMap(**fields)
return InventoryMap(**fields)
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_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"
class InventoryMap(BaseObject):
__tablename__ = "inventory_map"
id: Mapped[int] = mapped_column(init=False, primary_key=True, autoincrement=True)
inventory_id: Mapped[int] = mapped_column(ForeignKey("inventory.id"))
item_id: Mapped[int] = mapped_column(ForeignKey("item.id"))
expended: Mapped[bool] = mapped_column(nullable=False, default=False)
item: Mapped["Item"] = relationship(uselist=False, lazy="immediate", viewonly=True, init=False)
inventory: Mapped["Inventory"] = relationship(uselist=False, viewonly=True, init=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
@ -261,7 +73,19 @@ class Item(BaseObject, InventoryMixin, InventoryItemMixin, ModifierMixin):
self.equipped = False
return True
def use(self, item_property: prototypes.ItemProperty, charges=None):
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):
if item_property.charge_cost is None:
return True
avail = self.charges_available
@ -276,22 +100,42 @@ class Item(BaseObject, InventoryMixin, InventoryItemMixin, ModifierMixin):
def consume(self, count=1):
if count < 0:
return False
if not self.prototype.consumable:
if not self.item.consumable:
return False
if self.count < count:
return False
self.count -= count
if self.count == 0:
self.container.remove(self)
self.inventory.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.requires_attunement:
if not self.item.requires_attunement:
return False
if len(self.container.character.attuned_items) >= 3:
if len(self.inventory.character.attuned_items) >= 3:
return False
self.attuned = True
return True
@ -302,17 +146,101 @@ class Item(BaseObject, InventoryMixin, InventoryItemMixin, ModifierMixin):
self.attuned = False
return True
def move_to(self, target):
target_inventory = getattr(target, "inventory", target)
if self.container == target_inventory:
def move_to(self, inventory):
if inventory == self.inventory:
return False
self.container.contents.remove(self)
self.container = target_inventory
self.container.id = target_inventory.id
target_inventory.contents.append(self)
self.inventory.remove(self)
self.inventory = inventory
inventory.item_map.append(self)
return True
def __getattr__(self, name: str):
if name == sa_base.DEFAULT_STATE_ATTR:
raise AttributeError()
return getattr(self.prototype, name)
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)

View File

@ -5,20 +5,12 @@ 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, InventoryType
from ttfrog.db.schema.constants import DamageType
from ttfrog.db.schema.modifiers import ModifierMixin
__all__ = [
"ItemType",
"ItemProperty",
"Rarity",
"RechargeTime",
"Cost",
"BaseItem",
"BaseSpell",
"Armor",
"Shield",
"Weapon",
"Item",
"Spell",
]
@ -30,7 +22,6 @@ ITEM_TYPES = [
"ARMOR",
"SHIELD",
"CONTAINER",
"SPELLBOOK",
]
RECHARGE_TIMES = [
@ -59,13 +50,12 @@ def item_property_creator(fields):
return ItemProperty(**fields)
class BaseItem(BaseObject, ModifierMixin):
__tablename__ = "item_prototype"
__inventory_item_class__ = "Item"
class Item(BaseObject, ModifierMixin):
__tablename__ = "item"
__mapper_args__ = {"polymorphic_identity": ItemType.ITEM, "polymorphic_on": "item_type"}
id: Mapped[int] = mapped_column(init=False, primary_key=True, autoincrement=True)
name: Mapped[str] = mapped_column(String(collation="NOCASE"), nullable=False)
name: Mapped[str] = mapped_column(String(collation="NOCASE"), nullable=False, unique=True)
description: Mapped[str] = mapped_column(String, nullable=True, default=None)
item_type: Mapped[ItemType] = mapped_column(default=ItemType.ITEM, nullable=False)
@ -89,32 +79,25 @@ class BaseItem(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 BaseSpell(BaseItem):
__tablename__ = "spell_prototype"
__inventory_item_class__ = "Spell"
class Spell(Item):
__tablename__ = "spell"
__mapper_args__ = {"polymorphic_identity": ItemType.SPELL}
id: Mapped[int] = mapped_column(ForeignKey("item_prototype.id"), primary_key=True, init=False)
id: Mapped[int] = mapped_column(ForeignKey("item.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(BaseItem):
class Weapon(Item):
__tablename__ = "weapon"
__mapper_args__ = {"polymorphic_identity": ItemType.WEAPON}
id: Mapped[int] = mapped_column(ForeignKey("item_prototype.id"), primary_key=True, init=False)
id: Mapped[int] = mapped_column(ForeignKey("item.id"), primary_key=True, init=False)
item_type: Mapped[ItemType] = ItemType.WEAPON
damage_die: Mapped[str] = mapped_column(nullable=False, default="1d6")
@ -142,17 +125,17 @@ class Weapon(BaseItem):
return self.attack_range > 0
class Shield(BaseItem):
class Shield(Item):
__tablename__ = "shield"
__mapper_args__ = {"polymorphic_identity": ItemType.SHIELD}
id: Mapped[int] = mapped_column(ForeignKey("item_prototype.id"), primary_key=True, init=False)
id: Mapped[int] = mapped_column(ForeignKey("item.id"), primary_key=True, init=False)
item_type: Mapped[ItemType] = ItemType.SHIELD
class Armor(BaseItem):
class Armor(Item):
__tablename__ = "armor"
__mapper_args__ = {"polymorphic_identity": ItemType.ARMOR}
id: Mapped[int] = mapped_column(ForeignKey("item_prototype.id"), primary_key=True, init=False)
id: Mapped[int] = mapped_column(ForeignKey("item.id"), primary_key=True, init=False)
item_type: Mapped[ItemType] = ItemType.ARMOR
@ -162,7 +145,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_prototype_id: Mapped[int] = mapped_column(ForeignKey("item_prototype.id"), default=0)
item_id: Mapped[int] = mapped_column(ForeignKey("item.id"), default=0)
# action/reaction/bonus
# modifiers?

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 getattr(col, "info", {}).keys():
for key in col.info.keys():
if key.startswith("modifiable"):
return col
return None # pragma: no cover
@ -282,9 +282,7 @@ 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 either does not have the attribute '{attr_name}', or an error occurred when accessing it."
)
raise AttributeError(f"{self.__class__.__name__} object does not have the attribute '{attr_name}'")
class ConditionMixin:

View File

@ -1,18 +1,5 @@
from ttfrog.db.schema import prototypes
from ttfrog.db.schema.inventory import InventoryType
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
from ttfrog.db.schema.container import Container
from ttfrog.db.schema.item import Item, ItemType, Spell
def test_equipment_inventory(db, carl):
@ -21,18 +8,21 @@ def test_equipment_inventory(db, carl):
db.add_or_update(carl)
# create some items
ten_foot_pole = prototypes.BaseItem(name="10ft. Pole", item_type=prototypes.ItemType.ITEM, consumable=False)
db.add_or_update(ten_foot_pole)
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])
# 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.
assert carl.equipment.add(ten_foot_pole)
assert carl.equipment.add(ten_foot_pole)
carl.equipment.add(ten_foot_pole)
carl.equipment.add(ten_foot_pole)
db.add_or_update(carl)
all_carls_poles = carl.equipment.get_all(ten_foot_pole)
@ -41,6 +31,8 @@ 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]
@ -50,6 +42,11 @@ 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()
@ -63,7 +60,6 @@ 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])
@ -76,12 +72,10 @@ def test_equipment_inventory(db, carl):
def test_inventory_bundles(db, carl):
with db.transaction():
arrows = prototypes.BaseItem(name="Arrows", item_type=prototypes.ItemType.ITEM, consumable=True, count=20)
arrows = Item(name="Arrows", item_type=ItemType.ITEM, consumable=True, count=20)
db.add_or_update([carl, arrows])
quiver = carl.equipment.add(arrows)
db.add_or_update([carl, quiver])
assert quiver.container == carl.equipment
db.add_or_update(carl)
# full quiver
assert arrows in carl.equipment
@ -104,8 +98,8 @@ def test_inventory_bundles(db, carl):
def test_spell_slots(db, carl, wizard):
with db.transaction():
prestidigitation = prototypes.BaseSpell(name="Prestidigitation", level=0, concentration=False)
fireball = prototypes.BaseSpell(name="Fireball", level=3, concentration=False)
prestidigitation = Spell(name="Prestidigitation", level=0, concentration=False)
fireball = Spell(name="Fireball", level=3, concentration=False)
db.add_or_update([carl, prestidigitation, fireball])
carl.spells.add(prestidigitation)
@ -166,77 +160,45 @@ def test_spell_slots(db, carl, wizard):
def test_containers(db, carl):
with db.transaction():
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
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])
# add some items to the bag of holding
assert pole.move_to(bag)
assert rope.move_to(bag)
assert bag_of_holding.add(ten_foot_pole)
assert bag_of_holding.add(rope)
db.add_or_update(bag_of_holding)
assert pole.container.id == bag.inventory.id
assert pole.container == bag.inventory
assert pole in bag.inventory
assert pole in bag
pole_from_bag = bag_of_holding.get(ten_foot_pole)
rope_from_bag = bag_of_holding.get(rope)
assert pole not in carl.equipment.contents
assert pole.container == bag.inventory
assert pole_from_bag.item == ten_foot_pole
assert pole_from_bag in bag_of_holding
assert pole_from_bag not in carl.equipment
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
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!
# add the bag of holding to carl's equipment
assert carl.equipment.add(bag_of_holding)
db.add_or_update(bag_of_holding)
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(coil_of_rope)
carls_rope = carl.equipment.get(rope)
assert carls_pole == pole_from_bag
assert carls_rope == rope_from_bag
# use some rope
assert carls_rope.consume(10)
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
@ -251,5 +213,6 @@ 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,7 +1,6 @@
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):
@ -29,6 +28,7 @@ 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 = prototypes.ItemProperty(
for_the_lulz = 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,7 +52,10 @@ def test_charges(db, carl):
charge_cost=2,
)
dagger_of_lulz = prototypes.Weapon(
# from sqlalchemy.orm import relationship
# help(relationship)
dagger_of_lulz = Weapon(
name="Dagger of Lulz",
description="This magical dagger has 6 charges. It regains 1d6 charges after a short rest.",
damage_die="1d4",
@ -65,9 +68,9 @@ def test_charges(db, carl):
attack_range_long=60,
magical=True,
charges=6,
recharge_time=prototypes.RechargeTime.SHORT_REST,
recharge_time=RechargeTime.SHORT_REST,
recharge_amount="1d6",
rarity=prototypes.Rarity["Very Rare"],
rarity=Rarity["Very Rare"],
requires_attunement=True,
properties=[for_the_lulz],
)
@ -97,10 +100,8 @@ def test_charges(db, carl):
def test_nocharges(db, carl):
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]
)
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])
db.add_or_update(wand_of_unlimited_smiles)
carl.equipment.add(wand_of_unlimited_smiles)
@ -114,13 +115,13 @@ def test_nocharges(db, carl):
def test_attunement(db, carl):
with db.transaction():
helm = prototypes.Armor(
helm = Armor(
name="Iron Helm",
rarity=prototypes.Rarity.Common,
rarity=Rarity.Common,
)
helm.add_modifier(Modifier("+1 AC (helmet)", target="armor_class", relative_value=1, stacks=True))
shield = prototypes.Shield(
shield = Shield(
name="Shield of Missile Attraction",
description="""
While holding this shield, you have resistance to damage from ranged weapon attacks.
@ -129,7 +130,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=prototypes.Rarity.Rare,
rarity=Rarity.Rare,
requires_attunement=True,
)