fix(rules): convert bane to a 50-ft enemies-only burst

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), matching the existing Doom approximation.

- bane.json: single-target condition -> AreaEffect burst radius_ft 50,
  targets "enemies", ConditionEffect shaken 10 rounds save_negates
  (range/save kept as medium/will for the registry pin)
- combat.py: _metamagic_transform_sub now copies AreaEffect.targets so
  an Extend Spell on an area spell preserves the allies/enemies filter
- tests: TestBaneEnemiesOnly (enemy in burst debuffed, allies/caster
  unaffected, successful Will save negates)
This commit is contained in:
2026-09-16 11:37:23 +02:00
parent e43066e0ef
commit 5e3505dc8c
3 changed files with 64 additions and 6 deletions
+13 -5
View File
@@ -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."
}
+1
View File
@@ -6613,6 +6613,7 @@ class CombatEngine:
for sub in effect.effects
),
save_for_half=effect.save_for_half,
targets=effect.targets,
)
return effect
+49
View File
@@ -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