feat(rules): restrict bless to allies in a 50-ft burst

SRD spells/bless.md: 'The caster and all allies within a 50-ft. burst,
centered on the caster'. Add an AreaEffect.targets filter (all/allies/
enemies) applied where area creatures are collected; bless.json now
declares radius_ft 50 and targets allies (the caster included).
This commit is contained in:
2026-09-16 11:14:46 +02:00
parent 312f9fd6b8
commit e43066e0ef
4 changed files with 87 additions and 12 deletions
+3 -2
View File
@@ -9,7 +9,8 @@
"effects": [ "effects": [
{ {
"type": "area", "type": "area",
"radius_ft": 20, "radius_ft": 50,
"targets": "allies",
"save_for_half": false, "save_for_half": false,
"effects": [ "effects": [
{ {
@@ -23,5 +24,5 @@
] ]
} }
], ],
"description": "All allies within 20 ft/level (modeled as a 20-ft burst applied to all creatures in area) gain a +1 morale bonus on attack rolls and on saving throws against fear effects (modeled as Will). Duration 1 min/level approximated as 10 rounds." "description": "The caster and all allies within a 50-ft burst centered on the caster gain a +1 morale bonus on attack rolls and on saving throws against fear effects (modeled as Will). Duration 1 min/level approximated as 10 rounds."
} }
+13 -2
View File
@@ -6499,20 +6499,31 @@ class CombatEngine:
caster's anchor cell and opens toward ``aim`` (PF1e CRB "Aiming a caster's anchor cell and opens toward ``aim`` (PF1e CRB "Aiming a
Spell" — see ``areas.py``); a creature is affected when ANY of its Spell" — see ``areas.py``); a creature is affected when ANY of its
occupied cells lies inside the template. occupied cells lies inside the template.
``effect.targets`` restricts the selection (SRD bless/bane bursts):
"allies" keeps only creatures on the caster's side (the caster
included — SRD bless "The caster and all allies"), "enemies" keeps
only the opposite side, "all" keeps everyone.
""" """
span_cells = effect.radius_ft // _SQUARE_FT span_cells = effect.radius_ft // _SQUARE_FT
if effect.shape == "burst": if effect.shape == "burst":
return self._creatures_in_range(aim, span_cells) affected = self._creatures_in_range(aim, span_cells)
else:
origin = caster.pos origin = caster.pos
if effect.shape == "cone": if effect.shape == "cone":
cells = cone_cells(self._grid, origin, aim, span_cells) cells = cone_cells(self._grid, origin, aim, span_cells)
else: else:
cells = line_cells(self._grid, origin, aim, span_cells) cells = line_cells(self._grid, origin, aim, span_cells)
return [ affected = [
state state
for state in self._states for state in self._states
if state.active and state.occupied_cells() & cells if state.active and state.occupied_cells() & cells
] ]
if effect.targets == "allies":
return [s for s in affected if s.side == caster.side]
if effect.targets == "enemies":
return [s for s in affected if s.side != caster.side]
return affected
def _creatures_in_range( def _creatures_in_range(
self, center: tuple[int, int], radius_cells: int self, center: tuple[int, int], radius_cells: int
+19
View File
@@ -33,6 +33,10 @@ SaveType = Literal["fort", "ref", "will", "none"]
# point), cone (90° quarter circle from the caster), line (5-ft ray from the # point), cone (90° quarter circle from the caster), line (5-ft ray from the
# caster to its length). # caster to its length).
AreaShape = Literal["burst", "cone", "line"] AreaShape = Literal["burst", "cone", "line"]
# PF1e area targeting (SRD spells/bless.md, spells/bane.md): a burst may
# affect only allies ("The caster and all allies within a 50-ft. burst") or
# only enemies ("Enemies within the area..."); the default affects everyone.
AreaTargets = Literal["all", "allies", "enemies"]
# PF1e casting times, normalized to budget codes (see combat._cast_time_slot). # PF1e casting times, normalized to budget codes (see combat._cast_time_slot).
CastTime = Literal["standard", "swift", "immediate", "free", "full_round", "one_round", "long"] CastTime = Literal["standard", "swift", "immediate", "free", "full_round", "one_round", "long"]
@@ -226,11 +230,14 @@ class AreaEffect:
- radius_ft: burst radius / cone length / line length, in feet - radius_ft: burst radius / cone length / line length, in feet
- effects: the effects applied to each creature in the area - effects: the effects applied to each creature in the area
- 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",
"enemies" — SRD bless/bane bursts)
""" """
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"
@dataclass(frozen=True) @dataclass(frozen=True)
class SummonEffect: class SummonEffect:
@@ -358,6 +365,10 @@ def _is_area_shape(value: str) -> TypeGuard[AreaShape]:
return value in get_args(AreaShape) return value in get_args(AreaShape)
def _is_area_targets(value: str) -> TypeGuard[AreaTargets]:
return value in get_args(AreaTargets)
def _is_cast_time(value: str) -> TypeGuard[CastTime]: def _is_cast_time(value: str) -> TypeGuard[CastTime]:
return value in get_args(CastTime) return value in get_args(CastTime)
@@ -566,11 +577,19 @@ def _parse_effect(data: Json) -> SpellEffect:
if not _is_area_shape(shape_raw): if not _is_area_shape(shape_raw):
msg = f"unknown area shape: {shape_raw!r} (expected one of {get_args(AreaShape)})" msg = f"unknown area shape: {shape_raw!r} (expected one of {get_args(AreaShape)})"
raise ValueError(msg) raise ValueError(msg)
targets_raw = _as_str(effect.get("targets", "all"))
if not _is_area_targets(targets_raw):
msg = (
f"unknown area targets: {targets_raw!r} "
f"(expected one of {get_args(AreaTargets)})"
)
raise ValueError(msg)
return AreaEffect( return AreaEffect(
shape=shape_raw, shape=shape_raw,
radius_ft=_as_int(effect.get("radius_ft", 20)), radius_ft=_as_int(effect.get("radius_ft", 20)),
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,
) )
if effect_type == "summon": if effect_type == "summon":
return SummonEffect( return SummonEffect(
+44
View File
@@ -635,6 +635,50 @@ class TestBullsStrength:
assert any("buff" in line for line in engine._transcript) assert any("buff" in line for line in engine._transcript)
class TestBlessAlliesOnly:
"""Given: a caster casting Bless (SRD: 50-ft burst, "the caster and all
allies")
When: _cast_spell is called
Then: the caster and allies inside the burst are buffed, enemies inside
the burst are not."""
def _bless_engine(
self,
) -> tuple[CombatEngine, CombatantState, CombatantState, CombatantState]:
caster = _make_caster()
ally = _make_target("ally", hp_max=20)
enemy = _make_target("enemy", hp_max=20)
c = CombatantState(combatant=caster, side="players", pos=(0, 0), hp=caster.hp_max)
a = CombatantState(combatant=ally, side="players", pos=(1, 0), hp=ally.hp_max)
e = CombatantState(combatant=enemy, side="monsters", pos=(2, 0), hp=enemy.hp_max)
legend = {".": TerrainType(type="floor", move_cost=1)}
spec = MapSpec(name="test", terrain=tuple(["." * 12] * 12), legend=legend)
grid = Grid.from_spec(spec)
engine = CombatEngine(
ScriptedRng([]), grid, [c, a, e],
spell_registry=load_spell_registry(_SPELLS_DIR),
)
return engine, c, a, e
def test_allies_buffed_enemy_not(self) -> None:
engine, c, a, e = self._bless_engine()
engine._cast_spell(c, c, "Bless")
caster_buffs = [m for b in c.active_buffs for m in b.modifiers]
ally_buffs = [m for b in a.active_buffs for m in b.modifiers]
assert any(m.target == "attack" and m.value == 1 for m in caster_buffs)
assert any(m.target == "attack" and m.value == 1 for m in ally_buffs)
assert not e.active_buffs
def test_enemy_outside_burst_unaffected(self) -> None:
engine, c, a, e = self._bless_engine()
# Move the enemy beyond the 50-ft burst (10 cells) before casting.
e.pos = (11, 0)
engine._cast_spell(c, c, "Bless")
assert not e.active_buffs
assert any(m.target == "attack" and m.value == 1
for b in c.active_buffs for m in b.modifiers)
class TestOutOfRange: class TestOutOfRange:
"""Given: a target beyond the spell's maximum range """Given: a target beyond the spell's maximum range
When: _cast_spell is called When: _cast_spell is called