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:
@@ -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."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user