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.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,
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user