add support for modifier overrides with proficiency and expertise
This commit is contained in:
parent
9a2d28ae75
commit
09549bf68c
|
@ -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):
|
||||
"""
|
||||
|
|
|
@ -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):
|
||||
"""
|
||||
|
|
|
@ -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")
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Reference in New Issue
Block a user