fix(rules): self-centered bursts for bless and bane - AreaEffect gains a data-driven 'centered_on_caster' flag (bless.json/bane.json set it per SRD bless.md '50-ft. burst, centered on the caster' and bane.md 'centered on you'); the burst origin is the caster's square regardless of the resolved target or a U7b target_pos, and with no chosen origin there is no primary save - every creature in the area rolls its own (mirror of the point-cast path); snapshot area key exposes centered_on_caster; golden transcript unchanged; +6 tests

This commit is contained in:
2026-09-19 22:38:39 +02:00
parent 4665a1c245
commit 9f0d86273a
7 changed files with 189 additions and 10 deletions
+10 -3
View File
@@ -1170,7 +1170,14 @@ Règles modélisées :
- Formes de zone (S1a) et scalage par niveau d'incanteur (S1b) :
`AreaEffect.shape` vaut `burst` (défaut), `cone` ou `line` (CRB « Magic —
Aiming a Spell »). Le **burst** reste un cercle centré sur la case résolue
(cible ou `target_pos`, U7b). Le **cône** (90° — « a quarter-circle », la
(cible ou `target_pos`, U7b) — **sauf** si l'`AreaEffect` déclare
`"centered_on_caster": true` : le point d'origine est alors **la case du
jeteur**, quel que soit la cible résolue ou le `target_pos` (SRD
spells/bless.md : « The caster and all allies within a 50-ft. burst,
centered on the caster » ; spells/bane.md : « 50-ft.-radius burst, centered
on you ») ; faute de cible primaire désignée, la sauvegarde primaire est
sautée et **chaque** créature de la zone jette la sienne (comme au
point-cast U7b). `bless.json` et `bane.json` portent le drapeau.. Le **cône** (90° — « a quarter-circle », la
largeur valant la longueur à chaque distance) et la **ligne** (5 ft de large,
longueur = `radius_ft`) partent de la **case du jeteur** et sont pointés
vers la cible résolue (`target_id`) ou le point désigné (`target_pos`) — le
@@ -1201,7 +1208,7 @@ Règles modélisées :
sauvegarde primaire de la cible résolue, jetée sur le chemin générique des
sorts avant la résolution de la zone, ne rejoue pas ce bonus (nul pour un
burst centré sur la cible, l'origine étant sa propre case). Le snapshot
d'observation expose `{shape, radius_ft}` via la clé `area`.
d'observation expose `{shape, radius_ft, centered_on_caster}` via la clé `area`.
Un `DamageEffect` peut déclarer `"scaling": {"dice": "1d6", "per_levels": 1,
"max_dice": 10}` (dés par niveau — Fireball/Lightning Bolt « 1d6 per caster
level (maximum 10d6) », Burning Hands « 1d4 per level (maximum 5d4) »,
@@ -1672,7 +1679,7 @@ Règles modélisées :
spells (résumés enrichis du registre : name, level, school, range_ft,
save, dc, summary, half_on_save, save_negates (E4), harmless (sort «
(harmless) » du SRD — sauvegarde sautée sur cible consentante), area {shape,
radius_ft} | None (U7a : premier AreaEffect de niveau supérieur du sort,
radius_ft, centered_on_caster} | None (U7a : premier AreaEffect de niveau supérieur du sort,
gabarit de zone structuré, None si le sort n'a pas de zone), cast_time
(code de temps d'incantation, « standard » pour un sort inconnu du
registre)),
+1
View File
@@ -11,6 +11,7 @@
"type": "area",
"radius_ft": 50,
"targets": "enemies",
"centered_on_caster": true,
"save_for_half": false,
"effects": [
{
+1
View File
@@ -11,6 +11,7 @@
"type": "area",
"radius_ft": 50,
"targets": "allies",
"centered_on_caster": true,
"save_for_half": false,
"effects": [
{
+33 -4
View File
@@ -506,6 +506,21 @@ def _condition_definition_dict(cond: Condition) -> dict[str, object]:
}
def _spell_centered_on_caster(spell: SpellSpec) -> bool:
"""True when the spell's WHOLE delivery is a caster-centered area.
SRD bless.md ("The caster and all allies within a 50-ft. burst, centered
on the caster") / bane.md ("50-ft.-radius burst, centered on you"): the
chosen target is irrelevant to the geometry, so there is no primary
target to save against - every creature in the area rolls its own (like
the U7b point cast).
"""
return bool(spell.effects) and all(
isinstance(effect, AreaEffect) and effect.centered_on_caster
for effect in spell.effects
)
def _spell_half_on_save(spell: SpellSpec) -> bool:
"""True if any effect grants half damage on a successful save."""
for effect in spell.effects:
@@ -536,13 +551,18 @@ def _spell_declares_projectiles(spell: SpellSpec) -> bool:
def _spell_area_dict(spell: SpellSpec) -> dict[str, object] | None:
"""Structured area template of a spell, or None if it has no top-level AreaEffect.
Returns ``{"shape": str, "radius_ft": int}`` for the FIRST AreaEffect found
Returns ``{"shape": str, "radius_ft": int, "centered_on_caster": bool}``
for the FIRST AreaEffect found
in ``spell.effects`` (a spell may declare several effects; only the first
area is exposed, and effects nested inside an AreaEffect are not scanned).
"""
for effect in spell.effects:
if isinstance(effect, AreaEffect):
return {"shape": effect.shape, "radius_ft": effect.radius_ft}
return {
"shape": effect.shape,
"radius_ft": effect.radius_ft,
"centered_on_caster": effect.centered_on_caster,
}
return None
@@ -6775,6 +6795,7 @@ class CombatEngine:
target, spell.save, dc, save_descriptors=spell_save_descriptors(spell)
)
if spell.save != "none" and target is not None
and not _spell_centered_on_caster(spell)
and not self._save_forgone(caster, target, spell)
else None
)
@@ -7358,7 +7379,11 @@ class CombatEngine:
designate"). Every creature whose occupied cells intersect the
template is subject to the effect.
"""
center = _burst_center(target, center_pos)
center = (
caster.pos
if effect.centered_on_caster
else _burst_center(target, center_pos)
)
if center is None:
return "no targets in area"
affected = self._area_creatures(effect, caster, center)
@@ -7382,7 +7407,11 @@ class CombatEngine:
if spell_resistance and not self._check_spell_resistance(caster, affected_state):
continue
creature_saved = self._area_save(
affected_state, target, effect, saved=saved, dc=dc,
# A caster-centered burst has NO primary save (skipped at the
# cast site): the resolved target must roll its own like every
# other creature, so it is not special-cased here.
affected_state, None if effect.centered_on_caster else target,
effect, saved=saved, dc=dc,
save_type=save_type, origin_cells=origin_cells,
)
for sub_effect in effect.effects:
+8
View File
@@ -315,12 +315,19 @@ class AreaEffect:
- save_for_half: if True, successful save halves damage
- targets: which creatures in the area are affected ("all", "allies",
"enemies" — SRD bless/bane bursts)
- centered_on_caster: the burst's point of origin is the CASTER, not the
resolved target or the U7b point (SRD bless.md: "The caster and all
allies within a 50-ft. burst, centered on the caster"; bane.md: "50-ft.
radius burst, centered on you"). Burst shape only; honored by the area
application, which also skips the primary save so every creature in
the area rolls its own.
"""
shape: AreaShape = "burst"
radius_ft: int = 20
effects: tuple[SpellEffect, ...] = ()
save_for_half: bool = True
targets: AreaTargets = "all"
centered_on_caster: bool = False
@dataclass(frozen=True)
class SummonEffect:
@@ -755,6 +762,7 @@ def _parse_area_effect(effect: dict[str, Json]) -> AreaEffect:
effects=tuple(_parse_effect(e) for e in _as_list(effect["effects"])),
save_for_half=bool(effect.get("save_for_half", True)),
targets=targets_raw,
centered_on_caster=bool(effect.get("centered_on_caster", False)),
)
+133
View File
@@ -0,0 +1,133 @@
"""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
+3 -3
View File
@@ -88,7 +88,7 @@ def test_area_snapshot_burst_exposes_shape_and_radius() -> None:
When: its snapshot dict is built
Then: area equals {"shape": "burst", "radius_ft": 20}."""
s = _spell_dict_for(_FIREBALL, "Fireball")
assert s["area"] == {"shape": "burst", "radius_ft": 20}
assert s["area"] == {"shape": "burst", "radius_ft": 20, "centered_on_caster": False}
def test_area_snapshot_single_target_is_none() -> None:
@@ -104,7 +104,7 @@ def test_area_snapshot_first_area_wins() -> None:
When: its snapshot dict is built
Then: only the FIRST AreaEffect is exposed."""
s = _spell_dict_for(_TWO_AREAS, "Twin Burst")
assert s["area"] == {"shape": "burst", "radius_ft": 20}
assert s["area"] == {"shape": "burst", "radius_ft": 20, "centered_on_caster": False}
def test_area_snapshot_unknown_spell_is_none() -> None:
@@ -175,5 +175,5 @@ def test_area_snapshot_is_json_serializable() -> None:
json.dumps(data)
restored = json.loads(json.dumps(data))
spells = restored["combatants"][0]["spells"]
assert spells[0]["area"] == {"shape": "burst", "radius_ft": 20}
assert spells[0]["area"] == {"shape": "burst", "radius_ft": 20, "centered_on_caster": False}
assert spells[1]["area"] is None