diff --git a/data/spells/bless.json b/data/spells/bless.json index f43b0a7..4c598b6 100644 --- a/data/spells/bless.json +++ b/data/spells/bless.json @@ -9,7 +9,8 @@ "effects": [ { "type": "area", - "radius_ft": 20, + "radius_ft": 50, + "targets": "allies", "save_for_half": false, "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." } diff --git a/src/pf1e_simulator/combat.py b/src/pf1e_simulator/combat.py index 38d6ac0..84e4136 100644 --- a/src/pf1e_simulator/combat.py +++ b/src/pf1e_simulator/combat.py @@ -6499,20 +6499,31 @@ class CombatEngine: caster's anchor cell and opens toward ``aim`` (PF1e CRB "Aiming a Spell" — see ``areas.py``); a creature is affected when ANY of its 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 if effect.shape == "burst": - return self._creatures_in_range(aim, span_cells) - origin = caster.pos - if effect.shape == "cone": - cells = cone_cells(self._grid, origin, aim, span_cells) + affected = self._creatures_in_range(aim, span_cells) else: - cells = line_cells(self._grid, origin, aim, span_cells) - return [ - state - for state in self._states - if state.active and state.occupied_cells() & cells - ] + origin = caster.pos + if effect.shape == "cone": + cells = cone_cells(self._grid, origin, aim, span_cells) + else: + cells = line_cells(self._grid, origin, aim, span_cells) + affected = [ + state + for state in self._states + 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( self, center: tuple[int, int], radius_cells: int diff --git a/src/pf1e_simulator/spells.py b/src/pf1e_simulator/spells.py index 0b80188..b811914 100644 --- a/src/pf1e_simulator/spells.py +++ b/src/pf1e_simulator/spells.py @@ -33,6 +33,10 @@ SaveType = Literal["fort", "ref", "will", "none"] # point), cone (90° quarter circle from the caster), line (5-ft ray from the # caster to its length). 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). 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 - effects: the effects applied to each creature in the area - 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" radius_ft: int = 20 effects: tuple[SpellEffect, ...] = () save_for_half: bool = True + targets: AreaTargets = "all" @dataclass(frozen=True) class SummonEffect: @@ -358,6 +365,10 @@ def _is_area_shape(value: str) -> TypeGuard[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]: return value in get_args(CastTime) @@ -566,11 +577,19 @@ def _parse_effect(data: Json) -> SpellEffect: if not _is_area_shape(shape_raw): msg = f"unknown area shape: {shape_raw!r} (expected one of {get_args(AreaShape)})" 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( shape=shape_raw, radius_ft=_as_int(effect.get("radius_ft", 20)), 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, ) if effect_type == "summon": return SummonEffect( diff --git a/tests/test_spells.py b/tests/test_spells.py index 209611c..655d4bb 100644 --- a/tests/test_spells.py +++ b/tests/test_spells.py @@ -635,6 +635,50 @@ class TestBullsStrength: 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: """Given: a target beyond the spell's maximum range When: _cast_spell is called