diff --git a/src/ttfrog/db/schema/character.py b/src/ttfrog/db/schema/character.py index 6fc68e6..8a5703f 100644 --- a/src/ttfrog/db/schema/character.py +++ b/src/ttfrog/db/schema/character.py @@ -57,7 +57,7 @@ class Ancestry(BaseObject, ModifierMixin): creature_type: Mapped[str] = mapped_column(nullable=False, default="humanoid") size: Mapped[str] = mapped_column(nullable=False, default="medium") - walk_speed: Mapped[int] = mapped_column(nullable=False, default=30, info={"min": 0, "max": 99}) + speed: Mapped[int] = mapped_column(nullable=False, default=30, info={"min": 0, "max": 99}) _fly_speed: Mapped[int] = mapped_column(init=False, nullable=True, info={"min": 0, "max": 99}) _climb_speed: Mapped[int] = mapped_column(init=False, nullable=True, info={"min": 0, "max": 99}) @@ -71,10 +71,6 @@ class Ancestry(BaseObject, ModifierMixin): def traits(self): return [mapping.trait for mapping in self._traits] - @property - def speed(self): - return self.walk_speed - @property def climb_speed(self): return self._climb_speed or int(self.speed / 2) @@ -213,6 +209,10 @@ class Character(BaseObject, SlugMixin, ModifierMixin): def proficiency_bonus(self): return 1 + int(0.5 + self.level / 4) + @property + def expertise_bonus(self): + return 2 * self.proficiency_bonus + @property def proficiencies(self): unified = {} @@ -282,20 +282,33 @@ class Character(BaseObject, SlugMixin, ModifierMixin): return mapping[0] def check_modifier(self, skill: Skill, save: bool = False): + # if the skill is not assigned, but we have modifiers, apply them to zero. if skill not in self.skills: - return self.check_modifier(skill.parent, save=save) if skill.parent else 0 + target = f"{skill.name.lower()}_{'save' if save else 'check'}" + if self.has_modifier(target): + modified = self._apply_modifiers(target, 0) + return modified - attr = skill.parent.name.lower() if skill.parent else skill.name.lower() + # if the skill is a stat, start with the bonus value + attr = skill.name.lower() stat = getattr(self, attr, None) - initial = stat.bonus if stat else 0 + initial = getattr(stat, "bonus", None) - mapping = [mapping for mapping in self._skills if mapping.skill_id == skill.id][0] + # if the skill isn't a stat, try the parent. + if initial is None and skill.parent: + stat = getattr(self, skill.parent.name.lower(), None) + initial = getattr(stat, "bonus", initial) - if mapping.expert and not save: - initial += 2 * self.proficiency_bonus - elif mapping.proficient: - initial += self.proficiency_bonus + # if the skill is a proficiency, apply the bonus to the initial value + if skill in self.skills: + mapping = [mapping for mapping in self._skills if mapping.skill_id == skill.id][-1] + print(f"Found mapping: {mapping}") + if mapping.expert and not save: + initial += 2 * self.proficiency_bonus + elif mapping.proficient: + initial += self.proficiency_bonus + # 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): @@ -324,7 +337,7 @@ class Character(BaseObject, SlugMixin, ModifierMixin): 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) + self.remove_skill(skill, proficient=True, expert=False, character_class=target) def remove_class_attribute(self, attribute): self.character_class_attribute_map = [ @@ -353,22 +366,54 @@ class Character(BaseObject, SlugMixin, ModifierMixin): 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) + if not self.id: + raise Exception("Cannot add a skill before the character has been persisted.") + skillmap = None + exists = False + if skill in self.skills: + for mapping in self._skills: + if mapping.skill_id != skill.id: + continue + if character_class is None and mapping.character_class_id: + continue + if (character_class is None and mapping.character_class_id is None) or ( + mapping.character_class_id == character_class.id + ): + skillmap = mapping + exists = True + break + + if not skillmap: + skillmap = CharacterSkillMap(skill_id=skill.id, character_id=self.id) + + skillmap.proficient = proficient + skillmap.expert = expert + if character_class: + skillmap.character_class_id = character_class.id + + if not exists: + self._skills.append(skillmap) return True return False - def remove_skill(self, skill, character_class=None): - self._skills = [ + def remove_skill(self, skill, proficient, expert, character_class): + to_delete = [ mapping for mapping in self._skills - if mapping.skill_id != skill.id and mapping.character_class_id != character_class.id + if ( + mapping.skill_id == skill.id + and mapping.proficient == proficient + and mapping.expert == expert + and ( + (mapping.character_class_id is None and character_class is None) + or (character_class and mapping.character_class_id == character_class.id) + ) + ) ] + if not to_delete: + return False + self._skills = [m for m in self._skills if m not in to_delete] + return True def __after_insert__(self, session): """ diff --git a/src/ttfrog/db/schema/modifiers.py b/src/ttfrog/db/schema/modifiers.py index 744ef98..2fc607f 100644 --- a/src/ttfrog/db/schema/modifiers.py +++ b/src/ttfrog/db/schema/modifiers.py @@ -63,9 +63,12 @@ class Modifier(BaseObject): id: Mapped[int] = mapped_column(init=False, primary_key=True, autoincrement=True) name: Mapped[str] = mapped_column(nullable=False) target: Mapped[str] = mapped_column(nullable=False) + stacks: Mapped[bool] = mapped_column(nullable=False, default=False) absolute_value: Mapped[int] = mapped_column(nullable=True, default=None) - relative_value: Mapped[int] = mapped_column(nullable=True, default=None) multiply_value: Mapped[float] = mapped_column(nullable=True, default=None) + multiply_attribute: Mapped[str] = mapped_column(nullable=True, default=None) + relative_value: Mapped[int] = mapped_column(nullable=True, default=None) + relative_attribute: Mapped[str] = mapped_column(nullable=True, default=None) new_value: Mapped[str] = mapped_column(nullable=True, default=None) description: Mapped[str] = mapped_column(default="") @@ -123,6 +126,9 @@ class ModifierMixin: all_modifiers[mapping.modifier.target].append(mapping.modifier) return all_modifiers + def has_modifier(self, name: str): + return True if self.modifiers.get(name, None) else False + def add_modifier(self, modifier: Modifier) -> bool: """ Associate a modifier to the current instance if it isn't already. @@ -191,6 +197,29 @@ class ModifierMixin: return get_attr(self, attr_name.split(".")) + def _apply_one_modifier(self, modifier, initial, modified): + print(f"Trying to apply {modifier}") + if modifier.new_value is not None: + return modifier.new_value + elif modifier.absolute_value is not None: + return modifier.absolute_value + + base_value = modified if modifier.stacks else initial + + if modifier.multiply_attribute is not None: + return int(base_value * getattr(self, modifier.multiply_attribute) + 0.5) + + if modifier.multiply_value is not None: + return int(base_value * modifier.multiply_value + 0.5) + + if modifier.relative_attribute is not None: + return base_value + getattr(self, modifier.relative_attribute) + + if modifier.relative_value is not None: + return base_value + modifier.relative_value + + raise Exception(f"Cannot apply modifier: {modifier = }") + def _apply_modifiers(self, target: str, initial: Any, modifiable_class: type = None) -> Modifiable: """ Apply all the modifiers for a given target and return the modified value. @@ -212,27 +241,17 @@ class ModifierMixin: if not modifiable_class: modifiable_class = globals()["ModifiableInt"] if isinstance(initial, int) else globals()["ModifiableStr"] - # get the modifiers in order from most to least recent - modifiers = list(reversed(self.modifiers.get(target, []))) + modifiers = self.modifiers.get(target, []) - if isinstance(initial, int): - absolute = [mod for mod in modifiers if mod.absolute_value is not None] - if absolute: - modified = absolute[0].absolute_value - else: - multiple = [mod for mod in modifiers if mod.multiply_value is not None] - if multiple: - modified = int(initial * multiple[0].multiply_value + 0.5) - else: - modified = initial + sum(mod.relative_value for mod in modifiers if mod.relative_value is not None) - else: - new = [mod for mod in modifiers if mod.new_value is not None] - if new: - modified = new[0].new_value - else: - modified = initial + nonstacking = [m for m in modifiers if not m.stacks] + if nonstacking: + return modifiable_class(base=initial, modified=self._apply_one_modifier(nonstacking[-1], initial, initial)) - return modifiable_class(base=initial, modified=modified) if modified is not None else None + modified = initial + for modifier in modifiers: + if modifier.stacks: + modified = self._apply_one_modifier(modifier, initial, modified) + return modifiable_class(base=initial, modified=modified) def __setattr__(self, attr_name, value): """ diff --git a/test/conftest.py b/test/conftest.py index 7673467..522b097 100644 --- a/test/conftest.py +++ b/test/conftest.py @@ -37,8 +37,12 @@ def bootstrap(db): human = schema.Ancestry("human") tiefling = schema.Ancestry("tiefling") - tiefling.add_modifier(schema.Modifier("Ability Score Increase", target="intelligence", relative_value=1)) - tiefling.add_modifier(schema.Modifier("Ability Score Increase", target="charisma", relative_value=2)) + tiefling.add_modifier( + schema.Modifier("Ability Score Increase", target="intelligence", stacks=True, relative_value=1) + ) + tiefling.add_modifier( + schema.Modifier("Ability Score Increase", target="charisma", stacks=True, relative_value=2) + ) # ancestry traits darkvision = schema.AncestryTrait("Darkvision") diff --git a/test/test_schema.py b/test/test_schema.py index 1fda5c4..f6e0cb2 100644 --- a/test/test_schema.py +++ b/test/test_schema.py @@ -132,7 +132,7 @@ def test_ancestries(db, bootstrap): porc = schema.Ancestry( name="Pygmy Orc", size="Small", - walk_speed=25, + speed=25, ) assert porc.name == "Pygmy Orc" assert porc.creature_type == "humanoid" @@ -150,6 +150,7 @@ def test_ancestries(db, bootstrap): str_bonus = schema.Modifier( name="STR+3 (Pygmy Orc)", target="strength", + stacks=True, relative_value=3, description="Your Strength score is increased by 3.", ) @@ -186,7 +187,7 @@ def test_modifiers(db, bootstrap): db.add_or_update([carl, marx]) assert carl.speed == carl.ancestry.speed == 30 - cold = schema.Modifier(target="speed", relative_value=-10, name="Cold") + cold = schema.Modifier(target="speed", stacks=True, relative_value=-10, name="Cold") hasted = schema.Modifier(target="speed", multiply_value=2.0, name="Hasted") slowed = schema.Modifier(target="speed", multiply_value=0.5, name="Slowed") restrained = schema.Modifier(target="speed", absolute_value=0, name="Restrained") @@ -201,11 +202,11 @@ def test_modifiers(db, bootstrap): # speed is doubled assert carl.remove_modifier(cold) + assert carl.speed == 30 assert carl.add_modifier(hasted) assert carl.speed == 60 - # speed is halved - assert carl.remove_modifier(hasted) + # speed is halved, overriding hasted because it was applied after assert carl.add_modifier(slowed) assert carl.speed == 15 @@ -219,8 +220,49 @@ def test_modifiers(db, bootstrap): # back to normal assert carl.remove_modifier(slowed) + assert carl.remove_modifier(hasted) assert carl.speed == carl.ancestry.speed # modifiers can modify string values too assert carl.add_modifier(reduced) assert carl.size == "Tiny" + + # modifiers can be applied to skills, even if the character doesn't have a skill associated. + athletics = db.Skill.filter_by(name="athletics").one() + assert athletics not in carl.skills + assert carl.check_modifier(athletics) == 0 + temp_proficiency = schema.Modifier( + "Expertise in Athletics", + target="athletics_check", + stacks=True, + relative_attribute="expertise_bonus", + ) + assert carl.add_modifier(temp_proficiency) + assert carl.check_modifier(athletics) == carl.expertise_bonus + carl.strength.bonus == 2 + assert carl.remove_modifier(temp_proficiency) + + # 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 + + # 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 + + # manually override proficiency with expertise + carl.add_skill(athletics, expert=True) + assert carl.check_modifier(athletics) == 2 + 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 + + # 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