Compare commits
7 Commits
9f75630c74
...
b09b07d172
Author | SHA1 | Date | |
---|---|---|---|
|
b09b07d172 | ||
|
01a4360dca | ||
|
709b0f5ad0 | ||
|
68a8f4920b | ||
|
0eda35b90d | ||
|
5c80565264 | ||
|
5d9fde949d |
|
@ -75,8 +75,9 @@ class SQLDatabaseManager:
|
||||||
return base64.urlsafe_b64encode(sha1bytes.digest()).decode("ascii")[:10]
|
return base64.urlsafe_b64encode(sha1bytes.digest()).decode("ascii")[:10]
|
||||||
|
|
||||||
def init(self):
|
def init(self):
|
||||||
self.session.configure(bind=self.engine)
|
|
||||||
self.metadata.bind = self.engine
|
self.metadata.bind = self.engine
|
||||||
|
self.session.remove()
|
||||||
|
self.session.configure(bind=self.engine)
|
||||||
self.metadata.create_all(self.engine)
|
self.metadata.create_all(self.engine)
|
||||||
|
|
||||||
def dump(self, names: list = []):
|
def dump(self, names: list = []):
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
from .character import *
|
from .character import *
|
||||||
from .classes import *
|
from .classes import *
|
||||||
from .constants import *
|
from .constants import *
|
||||||
|
from .container import *
|
||||||
from .log import *
|
from .log import *
|
||||||
from .modifiers import *
|
from .modifiers import *
|
||||||
from .skill import *
|
from .skill import *
|
||||||
|
|
|
@ -1,5 +1,7 @@
|
||||||
import itertools
|
import itertools
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
|
from functools import cached_property
|
||||||
|
from typing import List
|
||||||
|
|
||||||
from sqlalchemy import ForeignKey, String, Text, UniqueConstraint
|
from sqlalchemy import ForeignKey, String, Text, UniqueConstraint
|
||||||
from sqlalchemy.ext.associationproxy import association_proxy
|
from sqlalchemy.ext.associationproxy import association_proxy
|
||||||
|
@ -9,7 +11,6 @@ from ttfrog.db.base import BaseObject, SlugMixin
|
||||||
from ttfrog.db.schema.classes import CharacterClass, ClassFeature
|
from ttfrog.db.schema.classes import CharacterClass, ClassFeature
|
||||||
from ttfrog.db.schema.constants import DamageType, Defenses
|
from ttfrog.db.schema.constants import DamageType, Defenses
|
||||||
from ttfrog.db.schema.inventory import Inventory, InventoryMap, InventoryType
|
from ttfrog.db.schema.inventory import Inventory, InventoryMap, InventoryType
|
||||||
from ttfrog.db.schema.item import ItemType
|
|
||||||
from ttfrog.db.schema.modifiers import Modifier, ModifierMixin, Stat
|
from ttfrog.db.schema.modifiers import Modifier, ModifierMixin, Stat
|
||||||
from ttfrog.db.schema.skill import Skill
|
from ttfrog.db.schema.skill import Skill
|
||||||
|
|
||||||
|
@ -106,9 +107,9 @@ class Ancestry(BaseObject, ModifierMixin):
|
||||||
size: Mapped[str] = mapped_column(nullable=False, default="medium")
|
size: Mapped[str] = mapped_column(nullable=False, default="medium")
|
||||||
speed: Mapped[int] = mapped_column(nullable=False, default=30, info={"min": 0, "max": 99})
|
speed: Mapped[int] = mapped_column(nullable=False, default=30, info={"min": 0, "max": 99})
|
||||||
|
|
||||||
fly_speed: Mapped[int] = mapped_column(init=False, nullable=True, info={"min": 0, "max": 99})
|
fly_speed: Mapped[int] = mapped_column(nullable=True, info={"min": 0, "max": 99}, default=None)
|
||||||
climb_speed: Mapped[int] = mapped_column(init=False, nullable=True, info={"min": 0, "max": 99})
|
climb_speed: Mapped[int] = mapped_column(nullable=True, info={"min": 0, "max": 99}, default=None)
|
||||||
swim_speed: Mapped[int] = mapped_column(init=False, nullable=True, info={"min": 0, "max": 99})
|
swim_speed: Mapped[int] = mapped_column(nullable=True, info={"min": 0, "max": 99}, default=None)
|
||||||
|
|
||||||
_traits = relationship(
|
_traits = relationship(
|
||||||
"AncestryTraitMap", init=False, uselist=True, cascade="all,delete,delete-orphan", lazy="immediate"
|
"AncestryTraitMap", init=False, uselist=True, cascade="all,delete,delete-orphan", lazy="immediate"
|
||||||
|
@ -261,15 +262,20 @@ class Character(BaseObject, SlugMixin, ModifierMixin):
|
||||||
ancestry_id: Mapped[int] = mapped_column(ForeignKey("ancestry.id"), nullable=False, default="1")
|
ancestry_id: Mapped[int] = mapped_column(ForeignKey("ancestry.id"), nullable=False, default="1")
|
||||||
ancestry: Mapped["Ancestry"] = relationship(uselist=False, default=None)
|
ancestry: Mapped["Ancestry"] = relationship(uselist=False, default=None)
|
||||||
|
|
||||||
_inventories = relationship("Inventory", uselist=True, cascade="all,delete,delete-orphan", lazy="immediate")
|
_inventories: Mapped[List["Inventory"]] = relationship(
|
||||||
inventories = association_proxy("_inventories", "id", creator=inventory_creator)
|
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")
|
_hit_dice = relationship("HitDie", uselist=True, cascade="all,delete,delete-orphan", lazy="immediate")
|
||||||
_spell_slots = relationship("SpellSlot", uselist=True, cascade="all,delete,delete-orphan", lazy="immediate")
|
_spell_slots = relationship("SpellSlot", uselist=True, cascade="all,delete,delete-orphan", lazy="immediate")
|
||||||
|
|
||||||
@property
|
@cached_property
|
||||||
|
def inventories(self):
|
||||||
|
return dict([(inventory.inventory_type, inventory) for inventory in self._inventories])
|
||||||
|
|
||||||
|
@cached_property
|
||||||
def spells(self):
|
def spells(self):
|
||||||
return [inv for inv in self._inventories if inv.inventory_type == InventoryType.SPELL][0]
|
return self.inventories[InventoryType.SPELL]
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def prepared_spells(self):
|
def prepared_spells(self):
|
||||||
|
@ -392,9 +398,9 @@ class Character(BaseObject, SlugMixin, ModifierMixin):
|
||||||
def class_features(self):
|
def class_features(self):
|
||||||
return dict([(mapping.class_feature.name, mapping.option) for mapping in self.character_class_feature_map])
|
return dict([(mapping.class_feature.name, mapping.option) for mapping in self.character_class_feature_map])
|
||||||
|
|
||||||
@property
|
@cached_property
|
||||||
def equipment(self):
|
def equipment(self):
|
||||||
return [inv for inv in self._inventories if inv.inventory_type == InventoryType.EQUIPMENT][0]
|
return self.inventories[InventoryType.EQUIPMENT]
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def equipped_items(self):
|
def equipped_items(self):
|
||||||
|
@ -420,47 +426,6 @@ class Character(BaseObject, SlugMixin, ModifierMixin):
|
||||||
mapping.attuned = False
|
mapping.attuned = False
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def equip(self, mapping):
|
|
||||||
if mapping.equipped:
|
|
||||||
return False
|
|
||||||
mapping.equipped = True
|
|
||||||
return True
|
|
||||||
|
|
||||||
def unequip(self, mapping):
|
|
||||||
if not mapping.equipped:
|
|
||||||
return False
|
|
||||||
mapping.equipped = False
|
|
||||||
return True
|
|
||||||
|
|
||||||
def prepare(self, mapping):
|
|
||||||
if mapping.item.item_type != ItemType.SPELL:
|
|
||||||
return False
|
|
||||||
if mapping.item.level > 0 and not self.spell_slots_by_level[mapping.item.level]:
|
|
||||||
return False
|
|
||||||
return self.equip(mapping)
|
|
||||||
|
|
||||||
def unprepare(self, mapping):
|
|
||||||
if mapping.item.item_type != ItemType.SPELL:
|
|
||||||
return False
|
|
||||||
return self.unequip(mapping)
|
|
||||||
|
|
||||||
def cast(self, mapping: InventoryMap, level=0):
|
|
||||||
if not mapping.prepared:
|
|
||||||
return False
|
|
||||||
if not level:
|
|
||||||
level = mapping.item.level
|
|
||||||
|
|
||||||
# cantrips
|
|
||||||
if level == 0:
|
|
||||||
return True
|
|
||||||
|
|
||||||
# expend the spell slot
|
|
||||||
avail = self.spell_slots_available[level]
|
|
||||||
if not avail:
|
|
||||||
return False
|
|
||||||
avail[0].expended = True
|
|
||||||
return True
|
|
||||||
|
|
||||||
def level_in_class(self, charclass):
|
def level_in_class(self, charclass):
|
||||||
mapping = [mapping for mapping in self.class_map if mapping.character_class_id == charclass.id]
|
mapping = [mapping for mapping in self.class_map if mapping.character_class_id == charclass.id]
|
||||||
if not mapping:
|
if not mapping:
|
||||||
|
|
21
src/ttfrog/db/schema/container.py
Normal file
21
src/ttfrog/db/schema/container.py
Normal file
|
@ -0,0 +1,21 @@
|
||||||
|
from sqlalchemy import ForeignKey
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from ttfrog.db.schema.inventory import Inventory, 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),
|
||||||
|
)
|
|
@ -1,9 +1,10 @@
|
||||||
|
from typing import List
|
||||||
|
|
||||||
from sqlalchemy import ForeignKey, UniqueConstraint
|
from sqlalchemy import ForeignKey, UniqueConstraint
|
||||||
from sqlalchemy.ext.associationproxy import association_proxy
|
|
||||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
from ttfrog.db.base import BaseObject, EnumField
|
from ttfrog.db.base import BaseObject, EnumField
|
||||||
from ttfrog.db.schema.item import Item, ItemType
|
from ttfrog.db.schema.item import Item, ItemProperty, ItemType
|
||||||
|
|
||||||
|
|
||||||
class InventoryType(EnumField):
|
class InventoryType(EnumField):
|
||||||
|
@ -18,29 +19,62 @@ inventory_type_map = {
|
||||||
ItemType.SHIELD,
|
ItemType.SHIELD,
|
||||||
ItemType.ITEM,
|
ItemType.ITEM,
|
||||||
ItemType.SCROLL,
|
ItemType.SCROLL,
|
||||||
|
ItemType.CONTAINER,
|
||||||
],
|
],
|
||||||
InventoryType.SPELL: [ItemType.SPELL],
|
InventoryType.SPELL: [ItemType.SPELL],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def inventory_map_creator(fields):
|
def inventory_map_creator(fields):
|
||||||
if isinstance(fields, InventoryMap):
|
# if isinstance(fields, InventoryMap):
|
||||||
return fields
|
# return fields
|
||||||
|
# return InventoryMap(**fields)
|
||||||
return InventoryMap(**fields)
|
return InventoryMap(**fields)
|
||||||
|
|
||||||
|
|
||||||
class Inventory(BaseObject):
|
class Inventory(BaseObject):
|
||||||
__tablename__ = "inventory"
|
__tablename__ = "inventory"
|
||||||
__table_args__ = (UniqueConstraint("character_id", "inventory_type"),)
|
__table_args__ = (UniqueConstraint("character_id", "container_id", "inventory_type"),)
|
||||||
id: Mapped[int] = mapped_column(init=False, primary_key=True, autoincrement=True)
|
id: Mapped[int] = mapped_column(init=False, primary_key=True, autoincrement=True)
|
||||||
character_id: Mapped[int] = mapped_column(ForeignKey("character.id"))
|
|
||||||
inventory_type: Mapped[InventoryType] = mapped_column(nullable=False)
|
inventory_type: Mapped[InventoryType] = mapped_column(nullable=False)
|
||||||
|
|
||||||
_inventory_map = relationship("InventoryMap", lazy="immediate", uselist=True, cascade="all,delete,delete-orphan")
|
item_map: Mapped[List["InventoryMap"]] = relationship(
|
||||||
inventory_map = association_proxy("_inventory_map", "id", creator=inventory_map_creator)
|
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):
|
def get(self, item):
|
||||||
return [mapping for mapping in self._inventory_map if mapping.item == 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):
|
def add(self, item):
|
||||||
if item.item_type not in inventory_type_map[self.inventory_type]:
|
if item.item_type not in inventory_type_map[self.inventory_type]:
|
||||||
|
@ -48,23 +82,25 @@ class Inventory(BaseObject):
|
||||||
mapping = InventoryMap(inventory_id=self.id, item_id=item.id)
|
mapping = InventoryMap(inventory_id=self.id, item_id=item.id)
|
||||||
if item.consumable:
|
if item.consumable:
|
||||||
mapping.count = item.count
|
mapping.count = item.count
|
||||||
self.inventory_map.append(mapping)
|
if item.charges:
|
||||||
|
mapping.charges = [Charge(inventory_map_id=mapping.id) for i in range(item.charges)]
|
||||||
|
self.item_map.append(mapping)
|
||||||
return mapping
|
return mapping
|
||||||
|
|
||||||
def remove(self, mapping):
|
def remove(self, mapping):
|
||||||
if mapping.id not in self.inventory_map:
|
if mapping in self.item_map:
|
||||||
return False
|
self.item_map.remove(mapping)
|
||||||
self.inventory_map.remove(mapping.id)
|
return True
|
||||||
return True
|
return False
|
||||||
|
|
||||||
def __contains__(self, obj):
|
def __contains__(self, obj):
|
||||||
for mapping in self._inventory_map:
|
for item in self.all_items:
|
||||||
if mapping.item == obj:
|
if item == obj:
|
||||||
return True
|
return True
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def __iter__(self):
|
def __iter__(self):
|
||||||
yield from self._inventory_map
|
yield from self.all_items
|
||||||
|
|
||||||
|
|
||||||
class InventoryMap(BaseObject):
|
class InventoryMap(BaseObject):
|
||||||
|
@ -81,12 +117,56 @@ class InventoryMap(BaseObject):
|
||||||
|
|
||||||
always_prepared: Mapped[bool] = mapped_column(default=False)
|
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: []
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def charges_available(self):
|
||||||
|
return [charge for charge in self.charges if not charge.expended]
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def prepared(self):
|
def prepared(self):
|
||||||
if self.item.item_type == ItemType.SPELL:
|
if self.item.item_type == ItemType.SPELL:
|
||||||
return self.equipped or self.always_prepared
|
return self.equipped or self.always_prepared
|
||||||
|
|
||||||
def use(self, count=1):
|
def equip(self):
|
||||||
|
if self.equipped:
|
||||||
|
return False
|
||||||
|
self.equipped = True
|
||||||
|
return True
|
||||||
|
|
||||||
|
def unequip(self):
|
||||||
|
if not self.equipped:
|
||||||
|
return False
|
||||||
|
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):
|
||||||
|
if item_property.charge_cost is None:
|
||||||
|
return True
|
||||||
|
avail = self.charges_available
|
||||||
|
if charges is None:
|
||||||
|
charges = item_property.charge_cost
|
||||||
|
if len(avail) < charges:
|
||||||
|
return False
|
||||||
|
for charge in avail[:charges]:
|
||||||
|
charge.expended = True
|
||||||
|
return True
|
||||||
|
|
||||||
|
def consume(self, count=1):
|
||||||
if count < 0:
|
if count < 0:
|
||||||
return False
|
return False
|
||||||
if not self.item.consumable:
|
if not self.item.consumable:
|
||||||
|
@ -98,3 +178,46 @@ class InventoryMap(BaseObject):
|
||||||
self.inventory.remove(self)
|
self.inventory.remove(self)
|
||||||
return 0
|
return 0
|
||||||
return self.count
|
return self.count
|
||||||
|
|
||||||
|
def cast(self, level=0):
|
||||||
|
if self.item.item_type != ItemType.SPELL:
|
||||||
|
return False
|
||||||
|
|
||||||
|
if not self.prepared:
|
||||||
|
return False
|
||||||
|
if not level:
|
||||||
|
level = self.item.level
|
||||||
|
|
||||||
|
# cantrips
|
||||||
|
if level == 0:
|
||||||
|
return True
|
||||||
|
|
||||||
|
# expend the spell slot
|
||||||
|
avail = self.inventory.character.spell_slots_available[level]
|
||||||
|
if not avail:
|
||||||
|
return False
|
||||||
|
avail[0].expended = True
|
||||||
|
return True
|
||||||
|
|
||||||
|
def attune(self):
|
||||||
|
if self.attuned:
|
||||||
|
return False
|
||||||
|
if not self.item.requires_attunement:
|
||||||
|
return False
|
||||||
|
if len(self.inventory.character.attuned_items) >= 3:
|
||||||
|
return False
|
||||||
|
self.attuned = True
|
||||||
|
return True
|
||||||
|
|
||||||
|
def unattune(self):
|
||||||
|
if not self.attuned:
|
||||||
|
return False
|
||||||
|
self.attuned = False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
|
@ -1,3 +1,5 @@
|
||||||
|
from typing import List
|
||||||
|
|
||||||
from sqlalchemy import ForeignKey, String
|
from sqlalchemy import ForeignKey, String
|
||||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
@ -19,18 +21,39 @@ ITEM_TYPES = [
|
||||||
"WEAPON",
|
"WEAPON",
|
||||||
"ARMOR",
|
"ARMOR",
|
||||||
"SHIELD",
|
"SHIELD",
|
||||||
|
"CONTAINER",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
RECHARGE_TIMES = [
|
||||||
|
"short rest",
|
||||||
|
"long rest",
|
||||||
|
"dawn",
|
||||||
|
]
|
||||||
|
|
||||||
|
COST_TYPES = ["Action", "Bonus Action", "Reaction"]
|
||||||
|
|
||||||
RARITY = ["Common", "Uncommon", "Rare", "Very Rare", "Legendary", "Artifact"]
|
RARITY = ["Common", "Uncommon", "Rare", "Very Rare", "Legendary", "Artifact"]
|
||||||
|
|
||||||
|
|
||||||
ItemType = EnumField("ItemType", ((k, k) for k in ITEM_TYPES))
|
ItemType = EnumField("ItemType", ((k, k) for k in ITEM_TYPES))
|
||||||
Rarity = EnumField("Rarity", ((k, k) for k in RARITY))
|
Rarity = EnumField("Rarity", ((k, k) for k in RARITY))
|
||||||
|
RechargeTime = EnumField("RechargeTime", ((k.replace(" ", "_").upper(), k) for k in RECHARGE_TIMES))
|
||||||
|
Cost = EnumField("Cost", ((k, k) for k in COST_TYPES))
|
||||||
|
|
||||||
|
|
||||||
|
def item_property_creator(fields):
|
||||||
|
if isinstance(fields, list):
|
||||||
|
for f in fields:
|
||||||
|
yield f
|
||||||
|
elif isinstance(fields, ItemProperty):
|
||||||
|
return fields
|
||||||
|
return ItemProperty(**fields)
|
||||||
|
|
||||||
|
|
||||||
class Item(BaseObject, ModifierMixin):
|
class Item(BaseObject, ModifierMixin):
|
||||||
__tablename__ = "item"
|
__tablename__ = "item"
|
||||||
__mapper_args__ = {"polymorphic_identity": ItemType.ITEM, "polymorphic_on": "item_type"}
|
__mapper_args__ = {"polymorphic_identity": ItemType.ITEM, "polymorphic_on": "item_type"}
|
||||||
|
|
||||||
id: Mapped[int] = mapped_column(init=False, primary_key=True, autoincrement=True)
|
id: Mapped[int] = mapped_column(init=False, primary_key=True, autoincrement=True)
|
||||||
name: Mapped[str] = mapped_column(String(collation="NOCASE"), nullable=False, unique=True)
|
name: Mapped[str] = mapped_column(String(collation="NOCASE"), nullable=False, unique=True)
|
||||||
description: Mapped[str] = mapped_column(String, nullable=True, default=None)
|
description: Mapped[str] = mapped_column(String, nullable=True, default=None)
|
||||||
|
@ -42,29 +65,43 @@ class Item(BaseObject, ModifierMixin):
|
||||||
consumable: Mapped[bool] = mapped_column(default=False)
|
consumable: Mapped[bool] = mapped_column(default=False)
|
||||||
count: Mapped[int] = mapped_column(nullable=False, default=1)
|
count: Mapped[int] = mapped_column(nullable=False, default=1)
|
||||||
|
|
||||||
|
charges: Mapped[int] = mapped_column(nullable=True, info={"min": 0}, default=None)
|
||||||
|
recharge_time: Mapped[RechargeTime] = mapped_column(default=RechargeTime.LONG_REST)
|
||||||
|
recharge_amount: Mapped[str] = mapped_column(String(collation="NOCASE"), default="1")
|
||||||
|
|
||||||
_class_restrictions: Mapped[int] = mapped_column(ForeignKey("character_class.id"), nullable=True, default=None)
|
_class_restrictions: Mapped[int] = mapped_column(ForeignKey("character_class.id"), nullable=True, default=None)
|
||||||
class_restrictions: Mapped["CharacterClass"] = relationship(init=False)
|
class_restrictions: Mapped["CharacterClass"] = relationship(init=False)
|
||||||
|
|
||||||
|
properties: Mapped[List["ItemProperty"]] = relationship(
|
||||||
|
uselist=True, cascade="all,delete,delete-orphan", lazy="immediate", default_factory=lambda: []
|
||||||
|
)
|
||||||
|
|
||||||
# _spells: Mapped[int] = mapped_column(ForeignKey("spell.id"), nullable=True, default=None)
|
# _spells: Mapped[int] = mapped_column(ForeignKey("spell.id"), nullable=True, default=None)
|
||||||
# spells: Mapped["Spell"] = relationship(init=False)
|
# spells: Mapped["Spell"] = relationship(init=False)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def has_charges(self):
|
||||||
|
return self.charges is not None
|
||||||
|
|
||||||
|
|
||||||
class Spell(Item):
|
class Spell(Item):
|
||||||
__tablename__ = "spell"
|
__tablename__ = "spell"
|
||||||
__mapper_args__ = {"polymorphic_identity": ItemType.SPELL}
|
__mapper_args__ = {"polymorphic_identity": ItemType.SPELL}
|
||||||
id: Mapped[int] = mapped_column(ForeignKey("item.id"), primary_key=True, init=False)
|
id: Mapped[int] = mapped_column(ForeignKey("item.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)
|
level: Mapped[int] = mapped_column(nullable=False, info={"min": 0, "max": 9}, default=0)
|
||||||
concentration: Mapped[bool] = mapped_column(default=False)
|
concentration: Mapped[bool] = mapped_column(default=False)
|
||||||
item_type: Mapped[ItemType] = mapped_column(default=ItemType.SPELL, init=False)
|
|
||||||
|
|
||||||
|
|
||||||
class Weapon(Item):
|
class Weapon(Item):
|
||||||
__tablename__ = "weapon"
|
__tablename__ = "weapon"
|
||||||
__mapper_args__ = {"polymorphic_identity": ItemType.WEAPON}
|
__mapper_args__ = {"polymorphic_identity": ItemType.WEAPON}
|
||||||
id: Mapped[int] = mapped_column(ForeignKey("item.id"), primary_key=True, init=False)
|
id: Mapped[int] = mapped_column(ForeignKey("item.id"), primary_key=True, init=False)
|
||||||
|
item_type: Mapped[ItemType] = ItemType.WEAPON
|
||||||
|
|
||||||
damage_die: Mapped[str] = mapped_column(nullable=False, default="1d6")
|
damage_die: Mapped[str] = mapped_column(nullable=False, default="1d6")
|
||||||
damage_type: Mapped[DamageType] = mapped_column(nullable=False, default=DamageType.slashing)
|
damage_type: Mapped[DamageType] = mapped_column(nullable=False, default=DamageType.slashing)
|
||||||
item_type: Mapped[ItemType] = mapped_column(default=ItemType.WEAPON)
|
|
||||||
attack_range: Mapped[int] = mapped_column(nullable=False, info={"min": 0}, default=0)
|
attack_range: Mapped[int] = mapped_column(nullable=False, info={"min": 0}, default=0)
|
||||||
attack_range_long: Mapped[int] = mapped_column(nullable=True, info={"min": 0}, default=None)
|
attack_range_long: Mapped[int] = mapped_column(nullable=True, info={"min": 0}, default=None)
|
||||||
targets: Mapped[int] = mapped_column(nullable=False, info={"min": 1}, default=1)
|
targets: Mapped[int] = mapped_column(nullable=False, info={"min": 1}, default=1)
|
||||||
|
@ -81,7 +118,34 @@ class Weapon(Item):
|
||||||
versatile: Mapped[bool] = mapped_column(default=False)
|
versatile: Mapped[bool] = mapped_column(default=False)
|
||||||
silvered: Mapped[bool] = mapped_column(default=False)
|
silvered: Mapped[bool] = mapped_column(default=False)
|
||||||
adamantine: Mapped[bool] = mapped_column(default=False)
|
adamantine: Mapped[bool] = mapped_column(default=False)
|
||||||
|
magical: Mapped[bool] = mapped_column(default=False)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def ranged(self):
|
def ranged(self):
|
||||||
return self.attack_range > 0
|
return self.attack_range > 0
|
||||||
|
|
||||||
|
|
||||||
|
class Shield(Item):
|
||||||
|
__tablename__ = "shield"
|
||||||
|
__mapper_args__ = {"polymorphic_identity": ItemType.SHIELD}
|
||||||
|
id: Mapped[int] = mapped_column(ForeignKey("item.id"), primary_key=True, init=False)
|
||||||
|
item_type: Mapped[ItemType] = ItemType.SHIELD
|
||||||
|
|
||||||
|
|
||||||
|
class Armor(Item):
|
||||||
|
__tablename__ = "armor"
|
||||||
|
__mapper_args__ = {"polymorphic_identity": ItemType.ARMOR}
|
||||||
|
id: Mapped[int] = mapped_column(ForeignKey("item.id"), primary_key=True, init=False)
|
||||||
|
item_type: Mapped[ItemType] = ItemType.ARMOR
|
||||||
|
|
||||||
|
|
||||||
|
class ItemProperty(BaseObject):
|
||||||
|
__tablename__ = "item_property"
|
||||||
|
id: Mapped[int] = mapped_column(init=False, primary_key=True, autoincrement=True)
|
||||||
|
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)
|
||||||
|
|
||||||
|
# action/reaction/bonus
|
||||||
|
# modifiers?
|
||||||
|
|
|
@ -9,7 +9,7 @@ def test_dump_load(db, bootstrap):
|
||||||
|
|
||||||
# clear the database and reinitialize
|
# clear the database and reinitialize
|
||||||
db.metadata.drop_all(bind=db.engine)
|
db.metadata.drop_all(bind=db.engine)
|
||||||
db.init()
|
db.metadata.create_all(db.engine)
|
||||||
|
|
||||||
# load the dump
|
# load the dump
|
||||||
db.load(data)
|
db.load(data)
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
|
from ttfrog.db.schema.container import Container
|
||||||
from ttfrog.db.schema.item import Item, ItemType, Spell
|
from ttfrog.db.schema.item import Item, ItemType, Spell
|
||||||
|
|
||||||
|
|
||||||
|
@ -24,7 +25,7 @@ def test_equipment_inventory(db, carl):
|
||||||
carl.equipment.add(ten_foot_pole)
|
carl.equipment.add(ten_foot_pole)
|
||||||
db.add_or_update(carl)
|
db.add_or_update(carl)
|
||||||
|
|
||||||
all_carls_poles = carl.equipment.get(ten_foot_pole)
|
all_carls_poles = carl.equipment.get_all(ten_foot_pole)
|
||||||
assert len(all_carls_poles) == 3
|
assert len(all_carls_poles) == 3
|
||||||
|
|
||||||
# check the "contains" logic
|
# check the "contains" logic
|
||||||
|
@ -33,21 +34,31 @@ def test_equipment_inventory(db, carl):
|
||||||
assert fireball in carl.spells
|
assert fireball in carl.spells
|
||||||
assert fireball not in carl.equipment
|
assert fireball not in carl.equipment
|
||||||
|
|
||||||
|
pole_one = all_carls_poles[0]
|
||||||
|
|
||||||
# equip one pole
|
# equip one pole
|
||||||
assert carl.equip(all_carls_poles[0])
|
assert pole_one.equip()
|
||||||
|
|
||||||
# can't equip it twice
|
# can't equip it twice
|
||||||
assert not carl.equip(all_carls_poles[0])
|
assert not pole_one.equip()
|
||||||
|
|
||||||
# unequip it
|
# can't prepare or cast an item
|
||||||
assert carl.unequip(all_carls_poles[0])
|
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()
|
||||||
|
|
||||||
|
assert pole_one.unequip()
|
||||||
|
|
||||||
# can't unequip the unequipped ones
|
# can't unequip the unequipped ones
|
||||||
assert not carl.unequip(all_carls_poles[1])
|
assert not all_carls_poles[1].unequip()
|
||||||
assert not carl.unequip(all_carls_poles[2])
|
assert not all_carls_poles[2].unequip()
|
||||||
|
|
||||||
# drop one pole
|
# drop one pole
|
||||||
assert carl.equipment.remove(all_carls_poles[0])
|
assert carl.equipment.remove(pole_one)
|
||||||
assert ten_foot_pole in carl.equipment
|
assert ten_foot_pole in carl.equipment
|
||||||
|
|
||||||
# drop the remaining poles
|
# drop the remaining poles
|
||||||
|
@ -56,7 +67,7 @@ def test_equipment_inventory(db, carl):
|
||||||
assert ten_foot_pole not in carl.equipment
|
assert ten_foot_pole not in carl.equipment
|
||||||
|
|
||||||
# can't drop what you don't have
|
# can't drop what you don't have
|
||||||
assert not carl.equipment.remove(all_carls_poles[0])
|
assert not carl.equipment.remove(pole_one)
|
||||||
|
|
||||||
|
|
||||||
def test_inventory_bundles(db, carl):
|
def test_inventory_bundles(db, carl):
|
||||||
|
@ -71,17 +82,17 @@ def test_inventory_bundles(db, carl):
|
||||||
assert quiver.count == 20
|
assert quiver.count == 20
|
||||||
|
|
||||||
# use one
|
# use one
|
||||||
assert quiver.use(1) == 19
|
assert quiver.consume(1) == 19
|
||||||
assert quiver.count == 19
|
assert quiver.count == 19
|
||||||
|
|
||||||
# cannot use more than you have
|
# cannot use more than you have
|
||||||
assert not quiver.use(20)
|
assert not quiver.consume(20)
|
||||||
|
|
||||||
# cannot use a negative amount
|
# cannot use a negative amount
|
||||||
assert not quiver.use(-1)
|
assert not quiver.consume(-1)
|
||||||
|
|
||||||
# consume all remaining arrows
|
# consume all remaining arrows
|
||||||
assert quiver.use(19) == 0
|
assert quiver.consume(19) == 0
|
||||||
assert arrows not in carl.equipment
|
assert arrows not in carl.equipment
|
||||||
|
|
||||||
|
|
||||||
|
@ -96,8 +107,6 @@ def test_spell_slots(db, carl, wizard):
|
||||||
db.add_or_update(carl)
|
db.add_or_update(carl)
|
||||||
|
|
||||||
# verify carl has the spell slots granted by wizard at 1st level
|
# verify carl has the spell slots granted by wizard at 1st level
|
||||||
print(carl.levels)
|
|
||||||
print(carl.spell_slots)
|
|
||||||
assert len(carl.spell_slots) == 2
|
assert len(carl.spell_slots) == 2
|
||||||
assert carl.spell_slots[0].spell_level == 1
|
assert carl.spell_slots[0].spell_level == 1
|
||||||
assert carl.spell_slots[1].spell_level == 1
|
assert carl.spell_slots[1].spell_level == 1
|
||||||
|
@ -109,16 +118,13 @@ def test_spell_slots(db, carl, wizard):
|
||||||
assert fireball not in carl.prepared_spells
|
assert fireball not in carl.prepared_spells
|
||||||
|
|
||||||
# prepare the cantrip
|
# prepare the cantrip
|
||||||
carls_prestidigitation = carl.spells.get(prestidigitation)[0]
|
carls_prestidigitation = carl.spells.get(prestidigitation)
|
||||||
assert carl.prepare(carls_prestidigitation)
|
assert carls_prestidigitation.prepare()
|
||||||
assert carl.cast(carls_prestidigitation)
|
assert carls_prestidigitation.cast()
|
||||||
|
|
||||||
# prepare() and cast() require a spell from the spell inventory
|
|
||||||
carls_fireball = carl.spells.get(fireball)[0]
|
|
||||||
|
|
||||||
# can't prepare a 3rd level spell if you don't have 3rd level slots
|
# can't prepare a 3rd level spell if you don't have 3rd level slots
|
||||||
assert carl.spellcaster_level == 1
|
assert carl.spellcaster_level == 1
|
||||||
assert not carl.prepare(carls_fireball)
|
assert not carl.spells.get(fireball).prepare()
|
||||||
|
|
||||||
# make carl a 5th level wizard so he gets a 3rd level spell slot
|
# make carl a 5th level wizard so he gets a 3rd level spell slot
|
||||||
carl.level_up(wizard, num_levels=4)
|
carl.level_up(wizard, num_levels=4)
|
||||||
|
@ -126,10 +132,11 @@ def test_spell_slots(db, carl, wizard):
|
||||||
assert carl.spellcaster_level == 3
|
assert carl.spellcaster_level == 3
|
||||||
|
|
||||||
# cast fireball until he's out of 3rd level slots
|
# cast fireball until he's out of 3rd level slots
|
||||||
assert carl.prepare(carls_fireball)
|
assert not carl.spells.get(fireball).cast()
|
||||||
assert carl.cast(carls_fireball)
|
assert carl.spells.get(fireball).prepare()
|
||||||
assert carl.cast(carls_fireball)
|
assert carl.spells.get(fireball).cast()
|
||||||
assert not carl.cast(carls_fireball)
|
assert carl.spells.get(fireball).cast()
|
||||||
|
assert not carl.spells.get(fireball).cast()
|
||||||
|
|
||||||
# level up to 7th level, gaining 1 4th level slot and 1 more 3rd level slot
|
# level up to 7th level, gaining 1 4th level slot and 1 more 3rd level slot
|
||||||
carl.add_class(wizard)
|
carl.add_class(wizard)
|
||||||
|
@ -139,9 +146,41 @@ def test_spell_slots(db, carl, wizard):
|
||||||
assert len(carl.spell_slots_available[3]) == 1
|
assert len(carl.spell_slots_available[3]) == 1
|
||||||
|
|
||||||
# cast at 4th level
|
# cast at 4th level
|
||||||
assert carl.cast(carls_fireball, 4)
|
assert carl.spells.get(fireball).cast(level=4)
|
||||||
assert not carl.cast(carls_fireball, 4)
|
assert not carl.spells.get(fireball).cast(level=4)
|
||||||
|
|
||||||
# use the last 3rd level slot
|
# use the last 3rd level slot
|
||||||
assert carl.cast(carls_fireball)
|
assert carl.spells.get(fireball).cast()
|
||||||
assert not carl.cast(carls_fireball)
|
assert not carl.spells.get(fireball).cast()
|
||||||
|
|
||||||
|
# unprepare it
|
||||||
|
assert carl.spells.get(fireball).unprepare()
|
||||||
|
assert not carl.spells.get(fireball).unprepare()
|
||||||
|
|
||||||
|
|
||||||
|
def test_containers(db, carl):
|
||||||
|
with db.transaction():
|
||||||
|
ten_foot_pole = Item(name="10ft. Pole", item_type=ItemType.ITEM, consumable=False)
|
||||||
|
bag_of_holding = Container(name="Bag of Holding")
|
||||||
|
db.add_or_update([carl, ten_foot_pole, bag_of_holding])
|
||||||
|
|
||||||
|
# add the ten_foot_pole to the bag of holding
|
||||||
|
assert bag_of_holding.inventory.add(ten_foot_pole)
|
||||||
|
db.add_or_update(bag_of_holding)
|
||||||
|
pole_from_bag = bag_of_holding.inventory.get(ten_foot_pole)
|
||||||
|
assert pole_from_bag
|
||||||
|
assert pole_from_bag in bag_of_holding.inventory
|
||||||
|
assert pole_from_bag not in carl.equipment
|
||||||
|
|
||||||
|
# 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
|
||||||
|
|
||||||
|
# test equality of mappings
|
||||||
|
carls_bag = carl.equipment.get(bag_of_holding)
|
||||||
|
carls_pole = carl.equipment.get(ten_foot_pole)
|
||||||
|
assert carls_pole == pole_from_bag
|
||||||
|
|
||||||
|
# remove the pole from the bag
|
||||||
|
assert carls_bag.item.inventory.remove(pole_from_bag)
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
from ttfrog.db.schema.constants import DamageType, Defenses
|
from ttfrog.db.schema.constants import DamageType, Defenses
|
||||||
from ttfrog.db.schema.item import Item, ItemType, Rarity, Weapon
|
from ttfrog.db.schema.item import Armor, Item, ItemProperty, Rarity, RechargeTime, Shield, Weapon
|
||||||
from ttfrog.db.schema.modifiers import Modifier
|
from ttfrog.db.schema.modifiers import Modifier
|
||||||
|
|
||||||
|
|
||||||
|
@ -40,16 +40,88 @@ def test_weapons(db):
|
||||||
assert dagger.melee
|
assert dagger.melee
|
||||||
|
|
||||||
|
|
||||||
|
def test_charges(db, carl):
|
||||||
|
with db.transaction():
|
||||||
|
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
|
||||||
|
saving throw. On a failure, the target is forced to grin for one minute. While grinning, the target
|
||||||
|
cannot speak. The target can repeat the saving throw at the start of their turn."
|
||||||
|
""",
|
||||||
|
charge_cost=2,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 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",
|
||||||
|
damage_type=DamageType.slashing,
|
||||||
|
melee=True,
|
||||||
|
finesse=True,
|
||||||
|
light=True,
|
||||||
|
thrown=True,
|
||||||
|
attack_range=20,
|
||||||
|
attack_range_long=60,
|
||||||
|
magical=True,
|
||||||
|
charges=6,
|
||||||
|
recharge_time=RechargeTime.SHORT_REST,
|
||||||
|
recharge_amount="1d6",
|
||||||
|
rarity=Rarity["Very Rare"],
|
||||||
|
requires_attunement=True,
|
||||||
|
properties=[for_the_lulz],
|
||||||
|
)
|
||||||
|
db.add_or_update([carl, dagger_of_lulz])
|
||||||
|
|
||||||
|
assert for_the_lulz in dagger_of_lulz.properties
|
||||||
|
|
||||||
|
assert carl.equipment.add(dagger_of_lulz)
|
||||||
|
db.add_or_update(carl)
|
||||||
|
|
||||||
|
carls_dagger = carl.equipment.get(dagger_of_lulz)
|
||||||
|
assert carls_dagger.equip()
|
||||||
|
assert carls_dagger.attune()
|
||||||
|
|
||||||
|
assert len(carls_dagger.charges) == dagger_of_lulz.charges == 6
|
||||||
|
assert len(carls_dagger.charges_available) == dagger_of_lulz.charges == 6
|
||||||
|
assert carls_dagger.use(for_the_lulz)
|
||||||
|
assert len(carls_dagger.charges_available) == 4
|
||||||
|
|
||||||
|
# use the remaining charges
|
||||||
|
assert carls_dagger.use(for_the_lulz)
|
||||||
|
assert carls_dagger.use(for_the_lulz)
|
||||||
|
|
||||||
|
# all out of charges
|
||||||
|
assert len(carls_dagger.charges_available) == 0
|
||||||
|
assert not carls_dagger.use(for_the_lulz)
|
||||||
|
|
||||||
|
|
||||||
|
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])
|
||||||
|
db.add_or_update(wand_of_unlimited_smiles)
|
||||||
|
|
||||||
|
carl.equipment.add(wand_of_unlimited_smiles)
|
||||||
|
db.add_or_update(carl)
|
||||||
|
|
||||||
|
# no charges means you can use it at will
|
||||||
|
assert carl.equipment.get(wand_of_unlimited_smiles).use(smiles)
|
||||||
|
assert carl.equipment.get(wand_of_unlimited_smiles).use(smiles)
|
||||||
|
assert carl.equipment.get(wand_of_unlimited_smiles).use(smiles)
|
||||||
|
|
||||||
|
|
||||||
def test_attunement(db, carl):
|
def test_attunement(db, carl):
|
||||||
with db.transaction():
|
with db.transaction():
|
||||||
helm = Item(
|
helm = Armor(
|
||||||
name="Iron Helm",
|
name="Iron Helm",
|
||||||
item_type=ItemType.ARMOR,
|
|
||||||
rarity=Rarity.Common,
|
rarity=Rarity.Common,
|
||||||
)
|
)
|
||||||
helm.add_modifier(Modifier("+1 AC (helmet)", target="armor_class", relative_value=1, stacks=True))
|
helm.add_modifier(Modifier("+1 AC (helmet)", target="armor_class", relative_value=1, stacks=True))
|
||||||
|
|
||||||
shield = Item(
|
shield = Shield(
|
||||||
name="Shield of Missile Attraction",
|
name="Shield of Missile Attraction",
|
||||||
description="""
|
description="""
|
||||||
While holding this shield, you have resistance to damage from ranged weapon attacks.
|
While holding this shield, you have resistance to damage from ranged weapon attacks.
|
||||||
|
@ -58,7 +130,6 @@ def test_attunement(db, carl):
|
||||||
or similar magic. Removing the shield fails to end the curse on you. Whenever a ranged weapon attack is made
|
or similar magic. Removing the shield fails to end the curse on you. Whenever a ranged weapon attack is made
|
||||||
against a target within 10 feet of you, the curse causes you to become the target instead.
|
against a target within 10 feet of you, the curse causes you to become the target instead.
|
||||||
""",
|
""",
|
||||||
item_type=ItemType.SHIELD,
|
|
||||||
rarity=Rarity.Rare,
|
rarity=Rarity.Rare,
|
||||||
requires_attunement=True,
|
requires_attunement=True,
|
||||||
)
|
)
|
||||||
|
@ -79,29 +150,43 @@ def test_attunement(db, carl):
|
||||||
assert carl.equipment.add(helm)
|
assert carl.equipment.add(helm)
|
||||||
db.add_or_update(carl)
|
db.add_or_update(carl)
|
||||||
|
|
||||||
carls_shield = carl.equipment.get(shield)[0]
|
carls_shield = carl.equipment.get(shield)
|
||||||
|
|
||||||
assert carl.armor_class == 10
|
assert carl.armor_class == 10
|
||||||
assert len(carl.attuned_items) == 0
|
assert len(carl.attuned_items) == 0
|
||||||
|
|
||||||
carl.equip(carls_shield)
|
carls_shield.equip()
|
||||||
|
|
||||||
assert plus_two_ac in carl.modifiers["armor_class"]
|
assert plus_two_ac in carl.modifiers["armor_class"]
|
||||||
assert ranged_resistance not in carl.modifiers[DamageType.ranged_weapon_attacks]
|
assert ranged_resistance not in carl.modifiers[DamageType.ranged_weapon_attacks]
|
||||||
assert carl.armor_class == 12
|
assert carl.armor_class == 12
|
||||||
assert carls_shield not in carl.attuned_items
|
assert carls_shield not in carl.attuned_items
|
||||||
|
|
||||||
carl.attune(carls_shield)
|
assert carls_shield.attune()
|
||||||
|
assert not carls_shield.attune()
|
||||||
assert carl.armor_class == 12
|
assert carl.armor_class == 12
|
||||||
assert plus_two_ac in carl.modifiers["armor_class"]
|
assert plus_two_ac in carl.modifiers["armor_class"]
|
||||||
assert ranged_resistance in carl.modifiers[DamageType.ranged_weapon_attacks]
|
assert ranged_resistance in carl.modifiers[DamageType.ranged_weapon_attacks]
|
||||||
assert carls_shield in carl.attuned_items
|
assert carls_shield in carl.attuned_items
|
||||||
|
|
||||||
assert carl.equip(carl.equipment.get(helm)[0])
|
assert carl.equipment.get(helm).equip()
|
||||||
assert carl.armor_class == 13
|
assert carl.armor_class == 13
|
||||||
|
|
||||||
assert carl.unattune(carls_shield)
|
assert carls_shield.unattune()
|
||||||
|
assert not carls_shield.unattune()
|
||||||
assert carl.armor_class == 13
|
assert carl.armor_class == 13
|
||||||
assert ranged_resistance not in carl.modifiers[DamageType.ranged_weapon_attacks]
|
assert ranged_resistance not in carl.modifiers[DamageType.ranged_weapon_attacks]
|
||||||
assert carl.unequip(carls_shield)
|
assert carls_shield.unequip()
|
||||||
assert carl.armor_class == 11
|
assert carl.armor_class == 11
|
||||||
|
|
||||||
|
# can only attune 3 items
|
||||||
|
assert carl.equipment.add(shield)
|
||||||
|
assert carl.equipment.add(shield)
|
||||||
|
assert carl.equipment.add(shield)
|
||||||
|
db.add_or_update(carl)
|
||||||
|
|
||||||
|
assert carl.equipment.get_all(shield)[0].attune()
|
||||||
|
assert carl.equipment.get_all(shield)[1].attune()
|
||||||
|
assert carl.equipment.get_all(shield)[2].attune()
|
||||||
|
assert len(carl.attuned_items) == 3
|
||||||
|
assert not carl.equipment.get_all(shield)[3].attune()
|
||||||
|
|
Loading…
Reference in New Issue
Block a user