reimplementing inventories
This commit is contained in:
parent
926d2fdaf6
commit
1b9ff9b393
|
@ -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):
|
||||
"""
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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)
|
|
@ -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)
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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?
|
|
@ -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)
|
||||
|
|
|
@ -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,
|
||||
)
|
||||
|
||||
|
|
Loading…
Reference in New Issue
Block a user