From 9f0d86273af48a93d0f93eb7dd744b59e672ba9c Mon Sep 17 00:00:00 2001 From: ctan Date: Sat, 19 Sep 2026 22:38:39 +0200 Subject: [PATCH] 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 --- README.md | 13 ++- data/spells/bane.json | 1 + data/spells/bless.json | 1 + src/pf1e_simulator/combat.py | 37 ++++++++- src/pf1e_simulator/spells.py | 8 ++ tests/test_self_centered_burst.py | 133 ++++++++++++++++++++++++++++++ tests/test_spell_area_snapshot.py | 6 +- 7 files changed, 189 insertions(+), 10 deletions(-) create mode 100644 tests/test_self_centered_burst.py diff --git a/README.md b/README.md index d9a4f1a..b4863c1 100644 --- a/README.md +++ b/README.md @@ -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)), diff --git a/data/spells/bane.json b/data/spells/bane.json index 4fac823..773ab80 100644 --- a/data/spells/bane.json +++ b/data/spells/bane.json @@ -11,6 +11,7 @@ "type": "area", "radius_ft": 50, "targets": "enemies", + "centered_on_caster": true, "save_for_half": false, "effects": [ { diff --git a/data/spells/bless.json b/data/spells/bless.json index 4c598b6..4c5dacf 100644 --- a/data/spells/bless.json +++ b/data/spells/bless.json @@ -11,6 +11,7 @@ "type": "area", "radius_ft": 50, "targets": "allies", + "centered_on_caster": true, "save_for_half": false, "effects": [ { diff --git a/src/pf1e_simulator/combat.py b/src/pf1e_simulator/combat.py index 1a089bc..a290821 100644 --- a/src/pf1e_simulator/combat.py +++ b/src/pf1e_simulator/combat.py @@ -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: diff --git a/src/pf1e_simulator/spells.py b/src/pf1e_simulator/spells.py index 5b5f840..7238f12 100644 --- a/src/pf1e_simulator/spells.py +++ b/src/pf1e_simulator/spells.py @@ -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)), ) diff --git a/tests/test_self_centered_burst.py b/tests/test_self_centered_burst.py new file mode 100644 index 0000000..3a25125 --- /dev/null +++ b/tests/test_self_centered_burst.py @@ -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 diff --git a/tests/test_spell_area_snapshot.py b/tests/test_spell_area_snapshot.py index c659aaa..76f9461 100644 --- a/tests/test_spell_area_snapshot.py +++ b/tests/test_spell_area_snapshot.py @@ -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