feat(combat): area-of-effect spells
This commit is contained in:
@@ -83,6 +83,7 @@ from pf1e_simulator.conditions import CONDITIONS_BY_NAME
|
||||
from pf1e_simulator.effects import ActiveBuff, ActiveCondition, StatModifier, resolve_modifiers
|
||||
from pf1e_simulator.models import PREDEFINED_SKILLS
|
||||
from pf1e_simulator.spells import (
|
||||
AreaEffect,
|
||||
BuffEffect,
|
||||
ConditionEffect,
|
||||
DamageEffect,
|
||||
@@ -1040,6 +1041,8 @@ class CombatEngine:
|
||||
maximize: bool = False,
|
||||
saved: bool,
|
||||
) -> str:
|
||||
if isinstance(effect, AreaEffect):
|
||||
return self._apply_area_effect(effect, target, caster_live, target_live, spell_name, saved, empower=empower, maximize=maximize)
|
||||
if isinstance(effect, DamageEffect):
|
||||
raw = effect.formula.roll(self._rng)
|
||||
if maximize:
|
||||
@@ -1078,6 +1081,7 @@ class CombatEngine:
|
||||
target.hp = min(target.hp + heal, target.combatant.hp_max)
|
||||
actual = target.hp - old_hp
|
||||
return f"+{actual} HP"
|
||||
# BuffEffect
|
||||
target.active_buffs.append(
|
||||
ActiveBuff(
|
||||
modifiers=effect.modifiers,
|
||||
@@ -1089,6 +1093,70 @@ class CombatEngine:
|
||||
target.effects.extend(effect.modifiers)
|
||||
return f"buff {len(effect.modifiers)} mods ({effect.duration_rounds}r)"
|
||||
|
||||
def _apply_area_effect(
|
||||
self,
|
||||
effect: AreaEffect,
|
||||
target: CombatantState,
|
||||
caster_live: _LiveStats,
|
||||
target_live: _LiveStats,
|
||||
spell_name: str,
|
||||
saved: bool,
|
||||
*,
|
||||
empower: bool = False,
|
||||
maximize: bool = False,
|
||||
) -> str:
|
||||
"""Apply an area effect to all creatures within radius.
|
||||
|
||||
The target parameter is used as the center point for the burst.
|
||||
"""
|
||||
radius_cells = effect.radius_ft // _SQUARE_FT
|
||||
center = target.pos
|
||||
|
||||
# Find all creatures in radius
|
||||
affected: list[CombatantState] = []
|
||||
for state in self._states:
|
||||
if not state.active:
|
||||
continue
|
||||
dist = self._grid.distance(center, state.pos)
|
||||
if dist <= radius_cells:
|
||||
affected.append(state)
|
||||
|
||||
if not affected:
|
||||
return "no targets in area"
|
||||
|
||||
total_damage = 0
|
||||
total_conditions = 0
|
||||
total_buffs = 0
|
||||
|
||||
for affected_state in affected:
|
||||
# Each creature gets a save
|
||||
creature_saved = False
|
||||
if effect.save_for_half:
|
||||
# Use the primary target's save result for the "primary" target
|
||||
# Others roll their own
|
||||
if affected_state is target:
|
||||
creature_saved = saved
|
||||
else:
|
||||
# Roll save for this creature
|
||||
# For fireball, it's a Reflex save
|
||||
save_result = self.resolve_save(affected_state, "ref", 15) # DC from spell
|
||||
creature_saved = save_result.success
|
||||
|
||||
for sub_effect in effect.effects:
|
||||
result = self._apply_single_spell_effect(
|
||||
sub_effect, affected_state, caster_live, self._stats[affected_state.combatant.id],
|
||||
spell_name=spell_name, saved=creature_saved, empower=empower, maximize=maximize
|
||||
)
|
||||
if "damage" in result:
|
||||
total_damage += int(result.split()[0])
|
||||
elif "damage" not in result and result:
|
||||
if "negated" not in result and "buff" not in result:
|
||||
total_conditions += 1
|
||||
elif "buff" in result:
|
||||
total_buffs += 1
|
||||
|
||||
return f"area: {len(affected)} targets, {total_damage} total damage, {total_conditions} conditions, {total_buffs} buffs"
|
||||
|
||||
def _apply_metamagic(
|
||||
self, spell: SpellSpec, metamagic_feats: list[str]
|
||||
) -> tuple[SpellEffect, ...]:
|
||||
@@ -1116,6 +1184,37 @@ class CombatEngine:
|
||||
elif isinstance(effect, HealEffect):
|
||||
# Empower/Maximize also apply to healing
|
||||
new_effects.append(effect)
|
||||
elif isinstance(effect, AreaEffect):
|
||||
new_sub_effects = []
|
||||
for sub in effect.effects:
|
||||
if isinstance(sub, DamageEffect):
|
||||
new_sub_effects.append(sub)
|
||||
elif isinstance(sub, ConditionEffect):
|
||||
new_duration = sub.duration_rounds * 2 if has_extend else sub.duration_rounds
|
||||
new_sub_effects.append(
|
||||
ConditionEffect(
|
||||
condition_name=sub.condition_name,
|
||||
duration_rounds=new_duration,
|
||||
save_negates=sub.save_negates,
|
||||
)
|
||||
)
|
||||
elif isinstance(sub, HealEffect):
|
||||
new_sub_effects.append(sub)
|
||||
else: # BuffEffect
|
||||
new_duration = sub.duration_rounds * 2 if has_extend else sub.duration_rounds
|
||||
new_sub_effects.append(
|
||||
BuffEffect(
|
||||
modifiers=sub.modifiers,
|
||||
duration_rounds=new_duration,
|
||||
)
|
||||
)
|
||||
new_effects.append(
|
||||
AreaEffect(
|
||||
radius_ft=effect.radius_ft,
|
||||
effects=tuple(new_sub_effects),
|
||||
save_for_half=effect.save_for_half,
|
||||
)
|
||||
)
|
||||
else: # BuffEffect
|
||||
new_duration = effect.duration_rounds * 2 if has_extend else effect.duration_rounds
|
||||
new_effects.append(
|
||||
|
||||
@@ -54,7 +54,22 @@ class BuffEffect:
|
||||
duration_rounds: int
|
||||
|
||||
|
||||
SpellEffect = DamageEffect | ConditionEffect | HealEffect | BuffEffect
|
||||
@dataclass(frozen=True)
|
||||
class AreaEffect:
|
||||
"""Area of effect spell (burst only for Phase 0).
|
||||
|
||||
- shape: only "burst" supported (circle around target point)
|
||||
- radius_ft: radius in feet (e.g., 20 for fireball)
|
||||
- effects: the effects applied to each creature in the area
|
||||
- save_for_half: if True, successful save halves damage
|
||||
"""
|
||||
shape: Literal["burst"] = "burst"
|
||||
radius_ft: int = 20
|
||||
effects: tuple[SpellEffect, ...] = ()
|
||||
save_for_half: bool = True
|
||||
|
||||
|
||||
SpellEffect = DamageEffect | ConditionEffect | HealEffect | BuffEffect | AreaEffect
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -111,10 +126,15 @@ def _parse_effect(data: dict[str, Json]) -> SpellEffect:
|
||||
)
|
||||
for m in cast("list[dict[str, Json]]", data["modifiers"])
|
||||
),
|
||||
duration_rounds=int(data["duration_rounds"]), # type: ignore[arg-type]
|
||||
duration_rounds=int(data.get("duration_rounds", 0)), # type: ignore[arg-type]
|
||||
)
|
||||
msg = f"Unknown effect type: {effect_type}"
|
||||
raise ValueError(msg)
|
||||
if effect_type == "area":
|
||||
return AreaEffect(
|
||||
radius_ft=int(data.get("radius_ft", 20)), # type: ignore[arg-type]
|
||||
effects=tuple(_parse_effect(e) for e in data["effects"]), # type: ignore[index]
|
||||
save_for_half=bool(data.get("save_for_half", True)),
|
||||
)
|
||||
raise ValueError(f"Unknown effect type: {effect_type}")
|
||||
|
||||
|
||||
def load_spell(path: str | Path) -> SpellSpec:
|
||||
|
||||
Reference in New Issue
Block a user