feat(combat): area-of-effect spells

This commit is contained in:
ctan
2026-08-30 00:36:16 +02:00
parent de6c605808
commit 2615c9ccb2
4 changed files with 155 additions and 16 deletions
+11 -4
View File
@@ -7,10 +7,17 @@
"base_dc": 10,
"effects": [
{
"type": "damage",
"formula": "6d6",
"types": ["fire"],
"half_on_save": true
"type": "area",
"radius_ft": 20,
"save_for_half": true,
"effects": [
{
"type": "damage",
"formula": "6d6",
"types": ["fire"],
"half_on_save": true
}
]
}
],
"description": "A glowing bead streaks from you and bursts into a 20-foot radius explosion of fire, dealing 6d6 points of fire damage. Reflex save for half."
+99
View File
@@ -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(
+24 -4
View File
@@ -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:
+21 -8
View File
@@ -19,6 +19,7 @@ from pf1e_simulator.models import (
)
from pf1e_simulator.rng import ScriptedRng
from pf1e_simulator.spells import (
AreaEffect,
BuffEffect,
ConditionEffect,
DamageEffect,
@@ -132,9 +133,15 @@ class TestSpellLoading:
assert spell.range == "long"
assert spell.save == "ref"
assert spell.base_dc == 10
assert len(spell.effects) == 1
assert isinstance(spell.effects[0], DamageEffect)
assert spell.effects[0].half_on_save is True
assert isinstance(spell.effects[0], AreaEffect)
area_eff = spell.effects[0]
assert isinstance(area_eff, AreaEffect)
assert area_eff.radius_ft == 20
assert area_eff.save_for_half is True
assert len(area_eff.effects) == 1
inner = area_eff.effects[0]
assert isinstance(inner, DamageEffect)
assert inner.half_on_save is True
def test_hold_person_has_condition_effect(self) -> None:
spell = load_spell(_SPELLS_DIR / "hold_person.json")
@@ -211,7 +218,8 @@ class TestFireballSave:
def test_failed_save_full_damage(self) -> None:
caster = _make_caster()
target = _make_target(hp_max=200)
engine, c, t = _make_engine(caster, target, [2, 3, 3, 3, 3, 3, 3])
# 2 save rolls (2 targets in area) + 6 damage dice
engine, c, t = _make_engine(caster, target, [2, 2, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3])
hp_before = t.hp
engine._cast_spell(c, t, "Fireball")
assert t.hp == hp_before - (3 + 3 + 3 + 3 + 3 + 3)
@@ -219,7 +227,8 @@ class TestFireballSave:
def test_successful_save_half_damage(self) -> None:
caster = _make_caster()
target = _make_target(hp_max=200)
engine, c, t = _make_engine(caster, target, [20, 3, 3, 3, 3, 3, 3])
# 2 save rolls (2 targets in area) + 6 damage dice
engine, c, t = _make_engine(caster, target, [20, 20, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3])
hp_before = t.hp
engine._cast_spell(c, t, "Fireball")
full = 3 + 3 + 3 + 3 + 3 + 3
@@ -228,11 +237,13 @@ class TestFireballSave:
def test_transcript_shows_saved(self) -> None:
caster = _make_caster()
target = _make_target()
engine, c, t = _make_engine(caster, target, [20, 3, 3, 3, 3, 3, 3])
# 2 save rolls (2 targets in area) + 6 damage dice
engine, c, t = _make_engine(caster, target, [20, 20, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3])
engine._cast_spell(c, t, "Fireball")
assert any("SAVED" in line for line in engine._transcript)
class TestHoldPerson:
"""Given: a caster casting Hold Person (will negates, paralyzed)
When: target fails save
@@ -375,7 +386,8 @@ class TestSpellDcScalesWithLevel:
def test_dc_is_ten_plus_spell_level_plus_ability_mod(self) -> None:
caster = _make_caster(level=5) # int 18 -> mod +4
target = _make_target()
engine, c, t = _make_engine(caster, target, [20, 3, 3, 3, 3, 3, 3])
# 2 save rolls (2 targets in area) + 6 damage dice
engine, c, t = _make_engine(caster, target, [20, 20, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3])
engine._cast_spell(c, t, "Fireball") # fireball is level 3
# DC = 10 + spell_level(3) + int_mod(4) = 17
assert any("DC17" in line for line in engine._transcript)
@@ -393,7 +405,8 @@ class TestSpellDamageWithDr:
target_with_dr = target.model_copy(update={
"dr": DamageReduction(amount=5, bypass=frozenset()),
})
engine, c, t = _make_engine(caster, target_with_dr, [2, 3, 3, 3, 3, 3, 3])
# 2 save rolls (2 targets in area) + 6 damage dice
engine, c, t = _make_engine(caster, target_with_dr, [2, 2, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3])
hp_before = t.hp
engine._cast_spell(c, t, "Fireball")
# Save fails (2), 6d6 rolls all 3 = 18 damage, DR 5/- -> 13 damage