"""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 ADDITIONAL rounds = 7 total at 18th) When: resolved for caster levels 1 / 3 / 18 Then: it is 1 / 2 / 7 rounds (SRD spells/acid-arrow.md: "1 round + 1 round per three levels [...] to a maximum of 6 additional rounds at 18th level" - the cap counts ADDITIONAL rounds, so the total is 1 + 6 = 7).""" 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_seven_total(self) -> None: assert _acid_rounds(18) == 7 assert _acid_rounds(20) == 7 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} ] class TestRecurringTicksRecordDamageTaken: """Given: a target burning from Acid Arrow When: each recurring tick lands Then: attrition's damage_taken grows by the tick (README "dégâts subis"); the initial spell hit was already counted at cast time.""" def test_each_tick_counts_as_damage_taken(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 engine._stats["victim"].damage_taken == 6 assert engine._stats["caster"].damage_dealt == 6 engine._apply_recurring_damage(v) assert engine._stats["victim"].damage_taken == 10 engine._apply_recurring_damage(v) assert engine._stats["victim"].damage_taken == 18