From 9a2d28ae75f5b293072928c956e787b0e88a8ace Mon Sep 17 00:00:00 2001 From: evilchili Date: Mon, 6 May 2024 00:13:52 -0700 Subject: [PATCH] add support for skills --- src/ttfrog/db/base.py | 33 ------ src/ttfrog/db/manager.py | 14 ++- src/ttfrog/db/schema/character.py | 160 ++++++++++++++++++++++++------ src/ttfrog/db/schema/classes.py | 55 +++++++++- src/ttfrog/db/schema/modifiers.py | 2 +- src/ttfrog/db/schema/property.py | 22 ++-- test/conftest.py | 21 +++- test/test_schema.py | 84 +++++++++++----- 8 files changed, 278 insertions(+), 113 deletions(-) diff --git a/src/ttfrog/db/base.py b/src/ttfrog/db/base.py index a3e9ebe..0744e3f 100644 --- a/src/ttfrog/db/base.py +++ b/src/ttfrog/db/base.py @@ -54,36 +54,6 @@ class BaseObject(MappedAsDataclass, DeclarativeBase): 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): """ A serializable enum. @@ -93,9 +63,6 @@ class EnumField(enum.Enum): return self.value -SavingThrowsMixin = multivalue_string_factory("saving_throws") -SkillsMixin = multivalue_string_factory("skills") - STATS = ["STR", "DEX", "CON", "INT", "WIS", "CHA"] CREATURE_TYPES = [ "aberation", diff --git a/src/ttfrog/db/manager.py b/src/ttfrog/db/manager.py index 83761fd..5555eb7 100644 --- a/src/ttfrog/db/manager.py +++ b/src/ttfrog/db/manager.py @@ -7,7 +7,7 @@ from functools import cached_property import transaction from pyramid_sqlalchemy.meta import Session -from sqlalchemy import create_engine +from sqlalchemy import create_engine, event import ttfrog.db.schema from ttfrog.path import database @@ -92,3 +92,15 @@ class 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) diff --git a/src/ttfrog/db/schema/character.py b/src/ttfrog/db/schema/character.py index f4dadad..6fc68e6 100644 --- a/src/ttfrog/db/schema/character.py +++ b/src/ttfrog/db/schema/character.py @@ -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.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.modifiers import Modifier, ModifierMixin, Stat +from ttfrog.db.schema.property import Skill __all__ = [ "Ancestry", @@ -23,6 +24,12 @@ def class_map_creator(fields): return CharacterClassMap(**fields) +def skill_creator(fields): + if isinstance(fields, CharacterSkillMap): + return fields + return CharacterSkillMap(**fields) + + def attr_map_creator(fields): if isinstance(fields, CharacterClassAttributeMap): return fields @@ -46,7 +53,7 @@ class Ancestry(BaseObject, ModifierMixin): __tablename__ = "ancestry" 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") size: Mapped[str] = mapped_column(nullable=False, default="medium") @@ -97,13 +104,26 @@ class AncestryTrait(BaseObject, ModifierMixin): __tablename__ = "ancestry_trait" 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="") def __repr__(self): 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): __tablename__ = "class_map" __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" 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}) 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}) - _proficiencies: Mapped[str] = mapped_column(nullable=False, default="") - class_map = relationship("CharacterClassMap", cascade="all,delete,delete-orphan") 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") 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: 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 def modifiers(self): unified = {} @@ -197,6 +227,10 @@ class Character(BaseObject, SlugMixin, SavingThrowsMixin, SkillsMixin, ModifierM unified.update(**super().modifiers) return unified + @property + def check_modifiers(self): + return [self.check_modifier(skill) for skill in self.skills] + @property def classes(self): 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): 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): if level == 0: 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: - level_in_class = level_in_class[0] - level_in_class.level = level - else: + + # 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 + + # add class attributes with default values for lvl in range(1, level + 1): - if not newclass.attributes_by_level[lvl]: - continue - for attr_name, attr in newclass.attributes_by_level[lvl].items(): - self.add_class_attribute(attr, attr.options[0]) + for attr in newclass.attributes_at_level(lvl): + self.add_class_attribute(newclass, 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): self.class_map = [m for m in self.class_map if m.character_class != target] for mapping in self.character_class_attribute_map: if mapping.character_class.id == target.id: 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): self.character_class_attribute_map = [ m for m in self.character_class_attribute_map if m.class_attribute.id != attribute.id ] - def add_class_attribute(self, attribute, option): - for thisclass in self.classes.values(): - current_level = self.levels[thisclass.name] - current_attributes = thisclass.attributes_by_level.get(current_level, {}) - if attribute.name in current_attributes: - if attribute.name in self.class_attributes: - return True - self.attribute_list.append( - CharacterClassAttributeMap( - character_id=self.id, - class_attribute_id=attribute.id, - option_id=option.id, - class_attribute=attribute, - ) - ) - return True + def has_class_attribute(self, attribute): + return attribute in [m.class_attribute for m in self.character_class_attribute_map] + + def add_class_attribute(self, character_class, attribute, option): + if self.has_class_attribute(attribute): + return False + mapping = self.level_in_class(character_class) + if not mapping: + return False + if attribute not in mapping.character_class.attributes_at_level(mapping.level): + return False + self.attribute_list.append( + CharacterClassAttributeMap( + character_id=self.id, + class_attribute_id=attribute.id, + 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 + + 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) diff --git a/src/ttfrog/db/schema/classes.py b/src/ttfrog/db/schema/classes.py index 76ff809..bf14ef0 100644 --- a/src/ttfrog/db/schema/classes.py +++ b/src/ttfrog/db/schema/classes.py @@ -1,18 +1,41 @@ +import itertools 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 ttfrog.db.base import BaseObject, SavingThrowsMixin, SkillsMixin +from ttfrog.db.base import BaseObject +from ttfrog.db.schema.property import Skill __all__ = [ "ClassAttributeMap", "ClassAttribute", "ClassAttributeOption", "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): __tablename__ = "class_attribute_map" 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) -class CharacterClass(BaseObject, SavingThrowsMixin, SkillsMixin): +class CharacterClass(BaseObject): __tablename__ = "character_class" id: Mapped[int] = mapped_column(init=False, primary_key=True, autoincrement=True) name: Mapped[str] = mapped_column(index=True, unique=True) hit_dice: Mapped[str] = mapped_column(default="1d6") 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") + _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): if not self.attributes or attribute not in self.attributes: 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): by_level = defaultdict(list) for mapping in self.attributes: - by_level[mapping.level] = {mapping.attribute.name: mapping.attribute} + by_level[mapping.level].append(mapping.attribute) 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])) diff --git a/src/ttfrog/db/schema/modifiers.py b/src/ttfrog/db/schema/modifiers.py index 01b2d9a..744ef98 100644 --- a/src/ttfrog/db/schema/modifiers.py +++ b/src/ttfrog/db/schema/modifiers.py @@ -256,4 +256,4 @@ class ModifierMixin: self._get_modifiable_base(col.info.get("modifiable_base", col.name)), modifiable_class=col.info.get("modifiable_class", None), ) - return super().__getattr__(attr_name) + raise AttributeError(f"No such attribute: {attr_name}.") diff --git a/src/ttfrog/db/schema/property.py b/src/ttfrog/db/schema/property.py index 46b8913..edabd88 100644 --- a/src/ttfrog/db/schema/property.py +++ b/src/ttfrog/db/schema/property.py @@ -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 __all__ = [ "Skill", - "Proficiency", ] class Skill(BaseObject): __tablename__ = "skill" - id = Column(Integer, primary_key=True, autoincrement=True) - name = Column(String, index=True, unique=True) - description = Column(Text) + id: Mapped[int] = mapped_column(init=False, primary_key=True, autoincrement=True) + name: Mapped[str] = mapped_column(String(collation="NOCASE"), nullable=False, unique=True) + base_id: Mapped[int] = mapped_column(ForeignKey("skill.id"), nullable=True, default=None) - def __repr__(self): - 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) + parent: Mapped["Skill"] = relationship(init=False, remote_side=id, uselist=False, lazy="immediate") diff --git a/test/conftest.py b/test/conftest.py index 8c801a7..7673467 100644 --- a/test/conftest.py +++ b/test/conftest.py @@ -50,20 +50,39 @@ def bootstrap(db): 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 fighting_style = schema.ClassAttribute("Fighting Style") fighting_style.add_option(name="Archery") fighting_style.add_option(name="Defense") 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) + 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") db.add_or_update([rogue, fighter]) # 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) diff --git a/test/test_schema.py b/test/test_schema.py index 94790d3..1fda5c4 100644 --- a/test/test_schema.py +++ b/test/test_schema.py @@ -24,6 +24,12 @@ def test_manage_character(db, bootstrap): assert char.charisma == 10 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 tiefling = db.Ancestry.filter_by(name="tiefling").one() char.ancestry = tiefling @@ -33,11 +39,21 @@ def test_manage_character(db, bootstrap): assert char.ancestry.name == "tiefling" 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 char.ancestry = db.Ancestry.filter_by(name="dragonborn").one() db.add_or_update(char) 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 char.ancestry = human db.add_or_update(char) @@ -54,33 +70,43 @@ def test_manage_character(db, bootstrap): assert char.class_attributes == {} # 'fighting style' is available, but not at this level - fighting_style = fighter.attributes_by_level[2]["Fighting Style"] - assert char.add_class_attribute(fighting_style, fighting_style.options[0]) is False + fighting_style = fighter.attribute("Fighting Style") + 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) assert char.class_attributes == {} # level up - char.add_class(fighter, level=2) + char.add_class(fighter, level=7) db.add_or_update(char) - assert char.levels == {"fighter": 2} - assert char.level == 2 + assert char.levels == {"fighter": 7} + assert char.level == 7 # 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.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) - # 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) db.add_or_update(char) - assert char.level == 3 - assert char.levels == {"fighter": 2, "rogue": 1} + assert char.level == 8 + assert char.levels == {"fighter": 7, "rogue": 1} # remove a class char.remove_class(rogue) db.add_or_update(char) - assert char.levels == {"fighter": 2} - assert char.level == 2 + assert char.levels == {"fighter": 7} + assert char.level == 7 # remove remaining class by setting level to zero char.add_class(fighter, level=0) @@ -88,13 +114,19 @@ def test_manage_character(db, bootstrap): assert char.levels == {} 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 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["class_map"] if m["character_id"] == char.id] -def test_ancestries(db): +def test_ancestries(db, bootstrap): with db.transaction(): # create the Pygmy Orc ancestry porc = schema.Ancestry( @@ -102,7 +134,6 @@ def test_ancestries(db): size="Small", walk_speed=25, ) - db.add_or_update(porc) assert porc.name == "Pygmy Orc" assert porc.creature_type == "humanoid" assert porc.size == "Small" @@ -115,26 +146,33 @@ def test_ancestries(db): db.add_or_update(porc) assert endurance in porc.traits - # add a +1 STR modifier - str_plus_one = schema.Modifier( - name="STR+1 (Pygmy Orc)", + # add a +3 STR modifier + str_bonus = schema.Modifier( + name="STR+3 (Pygmy Orc)", target="strength", - relative_value=1, - description="Your Strength score is increased by 1.", + relative_value=3, + description="Your Strength score is increased by 3.", ) - assert porc.add_modifier(str_plus_one) is True - assert porc.add_modifier(str_plus_one) is False # test idempotency - assert str_plus_one in porc.modifiers["strength"] + assert porc.add_modifier(str_bonus) is True + assert porc.add_modifier(str_bonus) is False # test idempotency + assert str_bonus in porc.modifiers["strength"] # now create an orc character and assert it gets traits and modifiers grognak = schema.Character(name="Grognak the Mighty", ancestry=porc) db.add_or_update(grognak) + assert endurance in grognak.traits # verify the strength bonus is applied assert grognak.strength.base == 10 - assert str_plus_one in grognak.modifiers["strength"] - assert grognak.strength == 11 + assert grognak.strength == 13 + 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):