From beee8e4e6acd24a6e6318e5e4ac4480af6dd86f7 Mon Sep 17 00:00:00 2001 From: ctan Date: Sat, 29 Aug 2026 20:44:17 +0200 Subject: [PATCH] feat(combat): metamagic --- src/pf1e_simulator/combat.py | 175 ++++++++++++++++++++++++++--------- src/pf1e_simulator/dice.py | 6 +- src/pf1e_simulator/models.py | 1 + 3 files changed, 137 insertions(+), 45 deletions(-) diff --git a/src/pf1e_simulator/combat.py b/src/pf1e_simulator/combat.py index 5f5bfe8..b352ad6 100644 --- a/src/pf1e_simulator/combat.py +++ b/src/pf1e_simulator/combat.py @@ -82,9 +82,11 @@ from typing import TYPE_CHECKING from pf1e_simulator.conditions import CONDITIONS_BY_NAME from pf1e_simulator.effects import ActiveBuff, ActiveCondition, StatModifier, resolve_modifiers from pf1e_simulator.spells import ( + BuffEffect, ConditionEffect, DamageEffect, HealEffect, + SpellEffect, spell_range_ft, ) @@ -853,8 +855,16 @@ class CombatEngine: ) caster_live = self._stats[caster.combatant.id] target_live = self._stats[target.combatant.id] + # Apply metamagic feats to spell effects + metamagic_feats = caster.combatant.metamagic_feats + metamagic_effects = self._apply_metamagic(spell, metamagic_feats) hp_before = target.hp - parts = self._apply_spell_effects(spell, target, save_result, caster_live, target_live) + parts = self._apply_spell_effects_from_list( + metamagic_effects, target, save_result, caster_live, target_live, + spell_name=spell.name, + empower="Empower Spell" in metamagic_feats, + maximize="Maximize Spell" in metamagic_feats, + ) save_str = _save_label(save_result) self._log( f"round {self._current_round} {caster.combatant.id}: " @@ -868,7 +878,6 @@ class CombatEngine: f"round {self._current_round} {caster.combatant.id}: " f"{target.combatant.id} dead" ) - def _apply_spell_effects( self, spell: SpellSpec, @@ -877,51 +886,131 @@ class CombatEngine: caster_live: _LiveStats, target_live: _LiveStats, ) -> list[str]: - parts: list[str] = [] + """Legacy wrapper for compatibility.""" + return self._apply_spell_effects_from_list( + spell.effects, target, save_result, caster_live, target_live, + spell_name=spell.name + ) + + def _apply_spell_effects_from_list( + self, + effects: tuple[SpellEffect, ...], + target: CombatantState, + save_result: SaveResult | None, + caster_live: _LiveStats, + target_live: _LiveStats, + *, + spell_name: str, + empower: bool = False, + maximize: bool = False, + ) -> list[str]: saved = save_result is not None and save_result.success - for effect in spell.effects: - if isinstance(effect, DamageEffect): - raw = effect.formula.roll(self._rng) - dmg = max(0, raw // 2) if saved and effect.half_on_save else raw - dmg = self._apply_dr_for_types(dmg, frozenset(effect.types), target) - target.hp -= dmg - caster_live.damage_dealt += dmg - target_live.damage_taken += dmg - parts.append(f"{dmg} damage") - elif isinstance(effect, ConditionEffect): - if saved and effect.save_negates: - parts.append(f"{effect.condition_name} negated") - else: - cond = CONDITIONS_BY_NAME.get(effect.condition_name) - if cond is not None: - target.active_conditions.append( - ActiveCondition( - condition=cond, - rounds_left=effect.duration_rounds, - source=f"spell: {spell.name}", - ) - ) - # Also update old conditions field for backwards compatibility - target.conditions.append(cond) - parts.append(f"{effect.condition_name} ({effect.duration_rounds}r)") - elif isinstance(effect, HealEffect): - heal = effect.formula.roll(self._rng) - old_hp = target.hp - target.hp = min(target.hp + heal, target.combatant.hp_max) - actual = target.hp - old_hp - parts.append(f"+{actual} HP") - else: - target.active_buffs.append( - ActiveBuff( - modifiers=effect.modifiers, + return [ + self._apply_single_spell_effect( + effect, target, caster_live, target_live, + spell_name=spell_name, empower=empower, maximize=maximize, + saved=saved + ) + for effect in effects + ] + + def _apply_single_spell_effect( + self, + effect: SpellEffect, + target: CombatantState, + caster_live: _LiveStats, + target_live: _LiveStats, + *, + spell_name: str, + empower: bool = False, + maximize: bool = False, + saved: bool, + ) -> str: + if isinstance(effect, DamageEffect): + raw = effect.formula.roll(self._rng) + if maximize: + raw = effect.formula.max_value() + dmg = max(0, raw // 2) if saved and effect.half_on_save else raw + if empower: + dmg = int(dmg * 1.5) + dmg = self._apply_dr_for_types(dmg, frozenset(effect.types), target) + target.hp -= dmg + caster_live.damage_dealt += dmg + target_live.damage_taken += dmg + return f"{dmg} damage" + if isinstance(effect, ConditionEffect): + if saved and effect.save_negates: + return f"{effect.condition_name} negated" + cond = CONDITIONS_BY_NAME.get(effect.condition_name) + if cond is not None: + target.active_conditions.append( + ActiveCondition( + condition=cond, rounds_left=effect.duration_rounds, - source=f"spell: {spell.name}", + source=f"spell: {spell_name}", ) ) - # Also update old effects field for backwards compatibility - target.effects.extend(effect.modifiers) - parts.append(f"buff {len(effect.modifiers)} mods ({effect.duration_rounds}r)") - return parts + # Also update old conditions field for backwards compatibility + target.conditions.append(cond) + return f"{effect.condition_name} ({effect.duration_rounds}r)" + return "" + if isinstance(effect, HealEffect): + heal = effect.formula.roll(self._rng) + if maximize: + heal = effect.formula.max_value() + if empower: + heal = int(heal * 1.5) + old_hp = target.hp + target.hp = min(target.hp + heal, target.combatant.hp_max) + actual = target.hp - old_hp + return f"+{actual} HP" + target.active_buffs.append( + ActiveBuff( + modifiers=effect.modifiers, + rounds_left=effect.duration_rounds, + source=f"spell: {spell_name}", + ) + ) + # Also update old effects field for backwards compatibility + target.effects.extend(effect.modifiers) + return f"buff {len(effect.modifiers)} mods ({effect.duration_rounds}r)" + + def _apply_metamagic( + self, spell: SpellSpec, metamagic_feats: list[str] + ) -> tuple[SpellEffect, ...]: + """Apply metamagic feats to spell effects on the fly.""" + if not metamagic_feats: + return spell.effects + new_effects: list[SpellEffect] = [] + has_extend = "Extend Spell" in metamagic_feats + + for effect in spell.effects: + if isinstance(effect, DamageEffect): + # Empower: 1.5x damage (applied at roll time in _apply_spell_effects_from_list) + # Maximize: max damage (applied at roll time) + # We mark these via a custom attribute on the formula + new_effects.append(effect) + elif isinstance(effect, ConditionEffect): + new_duration = effect.duration_rounds * 2 if has_extend else effect.duration_rounds + new_effects.append( + ConditionEffect( + condition_name=effect.condition_name, + duration_rounds=new_duration, + save_negates=effect.save_negates, + ) + ) + elif isinstance(effect, HealEffect): + # Empower/Maximize also apply to healing + new_effects.append(effect) + else: # BuffEffect + new_duration = effect.duration_rounds * 2 if has_extend else effect.duration_rounds + new_effects.append( + BuffEffect( + modifiers=effect.modifiers, + duration_rounds=new_duration, + ) + ) + return tuple(new_effects) def _resolve_swing( self, diff --git a/src/pf1e_simulator/dice.py b/src/pf1e_simulator/dice.py index e3c903f..1f85299 100644 --- a/src/pf1e_simulator/dice.py +++ b/src/pf1e_simulator/dice.py @@ -80,10 +80,12 @@ class DiceExpr(BaseModel): def mean(self) -> float: """Expected value: count * (sides + 1) / 2 + bonus.""" - if self.count == 0: - return float(self.bonus) return self.count * (self.sides + 1) / 2 + self.bonus + def max_value(self) -> int: + """Maximum possible value: count * sides + bonus.""" + return self.count * self.sides + self.bonus + def parse_dice(text: str) -> DiceExpr: """Parse a dice notation string into a DiceExpr. diff --git a/src/pf1e_simulator/models.py b/src/pf1e_simulator/models.py index e2fcdc5..9e55eff 100644 --- a/src/pf1e_simulator/models.py +++ b/src/pf1e_simulator/models.py @@ -115,3 +115,4 @@ class Combatant(_FrozenModel): spells: list[str] = [] casting_ability: str = "wis" # ability used for spell DCs: str/dex/con/int/wis/cha resources: dict[str, int] = Field(default_factory=dict) # resource pools per combat + metamagic_feats: list[str] = Field(default_factory=list) # e.g. Empower/Maximize/Extend Spell