feat(combat): metamagic
This commit is contained in:
+132
-43
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user