Files
pf1e-simulator/tests/test_self_centered_burst.py
T

134 lines
6.0 KiB
Python

"""Self-centered bursts: Bless and Bane originate on the CASTER.
PF1e (SRD spells/bless.md): "Area: The caster and all allies within a 50-ft.
burst, centered on the caster"; (SRD spells/bane.md): "Area: 50-ft.-radius
burst, centered on you". The engine honors a data-driven
``AreaEffect.centered_on_caster`` flag (set on bless.json / bane.json): the
burst's point of origin is the caster's square regardless of the resolved
target (or a U7b ``target_pos``), and there is no primary save - every
creature in the area rolls its own, like the point-targeted path.
"""
from __future__ import annotations
from pf1e_simulator.combat import CombatantState, CombatEngine
from pf1e_simulator.grid import Grid
from pf1e_simulator.map import MapSpec, TerrainType
from pf1e_simulator.rng import ScriptedRng
from pf1e_simulator.spells import SPELLS_DIR, AreaEffect, load_spell_registry
from tests.test_spells import _make_caster, _make_target
def _engine(queue: list[int], states: list[CombatantState]) -> CombatEngine:
legend = {".": TerrainType(type="floor", move_cost=1)}
spec = MapSpec(name="open", terrain=tuple(["." * 12] * 12), legend=legend)
return CombatEngine(
ScriptedRng(queue), Grid.from_spec(spec), states,
spell_registry=load_spell_registry(SPELLS_DIR),
)
def _state(cid: str, side: str, pos: tuple[int, int], *, caster: bool = False,
spells: tuple[str, ...] = ()) -> CombatantState:
model = (
_make_caster(cid).model_copy(update={"spells": list(spells)})
if caster
else _make_target(cid)
)
return CombatantState(combatant=model, side=side, pos=pos, hp=model.hp_max)
def _has_buff(state: CombatantState, spell_name: str) -> bool:
return any(b.source == f"spell: {spell_name}" for b in state.active_buffs)
def _has_condition(state: CombatantState, name: str) -> bool:
return any(ac.condition.name == name for ac in state.active_conditions)
def test_registry_marks_bless_and_bane_centered() -> None:
registry = load_spell_registry(SPELLS_DIR)
for name in ("Bless", "Bane"):
areas = [e for e in registry[name].effects if isinstance(e, AreaEffect)]
assert len(areas) == 1
assert areas[0].centered_on_caster is True
fireball = registry["Fireball"].effects[0]
assert isinstance(fireball, AreaEffect)
assert fireball.centered_on_caster is False
def test_bless_burst_is_centered_on_the_caster_not_the_target() -> None:
"""Given: a caster at (0,0), an ally at (1,0) and a far ally at (11,11)
When: Bless is cast with the FAR ally as the resolved target
Then: the 50-ft burst opens on the caster: the near ally and the caster
are buffed, the far target (65 ft away, outside the burst) is not — the
chosen target does not move the origin (SRD bless.md)."""
caster = _state("caster", "players", (0, 0), caster=True, spells=("Bless",))
near = _state("near", "players", (1, 0))
far = _state("far", "players", (11, 11))
engine = _engine([], [caster, near, far])
engine._cast_spell(caster, far, "Bless")
assert _has_buff(caster, "Bless")
assert _has_buff(near, "Bless")
assert not _has_buff(far, "Bless")
def test_bless_ignores_an_explicit_center_pos() -> None:
"""Given: the same geometry
When: Bless is point-cast (U7b) with center_pos far from the caster
Then: the caster-centered flag wins — the burst still opens on the
caster ("centered on the caster" is not a chosen origin)."""
caster = _state("caster", "players", (0, 0), caster=True, spells=("Bless",))
near = _state("near", "players", (1, 0))
engine = _engine([], [caster, near])
engine._cast_spell(caster, near, "Bless", center_pos=(10, 10))
assert _has_buff(near, "Bless")
def test_bane_affects_enemies_in_the_casters_burst_with_own_saves() -> None:
"""Given: an enemy at (2,0) inside the caster's 50-ft burst and a far
enemy at (10,10) chosen as the resolved target
When: Bane is cast and the near enemy's Will save (the ONLY die in the
queue) succeeds
Then: there is no primary save on the far target (a centered burst has no
chosen origin — every creature in the area rolls its own); the near enemy
saves and takes nothing, the far enemy is outside the burst and rolls
nothing."""
caster = _state("caster", "players", (0, 0), caster=True, spells=("Bane",))
near = _state("near", "monsters", (2, 0))
far = _state("far", "monsters", (10, 10))
engine = _engine([20], [caster, near, far])
engine._cast_spell(caster, far, "Bane")
assert not _has_condition(near, "bane")
assert not _has_condition(far, "bane")
assert any("cast Bane vs far" in line and "NO SAVE" in line
for line in engine._transcript)
def test_bane_failed_save_imposes_the_condition() -> None:
"""Given: the same geometry
When: the near enemy fails its own Will save (d20=1, the only die)
Then: the bane condition lands on it — and the far target consumed no
primary die (the queue would be exhausted otherwise)."""
caster = _state("caster", "players", (0, 0), caster=True, spells=("Bane",))
near = _state("near", "monsters", (2, 0))
far = _state("far", "monsters", (10, 10))
engine = _engine([1], [caster, near, far])
engine._cast_spell(caster, far, "Bane")
assert _has_condition(near, "bane")
assert not _has_condition(far, "bane")
def test_target_centered_burst_still_uses_the_target_cell() -> None:
"""Contrast: Fireball (no centered_on_caster flag) still bursts on the
resolved target — the primary save and the origin are unchanged."""
caster = _state("caster", "players", (0, 0), caster=True,
spells=("Fireball",))
enemy = _state("enemy", "monsters", (5, 0))
# Rolls: primary Reflex save (d20=1, fails), then the 6d6 damage dice
# (base 1d6 + 5 per caster level, CRB scaling) - the target-centered
# path is untouched by the centered_on_caster flag.
engine = _engine([1, 1, 1, 1, 1, 1, 1], [caster, enemy])
engine._cast_spell(caster, enemy, "Fireball")
assert enemy.hp < enemy.combatant.hp_max