diff --git a/data/spells/bane.json b/data/spells/bane.json index 613f3a1..3d5978b 100644 --- a/data/spells/bane.json +++ b/data/spells/bane.json @@ -8,11 +8,19 @@ "cast_time": "standard", "effects": [ { - "type": "condition", - "condition_name": "shaken", - "duration_rounds": 10, - "save_negates": true + "type": "area", + "radius_ft": 50, + "targets": "enemies", + "save_for_half": false, + "effects": [ + { + "type": "condition", + "condition_name": "shaken", + "duration_rounds": 10, + "save_negates": true + } + ] } ], - "description": "Enemies within range take a -1 penalty on attack rolls and saving throws (approximated with the shaken condition, -2). Will negates. Concentration 1 min/level approximated as 10 rounds." -} + "description": "SRD spells/bane.md: enemies within a 50-ft burst centered on the caster take a -1 penalty on attack rolls and a -1 penalty on saving throws against fear effects; Will negates. The -1 penalty is approximated with the shaken condition (-2 on attacks and saves), matching the existing Doom approximation. Duration 1 min/level approximated as 10 rounds." +} \ No newline at end of file diff --git a/src/pf1e_simulator/combat.py b/src/pf1e_simulator/combat.py index 84e4136..dcc7cf7 100644 --- a/src/pf1e_simulator/combat.py +++ b/src/pf1e_simulator/combat.py @@ -6613,6 +6613,7 @@ class CombatEngine: for sub in effect.effects ), save_for_half=effect.save_for_half, + targets=effect.targets, ) return effect diff --git a/tests/test_spells.py b/tests/test_spells.py index 655d4bb..84e4986 100644 --- a/tests/test_spells.py +++ b/tests/test_spells.py @@ -679,6 +679,55 @@ class TestBlessAlliesOnly: for b in c.active_buffs for m in b.modifiers) +class TestBaneEnemiesOnly: + """Given: a caster casting Bane (SRD: 50-ft burst, enemies, Will negates) + When: _cast_spell is called + Then: enemies in the burst are debuffed, allies and caster are not.""" + + def _bane_engine( + self, + targets: list[Combatant], + enemy: CombatantState, + rng_queue: list[int], + ) -> CombatEngine: + caster = _make_caster() + c = CombatantState(combatant=caster, side="players", pos=(0, 0), hp=caster.hp_max) + states = [c, enemy, *targets] + legend = {".": TerrainType(type="floor", move_cost=1)} + spec = MapSpec(name="test", terrain=tuple(["." * 12] * 12), legend=legend) + grid = Grid.from_spec(spec) + return CombatEngine( + ScriptedRng(rng_queue), grid, states, + spell_registry=load_spell_registry(_SPELLS_DIR), + ) + + def test_enemy_debuffed_allies_unaffected(self) -> None: + ally = CombatantState( + combatant=_make_target("ally", hp_max=20), side="players", + pos=(1, 0), hp=20, + ) + enemy = CombatantState( + combatant=_make_target("enemy", hp_max=20), side="monsters", + pos=(2, 0), hp=20, + ) + engine = self._bane_engine([ally], enemy, [2]) # d20=2 → save fails + engine._cast_spell( + engine._states[0], enemy, "Bane", + ) + assert any(c.condition.name == "shaken" for c in enemy.active_conditions) + assert not ally.active_conditions + assert not engine._states[0].active_conditions + + def test_successful_save_negates(self) -> None: + enemy = CombatantState( + combatant=_make_target("enemy", hp_max=20), side="monsters", + pos=(2, 0), hp=20, + ) + engine = self._bane_engine([], enemy, [20]) # d20=20 → save succeeds + engine._cast_spell(engine._states[0], enemy, "Bane") + assert not enemy.active_conditions + + class TestOutOfRange: """Given: a target beyond the spell's maximum range When: _cast_spell is called