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
+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: