add support for skills

This commit is contained in:
evilchili 2024-05-06 00:13:52 -07:00
parent b574dacfa1
commit 9a2d28ae75
8 changed files with 278 additions and 113 deletions

View File

@ -54,36 +54,6 @@ class BaseObject(MappedAsDataclass, DeclarativeBase):
return str(dict(self)) return str(dict(self))
def multivalue_string_factory(name, column=Column(String), separator=";"):
"""
Generate a mixin class that adds a string column with getters and setters
that convert list values to strings and back again. Equivalent to:
class MultiValueString:
_name = column
@property
def name_property(self):
return self._name.split(';')
@name.setter
def name(self, val):
return ';'.join(val)
"""
attr = f"_{name}"
prop = property(lambda self: getattr(self, attr).split(separator))
setter = prop.setter(lambda self, val: setattr(self, attr, separator.join(val)))
return type(
"MultiValueString",
(object,),
{
attr: column,
f"{name}_property": prop,
name: setter,
},
)
class EnumField(enum.Enum): class EnumField(enum.Enum):
""" """
A serializable enum. A serializable enum.
@ -93,9 +63,6 @@ class EnumField(enum.Enum):
return self.value return self.value
SavingThrowsMixin = multivalue_string_factory("saving_throws")
SkillsMixin = multivalue_string_factory("skills")
STATS = ["STR", "DEX", "CON", "INT", "WIS", "CHA"] STATS = ["STR", "DEX", "CON", "INT", "WIS", "CHA"]
CREATURE_TYPES = [ CREATURE_TYPES = [
"aberation", "aberation",

View File

@ -7,7 +7,7 @@ from functools import cached_property
import transaction import transaction
from pyramid_sqlalchemy.meta import Session from pyramid_sqlalchemy.meta import Session
from sqlalchemy import create_engine from sqlalchemy import create_engine, event
import ttfrog.db.schema import ttfrog.db.schema
from ttfrog.path import database from ttfrog.path import database
@ -92,3 +92,15 @@ class SQLDatabaseManager:
db = SQLDatabaseManager() db = SQLDatabaseManager()
@event.listens_for(db.session, "after_flush")
def session_after_flush(session, flush_context):
"""
Listen to flush events looking for newly-created objects. For each one, if the
obj has a __after_insert__ method, call it.
"""
for obj in session.new:
callback = getattr(obj, "__after_insert__", None)
if callback:
callback(session)

View File

@ -1,10 +1,11 @@
from sqlalchemy import ForeignKey, Text, UniqueConstraint from sqlalchemy import ForeignKey, String, Text, UniqueConstraint
from sqlalchemy.ext.associationproxy import association_proxy 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, SavingThrowsMixin, SkillsMixin, SlugMixin from ttfrog.db.base import BaseObject, SlugMixin
from ttfrog.db.schema.classes import CharacterClass, ClassAttribute from ttfrog.db.schema.classes import CharacterClass, ClassAttribute
from ttfrog.db.schema.modifiers import Modifier, ModifierMixin, Stat from ttfrog.db.schema.modifiers import Modifier, ModifierMixin, Stat
from ttfrog.db.schema.property import Skill
__all__ = [ __all__ = [
"Ancestry", "Ancestry",
@ -23,6 +24,12 @@ def class_map_creator(fields):
return CharacterClassMap(**fields) return CharacterClassMap(**fields)
def skill_creator(fields):
if isinstance(fields, CharacterSkillMap):
return fields
return CharacterSkillMap(**fields)
def attr_map_creator(fields): def attr_map_creator(fields):
if isinstance(fields, CharacterClassAttributeMap): if isinstance(fields, CharacterClassAttributeMap):
return fields return fields
@ -46,7 +53,7 @@ class Ancestry(BaseObject, ModifierMixin):
__tablename__ = "ancestry" __tablename__ = "ancestry"
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(unique=True, nullable=False) name: Mapped[str] = mapped_column(String(collation="NOCASE"), nullable=False, unique=True)
creature_type: Mapped[str] = mapped_column(nullable=False, default="humanoid") creature_type: Mapped[str] = mapped_column(nullable=False, default="humanoid")
size: Mapped[str] = mapped_column(nullable=False, default="medium") size: Mapped[str] = mapped_column(nullable=False, default="medium")
@ -97,13 +104,26 @@ class AncestryTrait(BaseObject, ModifierMixin):
__tablename__ = "ancestry_trait" __tablename__ = "ancestry_trait"
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(nullable=False) name: Mapped[str] = mapped_column(String(collation="NOCASE"), nullable=False, unique=True)
description: Mapped[Text] = mapped_column(Text, default="") description: Mapped[Text] = mapped_column(Text, default="")
def __repr__(self): def __repr__(self):
return self.name return self.name
class CharacterSkillMap(BaseObject):
__tablename__ = "character_skill_map"
__table_args__ = (UniqueConstraint("skill_id", "character_id", "character_class_id"),)
id: Mapped[int] = mapped_column(init=False, primary_key=True, autoincrement=True)
skill_id: Mapped[int] = mapped_column(ForeignKey("skill.id"))
character_id: Mapped[int] = mapped_column(ForeignKey("character.id"), nullable=True, default=None)
character_class_id: Mapped[int] = mapped_column(ForeignKey("character_class.id"), nullable=True, default=None)
proficient: Mapped[bool] = mapped_column(default=True)
expert: Mapped[bool] = mapped_column(default=False)
skill = relationship("Skill", lazy="immediate")
class CharacterClassMap(BaseObject): class CharacterClassMap(BaseObject):
__tablename__ = "class_map" __tablename__ = "class_map"
__table_args__ = (UniqueConstraint("character_id", "character_class_id"),) __table_args__ = (UniqueConstraint("character_id", "character_class_id"),)
@ -141,12 +161,12 @@ class CharacterClassAttributeMap(BaseObject):
) )
class Character(BaseObject, SlugMixin, SavingThrowsMixin, SkillsMixin, ModifierMixin): class Character(BaseObject, SlugMixin, ModifierMixin):
__tablename__ = "character" __tablename__ = "character"
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(default="New Character", nullable=False) name: Mapped[str] = mapped_column(String(collation="NOCASE"), nullable=False, default="New Character")
hit_points: Mapped[int] = mapped_column(default=10, nullable=False, info={"min": 0, "max": 999}) hit_points: Mapped[int] = mapped_column(default=10, nullable=False, info={"min": 0, "max": 999})
temp_hit_points: Mapped[int] = mapped_column(default=0, nullable=False, info={"min": 0, "max": 999}) temp_hit_points: Mapped[int] = mapped_column(default=0, nullable=False, info={"min": 0, "max": 999})
@ -177,17 +197,27 @@ class Character(BaseObject, SlugMixin, SavingThrowsMixin, SkillsMixin, ModifierM
_vision: Mapped[int] = mapped_column(default=None, nullable=True, info={"min": 0, "modifiable": True}) _vision: Mapped[int] = mapped_column(default=None, nullable=True, info={"min": 0, "modifiable": True})
_proficiencies: Mapped[str] = mapped_column(nullable=False, default="")
class_map = relationship("CharacterClassMap", cascade="all,delete,delete-orphan") class_map = relationship("CharacterClassMap", cascade="all,delete,delete-orphan")
class_list = association_proxy("class_map", "id", creator=class_map_creator) class_list = association_proxy("class_map", "id", creator=class_map_creator)
_skills = relationship("CharacterSkillMap", uselist=True, cascade="all,delete,delete-orphan", lazy="immediate")
skills = association_proxy("_skills", "skill", creator=skill_creator)
character_class_attribute_map = relationship("CharacterClassAttributeMap", cascade="all,delete,delete-orphan") character_class_attribute_map = relationship("CharacterClassAttributeMap", cascade="all,delete,delete-orphan")
attribute_list = association_proxy("character_class_attribute_map", "id", creator=attr_map_creator) attribute_list = association_proxy("character_class_attribute_map", "id", creator=attr_map_creator)
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)
@property
def proficiency_bonus(self):
return 1 + int(0.5 + self.level / 4)
@property
def proficiencies(self):
unified = {}
unified.update(**self._proficiencies)
@property @property
def modifiers(self): def modifiers(self):
unified = {} unified = {}
@ -197,6 +227,10 @@ class Character(BaseObject, SlugMixin, SavingThrowsMixin, SkillsMixin, ModifierM
unified.update(**super().modifiers) unified.update(**super().modifiers)
return unified return unified
@property
def check_modifiers(self):
return [self.check_modifier(skill) for skill in self.skills]
@property @property
def classes(self): def classes(self):
return dict([(mapping.character_class.name, mapping.character_class) for mapping in self.class_map]) return dict([(mapping.character_class.name, mapping.character_class) for mapping in self.class_map])
@ -241,46 +275,106 @@ class Character(BaseObject, SlugMixin, SavingThrowsMixin, SkillsMixin, ModifierM
def class_attributes(self): def class_attributes(self):
return dict([(mapping.class_attribute.name, mapping.option) for mapping in self.character_class_attribute_map]) return dict([(mapping.class_attribute.name, mapping.option) for mapping in self.character_class_attribute_map])
def level_in_class(self, charclass):
mapping = [mapping for mapping in self.class_map if mapping.character_class_id == charclass.id]
if not mapping:
return None
return mapping[0]
def check_modifier(self, skill: Skill, save: bool = False):
if skill not in self.skills:
return self.check_modifier(skill.parent, save=save) if skill.parent else 0
attr = skill.parent.name.lower() if skill.parent else skill.name.lower()
stat = getattr(self, attr, None)
initial = stat.bonus if stat else 0
mapping = [mapping for mapping in self._skills if mapping.skill_id == skill.id][0]
if mapping.expert and not save:
initial += 2 * self.proficiency_bonus
elif mapping.proficient:
initial += self.proficiency_bonus
return self._apply_modifiers(f"{attr}_{'save' if save else 'check'}", initial)
def add_class(self, newclass, level=1): def add_class(self, newclass, level=1):
if level == 0: if level == 0:
return self.remove_class(newclass) return self.remove_class(newclass)
level_in_class = [mapping for mapping in self.class_map if mapping.character_class_id == newclass.id]
if level_in_class: # add the class mapping and/or set the character's level in the class
level_in_class = level_in_class[0] mapping = self.level_in_class(newclass)
level_in_class.level = level if not mapping:
else:
self.class_list.append(CharacterClassMap(character=self, character_class=newclass, level=level)) self.class_list.append(CharacterClassMap(character=self, character_class=newclass, level=level))
else:
mapping.level = level
# add class attributes with default values
for lvl in range(1, level + 1): for lvl in range(1, level + 1):
if not newclass.attributes_by_level[lvl]: for attr in newclass.attributes_at_level(lvl):
continue self.add_class_attribute(newclass, attr, attr.options[0])
for attr_name, attr in newclass.attributes_by_level[lvl].items():
self.add_class_attribute(attr, attr.options[0]) # add default class skills
for skill in newclass.skills[: newclass.starting_skills]:
self.add_skill(skill, proficient=True, character_class=newclass)
def remove_class(self, target): def remove_class(self, target):
self.class_map = [m for m in self.class_map if m.character_class != target] self.class_map = [m for m in self.class_map if m.character_class != target]
for mapping in self.character_class_attribute_map: for mapping in self.character_class_attribute_map:
if mapping.character_class.id == target.id: if mapping.character_class.id == target.id:
self.remove_class_attribute(mapping.class_attribute) self.remove_class_attribute(mapping.class_attribute)
for skill in target.skills:
self.remove_skill(skill, character_class=target)
def remove_class_attribute(self, attribute): def remove_class_attribute(self, attribute):
self.character_class_attribute_map = [ self.character_class_attribute_map = [
m for m in self.character_class_attribute_map if m.class_attribute.id != attribute.id m for m in self.character_class_attribute_map if m.class_attribute.id != attribute.id
] ]
def add_class_attribute(self, attribute, option): def has_class_attribute(self, attribute):
for thisclass in self.classes.values(): return attribute in [m.class_attribute for m in self.character_class_attribute_map]
current_level = self.levels[thisclass.name]
current_attributes = thisclass.attributes_by_level.get(current_level, {}) def add_class_attribute(self, character_class, attribute, option):
if attribute.name in current_attributes: if self.has_class_attribute(attribute):
if attribute.name in self.class_attributes: return False
return True mapping = self.level_in_class(character_class)
self.attribute_list.append( if not mapping:
CharacterClassAttributeMap( return False
character_id=self.id, if attribute not in mapping.character_class.attributes_at_level(mapping.level):
class_attribute_id=attribute.id, return False
option_id=option.id, self.attribute_list.append(
class_attribute=attribute, CharacterClassAttributeMap(
) character_id=self.id,
) class_attribute_id=attribute.id,
return True option_id=option.id,
class_attribute=attribute,
)
)
return True
def add_skill(self, skill, proficient=False, expert=False, character_class=None):
if not self.skills or skill not in self.skills:
if not self.id:
raise Exception(f"Cannot add a skill before the character has been persisted.")
mapping = CharacterSkillMap(skill_id=skill.id, character_id=self.id, proficient=proficient, expert=expert)
if character_class:
mapping.character_class_id = character_class.id
self._skills.append(mapping)
return True
return False return False
def remove_skill(self, skill, character_class=None):
self._skills = [
mapping
for mapping in self._skills
if mapping.skill_id != skill.id and mapping.character_class_id != character_class.id
]
def __after_insert__(self, session):
"""
Called by the session after_flush event listener to add default joins in other tables.
"""
for skill in session.query(Skill).filter(
Skill.name.in_(("strength", "dexterity", "constitution", "intelligence", "wisdom", "charisma"))
):
self.add_skill(skill, proficient=False, expert=False)

View File

@ -1,18 +1,41 @@
import itertools
from collections import defaultdict from collections import defaultdict
from sqlalchemy import ForeignKey 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, SavingThrowsMixin, SkillsMixin from ttfrog.db.base import BaseObject
from ttfrog.db.schema.property import Skill
__all__ = [ __all__ = [
"ClassAttributeMap", "ClassAttributeMap",
"ClassAttribute", "ClassAttribute",
"ClassAttributeOption", "ClassAttributeOption",
"CharacterClass", "CharacterClass",
"Skill",
"ClassSkillMap",
] ]
def skill_creator(fields):
if isinstance(fields, ClassSkillMap):
return fields
return ClassSkillMap(**fields)
class ClassSkillMap(BaseObject):
__tablename__ = "class_skill_map"
__table_args__ = (UniqueConstraint("skill_id", "character_class_id"),)
id: Mapped[int] = mapped_column(init=False, primary_key=True, autoincrement=True)
skill_id: Mapped[int] = mapped_column(ForeignKey("skill.id"))
character_class_id: Mapped[int] = mapped_column(ForeignKey("character_class.id"))
proficient: Mapped[bool] = mapped_column(default=True)
expert: Mapped[bool] = mapped_column(default=False)
skill = relationship("Skill", lazy="immediate")
class ClassAttributeMap(BaseObject): class ClassAttributeMap(BaseObject):
__tablename__ = "class_attribute_map" __tablename__ = "class_attribute_map"
class_attribute_id: Mapped[int] = mapped_column(ForeignKey("class_attribute.id"), primary_key=True) class_attribute_id: Mapped[int] = mapped_column(ForeignKey("class_attribute.id"), primary_key=True)
@ -49,15 +72,28 @@ class ClassAttributeOption(BaseObject):
attribute_id: Mapped[int] = mapped_column(ForeignKey("class_attribute.id"), nullable=True) attribute_id: Mapped[int] = mapped_column(ForeignKey("class_attribute.id"), nullable=True)
class CharacterClass(BaseObject, SavingThrowsMixin, SkillsMixin): class CharacterClass(BaseObject):
__tablename__ = "character_class" __tablename__ = "character_class"
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(index=True, unique=True) name: Mapped[str] = mapped_column(index=True, unique=True)
hit_dice: Mapped[str] = mapped_column(default="1d6") hit_dice: Mapped[str] = mapped_column(default="1d6")
hit_dice_stat: Mapped[str] = mapped_column(default="") hit_dice_stat: Mapped[str] = mapped_column(default="")
proficiencies: Mapped[str] = mapped_column(default="") starting_skills: int = mapped_column(nullable=False, default=0)
attributes = relationship("ClassAttributeMap", cascade="all,delete,delete-orphan", lazy="immediate") attributes = relationship("ClassAttributeMap", 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)
def add_skill(self, skill, expert=False):
if not self.skills or skill not in self.skills:
if not self.id:
raise Exception(f"Cannot add a skill before the class has been persisted.")
mapping = ClassSkillMap(character_class_id=self.id, skill_id=skill.id, proficient=True, expert=expert)
self.skills.append(mapping)
return True
return False
def add_attribute(self, attribute, level=1): def add_attribute(self, attribute, level=1):
if not self.attributes or attribute not in self.attributes: if not self.attributes or attribute not in self.attributes:
mapping = ClassAttributeMap(character_class_id=self.id, class_attribute_id=attribute.id, level=level) mapping = ClassAttributeMap(character_class_id=self.id, class_attribute_id=attribute.id, level=level)
@ -72,5 +108,14 @@ class CharacterClass(BaseObject, SavingThrowsMixin, SkillsMixin):
def attributes_by_level(self): def attributes_by_level(self):
by_level = defaultdict(list) by_level = defaultdict(list)
for mapping in self.attributes: for mapping in self.attributes:
by_level[mapping.level] = {mapping.attribute.name: mapping.attribute} by_level[mapping.level].append(mapping.attribute)
return by_level return by_level
def attribute(self, name: str):
for mapping in self.attributes:
if mapping.attribute.name.lower() == name.lower():
return mapping.attribute
return None
def attributes_at_level(self, level: int):
return list(itertools.chain(*[attrs for lvl, attrs in self.attributes_by_level.items() if lvl <= level]))

View File

@ -256,4 +256,4 @@ class ModifierMixin:
self._get_modifiable_base(col.info.get("modifiable_base", col.name)), self._get_modifiable_base(col.info.get("modifiable_base", col.name)),
modifiable_class=col.info.get("modifiable_class", None), modifiable_class=col.info.get("modifiable_class", None),
) )
return super().__getattr__(attr_name) raise AttributeError(f"No such attribute: {attr_name}.")

View File

@ -1,27 +1,17 @@
from sqlalchemy import Column, Integer, String, Text from sqlalchemy import ForeignKey, String
from sqlalchemy.orm import Mapped, mapped_column, relationship
from ttfrog.db.base import BaseObject from ttfrog.db.base import BaseObject
__all__ = [ __all__ = [
"Skill", "Skill",
"Proficiency",
] ]
class Skill(BaseObject): class Skill(BaseObject):
__tablename__ = "skill" __tablename__ = "skill"
id = Column(Integer, primary_key=True, autoincrement=True) id: Mapped[int] = mapped_column(init=False, primary_key=True, autoincrement=True)
name = Column(String, index=True, unique=True) name: Mapped[str] = mapped_column(String(collation="NOCASE"), nullable=False, unique=True)
description = Column(Text) base_id: Mapped[int] = mapped_column(ForeignKey("skill.id"), nullable=True, default=None)
def __repr__(self): parent: Mapped["Skill"] = relationship(init=False, remote_side=id, uselist=False, lazy="immediate")
return str(self.name)
class Proficiency(BaseObject):
__tablename__ = "proficiency"
id = Column(Integer, primary_key=True, autoincrement=True)
name = Column(String, index=True, unique=True)
def __repr__(self):
return str(self.name)

View File

@ -50,20 +50,39 @@ def bootstrap(db):
db.add_or_update([human, dragonborn, tiefling]) db.add_or_update([human, dragonborn, tiefling])
# skills
skills = {
name: schema.Skill(name=name)
for name in ("strength", "dexterity", "constitution", "intelligence", "wisdom", "charisma")
}
db.add_or_update(list(skills.values()))
acrobatics = schema.Skill(name="Acrobatics", base_id=skills["dexterity"].id)
athletics = schema.Skill(name="Athletics", base_id=skills["strength"].id)
db.add_or_update([acrobatics, athletics])
# classes # classes
fighting_style = schema.ClassAttribute("Fighting Style") fighting_style = schema.ClassAttribute("Fighting Style")
fighting_style.add_option(name="Archery") fighting_style.add_option(name="Archery")
fighting_style.add_option(name="Defense") fighting_style.add_option(name="Defense")
db.add_or_update(fighting_style) db.add_or_update(fighting_style)
fighter = schema.CharacterClass("fighter", hit_dice="1d10", hit_dice_stat="CON") fighter = schema.CharacterClass("fighter", hit_dice="1d10", hit_dice_stat="CON", starting_skills=2)
db.add_or_update(fighter)
# add skills
fighter.add_skill(acrobatics)
fighter.add_skill(athletics)
fighter.add_attribute(fighting_style, level=2) fighter.add_attribute(fighting_style, level=2)
db.add_or_update(fighter)
assert acrobatics in fighter.skills
assert athletics in fighter.skills
rogue = schema.CharacterClass("rogue", hit_dice="1d8", hit_dice_stat="DEX") rogue = schema.CharacterClass("rogue", hit_dice="1d8", hit_dice_stat="DEX")
db.add_or_update([rogue, fighter]) db.add_or_update([rogue, fighter])
# characters # characters
foo = schema.Character("Foo", ancestry=tiefling, _intelligence=14) foo = schema.Character("Foo", ancestry=tiefling, _intelligence=14)
db.add_or_update(foo)
foo.add_class(fighter, level=2) foo.add_class(fighter, level=2)
foo.add_class(rogue, level=3) foo.add_class(rogue, level=3)

View File

@ -24,6 +24,12 @@ def test_manage_character(db, bootstrap):
assert char.charisma == 10 assert char.charisma == 10
assert darkvision not in char.traits assert darkvision not in char.traits
# verify basic skills were added at creation time
for skill in db.Skill.filter(
schema.Skill.name.in_(("strength", "dexterity", "constitution", "intelligence", "wisdom", "charisma"))
):
assert char.check_modifier(skill) == 0
# switch ancestry to tiefling # switch ancestry to tiefling
tiefling = db.Ancestry.filter_by(name="tiefling").one() tiefling = db.Ancestry.filter_by(name="tiefling").one()
char.ancestry = tiefling char.ancestry = tiefling
@ -33,11 +39,21 @@ def test_manage_character(db, bootstrap):
assert char.ancestry.name == "tiefling" assert char.ancestry.name == "tiefling"
assert darkvision in char.traits assert darkvision in char.traits
# tiefling ancestry adds INT and CHA modifiers
assert char.intelligence == 11
assert char.intelligence.base == 10
assert char.charisma == 12
assert char.charisma.base == 10
# switch ancestry to dragonborn and assert darkvision persists # switch ancestry to dragonborn and assert darkvision persists
char.ancestry = db.Ancestry.filter_by(name="dragonborn").one() char.ancestry = db.Ancestry.filter_by(name="dragonborn").one()
db.add_or_update(char) db.add_or_update(char)
assert darkvision in char.traits assert darkvision in char.traits
# verify tiefling modifiers were removed
assert char.intelligence == 10
assert char.charisma == 10
# switch ancestry to human and assert darkvision is removed # switch ancestry to human and assert darkvision is removed
char.ancestry = human char.ancestry = human
db.add_or_update(char) db.add_or_update(char)
@ -54,33 +70,43 @@ def test_manage_character(db, bootstrap):
assert char.class_attributes == {} assert char.class_attributes == {}
# 'fighting style' is available, but not at this level # 'fighting style' is available, but not at this level
fighting_style = fighter.attributes_by_level[2]["Fighting Style"] fighting_style = fighter.attribute("Fighting Style")
assert char.add_class_attribute(fighting_style, fighting_style.options[0]) is False assert char.has_class_attribute(fighting_style) is False
assert char.add_class_attribute(fighter, fighting_style, fighting_style.options[0]) is False
db.add_or_update(char) db.add_or_update(char)
assert char.class_attributes == {} assert char.class_attributes == {}
# level up # level up
char.add_class(fighter, level=2) char.add_class(fighter, level=7)
db.add_or_update(char) db.add_or_update(char)
assert char.levels == {"fighter": 2} assert char.levels == {"fighter": 7}
assert char.level == 2 assert char.level == 7
# Assert the fighting style is added automatically and idempotent...ly? # Assert the fighting style is added automatically and idempotent...ly?
assert char.has_class_attribute(fighting_style)
assert char.class_attributes[fighting_style.name] == fighting_style.options[0] assert char.class_attributes[fighting_style.name] == fighting_style.options[0]
assert char.add_class_attribute(fighting_style, fighting_style.options[0]) is True assert char.add_class_attribute(fighter, fighting_style, fighting_style.options[0]) is False
assert char.has_class_attribute(fighting_style)
db.add_or_update(char) db.add_or_update(char)
# classes athletics = db.Skill.filter_by(name="athletics").one()
acrobatics = db.Skill.filter_by(name="acrobatics").one()
assert athletics in char.skills
assert acrobatics in char.skills
assert char.check_modifier(athletics) == char.proficiency_bonus + char.strength.bonus == 3
assert char.check_modifier(acrobatics) == char.proficiency_bonus + char.dexterity.bonus == 3
# multiclass
char.add_class(rogue, level=1) char.add_class(rogue, level=1)
db.add_or_update(char) db.add_or_update(char)
assert char.level == 3 assert char.level == 8
assert char.levels == {"fighter": 2, "rogue": 1} assert char.levels == {"fighter": 7, "rogue": 1}
# remove a class # remove a class
char.remove_class(rogue) char.remove_class(rogue)
db.add_or_update(char) db.add_or_update(char)
assert char.levels == {"fighter": 2} assert char.levels == {"fighter": 7}
assert char.level == 2 assert char.level == 7
# remove remaining class by setting level to zero # remove remaining class by setting level to zero
char.add_class(fighter, level=0) char.add_class(fighter, level=0)
@ -88,13 +114,19 @@ def test_manage_character(db, bootstrap):
assert char.levels == {} assert char.levels == {}
assert char.class_attributes == {} assert char.class_attributes == {}
# verify the proficiencies added by the classes have been removed
assert athletics not in char.skills
assert acrobatics not in char.skills
assert char.check_modifier(athletics) == 0
assert char.check_modifier(acrobatics) == 0
# ensure we're not persisting any orphan records in the map tables # ensure we're not persisting any orphan records in the map tables
dump = json.loads(db.dump()) dump = json.loads(db.dump())
assert not [m for m in dump["character_class_attribute_map"] if m["character_id"] == char.id] assert not [m for m in dump["character_class_attribute_map"] if m["character_id"] == char.id]
assert not [m for m in dump["class_map"] if m["character_id"] == char.id] assert not [m for m in dump["class_map"] if m["character_id"] == char.id]
def test_ancestries(db): def test_ancestries(db, bootstrap):
with db.transaction(): with db.transaction():
# create the Pygmy Orc ancestry # create the Pygmy Orc ancestry
porc = schema.Ancestry( porc = schema.Ancestry(
@ -102,7 +134,6 @@ def test_ancestries(db):
size="Small", size="Small",
walk_speed=25, walk_speed=25,
) )
db.add_or_update(porc)
assert porc.name == "Pygmy Orc" assert porc.name == "Pygmy Orc"
assert porc.creature_type == "humanoid" assert porc.creature_type == "humanoid"
assert porc.size == "Small" assert porc.size == "Small"
@ -115,26 +146,33 @@ def test_ancestries(db):
db.add_or_update(porc) db.add_or_update(porc)
assert endurance in porc.traits assert endurance in porc.traits
# add a +1 STR modifier # add a +3 STR modifier
str_plus_one = schema.Modifier( str_bonus = schema.Modifier(
name="STR+1 (Pygmy Orc)", name="STR+3 (Pygmy Orc)",
target="strength", target="strength",
relative_value=1, relative_value=3,
description="Your Strength score is increased by 1.", description="Your Strength score is increased by 3.",
) )
assert porc.add_modifier(str_plus_one) is True assert porc.add_modifier(str_bonus) is True
assert porc.add_modifier(str_plus_one) is False # test idempotency assert porc.add_modifier(str_bonus) is False # test idempotency
assert str_plus_one in porc.modifiers["strength"] assert str_bonus in porc.modifiers["strength"]
# now create an orc character and assert it gets traits and modifiers # now create an orc character and assert it gets traits and modifiers
grognak = schema.Character(name="Grognak the Mighty", ancestry=porc) grognak = schema.Character(name="Grognak the Mighty", ancestry=porc)
db.add_or_update(grognak) db.add_or_update(grognak)
assert endurance in grognak.traits assert endurance in grognak.traits
# verify the strength bonus is applied # verify the strength bonus is applied
assert grognak.strength.base == 10 assert grognak.strength.base == 10
assert str_plus_one in grognak.modifiers["strength"] assert grognak.strength == 13
assert grognak.strength == 11 assert grognak.strength.bonus == 1
assert str_bonus in grognak.modifiers["strength"]
# make sure bonuses are applied to checks and saves
strength = db.Skill.filter_by(name="strength").one()
assert grognak.check_modifier(strength) == 1
assert grognak.check_modifier(strength, save=True) == 1
def test_modifiers(db, bootstrap): def test_modifiers(db, bootstrap):