Compare commits

...

3 Commits

Author SHA1 Message Date
evilchili
708d6fe9e9 add spell inventory ui, refined level up ui, fixed bugs 2024-07-30 23:17:20 -07:00
evilchili
26bb645d22 added inventory management ui 2024-07-28 20:47:42 -07:00
evilchili
3f45dbe9b9 adding inventories 2024-07-28 13:55:19 -07:00
9 changed files with 536 additions and 76 deletions

View File

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

View File

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

View File

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

View 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

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

View File

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

View File

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

View File

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