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:
@@ -1170,7 +1170,14 @@ Règles modélisées :
|
|||||||
- Formes de zone (S1a) et scalage par niveau d'incanteur (S1b) :
|
- Formes de zone (S1a) et scalage par niveau d'incanteur (S1b) :
|
||||||
`AreaEffect.shape` vaut `burst` (défaut), `cone` ou `line` (CRB « Magic —
|
`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
|
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,
|
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
|
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
|
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
|
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
|
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
|
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,
|
Un `DamageEffect` peut déclarer `"scaling": {"dice": "1d6", "per_levels": 1,
|
||||||
"max_dice": 10}` (dés par niveau — Fireball/Lightning Bolt « 1d6 per caster
|
"max_dice": 10}` (dés par niveau — Fireball/Lightning Bolt « 1d6 per caster
|
||||||
level (maximum 10d6) », Burning Hands « 1d4 per level (maximum 5d4) »,
|
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,
|
spells (résumés enrichis du registre : name, level, school, range_ft,
|
||||||
save, dc, summary, half_on_save, save_negates (E4), harmless (sort «
|
save, dc, summary, half_on_save, save_negates (E4), harmless (sort «
|
||||||
(harmless) » du SRD — sauvegarde sautée sur cible consentante), area {shape,
|
(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
|
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
|
(code de temps d'incantation, « standard » pour un sort inconnu du
|
||||||
registre)),
|
registre)),
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
"type": "area",
|
"type": "area",
|
||||||
"radius_ft": 50,
|
"radius_ft": 50,
|
||||||
"targets": "enemies",
|
"targets": "enemies",
|
||||||
|
"centered_on_caster": true,
|
||||||
"save_for_half": false,
|
"save_for_half": false,
|
||||||
"effects": [
|
"effects": [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
"type": "area",
|
"type": "area",
|
||||||
"radius_ft": 50,
|
"radius_ft": 50,
|
||||||
"targets": "allies",
|
"targets": "allies",
|
||||||
|
"centered_on_caster": true,
|
||||||
"save_for_half": false,
|
"save_for_half": false,
|
||||||
"effects": [
|
"effects": [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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:
|
def _spell_half_on_save(spell: SpellSpec) -> bool:
|
||||||
"""True if any effect grants half damage on a successful save."""
|
"""True if any effect grants half damage on a successful save."""
|
||||||
for effect in spell.effects:
|
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:
|
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.
|
"""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
|
in ``spell.effects`` (a spell may declare several effects; only the first
|
||||||
area is exposed, and effects nested inside an AreaEffect are not scanned).
|
area is exposed, and effects nested inside an AreaEffect are not scanned).
|
||||||
"""
|
"""
|
||||||
for effect in spell.effects:
|
for effect in spell.effects:
|
||||||
if isinstance(effect, AreaEffect):
|
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
|
return None
|
||||||
|
|
||||||
|
|
||||||
@@ -6775,6 +6795,7 @@ class CombatEngine:
|
|||||||
target, spell.save, dc, save_descriptors=spell_save_descriptors(spell)
|
target, spell.save, dc, save_descriptors=spell_save_descriptors(spell)
|
||||||
)
|
)
|
||||||
if spell.save != "none" and target is not None
|
if spell.save != "none" and target is not None
|
||||||
|
and not _spell_centered_on_caster(spell)
|
||||||
and not self._save_forgone(caster, target, spell)
|
and not self._save_forgone(caster, target, spell)
|
||||||
else None
|
else None
|
||||||
)
|
)
|
||||||
@@ -7358,7 +7379,11 @@ class CombatEngine:
|
|||||||
designate"). Every creature whose occupied cells intersect the
|
designate"). Every creature whose occupied cells intersect the
|
||||||
template is subject to the effect.
|
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:
|
if center is None:
|
||||||
return "no targets in area"
|
return "no targets in area"
|
||||||
affected = self._area_creatures(effect, caster, center)
|
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):
|
if spell_resistance and not self._check_spell_resistance(caster, affected_state):
|
||||||
continue
|
continue
|
||||||
creature_saved = self._area_save(
|
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,
|
save_type=save_type, origin_cells=origin_cells,
|
||||||
)
|
)
|
||||||
for sub_effect in effect.effects:
|
for sub_effect in effect.effects:
|
||||||
|
|||||||
@@ -315,12 +315,19 @@ class AreaEffect:
|
|||||||
- save_for_half: if True, successful save halves damage
|
- save_for_half: if True, successful save halves damage
|
||||||
- targets: which creatures in the area are affected ("all", "allies",
|
- targets: which creatures in the area are affected ("all", "allies",
|
||||||
"enemies" — SRD bless/bane bursts)
|
"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"
|
shape: AreaShape = "burst"
|
||||||
radius_ft: int = 20
|
radius_ft: int = 20
|
||||||
effects: tuple[SpellEffect, ...] = ()
|
effects: tuple[SpellEffect, ...] = ()
|
||||||
save_for_half: bool = True
|
save_for_half: bool = True
|
||||||
targets: AreaTargets = "all"
|
targets: AreaTargets = "all"
|
||||||
|
centered_on_caster: bool = False
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class SummonEffect:
|
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"])),
|
effects=tuple(_parse_effect(e) for e in _as_list(effect["effects"])),
|
||||||
save_for_half=bool(effect.get("save_for_half", True)),
|
save_for_half=bool(effect.get("save_for_half", True)),
|
||||||
targets=targets_raw,
|
targets=targets_raw,
|
||||||
|
centered_on_caster=bool(effect.get("centered_on_caster", False)),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -88,7 +88,7 @@ def test_area_snapshot_burst_exposes_shape_and_radius() -> None:
|
|||||||
When: its snapshot dict is built
|
When: its snapshot dict is built
|
||||||
Then: area equals {"shape": "burst", "radius_ft": 20}."""
|
Then: area equals {"shape": "burst", "radius_ft": 20}."""
|
||||||
s = _spell_dict_for(_FIREBALL, "Fireball")
|
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:
|
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
|
When: its snapshot dict is built
|
||||||
Then: only the FIRST AreaEffect is exposed."""
|
Then: only the FIRST AreaEffect is exposed."""
|
||||||
s = _spell_dict_for(_TWO_AREAS, "Twin Burst")
|
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:
|
def test_area_snapshot_unknown_spell_is_none() -> None:
|
||||||
@@ -175,5 +175,5 @@ def test_area_snapshot_is_json_serializable() -> None:
|
|||||||
json.dumps(data)
|
json.dumps(data)
|
||||||
restored = json.loads(json.dumps(data))
|
restored = json.loads(json.dumps(data))
|
||||||
spells = restored["combatants"][0]["spells"]
|
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
|
assert spells[1]["area"] is None
|
||||||
|
|||||||
Reference in New Issue
Block a user