add support for modifier overrides with proficiency and expertise

This commit is contained in:
evilchili 2024-05-08 01:40:19 -07:00
parent 9a2d28ae75
commit 09549bf68c
4 changed files with 160 additions and 50 deletions

View File

@ -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 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)
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:
mapping.character_class_id = character_class.id
self._skills.append(mapping)
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):
"""

View File

@ -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, [])
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))
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
return modifiable_class(base=initial, modified=modified) if modified is not None else None
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):
"""

View File

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

View File

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