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
+132 -43
View File
@@ -82,9 +82,11 @@ from typing import TYPE_CHECKING
from pf1e_simulator.conditions import CONDITIONS_BY_NAME from pf1e_simulator.conditions import CONDITIONS_BY_NAME
from pf1e_simulator.effects import ActiveBuff, ActiveCondition, StatModifier, resolve_modifiers from pf1e_simulator.effects import ActiveBuff, ActiveCondition, StatModifier, resolve_modifiers
from pf1e_simulator.spells import ( from pf1e_simulator.spells import (
BuffEffect,
ConditionEffect, ConditionEffect,
DamageEffect, DamageEffect,
HealEffect, HealEffect,
SpellEffect,
spell_range_ft, spell_range_ft,
) )
@@ -853,8 +855,16 @@ class CombatEngine:
) )
caster_live = self._stats[caster.combatant.id] caster_live = self._stats[caster.combatant.id]
target_live = self._stats[target.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 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) save_str = _save_label(save_result)
self._log( self._log(
f"round {self._current_round} {caster.combatant.id}: " f"round {self._current_round} {caster.combatant.id}: "
@@ -868,7 +878,6 @@ class CombatEngine:
f"round {self._current_round} {caster.combatant.id}: " f"round {self._current_round} {caster.combatant.id}: "
f"{target.combatant.id} dead" f"{target.combatant.id} dead"
) )
def _apply_spell_effects( def _apply_spell_effects(
self, self,
spell: SpellSpec, spell: SpellSpec,
@@ -877,51 +886,131 @@ class CombatEngine:
caster_live: _LiveStats, caster_live: _LiveStats,
target_live: _LiveStats, target_live: _LiveStats,
) -> list[str]: ) -> 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 saved = save_result is not None and save_result.success
for effect in spell.effects: return [
if isinstance(effect, DamageEffect): self._apply_single_spell_effect(
raw = effect.formula.roll(self._rng) effect, target, caster_live, target_live,
dmg = max(0, raw // 2) if saved and effect.half_on_save else raw spell_name=spell_name, empower=empower, maximize=maximize,
dmg = self._apply_dr_for_types(dmg, frozenset(effect.types), target) saved=saved
target.hp -= dmg )
caster_live.damage_dealt += dmg for effect in effects
target_live.damage_taken += dmg ]
parts.append(f"{dmg} damage")
elif isinstance(effect, ConditionEffect): def _apply_single_spell_effect(
if saved and effect.save_negates: self,
parts.append(f"{effect.condition_name} negated") effect: SpellEffect,
else: target: CombatantState,
cond = CONDITIONS_BY_NAME.get(effect.condition_name) caster_live: _LiveStats,
if cond is not None: target_live: _LiveStats,
target.active_conditions.append( *,
ActiveCondition( spell_name: str,
condition=cond, empower: bool = False,
rounds_left=effect.duration_rounds, maximize: bool = False,
source=f"spell: {spell.name}", saved: bool,
) ) -> str:
) if isinstance(effect, DamageEffect):
# Also update old conditions field for backwards compatibility raw = effect.formula.roll(self._rng)
target.conditions.append(cond) if maximize:
parts.append(f"{effect.condition_name} ({effect.duration_rounds}r)") raw = effect.formula.max_value()
elif isinstance(effect, HealEffect): dmg = max(0, raw // 2) if saved and effect.half_on_save else raw
heal = effect.formula.roll(self._rng) if empower:
old_hp = target.hp dmg = int(dmg * 1.5)
target.hp = min(target.hp + heal, target.combatant.hp_max) dmg = self._apply_dr_for_types(dmg, frozenset(effect.types), target)
actual = target.hp - old_hp target.hp -= dmg
parts.append(f"+{actual} HP") caster_live.damage_dealt += dmg
else: target_live.damage_taken += dmg
target.active_buffs.append( return f"{dmg} damage"
ActiveBuff( if isinstance(effect, ConditionEffect):
modifiers=effect.modifiers, 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, rounds_left=effect.duration_rounds,
source=f"spell: {spell.name}", source=f"spell: {spell_name}",
) )
) )
# Also update old effects field for backwards compatibility # Also update old conditions field for backwards compatibility
target.effects.extend(effect.modifiers) target.conditions.append(cond)
parts.append(f"buff {len(effect.modifiers)} mods ({effect.duration_rounds}r)") return f"{effect.condition_name} ({effect.duration_rounds}r)"
return parts 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( def _resolve_swing(
self, self,
+4 -2
View File
@@ -80,10 +80,12 @@ class DiceExpr(BaseModel):
def mean(self) -> float: def mean(self) -> float:
"""Expected value: count * (sides + 1) / 2 + bonus.""" """Expected value: count * (sides + 1) / 2 + bonus."""
if self.count == 0:
return float(self.bonus)
return self.count * (self.sides + 1) / 2 + 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: def parse_dice(text: str) -> DiceExpr:
"""Parse a dice notation string into a DiceExpr. """Parse a dice notation string into a DiceExpr.
+1
View File
@@ -115,3 +115,4 @@ class Combatant(_FrozenModel):
spells: list[str] = [] spells: list[str] = []
casting_ability: str = "wis" # ability used for spell DCs: str/dex/con/int/wis/cha 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 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