feat(combat): metamagic

This commit is contained in:
2026-09-04 22:55:39 +02:00
parent eb8072ace7
commit d2c80c0d61
3 changed files with 137 additions and 45 deletions
+105 -16
View File
@@ -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:
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
parts.append(f"{dmg} damage")
elif isinstance(effect, ConditionEffect):
return f"{dmg} damage"
if isinstance(effect, ConditionEffect):
if saved and effect.save_negates:
parts.append(f"{effect.condition_name} negated")
else:
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 conditions field for backwards compatibility
target.conditions.append(cond)
parts.append(f"{effect.condition_name} ({effect.duration_rounds}r)")
elif isinstance(effect, HealEffect):
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
parts.append(f"+{actual} HP")
else:
return f"+{actual} HP"
target.active_buffs.append(
ActiveBuff(
modifiers=effect.modifiers,
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
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,
+4 -2
View File
@@ -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.
+1
View File
@@ -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