feat(combat): area-of-effect spells
This commit is contained in:
@@ -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."
|
||||
|
||||
@@ -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:
|
||||
|
||||
+21
-8
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user