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:
@@ -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:
|
||||
|
||||
@@ -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)),
|
||||
)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user