From 8ae344fd1632b594f7b34892ca46632cb74b147b Mon Sep 17 00:00:00 2001 From: ctan Date: Fri, 4 Sep 2026 22:55:43 +0200 Subject: [PATCH] feat(combat): area-of-effect spells --- data/spells/fireball.json | 15 ++++-- src/pf1e_simulator/combat.py | 99 ++++++++++++++++++++++++++++++++++++ src/pf1e_simulator/spells.py | 28 ++++++++-- tests/test_spells.py | 29 ++++++++--- 4 files changed, 155 insertions(+), 16 deletions(-) diff --git a/data/spells/fireball.json b/data/spells/fireball.json index a378bc2..86debf0 100644 --- a/data/spells/fireball.json +++ b/data/spells/fireball.json @@ -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." diff --git a/src/pf1e_simulator/combat.py b/src/pf1e_simulator/combat.py index 887caa9..94fd67b 100644 --- a/src/pf1e_simulator/combat.py +++ b/src/pf1e_simulator/combat.py @@ -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( diff --git a/src/pf1e_simulator/spells.py b/src/pf1e_simulator/spells.py index 3d3c00a..337bdb4 100644 --- a/src/pf1e_simulator/spells.py +++ b/src/pf1e_simulator/spells.py @@ -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: diff --git a/tests/test_spells.py b/tests/test_spells.py index f54f235..54de81e 100644 --- a/tests/test_spells.py +++ b/tests/test_spells.py @@ -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