From a59ebee37a7b88a16b13fc4d9a0de0245b0b7162 Mon Sep 17 00:00:00 2001 From: ctan Date: Sun, 13 Sep 2026 12:25:38 +0200 Subject: [PATCH] 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 --- README.md | 28 ++-- data/spells/acid_arrow.json | 11 +- src/pf1e_simulator/combat.py | 181 +++++++++++++++++++----- src/pf1e_simulator/spells.py | 54 ++++++++ tests/test_recurring_damage.py | 244 +++++++++++++++++++++++++++++++++ 5 files changed, 476 insertions(+), 42 deletions(-) create mode 100644 tests/test_recurring_damage.py diff --git a/README.md b/README.md index 4408a60..37ff933 100644 --- a/README.md +++ b/README.md @@ -961,6 +961,22 @@ Règles modélisées : Missile (auto-touché, pas d'attaque) et Scorching Ray sont ainsi SRD-conformes sur le nombre de projectiles ; la fiche dorée du liche (niveau d'incanteur 11) tire désormais ses cinq missiles. +- Dégâts récurrents de sort (S2c) : un `DamageEffect` peut déclarer + `"recurring": {"formula": "2d4", "types": ["acid"], "base_rounds": 1, + "per_levels": 3, "max": 6}` — le nombre de rounds de récurrence vaut + `base_rounds + niveau // per_levels`, plafonné à `max` (SRD Acid Arrow : + « For every three caster levels you possess, the acid [...] lasts for another + round [...] dealing another 2d4 points of damage in each round » → 1 round au + niveau 1, 2 au niveau 3, 6 au niveau 18+). À l'attaque initiale réussie, une + source de dégâts récurrents (`RecurringDamage` sur le `CombatantState`, liste + `recurring_damage`) est posée sur la cible vivante ; au **début du tour** de + la cible (dans `_turn_gate`, après le tick de saignement), chaque source + jetle ses dés, est **réduite par la résistance/immunité énergétique** (acide) + mais **jamais par la RD** (c'est un dégât de sort), inflige ce dégât, puis + perd un round ; arrivée à 0 round elle est retirée. L'observation expose + chaque source sous `recurring_damage` (`{formula, types, rounds_left}`). + Simplification documentée : la neutralisation d'acide (immersion/eau) n'est + pas modélisée — le dégât court jusqu'à l'expiration de sa durée. - Résistance aux sorts (SR) et résistances/immunités énergétiques : un combattant avec `spell_resistance > 0` oppose sa SR à chaque sort qui le cible — jet `d20 + niveau du jeteur` contre la SR ; si le total est inférieur, le sort est @@ -1340,7 +1356,10 @@ Règles modélisées : son tour), running (bool — course en cours jusqu'au prochain tour du combattant : il perd son bonus de Dextérité à la CA), bleed_damage (int — dégâts de saignement subis au début du tour du combattant, 0 = ne saigne - pas ; V5b ; `bleeding`/`confused` sont visibles via `conditions`). + pas ; V5b ; `bleeding`/`confused` sont visibles via `conditions`), + recurring_damage (S2c — liste des sources de dégâts récurrents de sort + actives, chacune `{formula, types, rounds_left}` ; tick au début du tour, + réduit par résistance énergétique, jamais par la RD). - Par paire combattant actif × cible : target_index, distance_ft, weapon_for, range_penalty, can_charge, can_step5_to_attack, has_line_of_effect, flanking, movement_cost_ft, target_threatens_me, enemies_threatening_me, @@ -1415,13 +1434,6 @@ plutôt que bricolées) : Limites de sorts (conformance D6-D9, documentées plutôt que bricolées — le moteur n'a pas encore de sauvegarde partielle) : -- **Acid Arrow — sans dégâts continus** (SRD spells/acid-arrow.md : portée - **longue** — corrigée, conformance D7 — et « For every three caster levels - you possess, the acid, unless neutralized, lasts for another round [...], - dealing another 2d4 points of damage in each round ») : le moteur n'a pas de - subsystem de dégâts continus par sort (ni neutralisation d'acide) ; seuls les - 2d4 de dégâts initiaux sont modélisés, les rounds de dégâts persistants sont - hors périmètre. - **Fear — sauvegarde totale** (SRD spells/fear.md : « each living creature in the area [...] becomes panicked unless it succeeds on a Will save [...]. If the Will save succeeds, the creature is shaken for 1 diff --git a/data/spells/acid_arrow.json b/data/spells/acid_arrow.json index c72a4dd..f96260f 100644 --- a/data/spells/acid_arrow.json +++ b/data/spells/acid_arrow.json @@ -12,8 +12,15 @@ "type": "damage", "formula": "2d4", "types": ["acid"], - "half_on_save": false + "half_on_save": false, + "recurring": { + "formula": "2d4", + "types": ["acid"], + "base_rounds": 1, + "per_levels": 3, + "max": 6 + } } ], - "description": "SRD spells/acid-arrow.md: ranged touch attack dealing 2d4 acid damage, plus another 2d4 per round for 1 extra round per three caster levels (max 6 additional rounds). The engine has no damage-over-time subsystem, so only the initial 2d4 is modeled - the ongoing acid is documented as a limitation. No save." + "description": "SRD spells/acid-arrow.md: ranged touch attack dealing 2d4 acid damage, plus another 2d4 acid damage at the start of each of the target's turns for 1 round + 1 round per three caster levels (maximum 6 additional rounds). Recurring damage is reduced by energy resistance/immunity, not by DR. No save." } diff --git a/src/pf1e_simulator/combat.py b/src/pf1e_simulator/combat.py index e70fe3b..79e4b15 100644 --- a/src/pf1e_simulator/combat.py +++ b/src/pf1e_simulator/combat.py @@ -123,7 +123,7 @@ from typing import TYPE_CHECKING, Literal from pf1e_simulator.areas import cone_cells, line_cells from pf1e_simulator.conditions import CONDITIONS_BY_NAME, Condition -from pf1e_simulator.dice import dice_to_str +from pf1e_simulator.dice import DiceExpr, dice_to_str from pf1e_simulator.effects import ActiveBuff, ActiveCondition, StatModifier, resolve_modifiers from pf1e_simulator.loaders.combatant import CombatantLoadError, load_combatant from pf1e_simulator.models import PREDEFINED_SKILLS @@ -134,6 +134,7 @@ from pf1e_simulator.spells import ( ConditionEffect, DamageEffect, HealEffect, + RecurringDamageSpec, SaveType, SpellEffect, SummonEffect, @@ -560,6 +561,23 @@ def _is_pc_combatant(combatant: Combatant) -> bool: return combatant.source.startswith(_PC_SOURCE_PREFIX) +@dataclass(frozen=True) +class RecurringDamage: + """One active damage-over-time source on a combatant (S2c, Acid Arrow). + + - ``formula``: dice rolled each recurring round (2d4) + - ``types``: energy types, reduced by resistance/immunity (never DR) + - ``rounds_left``: rounds this source still deals damage (decremented and + removed at the start of the owner's turn once it ticks) + - ``source``: human-readable origin for the transcript + """ + + formula: DiceExpr + types: frozenset[str] + rounds_left: int + source: str + + @dataclass class CombatantState: """Mutable runtime state of one combatant on the grid.""" @@ -633,6 +651,11 @@ class CombatantState: # Bleeding (V5b, not in the frozen data model): damage taken at the start # of each turn; any effect that restores hit points clears it. bleed_damage: int = 0 + # PF1e recurring spell damage (S2c, not in the frozen data model): each + # entry is one damage-over-time source (Acid Arrow's lingering acid) that + # deals its dice at the start of this combatant's turn for `rounds_left` + # rounds, reduced by energy resistance/immunity (never DR). + recurring_damage: list[RecurringDamage] = field(default_factory=list) def usable_attacks(self) -> list[AttackSpec]: return [ @@ -1303,6 +1326,14 @@ class CombatEngine: "aid_ac_bonus": state.aid_ac_bonus, "running": state.running, "bleed_damage": state.bleed_damage, + "recurring_damage": [ + { + "formula": dice_to_str(entry.formula), + "types": tuple(sorted(entry.types)), + "rounds_left": entry.rounds_left, + } + for entry in state.recurring_damage + ], "aoo_remaining": max( 0, self._aoo_allowance(state) - self._aoo_used.get(state.combatant.id, 0), @@ -1796,15 +1827,17 @@ class CombatEngine: state.bleed_damage = 0 def _turn_gate(self, state: CombatantState) -> bool | None: - """Start-of-turn effects: bleed tick, confused d% roll, cant_act flag. + """Start-of-turn effects: bleed tick, recurring spell damage, confused + d% roll, cant_act flag. Returns None when the turn proceeds to the policy; otherwise the turn is forfeited and the value is the battle-over flag. """ self._apply_bleed_damage(state) + self._apply_recurring_damage(state) if not state.active: - # Bleeding knocked the combatant out (dying or dead); the winner - # check covers the death case. + # Bleeding / acid knocked the combatant out (dying or dead); the + # winner check covers the death case. return self._winner_side() is not None confused_over = self._apply_confusion(state) if confused_over is not None: @@ -1825,6 +1858,34 @@ class CombatEngine: f"bleed {state.bleed_damage} (hp {old_hp}->{state.hp})" ) + def _apply_recurring_damage(self, state: CombatantState) -> None: + """PF1e recurring spell damage (S2c, Acid Arrow): at the start of the + owner's turn each active source rolls its dice, is reduced by energy + resistance/immunity (never DR — it is spell damage), deals that damage, + then loses one round; sources that reach 0 rounds are cleared.""" + if not state.recurring_damage: + return + remaining: list[RecurringDamage] = [] + for entry in state.recurring_damage: + raw = entry.formula.roll(self._rng) + dmg = self._apply_energy_resistance(raw, entry.types, state) + old_hp = state.hp + state.hp -= dmg + self._log( + f"round {self._current_round} {state.combatant.id}: " + f"{entry.source} {dmg} (hp {old_hp}->{state.hp})" + ) + if entry.rounds_left > 1: + remaining.append( + RecurringDamage( + formula=entry.formula, + types=entry.types, + rounds_left=entry.rounds_left - 1, + source=entry.source, + ) + ) + state.recurring_damage[:] = remaining + def _apply_confusion(self, state: CombatantState) -> bool | None: """PF1e confused: roll d% at the start of the turn to act randomly (CRB). @@ -5804,6 +5865,7 @@ class CombatEngine: effect, target, caster_live, target_live, saved=saved, empower=empower, maximize=maximize, caster=caster, crit=crit, ranged_touch=ranged_touch, attack_penalty=attack_penalty, + spell_name=spell_name, ) if isinstance(effect, ConditionEffect): return self._apply_condition_effect(effect, target, saved=saved, spell_name=spell_name) @@ -5825,6 +5887,7 @@ class CombatEngine: crit: bool = False, ranged_touch: bool = False, attack_penalty: int = 0, + spell_name: str = "", ) -> str: # PF1e (CRB, Magic — Caster Level): per-level spell damage is resolved # against the caster's level at cast time; the scaled formula then @@ -5842,34 +5905,13 @@ class CombatEngine: ) total = 0 for _ in range(count): - proj_crit = crit - if ranged_touch: - # Each ray rolls its own ranged touch attack (cover, - # concealment and critical threat included). - hit, proj_crit = self._spell_ranged_touch_attack_hits( - caster, target, - attack_penalty=attack_penalty, deals_damage=True, - ) - if not hit: - continue - raw = formula.roll(self._rng) - if maximize: - raw = formula.max_value() - if proj_crit: - # PF1e (CRB "Range — Touch"): a touch/ranged-touch spell that - # deals damage threatens on a natural 20 and deals double - # damage on a successful critical hit. - raw *= 2 - if saved and effect.half_on_save: - dmg = max(0, raw // 2) - elif saved and effect.save_negates: - dmg = 0 - else: - dmg = raw - if empower: - dmg = int(dmg * 1.5) - dmg = self._apply_energy_resistance(dmg, frozenset(effect.types), target) - target.hp -= dmg + dmg = self._resolve_one_projectile( + effect, formula, target, caster, + saved=saved, empower=empower, maximize=maximize, + crit=crit, ranged_touch=ranged_touch, attack_penalty=attack_penalty, + ) + if dmg is None: + continue # this ray missed — no damage, no stat/reaction caster_live.damage_dealt += dmg target_live.damage_taken += dmg self._check_reactions( @@ -5882,8 +5924,83 @@ class CombatEngine: # are lost (the engine's existing multi-attack convention). if not target.active: break + # PF1e recurring damage (S2c, Acid Arrow): a landed hit leaves acid + # that keeps burning for rounds_for(caster_level) rounds. Only attached + # while the target is still standing (a dead target has no turns). + if effect.recurring is not None and target.active: + self._attach_recurring_damage(effect.recurring, target, caster, spell_name) return f"{total} damage" + def _resolve_one_projectile( + self, + effect: DamageEffect, + formula: DiceExpr, + target: CombatantState, + caster: CombatantState, + *, + saved: bool, + empower: bool, + maximize: bool, + crit: bool, + ranged_touch: bool, + attack_penalty: int, + ) -> int | None: + """Resolve one projectile's hit + damage; None when the ray misses. + + Rolls the projectile's own ranged touch attack (when the spell is a + ray), then its own damage dice through maximize / crit / save / + empower / energy resistance, and applies the damage to the target. + """ + proj_crit = crit + if ranged_touch: + # Each ray rolls its own ranged touch attack (cover, concealment + # and critical threat included). + hit, proj_crit = self._spell_ranged_touch_attack_hits( + caster, target, + attack_penalty=attack_penalty, deals_damage=True, + ) + if not hit: + return None + raw = formula.roll(self._rng) + if maximize: + raw = formula.max_value() + if proj_crit: + # PF1e (CRB "Range — Touch"): a touch/ranged-touch spell that + # deals damage threatens on a natural 20 and deals double + # damage on a successful critical hit. + raw *= 2 + if saved and effect.half_on_save: + dmg = max(0, raw // 2) + elif saved and effect.save_negates: + dmg = 0 + else: + dmg = raw + if empower: + dmg = int(dmg * 1.5) + dmg = self._apply_energy_resistance(dmg, frozenset(effect.types), target) + target.hp -= dmg + return dmg + + def _attach_recurring_damage( + self, + spec: RecurringDamageSpec, + target: CombatantState, + caster: CombatantState, + spell_name: str, + ) -> None: + """Add a recurring-damage source to a target that just took the hit.""" + rounds = spec.rounds_for(caster.combatant.level) + if rounds <= 0: + return + target.recurring_damage.append( + RecurringDamage( + formula=spec.formula, + types=frozenset(spec.types), + rounds_left=rounds, + source=f"acid: {spell_name}", + ) + ) + def _apply_condition_effect( self, effect: ConditionEffect, diff --git a/src/pf1e_simulator/spells.py b/src/pf1e_simulator/spells.py index 425e4a7..9df6ce8 100644 --- a/src/pf1e_simulator/spells.py +++ b/src/pf1e_simulator/spells.py @@ -93,6 +93,29 @@ class ProjectileScaling: return min(self.max_projectiles, self.base + extra) +@dataclass(frozen=True) +class RecurringDamageSpec: + """Per-round recurring spell damage (PF1e Acid Arrow). + + - formula: dice rolled in each recurring round (2d4) + - types: energy types (acid) — reduced by energy resistance/immunity, + never by DR (it is spell damage) + - base_rounds / per_levels / max_rounds: rounds of recurrence scale with + caster level as ``base_rounds + caster_level // per_levels`` capped at + ``max_rounds`` (SRD spells/acid-arrow.md: "1 round + 1 round per three + levels [...] to a maximum of 6 additional rounds"). + """ + formula: DiceExpr + types: tuple[str, ...] + base_rounds: int + per_levels: int + max_rounds: int + + def rounds_for(self, caster_level: int) -> int: + """Recurring rounds for a caster of ``caster_level``.""" + return min(self.max_rounds, self.base_rounds + caster_level // self.per_levels) + + @dataclass(frozen=True) class DamageEffect: formula: DiceExpr @@ -113,6 +136,10 @@ class DamageEffect: # fires `count_for(caster_level)` projectiles, each rolling its own damage # and (for a ray) its own attack. Absent = a single projectile. projectiles: ProjectileScaling | None = None + # PF1e recurring (damage-over-time) component (Acid Arrow): after the + # initial hit the target takes this damage again at the start of each of + # its turns for `rounds_for(caster_level)` rounds. Absent = no DoT. + recurring: RecurringDamageSpec | None = None def effective_formula(self, caster_level: int) -> DiceExpr: """The damage formula resolved for a caster of ``caster_level``. @@ -417,6 +444,30 @@ def _parse_projectiles(value: Json) -> ProjectileScaling: ) +def _parse_recurring(value: Json) -> RecurringDamageSpec: + data = _as_dict(value) + formula = parse_dice(_as_str(data["formula"])) + base_rounds = _as_int(data.get("base_rounds", 1)) + per_levels = _as_int(data["per_levels"]) + max_rounds = _as_int(data["max"]) + if base_rounds < 0: + msg = f"recurring base_rounds must be >= 0, got {base_rounds}" + raise ValueError(msg) + if per_levels < 1: + msg = f"recurring per_levels must be >= 1, got {per_levels}" + raise ValueError(msg) + if max_rounds < base_rounds: + msg = f"recurring max must be >= base_rounds, got {max_rounds} < {base_rounds}" + raise ValueError(msg) + return RecurringDamageSpec( + formula=formula, + types=tuple(_as_str(t) for t in _as_list(data["types"])), + base_rounds=base_rounds, + per_levels=per_levels, + max_rounds=max_rounds, + ) + + def _parse_effect(data: Json) -> SpellEffect: effect = _as_dict(data) effect_type = _as_str(effect["type"]) @@ -438,6 +489,8 @@ def _parse_effect(data: Json) -> SpellEffect: scaling_flat = None if flat_raw is None else _parse_flat_scaling(flat_raw) proj_raw = effect.get("projectiles") projectiles = None if proj_raw is None else _parse_projectiles(proj_raw) + rec_raw = effect.get("recurring") + recurring = None if rec_raw is None else _parse_recurring(rec_raw) return DamageEffect( formula=formula, types=tuple(_as_str(t) for t in _as_list(effect["types"])), @@ -446,6 +499,7 @@ def _parse_effect(data: Json) -> SpellEffect: scaling=scaling, scaling_flat=scaling_flat, projectiles=projectiles, + recurring=recurring, ) if effect_type == "condition": return ConditionEffect( diff --git a/tests/test_recurring_damage.py b/tests/test_recurring_damage.py new file mode 100644 index 0000000..aca8778 --- /dev/null +++ b/tests/test_recurring_damage.py @@ -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} + ]