Compare commits
3 Commits
b68dda2b77
...
708d6fe9e9
Author | SHA1 | Date | |
---|---|---|---|
|
708d6fe9e9 | ||
|
26bb645d22 | ||
|
3f45dbe9b9 |
|
@ -20,8 +20,10 @@ def load(data: str = ""):
|
|||
rogue = db.CharacterClass.filter_by(name="rogue").one()
|
||||
|
||||
sabetha = schema.Character("Sabetha", ancestry=tiefling, _intelligence=14)
|
||||
sabetha.add_class(fighter, level=2)
|
||||
sabetha.add_class(rogue, level=3)
|
||||
sabetha.add_class(fighter)
|
||||
sabetha.add_class(rogue)
|
||||
sabetha.level_up(fighter, 2)
|
||||
sabetha.level_up(rogue, 3)
|
||||
|
||||
bob = schema.Character("Bob", ancestry=human)
|
||||
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
import itertools
|
||||
from collections import defaultdict
|
||||
|
||||
from sqlalchemy import ForeignKey, String, Text, UniqueConstraint
|
||||
|
@ -7,6 +8,8 @@ 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
|
||||
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.skill import Skill
|
||||
|
||||
|
@ -33,6 +36,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
|
||||
|
@ -45,6 +54,17 @@ def attr_map_creator(fields):
|
|||
return CharacterClassFeatureMap(**fields)
|
||||
|
||||
|
||||
class SpellSlot(BaseObject):
|
||||
__tablename__ = "spell_slot"
|
||||
id: Mapped[int] = mapped_column(init=False, primary_key=True, autoincrement=True)
|
||||
character_id: Mapped[int] = mapped_column(ForeignKey("character.id"))
|
||||
character_class_id: Mapped[int] = mapped_column(ForeignKey("character_class.id"))
|
||||
character_class = relationship("CharacterClass", lazy="immediate")
|
||||
|
||||
spell_level: Mapped[int] = mapped_column(nullable=False, info={"min": 1, "max": 9})
|
||||
expended: Mapped[bool] = mapped_column(nullable=False, default=False)
|
||||
|
||||
|
||||
class HitDie(BaseObject):
|
||||
__tablename__ = "hit_die"
|
||||
id: Mapped[int] = mapped_column(init=False, primary_key=True, autoincrement=True)
|
||||
|
@ -137,14 +157,12 @@ class CharacterClassMap(BaseObject):
|
|||
__tablename__ = "class_map"
|
||||
__table_args__ = (UniqueConstraint("character_id", "character_class_id"),)
|
||||
id: Mapped[int] = mapped_column(init=False, primary_key=True, autoincrement=True)
|
||||
character: Mapped["Character"] = relationship(uselist=False, viewonly=True)
|
||||
character_class: Mapped["CharacterClass"] = relationship(lazy="immediate")
|
||||
|
||||
character_id: Mapped[int] = mapped_column(ForeignKey("character.id"), init=False, nullable=False)
|
||||
character_class_id: Mapped[int] = mapped_column(ForeignKey("character_class.id"), init=False, nullable=False)
|
||||
|
||||
character_id: Mapped[int] = mapped_column(ForeignKey("character.id"), nullable=False)
|
||||
character_class_id: Mapped[int] = mapped_column(ForeignKey("character_class.id"), nullable=False)
|
||||
level: Mapped[int] = mapped_column(nullable=False, info={"min": 1, "max": 20}, default=1)
|
||||
|
||||
character_class: Mapped["CharacterClass"] = relationship(lazy="immediate", init=False, viewonly=True)
|
||||
|
||||
|
||||
class CharacterClassFeatureMap(BaseObject):
|
||||
__tablename__ = "character_class_feature_map"
|
||||
|
@ -219,6 +237,9 @@ class Character(BaseObject, SlugMixin, ModifierMixin):
|
|||
_reactions_per_turn: Mapped[int] = mapped_column(
|
||||
nullable=False, default=1, info={"min": 0, "max": 99, "modifiable": True}
|
||||
)
|
||||
_attacks_per_action: Mapped[int] = mapped_column(
|
||||
nullable=False, default=1, info={"min": 0, "max": 99, "modifiable": True}
|
||||
)
|
||||
|
||||
vision: Mapped[int] = mapped_column(default=None, nullable=True, info={"min": 0, "modifiable": True})
|
||||
exhaustion: Mapped[int] = mapped_column(nullable=False, default=0, info={"min": 0, "max": 5})
|
||||
|
@ -235,12 +256,44 @@ class Character(BaseObject, SlugMixin, ModifierMixin):
|
|||
conditions = association_proxy("_conditions", "condition", creator=condition_creator)
|
||||
|
||||
character_class_feature_map = relationship("CharacterClassFeatureMap", cascade="all,delete,delete-orphan")
|
||||
feature_list = association_proxy("character_class_feature_map", "id", creator=attr_map_creator)
|
||||
features = association_proxy("character_class_feature_map", "id", creator=attr_map_creator)
|
||||
|
||||
ancestry_id: Mapped[int] = mapped_column(ForeignKey("ancestry.id"), nullable=False, default="1")
|
||||
ancestry: Mapped["Ancestry"] = relationship(uselist=False, default=None)
|
||||
|
||||
_inventories = relationship("Inventory", uselist=True, cascade="all,delete,delete-orphan", lazy="immediate")
|
||||
inventories = association_proxy("_inventories", "id", creator=inventory_creator)
|
||||
|
||||
_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 spells(self):
|
||||
return [inv for inv in self._inventories if inv.inventory_type == InventoryType.SPELL][0]
|
||||
|
||||
@property
|
||||
def prepared_spells(self):
|
||||
hashmap = dict([(mapping.item.name, mapping) for mapping in self.spells if mapping.prepared])
|
||||
return list(hashmap.values())
|
||||
|
||||
@property
|
||||
def spell_slots(self):
|
||||
return list(itertools.chain(*[slot for lvl, slot in self.spell_slots_by_level.items()]))
|
||||
|
||||
@property
|
||||
def spell_slots_by_level(self):
|
||||
pool = defaultdict(list)
|
||||
for slot in self._spell_slots:
|
||||
pool[slot.spell_level].append(slot)
|
||||
return pool
|
||||
|
||||
@property
|
||||
def spell_slots_available(self):
|
||||
available = defaultdict(list)
|
||||
for slot in self._spell_slots:
|
||||
if not slot.expended:
|
||||
available[slot.spell_level].append(slot)
|
||||
return available
|
||||
|
||||
@property
|
||||
def hit_dice(self):
|
||||
|
@ -319,10 +372,59 @@ class Character(BaseObject, SlugMixin, ModifierMixin):
|
|||
def levels(self):
|
||||
return dict([(mapping.character_class.name, mapping.level) for mapping in self.class_map])
|
||||
|
||||
@property
|
||||
def spellcaster_level(self):
|
||||
return max(slot.spell_level for slot in self.spell_slots)
|
||||
|
||||
@property
|
||||
def class_features(self):
|
||||
return dict([(mapping.class_feature.name, mapping.option) for mapping in self.character_class_feature_map])
|
||||
|
||||
@property
|
||||
def equipment(self):
|
||||
return [inv for inv in self._inventories if inv.inventory_type == InventoryType.EQUIPMENT][0]
|
||||
|
||||
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):
|
||||
mapping = [mapping for mapping in self.class_map if mapping.character_class_id == charclass.id]
|
||||
if not mapping:
|
||||
|
@ -373,30 +475,40 @@ class Character(BaseObject, SlugMixin, ModifierMixin):
|
|||
# return the initial value plus any modifiers.
|
||||
return self._apply_modifiers(f"{attr}_{'save' if save else 'check'}", initial)
|
||||
|
||||
def add_class(self, newclass, level=1):
|
||||
if level == 0:
|
||||
return self.remove_class(newclass)
|
||||
def level_up(self, charclass, num_levels=1):
|
||||
for _ in range(num_levels):
|
||||
self._level_up_once(charclass)
|
||||
return self.level_in_class(charclass)
|
||||
|
||||
# add the class mapping and/or set the character's level in the class
|
||||
mapping = self.level_in_class(newclass)
|
||||
if not mapping:
|
||||
self.class_list.append(CharacterClassMap(character=self, character_class=newclass, level=level))
|
||||
else:
|
||||
mapping.level = level
|
||||
def _level_up_once(self, charclass):
|
||||
current = self.level_in_class(charclass)
|
||||
if not current:
|
||||
return False
|
||||
current.level += 1
|
||||
|
||||
# add class features with default values
|
||||
for lvl in range(1, level + 1):
|
||||
for attr in newclass.features_at_level(lvl):
|
||||
self.add_class_feature(newclass, attr, attr.options[0])
|
||||
# add new features
|
||||
for feature in charclass.features_at_level(current.level):
|
||||
self.add_class_feature(charclass, feature, feature.options[0])
|
||||
|
||||
# add default class skills
|
||||
# add new spell slots
|
||||
for slot in charclass.spell_slots_by_level[current.level]:
|
||||
self._spell_slots.append(
|
||||
SpellSlot(character_id=self.id, character_class_id=charclass.id, spell_level=slot.spell_level)
|
||||
)
|
||||
|
||||
# add a new hit die
|
||||
self._hit_dice.append(HitDie(character_id=self.id, character_class_id=charclass.id))
|
||||
|
||||
return current
|
||||
|
||||
def add_class(self, newclass):
|
||||
if self.level_in_class(newclass):
|
||||
return False
|
||||
self.class_list.append(CharacterClassMap(character_id=self.id, character_class_id=newclass.id, level=0))
|
||||
self._level_up_once(newclass)
|
||||
for skill in newclass.skills[: newclass.starting_skills]:
|
||||
self.add_skill(skill, proficient=True, character_class=newclass)
|
||||
|
||||
# add hit dice
|
||||
existing = len([die for die in self._hit_dice if die.character_class_id == newclass.id])
|
||||
for lvl in range(level - existing):
|
||||
self._hit_dice.append(HitDie(character_id=self.id, character_class_id=newclass.id))
|
||||
return True
|
||||
|
||||
def remove_class(self, target):
|
||||
self.class_map = [m for m in self.class_map if m.character_class != target]
|
||||
|
@ -406,6 +518,7 @@ class Character(BaseObject, SlugMixin, ModifierMixin):
|
|||
for skill in target.skills:
|
||||
self.remove_skill(skill, proficient=True, expert=False, character_class=target)
|
||||
self._hit_dice = [die for die in self._hit_dice if die.character_class != target]
|
||||
self._spell_slots = [slot for slot in self._spell_slots if slot.character_class != target]
|
||||
|
||||
def remove_class_feature(self, feature):
|
||||
self.character_class_feature_map = [
|
||||
|
@ -421,9 +534,9 @@ class Character(BaseObject, SlugMixin, ModifierMixin):
|
|||
mapping = self.level_in_class(character_class)
|
||||
if not mapping:
|
||||
return False
|
||||
if feature not in mapping.character_class.features_at_level(mapping.level):
|
||||
if feature not in character_class.features_at_level(mapping.level):
|
||||
return False
|
||||
self.feature_list.append(
|
||||
self.features.append(
|
||||
CharacterClassFeatureMap(
|
||||
character_id=self.id,
|
||||
class_feature_id=feature.id,
|
||||
|
@ -540,6 +653,9 @@ class Character(BaseObject, SlugMixin, ModifierMixin):
|
|||
def spend_hit_die(self, die):
|
||||
die.spent = True
|
||||
|
||||
def expend_sell_splot(self, slot):
|
||||
slot.expended = True
|
||||
|
||||
def __after_insert__(self, session):
|
||||
"""
|
||||
Called by the session after_flush event listener to add default joins in other tables.
|
||||
|
@ -548,3 +664,7 @@ class Character(BaseObject, SlugMixin, ModifierMixin):
|
|||
Skill.name.in_(("strength", "dexterity", "constitution", "intelligence", "wisdom", "charisma"))
|
||||
):
|
||||
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))
|
||||
session.add(self)
|
||||
|
|
|
@ -12,6 +12,7 @@ __all__ = [
|
|||
"ClassFeatureMap",
|
||||
"ClassFeature",
|
||||
"ClassFeatureOption",
|
||||
"ClassSpellSlotMap",
|
||||
"CharacterClass",
|
||||
"Skill",
|
||||
"ClassSkillMap",
|
||||
|
@ -44,6 +45,14 @@ class ClassFeatureMap(BaseObject):
|
|||
feature = relationship("ClassFeature", uselist=False, viewonly=True, lazy="immediate")
|
||||
|
||||
|
||||
class ClassSpellSlotMap(BaseObject):
|
||||
__tablename__ = "class_spell_slot_map"
|
||||
id: Mapped[int] = mapped_column(init=False, primary_key=True, autoincrement=True)
|
||||
character_class_id: Mapped[int] = mapped_column(ForeignKey("character_class.id"))
|
||||
class_level: Mapped[int] = mapped_column(nullable=False, info={"min": 1, "max": 20}, default=1)
|
||||
spell_level: Mapped[int] = mapped_column(nullable=False, info={"min": 1, "max": 9}, default=1)
|
||||
|
||||
|
||||
class ClassFeature(BaseObject):
|
||||
__tablename__ = "class_feature"
|
||||
id: Mapped[int] = mapped_column(init=False, primary_key=True, autoincrement=True)
|
||||
|
@ -81,6 +90,7 @@ class CharacterClass(BaseObject):
|
|||
starting_skills: int = mapped_column(nullable=False, default=0)
|
||||
|
||||
features = relationship("ClassFeatureMap", cascade="all,delete,delete-orphan", lazy="immediate")
|
||||
spell_slots = relationship("ClassSpellSlotMap", cascade="all,delete,delete-orphan", lazy="immediate")
|
||||
|
||||
_skills = relationship("ClassSkillMap", cascade="all,delete,delete-orphan", lazy="immediate")
|
||||
skills = association_proxy("_skills", "skill", creator=skill_creator)
|
||||
|
@ -104,6 +114,16 @@ class CharacterClass(BaseObject):
|
|||
return True
|
||||
return False
|
||||
|
||||
@property
|
||||
def spell_slots_by_level(self):
|
||||
by_level = defaultdict(list)
|
||||
for mapping in self.spell_slots:
|
||||
by_level[mapping.class_level].append(mapping)
|
||||
return by_level
|
||||
|
||||
def spell_slots_at_level(self, level: int):
|
||||
return list(itertools.chain(*[mapping for lvl, mapping in self.spell_slots_by_level.items() if lvl <= level]))
|
||||
|
||||
@property
|
||||
def features_by_level(self):
|
||||
by_level = defaultdict(list)
|
||||
|
|
108
src/ttfrog/db/schema/inventory.py
Normal file
108
src/ttfrog/db/schema/inventory.py
Normal file
|
@ -0,0 +1,108 @@
|
|||
from sqlalchemy import ForeignKey, UniqueConstraint
|
||||
from sqlalchemy.ext.associationproxy import association_proxy
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from ttfrog.db.base import BaseObject, EnumField
|
||||
from ttfrog.db.schema.item import Item, ItemType
|
||||
|
||||
|
||||
class InventoryType(EnumField):
|
||||
EQUIPMENT = "EQUIPMENT"
|
||||
SPELL = "SPELL"
|
||||
|
||||
|
||||
inventory_type_map = {
|
||||
InventoryType.EQUIPMENT: [
|
||||
ItemType.ITEM,
|
||||
ItemType.SCROLL,
|
||||
],
|
||||
InventoryType.SPELL: [ItemType.SPELL],
|
||||
}
|
||||
|
||||
|
||||
def inventory_map_creator(fields):
|
||||
if isinstance(fields, InventoryMap):
|
||||
return fields
|
||||
return InventoryMap(**fields)
|
||||
|
||||
|
||||
class Inventory(BaseObject):
|
||||
__tablename__ = "inventory"
|
||||
__table_args__ = (UniqueConstraint("character_id", "inventory_type"),)
|
||||
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_map = relationship("InventoryMap", lazy="immediate", uselist=True, cascade="all,delete,delete-orphan")
|
||||
inventory_map = association_proxy("_inventory_map", "id", creator=inventory_map_creator)
|
||||
|
||||
def get(self, item):
|
||||
return [mapping for mapping in self._inventory_map 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
|
||||
self.inventory_map.append(mapping)
|
||||
return mapping
|
||||
|
||||
def remove(self, mapping):
|
||||
if mapping.id not in self.inventory_map:
|
||||
return False
|
||||
self.inventory_map.remove(mapping.id)
|
||||
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 __contains__(self, obj):
|
||||
for mapping in self._inventory_map:
|
||||
if mapping.item == obj:
|
||||
return True
|
||||
return False
|
||||
|
||||
def __iter__(self):
|
||||
yield from self._inventory_map
|
||||
|
||||
|
||||
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"))
|
||||
item: Mapped["Item"] = relationship(uselist=False, lazy="immediate", viewonly=True, init=False)
|
||||
inventory: Mapped["Inventory"] = relationship(uselist=False, viewonly=True, init=False)
|
||||
|
||||
equipped: Mapped[bool] = mapped_column(default=False)
|
||||
count: Mapped[int] = mapped_column(nullable=False, default=1)
|
||||
|
||||
always_prepared: Mapped[bool] = mapped_column(default=False)
|
||||
|
||||
@property
|
||||
def prepared(self):
|
||||
if self.item.item_type == ItemType.SPELL:
|
||||
return self.equipped or self.always_prepared
|
||||
|
||||
def use(self, count=1):
|
||||
if count < 0:
|
||||
return False
|
||||
if not self.item.consumable:
|
||||
return False
|
||||
if self.count < count:
|
||||
return False
|
||||
self.count -= count
|
||||
if self.count == 0:
|
||||
self.inventory.remove(self)
|
||||
return 0
|
||||
return self.count
|
35
src/ttfrog/db/schema/item.py
Normal file
35
src/ttfrog/db/schema/item.py
Normal file
|
@ -0,0 +1,35 @@
|
|||
from sqlalchemy import ForeignKey, String
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from ttfrog.db.base import BaseObject, EnumField
|
||||
from ttfrog.db.schema.modifiers import ConditionMixin
|
||||
|
||||
__all__ = [
|
||||
"Item",
|
||||
"Spell",
|
||||
]
|
||||
|
||||
|
||||
class ItemType(EnumField):
|
||||
ITEM = "ITEM"
|
||||
SPELL = "SPELL"
|
||||
SCROLL = "SCROLL"
|
||||
|
||||
|
||||
class Item(BaseObject, ConditionMixin):
|
||||
__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, unique=True)
|
||||
consumable: Mapped[bool] = mapped_column(default=False)
|
||||
count: Mapped[int] = mapped_column(nullable=False, default=1)
|
||||
item_type: Mapped[ItemType] = mapped_column(default=ItemType.ITEM, nullable=False)
|
||||
|
||||
|
||||
class Spell(Item):
|
||||
__tablename__ = "spell"
|
||||
__mapper_args__ = {"polymorphic_identity": ItemType.SPELL}
|
||||
id: Mapped[int] = mapped_column(ForeignKey("item.id"), primary_key=True, init=False)
|
||||
level: Mapped[int] = mapped_column(nullable=False, info={"min": 0, "max": 9}, default=0)
|
||||
concentration: Mapped[bool] = mapped_column(default=False)
|
||||
item_type: Mapped[ItemType] = mapped_column(default=ItemType.SPELL, init=False)
|
|
@ -282,16 +282,7 @@ class ModifierMixin:
|
|||
raise AttributeError(f"No such attribute on {self.__class__.__name__} object: {attr_name}.")
|
||||
|
||||
|
||||
class Condition(BaseObject):
|
||||
__tablename__ = "condition"
|
||||
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(default="")
|
||||
|
||||
_modifiers = relationship("Modifier", uselist=True, cascade="all,delete,delete-orphan")
|
||||
_parent_condition_id: Mapped[int] = mapped_column(ForeignKey("condition.id"), nullable=True, default=None)
|
||||
conditions = relationship("Condition", lazy="immediate", uselist=True)
|
||||
|
||||
class ConditionMixin:
|
||||
@property
|
||||
def modifiers(self):
|
||||
"""
|
||||
|
@ -331,6 +322,17 @@ class Condition(BaseObject):
|
|||
self.conditions = [c for c in self.conditions if c != condition]
|
||||
return True
|
||||
|
||||
|
||||
class Condition(BaseObject, ConditionMixin):
|
||||
__tablename__ = "condition"
|
||||
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(default="")
|
||||
|
||||
_modifiers = relationship("Modifier", uselist=True, cascade="all,delete,delete-orphan")
|
||||
_parent_condition_id: Mapped[int] = mapped_column(ForeignKey("condition.id"), nullable=True, default=None)
|
||||
conditions = relationship("Condition", lazy="immediate", uselist=True)
|
||||
|
||||
def __str___(self):
|
||||
return self.name
|
||||
|
||||
|
|
|
@ -91,16 +91,50 @@ def bootstrap(db):
|
|||
assert acrobatics in fighter.skills
|
||||
assert athletics in fighter.skills
|
||||
|
||||
wizard = schema.CharacterClass(
|
||||
"wizard",
|
||||
hit_die_name="1d6",
|
||||
hit_die_stat_name="_intelligence",
|
||||
)
|
||||
db.add_or_update(wizard)
|
||||
wizard.spell_slots = [
|
||||
schema.ClassSpellSlotMap(wizard.id, class_level=1, spell_level=1),
|
||||
schema.ClassSpellSlotMap(wizard.id, class_level=1, spell_level=1),
|
||||
schema.ClassSpellSlotMap(wizard.id, class_level=2, spell_level=1),
|
||||
schema.ClassSpellSlotMap(wizard.id, class_level=3, spell_level=1),
|
||||
schema.ClassSpellSlotMap(wizard.id, class_level=3, spell_level=2),
|
||||
schema.ClassSpellSlotMap(wizard.id, class_level=3, spell_level=2),
|
||||
schema.ClassSpellSlotMap(wizard.id, class_level=4, spell_level=2),
|
||||
schema.ClassSpellSlotMap(wizard.id, class_level=5, spell_level=3),
|
||||
schema.ClassSpellSlotMap(wizard.id, class_level=5, spell_level=3),
|
||||
schema.ClassSpellSlotMap(wizard.id, class_level=6, spell_level=3),
|
||||
schema.ClassSpellSlotMap(wizard.id, class_level=7, spell_level=4),
|
||||
]
|
||||
|
||||
rogue = schema.CharacterClass("rogue", hit_die_name="1d8", hit_die_stat_name="_dexterity")
|
||||
db.add_or_update([rogue, fighter])
|
||||
db.add_or_update([rogue, fighter, wizard])
|
||||
|
||||
# characters
|
||||
foo = schema.Character("Foo", ancestry=tiefling, _intelligence=14)
|
||||
db.add_or_update(foo)
|
||||
foo.add_class(fighter, level=2)
|
||||
foo.add_class(rogue, level=3)
|
||||
# create a character: Carl the Wizard
|
||||
carl = schema.Character("Carl", ancestry=tiefling, _intelligence=14)
|
||||
carl.add_class(wizard)
|
||||
db.add_or_update(carl)
|
||||
|
||||
bar = schema.Character("Bar", ancestry=human)
|
||||
|
||||
# persist all the records we've created
|
||||
db.add_or_update([foo, bar])
|
||||
@pytest.fixture
|
||||
def carl(db, bootstrap):
|
||||
return db.Character.filter_by(name="Carl").one()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def wizard(db, bootstrap):
|
||||
return db.CharacterClass.filter_by(name="wizard").one()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def tiefling(db, bootstrap):
|
||||
return db.Ancestry.filter_by(name="tiefling").one()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def human(db, bootstrap):
|
||||
return db.Ancestry.filter_by(name="human").one()
|
||||
|
|
147
test/test_inventories.py
Normal file
147
test/test_inventories.py
Normal file
|
@ -0,0 +1,147 @@
|
|||
from ttfrog.db.schema.item import Item, ItemType, Spell
|
||||
|
||||
|
||||
def test_equipment_inventory(db, carl):
|
||||
with db.transaction():
|
||||
# trigger the creation of inventory mappings
|
||||
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])
|
||||
|
||||
# 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)
|
||||
db.add_or_update(carl)
|
||||
|
||||
all_carls_poles = carl.equipment.get(ten_foot_pole)
|
||||
assert len(all_carls_poles) == 3
|
||||
|
||||
# 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
|
||||
|
||||
# equip one pole
|
||||
assert carl.equip(all_carls_poles[0])
|
||||
|
||||
# can't equip it twice
|
||||
assert not carl.equip(all_carls_poles[0])
|
||||
|
||||
# unequip it
|
||||
assert carl.unequip(all_carls_poles[0])
|
||||
|
||||
# can't unequip the unequipped ones
|
||||
assert not carl.unequip(all_carls_poles[1])
|
||||
assert not carl.unequip(all_carls_poles[2])
|
||||
|
||||
# drop one pole
|
||||
assert carl.equipment.remove(all_carls_poles[0])
|
||||
assert ten_foot_pole in carl.equipment
|
||||
|
||||
# drop the remaining poles
|
||||
assert carl.equipment.remove(all_carls_poles[1])
|
||||
assert carl.equipment.remove(all_carls_poles[2])
|
||||
assert ten_foot_pole not in carl.equipment
|
||||
|
||||
# can't drop what you don't have
|
||||
assert not carl.equipment.remove(all_carls_poles[0])
|
||||
|
||||
|
||||
def test_inventory_bundles(db, carl):
|
||||
with db.transaction():
|
||||
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)
|
||||
|
||||
# full quiver
|
||||
assert arrows in carl.equipment
|
||||
assert quiver.count == 20
|
||||
|
||||
# use one
|
||||
assert quiver.use(1) == 19
|
||||
assert quiver.count == 19
|
||||
|
||||
# cannot use more than you have
|
||||
assert not quiver.use(20)
|
||||
|
||||
# cannot use a negative amount
|
||||
assert not quiver.use(-1)
|
||||
|
||||
# consume all remaining arrows
|
||||
assert quiver.use(19) == 0
|
||||
assert arrows not in carl.equipment
|
||||
|
||||
|
||||
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)
|
||||
db.add_or_update([carl, prestidigitation, fireball])
|
||||
|
||||
carl.spells.add(prestidigitation)
|
||||
carl.spells.add(fireball)
|
||||
db.add_or_update(carl)
|
||||
|
||||
# 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 carl.spell_slots[0].spell_level == 1
|
||||
assert carl.spell_slots[1].spell_level == 1
|
||||
|
||||
# carl knows the spells but hasn't prepared them
|
||||
assert prestidigitation in carl.spells
|
||||
assert fireball in carl.spells
|
||||
assert prestidigitation not in carl.prepared_spells
|
||||
assert fireball not in carl.prepared_spells
|
||||
|
||||
# prepare the cantrip
|
||||
carls_prestidigitation = carl.spells.get(prestidigitation)[0]
|
||||
assert carl.prepare(carls_prestidigitation)
|
||||
assert carl.cast(carls_prestidigitation)
|
||||
|
||||
# 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
|
||||
assert carl.spellcaster_level == 1
|
||||
assert not carl.prepare(carls_fireball)
|
||||
|
||||
# make carl a 5th level wizard so he gets a 3rd level spell slot
|
||||
carl.level_up(wizard, num_levels=4)
|
||||
assert carl.level == 5
|
||||
assert carl.spellcaster_level == 3
|
||||
|
||||
# cast fireball until he's out of 3rd level slots
|
||||
assert carl.prepare(carls_fireball)
|
||||
assert carl.cast(carls_fireball)
|
||||
assert carl.cast(carls_fireball)
|
||||
assert not carl.cast(carls_fireball)
|
||||
|
||||
# level up to 7th level, gaining 1 4th level slot and 1 more 3rd level slot
|
||||
carl.add_class(wizard)
|
||||
carl.level_up(wizard, num_levels=2)
|
||||
assert carl.spellcaster_level == 4
|
||||
assert len(carl.spell_slots_available[4]) == 1
|
||||
assert len(carl.spell_slots_available[3]) == 1
|
||||
|
||||
# cast at 4th level
|
||||
assert carl.cast(carls_fireball, 4)
|
||||
assert not carl.cast(carls_fireball, 4)
|
||||
|
||||
# use the last 3rd level slot
|
||||
assert carl.cast(carls_fireball)
|
||||
assert not carl.cast(carls_fireball)
|
|
@ -12,7 +12,7 @@ def test_manage_character(db, bootstrap):
|
|||
# create a human character (the default)
|
||||
char = schema.Character(name="Test Character", ancestry=human)
|
||||
db.add_or_update(char)
|
||||
assert char.id == 3
|
||||
assert char.id
|
||||
assert char.name == "Test Character"
|
||||
assert char.ancestry.name == "human"
|
||||
assert char.armor_class == 10
|
||||
|
@ -67,7 +67,7 @@ def test_manage_character(db, bootstrap):
|
|||
rogue = db.CharacterClass.filter_by(name="rogue").one()
|
||||
|
||||
# assign a class and level
|
||||
char.add_class(fighter, level=1)
|
||||
assert char.add_class(fighter)
|
||||
db.add_or_update(char)
|
||||
assert char.levels == {"fighter": 1}
|
||||
assert char.level == 1
|
||||
|
@ -81,7 +81,7 @@ def test_manage_character(db, bootstrap):
|
|||
assert char.class_features == {}
|
||||
|
||||
# level up
|
||||
char.add_class(fighter, level=7)
|
||||
char.level_up(fighter, num_levels=6)
|
||||
db.add_or_update(char)
|
||||
assert char.levels == {"fighter": 7}
|
||||
assert char.level == 7
|
||||
|
@ -109,7 +109,7 @@ def test_manage_character(db, bootstrap):
|
|||
char._dexterity = 10
|
||||
|
||||
# multiclass
|
||||
char.add_class(rogue, level=1)
|
||||
char.add_class(rogue)
|
||||
db.add_or_update(char)
|
||||
assert char.level == 8
|
||||
assert char.levels == {"fighter": 7, "rogue": 1}
|
||||
|
@ -132,8 +132,7 @@ def test_manage_character(db, bootstrap):
|
|||
assert char.hit_dice["fighter"][0].name == fighter.hit_die_name == "1d10"
|
||||
assert char.hit_dice["fighter"][0].stat == fighter.hit_die_stat_name == "_constitution"
|
||||
|
||||
# remove remaining class by setting level to zero
|
||||
char.add_class(fighter, level=0)
|
||||
char.remove_class(fighter)
|
||||
db.add_or_update(char)
|
||||
assert char.levels == {}
|
||||
assert char.class_features == {}
|
||||
|
@ -205,13 +204,9 @@ def test_ancestries(db, bootstrap):
|
|||
assert grognak.check_modifier(strength, save=True) == 1
|
||||
|
||||
|
||||
def test_modifiers(db, bootstrap):
|
||||
def test_modifiers(db, bootstrap, carl, tiefling, human, wizard):
|
||||
with db.transaction():
|
||||
human = db.Ancestry.filter_by(name="human").one()
|
||||
tiefling = db.Ancestry.filter_by(name="tiefling").one()
|
||||
|
||||
# no modifiers; speed is ancestry speed
|
||||
carl = schema.Character(name="Carl", ancestry=tiefling)
|
||||
marx = schema.Character(name="Marx", ancestry=human)
|
||||
db.add_or_update([carl, marx])
|
||||
assert carl.speed == carl.ancestry.speed == 30
|
||||
|
@ -304,41 +299,41 @@ def test_modifiers(db, bootstrap):
|
|||
assert carl.add_modifier(temp_proficiency)
|
||||
assert carl.check_modifier(athletics) == carl.expertise_bonus + carl.strength.bonus == 2
|
||||
assert carl.remove_modifier(temp_proficiency)
|
||||
assert carl.check_modifier(athletics) == 0
|
||||
|
||||
# fighters get proficiency in athletics by default
|
||||
fighter = db.CharacterClass.filter_by(name="fighter").one()
|
||||
carl.add_class(fighter)
|
||||
db.add_or_update(carl)
|
||||
assert carl.check_modifier(athletics) == 1
|
||||
assert carl.check_modifier(athletics) == carl.proficiency_bonus
|
||||
|
||||
# add the skill directly, which will grant proficiency but will not stack with proficiency from the class
|
||||
carl.add_skill(athletics, proficient=True)
|
||||
db.add_or_update(carl)
|
||||
assert len([s for s in carl.skills if s == athletics]) == 2
|
||||
assert carl.check_modifier(athletics) == 1
|
||||
assert carl.check_modifier(athletics) == 2
|
||||
|
||||
# manually override proficiency with expertise
|
||||
carl.add_skill(athletics, expert=True)
|
||||
assert carl.check_modifier(athletics) == 2
|
||||
assert carl.check_modifier(athletics) == 2 * carl.proficiency_bonus
|
||||
assert len([s for s in carl.skills if s == athletics]) == 2
|
||||
|
||||
# remove expertise
|
||||
carl.add_skill(athletics, proficient=True, expert=False)
|
||||
assert carl.check_modifier(athletics) == 1
|
||||
assert carl.check_modifier(athletics) == carl.proficiency_bonus
|
||||
|
||||
# remove the extra skill entirely, but the fighter proficiency remains
|
||||
carl.remove_skill(athletics, proficient=True, expert=False, character_class=None)
|
||||
assert len([s for s in carl.skills if s == athletics]) == 1
|
||||
assert carl.check_modifier(athletics) == 1
|
||||
assert carl.check_modifier(athletics) == carl.proficiency_bonus
|
||||
|
||||
# ensure you can't remove a skill already removed
|
||||
assert not carl.remove_skill(athletics, proficient=True, expert=False, character_class=None)
|
||||
|
||||
|
||||
def test_defenses(db, bootstrap):
|
||||
def test_defenses(db, bootstrap, tiefling, carl):
|
||||
with db.transaction():
|
||||
tiefling = db.Ancestry.filter_by(name="tiefling").one()
|
||||
carl = schema.Character(name="Carl", ancestry=tiefling)
|
||||
db.add_or_update(carl)
|
||||
assert carl.resistant(DamageType.fire)
|
||||
carl.apply_damage(5, DamageType.fire)
|
||||
assert carl.hit_points == 8 # half damage
|
||||
|
@ -387,13 +382,12 @@ def test_defenses(db, bootstrap):
|
|||
assert carl.hit_points == 8 # half damage
|
||||
|
||||
|
||||
def test_condition_immunity(db, bootstrap):
|
||||
def test_condition_immunity(db, bootstrap, carl, tiefling):
|
||||
"""
|
||||
Test immunities prevent conditions from being applied
|
||||
"""
|
||||
with db.transaction():
|
||||
tiefling = db.Ancestry.filter_by(name="tiefling").one()
|
||||
carl = schema.Character(name="Carl", ancestry=tiefling)
|
||||
db.add_or_update(carl)
|
||||
poisoned = schema.Condition(name=DamageType.poison)
|
||||
poison_immunity = schema.Modifier("Poison Immunity", target=DamageType.poison, new_value=Defenses.immune)
|
||||
db.add_or_update([carl, poisoned, poison_immunity])
|
||||
|
@ -423,11 +417,13 @@ def test_condition_immunity(db, bootstrap):
|
|||
assert carl.has_condition(poisoned)
|
||||
|
||||
|
||||
def test_partial_immunities(db, bootstrap):
|
||||
def test_partial_immunities(db, bootstrap, carl, tiefling):
|
||||
"""
|
||||
Test that individual modifiers applied by a condition can be negated even if not immune to the condition.
|
||||
"""
|
||||
with db.transaction():
|
||||
db.add_or_update(carl)
|
||||
|
||||
# Create some modifiers and conditions for this test
|
||||
fly = schema.Modifier(target="fly_speed", absolute_value=30, name="Fly Spell")
|
||||
cannot_move = schema.Modifier(name="Cannot Move (Petrified", target="speed", absolute_value=0)
|
||||
|
@ -449,10 +445,6 @@ def test_partial_immunities(db, bootstrap):
|
|||
assert petrified.add_condition(incapacitated)
|
||||
db.add_or_update([fly, cannot_move, poisoned, poison_immunity, incapacitated, petrified])
|
||||
|
||||
# hi carl
|
||||
tiefling = db.Ancestry.filter_by(name="tiefling").one()
|
||||
carl = schema.Character(name="Carl", ancestry=tiefling)
|
||||
|
||||
# carl casts fly!
|
||||
assert carl.fly_speed is None
|
||||
assert carl.add_modifier(fly)
|
||||
|
|
Loading…
Reference in New Issue
Block a user