feat(rules): add ongoing spell damage
Acid Arrow (SRD spells/acid-arrow.md) deals 2d4 acid, then 2d4 again at the start of each of the target's turns for 1 round + 1 per three caster levels (max 6). DamageEffect gains a RecurringDamageSpec (JSON 'recurring'); a landed hit attaches a RecurringDamage source to CombatantState.recurring_damage, ticked in _turn_gate (after bleed), reduced by energy resistance/immunity but never DR (spell damage), cleared at expiry, and exposed in the observation snapshot. Extracted _resolve_one_projectile to keep _apply_damage_effect under the complexity ceiling. Updated acid_arrow.json, added tests/test_recurring_damage.py, and the README. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
@@ -0,0 +1,244 @@
|
||||
"""Ongoing (recurring) spell damage (Package S2c).
|
||||
|
||||
PF1e (SRD spells/acid-arrow.md): "The arrow deals 2d4 points of acid damage
|
||||
[...] For every three caster levels you possess, the acid, unless neutralized,
|
||||
lasts for another round (to a maximum of 6 additional rounds at 18th level),
|
||||
dealing another 2d4 points of damage in each round."
|
||||
|
||||
The recurring component ticks at the START of the affected creature's turn, is
|
||||
reduced by energy resistance/immunity (acid) but NOT by DR (it is spell
|
||||
damage), and is cleared when its rounds run out.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from pf1e_simulator.combat import CombatantState, CombatEngine
|
||||
from pf1e_simulator.dice import parse_dice
|
||||
from pf1e_simulator.grid import Grid
|
||||
from pf1e_simulator.map import MapSpec, TerrainType
|
||||
from pf1e_simulator.models import (
|
||||
AbilityScores,
|
||||
ACProfile,
|
||||
AttackSpec,
|
||||
Combatant,
|
||||
DamageComponent,
|
||||
DamageReduction,
|
||||
Saves,
|
||||
)
|
||||
from pf1e_simulator.rng import ScriptedRng
|
||||
from pf1e_simulator.spells import DamageEffect, load_spell, load_spell_registry
|
||||
|
||||
_SPELLS_DIR = Path(__file__).resolve().parent.parent / "data" / "spells"
|
||||
|
||||
|
||||
def _caster(cid: str = "caster", *, level: int = 1) -> Combatant:
|
||||
attack = AttackSpec(
|
||||
id=f"{cid}-w",
|
||||
name="dagger",
|
||||
kind="melee",
|
||||
attack_bonus=2,
|
||||
damage=[DamageComponent(formula=parse_dice("1d4"), types=["piercing"])],
|
||||
)
|
||||
return Combatant(
|
||||
id=cid,
|
||||
name=cid,
|
||||
level=level,
|
||||
size="Medium",
|
||||
abilities=AbilityScores(
|
||||
str_score=10, dex_score=14, con_score=12,
|
||||
int_score=18, wis_score=12, cha_score=10,
|
||||
),
|
||||
hp_max=40,
|
||||
ac=ACProfile(total=12, touch=12, flat_footed=10),
|
||||
bab=2,
|
||||
initiative_mod=2,
|
||||
speed_land_ft=30,
|
||||
saves=Saves(fort=3, ref=4, will=4),
|
||||
attacks=[attack],
|
||||
casting_ability="int",
|
||||
spell_slots={0: 99, 1: 99, 2: 99, 3: 99, 4: 99, 5: 99, 6: 99},
|
||||
)
|
||||
|
||||
|
||||
def _victim(
|
||||
cid: str = "victim",
|
||||
*,
|
||||
hp_max: int = 200,
|
||||
resistances: dict[str, int] | None = None,
|
||||
dr: DamageReduction | None = None,
|
||||
) -> Combatant:
|
||||
return Combatant(
|
||||
id=cid,
|
||||
name=cid,
|
||||
level=1,
|
||||
size="Medium",
|
||||
abilities=AbilityScores(
|
||||
str_score=10, dex_score=10, con_score=10,
|
||||
int_score=10, wis_score=10, cha_score=10,
|
||||
),
|
||||
hp_max=hp_max,
|
||||
ac=ACProfile(total=10, touch=10, flat_footed=10),
|
||||
bab=1,
|
||||
initiative_mod=0,
|
||||
speed_land_ft=30,
|
||||
saves=Saves(fort=2, ref=2, will=2),
|
||||
attacks=[],
|
||||
resistances=resistances or {},
|
||||
dr=dr,
|
||||
)
|
||||
|
||||
|
||||
def _grid() -> Grid:
|
||||
legend = {".": TerrainType(type="floor", move_cost=1)}
|
||||
spec = MapSpec(name="test", terrain=tuple(["." * 12] * 12), legend=legend)
|
||||
return Grid.from_spec(spec)
|
||||
|
||||
|
||||
def _engine(
|
||||
caster: Combatant,
|
||||
victim: Combatant,
|
||||
queue: list[int],
|
||||
) -> tuple[CombatEngine, CombatantState, CombatantState]:
|
||||
c = CombatantState(combatant=caster, side="players", pos=(0, 0), hp=caster.hp_max)
|
||||
v = CombatantState(combatant=victim, side="monsters", pos=(3, 0), hp=victim.hp_max)
|
||||
reg = load_spell_registry(_SPELLS_DIR)
|
||||
engine = CombatEngine(ScriptedRng(queue), _grid(), [c, v], spell_registry=reg)
|
||||
return engine, c, v
|
||||
|
||||
|
||||
def _acid_rounds(level: int) -> int:
|
||||
spell = load_spell(_SPELLS_DIR / "acid_arrow.json")
|
||||
effect = spell.effects[0]
|
||||
assert isinstance(effect, DamageEffect)
|
||||
assert effect.recurring is not None
|
||||
return effect.recurring.rounds_for(level)
|
||||
|
||||
|
||||
class TestAcidArrowRecurringRounds:
|
||||
"""Given: Acid Arrow's recurring scaling (1 + one round per three levels,
|
||||
max 6)
|
||||
When: resolved for caster levels 1 / 3 / 18
|
||||
Then: it is 1 / 2 / 6 rounds (SRD spells/acid-arrow.md)."""
|
||||
|
||||
def test_one_round_at_low_level(self) -> None:
|
||||
assert _acid_rounds(1) == 1
|
||||
|
||||
def test_two_rounds_at_level_3(self) -> None:
|
||||
assert _acid_rounds(3) == 2
|
||||
|
||||
def test_caps_at_six(self) -> None:
|
||||
assert _acid_rounds(18) == 6
|
||||
assert _acid_rounds(20) == 6
|
||||
|
||||
|
||||
class TestAcidArrowAttachesRecurring:
|
||||
"""Given: a level-1 caster (1 recurring round) hits with Acid Arrow
|
||||
When: the ranged touch attack lands
|
||||
Then: the initial 2d4 is dealt AND one recurring source is attached."""
|
||||
|
||||
def test_initial_hit_attaches_source(self) -> None:
|
||||
caster = _caster(level=1)
|
||||
victim = _victim(hp_max=200)
|
||||
# ranged touch attack d20=15 (hits AC 10), then 2d4 initial (3+3=6)
|
||||
engine, c, v = _engine(caster, victim, [15, 3, 3])
|
||||
engine._cast_spell(c, v, "Acid Arrow")
|
||||
assert v.hp == 200 - 6
|
||||
assert len(v.recurring_damage) == 1
|
||||
assert v.recurring_damage[0].rounds_left == 1
|
||||
|
||||
|
||||
class TestRecurringTicksEachRound:
|
||||
"""Given: a level-3 caster (2 recurring rounds) hits with Acid Arrow
|
||||
When: the victim's turn starts twice
|
||||
Then: the acid deals 2d4 on each of the two turns, then clears."""
|
||||
|
||||
def test_ticks_then_expires(self) -> None:
|
||||
caster = _caster(level=3)
|
||||
victim = _victim(hp_max=200)
|
||||
# attack (15) + initial 2d4 (3+3) + tick1 2d4 (2+2) + tick2 2d4 (4+4)
|
||||
engine, c, v = _engine(caster, victim, [15, 3, 3, 2, 2, 4, 4])
|
||||
engine._cast_spell(c, v, "Acid Arrow")
|
||||
assert v.hp == 200 - 6
|
||||
assert len(v.recurring_damage) == 1
|
||||
assert v.recurring_damage[0].rounds_left == 2
|
||||
# first turn start: one tick, one round left
|
||||
engine._apply_recurring_damage(v)
|
||||
assert v.hp == 200 - 6 - 4
|
||||
assert len(v.recurring_damage) == 1
|
||||
# second turn start: final tick, then cleared
|
||||
engine._apply_recurring_damage(v)
|
||||
assert v.hp == 200 - 6 - 4 - 8
|
||||
assert v.recurring_damage == []
|
||||
|
||||
|
||||
class TestRecurringReducedByEnergyResistance:
|
||||
"""Given: a target with acid resistance 5 is burning from Acid Arrow
|
||||
When: the recurring acid ticks
|
||||
Then: each tick's 2d4 is reduced by the resistance (floored at 0)."""
|
||||
|
||||
def test_resistance_reduces_each_tick(self) -> None:
|
||||
caster = _caster(level=1)
|
||||
victim = _victim(hp_max=200, resistances={"acid": 5})
|
||||
# attack (15) + initial 2d4 (4+4=8 -> -5 = 3) + tick 2d4 (3+3=6 -> -5 = 1)
|
||||
engine, c, v = _engine(caster, victim, [15, 4, 4, 3, 3])
|
||||
engine._cast_spell(c, v, "Acid Arrow")
|
||||
assert v.hp == 200 - 3 # initial reduced 8 -> 3
|
||||
engine._apply_recurring_damage(v)
|
||||
assert v.hp == 200 - 3 - 1 # tick reduced 6 -> 1
|
||||
|
||||
|
||||
class TestRecurringNotReducedByDR:
|
||||
"""Given: a target with damage reduction is burning from Acid Arrow
|
||||
When: the recurring acid ticks
|
||||
Then: DR does NOT reduce the spell's recurring damage (CRB: DR does not
|
||||
negate energy damage from spells)."""
|
||||
|
||||
def test_dr_does_not_touch_recurring(self) -> None:
|
||||
caster = _caster(level=1)
|
||||
victim = _victim(hp_max=200, dr=DamageReduction(amount=10, bypass=("slashing",)))
|
||||
# attack (15) + initial 2d4 (3+3=6) + tick 2d4 (5+5=10)
|
||||
engine, c, v = _engine(caster, victim, [15, 3, 3, 5, 5])
|
||||
engine._cast_spell(c, v, "Acid Arrow")
|
||||
assert v.hp == 200 - 6
|
||||
engine._apply_recurring_damage(v)
|
||||
# DR 10 does not reduce the acid: full 10 dealt
|
||||
assert v.hp == 200 - 6 - 10
|
||||
|
||||
|
||||
class TestRecurringImmunityZeroesTick:
|
||||
"""Given: an acid-immune target is burning from Acid Arrow
|
||||
When: the recurring acid ticks
|
||||
Then: immunity zeroes the tick damage."""
|
||||
|
||||
def test_immunity_zeroes_tick(self) -> None:
|
||||
caster = _caster(level=1)
|
||||
victim = _victim(hp_max=200)
|
||||
victim = victim.model_copy(update={"immunities": ("acid",)})
|
||||
# attack (15) + initial 2d4 (3+3, immune -> 0) + tick (4+4, immune -> 0)
|
||||
engine, c, v = _engine(caster, victim, [15, 3, 3, 4, 4])
|
||||
engine._cast_spell(c, v, "Acid Arrow")
|
||||
assert v.hp == 200
|
||||
engine._apply_recurring_damage(v)
|
||||
assert v.hp == 200
|
||||
|
||||
|
||||
class TestRecurringSnapshot:
|
||||
"""Given: a target burning from Acid Arrow
|
||||
When: the observation snapshot is built
|
||||
Then: recurring_damage lists each active source's formula, types and
|
||||
rounds_left."""
|
||||
|
||||
def test_snapshot_exposes_recurring(self) -> None:
|
||||
caster = _caster(level=3)
|
||||
victim = _victim(hp_max=200)
|
||||
engine, c, v = _engine(caster, victim, [15, 3, 3])
|
||||
engine._cast_spell(c, v, "Acid Arrow")
|
||||
obs = engine._build_observation(v).to_dict()
|
||||
entry = next(
|
||||
cb for cb in obs["combatants"] if cb["id"] == "victim"
|
||||
)
|
||||
assert entry["recurring_damage"] == [
|
||||
{"formula": "2d4", "types": ("acid",), "rounds_left": 2}
|
||||
]
|
||||
Reference in New Issue
Block a user