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")
|
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")
|
||||||
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})
|
_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})
|
_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):
|
def traits(self):
|
||||||
return [mapping.trait for mapping in self._traits]
|
return [mapping.trait for mapping in self._traits]
|
||||||
|
|
||||||
@property
|
|
||||||
def speed(self):
|
|
||||||
return self.walk_speed
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def climb_speed(self):
|
def climb_speed(self):
|
||||||
return self._climb_speed or int(self.speed / 2)
|
return self._climb_speed or int(self.speed / 2)
|
||||||
|
@ -213,6 +209,10 @@ class Character(BaseObject, SlugMixin, ModifierMixin):
|
||||||
def proficiency_bonus(self):
|
def proficiency_bonus(self):
|
||||||
return 1 + int(0.5 + self.level / 4)
|
return 1 + int(0.5 + self.level / 4)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def expertise_bonus(self):
|
||||||
|
return 2 * self.proficiency_bonus
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def proficiencies(self):
|
def proficiencies(self):
|
||||||
unified = {}
|
unified = {}
|
||||||
|
@ -282,20 +282,33 @@ class Character(BaseObject, SlugMixin, ModifierMixin):
|
||||||
return mapping[0]
|
return mapping[0]
|
||||||
|
|
||||||
def check_modifier(self, skill: Skill, save: bool = False):
|
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:
|
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)
|
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:
|
# if the skill is a proficiency, apply the bonus to the initial value
|
||||||
initial += 2 * self.proficiency_bonus
|
if skill in self.skills:
|
||||||
elif mapping.proficient:
|
mapping = [mapping for mapping in self._skills if mapping.skill_id == skill.id][-1]
|
||||||
initial += self.proficiency_bonus
|
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)
|
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):
|
||||||
|
@ -324,7 +337,7 @@ class Character(BaseObject, SlugMixin, ModifierMixin):
|
||||||
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:
|
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):
|
def remove_class_attribute(self, attribute):
|
||||||
self.character_class_attribute_map = [
|
self.character_class_attribute_map = [
|
||||||
|
@ -353,22 +366,54 @@ class Character(BaseObject, SlugMixin, ModifierMixin):
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def add_skill(self, skill, proficient=False, expert=False, character_class=None):
|
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:
|
||||||
if not self.id:
|
raise Exception("Cannot add a skill before the character has been persisted.")
|
||||||
raise Exception(f"Cannot add a skill before the character has been persisted.")
|
skillmap = None
|
||||||
mapping = CharacterSkillMap(skill_id=skill.id, character_id=self.id, proficient=proficient, expert=expert)
|
exists = False
|
||||||
if character_class:
|
if skill in self.skills:
|
||||||
mapping.character_class_id = character_class.id
|
for mapping in self._skills:
|
||||||
self._skills.append(mapping)
|
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 True
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def remove_skill(self, skill, character_class=None):
|
def remove_skill(self, skill, proficient, expert, character_class):
|
||||||
self._skills = [
|
to_delete = [
|
||||||
mapping
|
mapping
|
||||||
for mapping in self._skills
|
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):
|
def __after_insert__(self, session):
|
||||||
"""
|
"""
|
||||||
|
|
|
@ -63,9 +63,12 @@ class Modifier(BaseObject):
|
||||||
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(nullable=False)
|
||||||
target: 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)
|
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_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)
|
new_value: Mapped[str] = mapped_column(nullable=True, default=None)
|
||||||
description: Mapped[str] = mapped_column(default="")
|
description: Mapped[str] = mapped_column(default="")
|
||||||
|
|
||||||
|
@ -123,6 +126,9 @@ class ModifierMixin:
|
||||||
all_modifiers[mapping.modifier.target].append(mapping.modifier)
|
all_modifiers[mapping.modifier.target].append(mapping.modifier)
|
||||||
return all_modifiers
|
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:
|
def add_modifier(self, modifier: Modifier) -> bool:
|
||||||
"""
|
"""
|
||||||
Associate a modifier to the current instance if it isn't already.
|
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("."))
|
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:
|
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.
|
Apply all the modifiers for a given target and return the modified value.
|
||||||
|
@ -212,27 +241,17 @@ class ModifierMixin:
|
||||||
if not modifiable_class:
|
if not modifiable_class:
|
||||||
modifiable_class = globals()["ModifiableInt"] if isinstance(initial, int) else globals()["ModifiableStr"]
|
modifiable_class = globals()["ModifiableInt"] if isinstance(initial, int) else globals()["ModifiableStr"]
|
||||||
|
|
||||||
# get the modifiers in order from most to least recent
|
modifiers = self.modifiers.get(target, [])
|
||||||
modifiers = list(reversed(self.modifiers.get(target, [])))
|
|
||||||
|
|
||||||
if isinstance(initial, int):
|
nonstacking = [m for m in modifiers if not m.stacks]
|
||||||
absolute = [mod for mod in modifiers if mod.absolute_value is not None]
|
if nonstacking:
|
||||||
if absolute:
|
return modifiable_class(base=initial, modified=self._apply_one_modifier(nonstacking[-1], initial, initial))
|
||||||
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
|
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):
|
def __setattr__(self, attr_name, value):
|
||||||
"""
|
"""
|
||||||
|
|
|
@ -37,8 +37,12 @@ def bootstrap(db):
|
||||||
human = schema.Ancestry("human")
|
human = schema.Ancestry("human")
|
||||||
|
|
||||||
tiefling = schema.Ancestry("tiefling")
|
tiefling = schema.Ancestry("tiefling")
|
||||||
tiefling.add_modifier(schema.Modifier("Ability Score Increase", target="intelligence", relative_value=1))
|
tiefling.add_modifier(
|
||||||
tiefling.add_modifier(schema.Modifier("Ability Score Increase", target="charisma", relative_value=2))
|
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
|
# ancestry traits
|
||||||
darkvision = schema.AncestryTrait("Darkvision")
|
darkvision = schema.AncestryTrait("Darkvision")
|
||||||
|
|
|
@ -132,7 +132,7 @@ def test_ancestries(db, bootstrap):
|
||||||
porc = schema.Ancestry(
|
porc = schema.Ancestry(
|
||||||
name="Pygmy Orc",
|
name="Pygmy Orc",
|
||||||
size="Small",
|
size="Small",
|
||||||
walk_speed=25,
|
speed=25,
|
||||||
)
|
)
|
||||||
assert porc.name == "Pygmy Orc"
|
assert porc.name == "Pygmy Orc"
|
||||||
assert porc.creature_type == "humanoid"
|
assert porc.creature_type == "humanoid"
|
||||||
|
@ -150,6 +150,7 @@ def test_ancestries(db, bootstrap):
|
||||||
str_bonus = schema.Modifier(
|
str_bonus = schema.Modifier(
|
||||||
name="STR+3 (Pygmy Orc)",
|
name="STR+3 (Pygmy Orc)",
|
||||||
target="strength",
|
target="strength",
|
||||||
|
stacks=True,
|
||||||
relative_value=3,
|
relative_value=3,
|
||||||
description="Your Strength score is increased by 3.",
|
description="Your Strength score is increased by 3.",
|
||||||
)
|
)
|
||||||
|
@ -186,7 +187,7 @@ def test_modifiers(db, bootstrap):
|
||||||
db.add_or_update([carl, marx])
|
db.add_or_update([carl, marx])
|
||||||
assert carl.speed == carl.ancestry.speed == 30
|
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")
|
hasted = schema.Modifier(target="speed", multiply_value=2.0, name="Hasted")
|
||||||
slowed = schema.Modifier(target="speed", multiply_value=0.5, name="Slowed")
|
slowed = schema.Modifier(target="speed", multiply_value=0.5, name="Slowed")
|
||||||
restrained = schema.Modifier(target="speed", absolute_value=0, name="Restrained")
|
restrained = schema.Modifier(target="speed", absolute_value=0, name="Restrained")
|
||||||
|
@ -201,11 +202,11 @@ def test_modifiers(db, bootstrap):
|
||||||
|
|
||||||
# speed is doubled
|
# speed is doubled
|
||||||
assert carl.remove_modifier(cold)
|
assert carl.remove_modifier(cold)
|
||||||
|
assert carl.speed == 30
|
||||||
assert carl.add_modifier(hasted)
|
assert carl.add_modifier(hasted)
|
||||||
assert carl.speed == 60
|
assert carl.speed == 60
|
||||||
|
|
||||||
# speed is halved
|
# speed is halved, overriding hasted because it was applied after
|
||||||
assert carl.remove_modifier(hasted)
|
|
||||||
assert carl.add_modifier(slowed)
|
assert carl.add_modifier(slowed)
|
||||||
assert carl.speed == 15
|
assert carl.speed == 15
|
||||||
|
|
||||||
|
@ -219,8 +220,49 @@ def test_modifiers(db, bootstrap):
|
||||||
|
|
||||||
# back to normal
|
# back to normal
|
||||||
assert carl.remove_modifier(slowed)
|
assert carl.remove_modifier(slowed)
|
||||||
|
assert carl.remove_modifier(hasted)
|
||||||
assert carl.speed == carl.ancestry.speed
|
assert carl.speed == carl.ancestry.speed
|
||||||
|
|
||||||
# modifiers can modify string values too
|
# modifiers can modify string values too
|
||||||
assert carl.add_modifier(reduced)
|
assert carl.add_modifier(reduced)
|
||||||
assert carl.size == "Tiny"
|
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