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:
2026-09-13 12:25:38 +02:00
parent 88ef9f39be
commit a59ebee37a
5 changed files with 476 additions and 42 deletions
+20 -8
View File
@@ -961,6 +961,22 @@ Règles modélisées :
Missile (auto-touché, pas d'attaque) et Scorching Ray sont ainsi SRD-conformes 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) sur le nombre de projectiles ; la fiche dorée du liche (niveau d'incanteur 11)
tire désormais ses cinq missiles. 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 - 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 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 `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 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 — 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 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, - Par paire combattant actif × cible : target_index, distance_ft, weapon_for,
range_penalty, can_charge, can_step5_to_attack, has_line_of_effect, range_penalty, can_charge, can_step5_to_attack, has_line_of_effect,
flanking, movement_cost_ft, target_threatens_me, enemies_threatening_me, 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 Limites de sorts (conformance D6-D9, documentées plutôt que bricolées — le
moteur n'a pas encore de sauvegarde partielle) : 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 - **Fear — sauvegarde totale** (SRD spells/fear.md : « each
living creature in the area [...] becomes panicked unless it succeeds on a 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 Will save [...]. If the Will save succeeds, the creature is shaken for 1
+9 -2
View File
@@ -12,8 +12,15 @@
"type": "damage", "type": "damage",
"formula": "2d4", "formula": "2d4",
"types": ["acid"], "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."
} }
+149 -32
View File
@@ -123,7 +123,7 @@ from typing import TYPE_CHECKING, Literal
from pf1e_simulator.areas import cone_cells, line_cells from pf1e_simulator.areas import cone_cells, line_cells
from pf1e_simulator.conditions import CONDITIONS_BY_NAME, Condition 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.effects import ActiveBuff, ActiveCondition, StatModifier, resolve_modifiers
from pf1e_simulator.loaders.combatant import CombatantLoadError, load_combatant from pf1e_simulator.loaders.combatant import CombatantLoadError, load_combatant
from pf1e_simulator.models import PREDEFINED_SKILLS from pf1e_simulator.models import PREDEFINED_SKILLS
@@ -134,6 +134,7 @@ from pf1e_simulator.spells import (
ConditionEffect, ConditionEffect,
DamageEffect, DamageEffect,
HealEffect, HealEffect,
RecurringDamageSpec,
SaveType, SaveType,
SpellEffect, SpellEffect,
SummonEffect, SummonEffect,
@@ -560,6 +561,23 @@ def _is_pc_combatant(combatant: Combatant) -> bool:
return combatant.source.startswith(_PC_SOURCE_PREFIX) 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 @dataclass
class CombatantState: class CombatantState:
"""Mutable runtime state of one combatant on the grid.""" """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 # Bleeding (V5b, not in the frozen data model): damage taken at the start
# of each turn; any effect that restores hit points clears it. # of each turn; any effect that restores hit points clears it.
bleed_damage: int = 0 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]: def usable_attacks(self) -> list[AttackSpec]:
return [ return [
@@ -1303,6 +1326,14 @@ class CombatEngine:
"aid_ac_bonus": state.aid_ac_bonus, "aid_ac_bonus": state.aid_ac_bonus,
"running": state.running, "running": state.running,
"bleed_damage": state.bleed_damage, "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( "aoo_remaining": max(
0, 0,
self._aoo_allowance(state) - self._aoo_used.get(state.combatant.id, 0), self._aoo_allowance(state) - self._aoo_used.get(state.combatant.id, 0),
@@ -1796,15 +1827,17 @@ class CombatEngine:
state.bleed_damage = 0 state.bleed_damage = 0
def _turn_gate(self, state: CombatantState) -> bool | None: 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 Returns None when the turn proceeds to the policy; otherwise the turn
is forfeited and the value is the battle-over flag. is forfeited and the value is the battle-over flag.
""" """
self._apply_bleed_damage(state) self._apply_bleed_damage(state)
self._apply_recurring_damage(state)
if not state.active: if not state.active:
# Bleeding knocked the combatant out (dying or dead); the winner # Bleeding / acid knocked the combatant out (dying or dead); the
# check covers the death case. # winner check covers the death case.
return self._winner_side() is not None return self._winner_side() is not None
confused_over = self._apply_confusion(state) confused_over = self._apply_confusion(state)
if confused_over is not None: if confused_over is not None:
@@ -1825,6 +1858,34 @@ class CombatEngine:
f"bleed {state.bleed_damage} (hp {old_hp}->{state.hp})" 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: def _apply_confusion(self, state: CombatantState) -> bool | None:
"""PF1e confused: roll d% at the start of the turn to act randomly (CRB). """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, effect, target, caster_live, target_live,
saved=saved, empower=empower, maximize=maximize, caster=caster, saved=saved, empower=empower, maximize=maximize, caster=caster,
crit=crit, ranged_touch=ranged_touch, attack_penalty=attack_penalty, crit=crit, ranged_touch=ranged_touch, attack_penalty=attack_penalty,
spell_name=spell_name,
) )
if isinstance(effect, ConditionEffect): if isinstance(effect, ConditionEffect):
return self._apply_condition_effect(effect, target, saved=saved, spell_name=spell_name) return self._apply_condition_effect(effect, target, saved=saved, spell_name=spell_name)
@@ -5825,6 +5887,7 @@ class CombatEngine:
crit: bool = False, crit: bool = False,
ranged_touch: bool = False, ranged_touch: bool = False,
attack_penalty: int = 0, attack_penalty: int = 0,
spell_name: str = "",
) -> str: ) -> str:
# PF1e (CRB, Magic — Caster Level): per-level spell damage is resolved # PF1e (CRB, Magic — Caster Level): per-level spell damage is resolved
# against the caster's level at cast time; the scaled formula then # against the caster's level at cast time; the scaled formula then
@@ -5842,34 +5905,13 @@ class CombatEngine:
) )
total = 0 total = 0
for _ in range(count): for _ in range(count):
proj_crit = crit dmg = self._resolve_one_projectile(
if ranged_touch: effect, formula, target, caster,
# Each ray rolls its own ranged touch attack (cover, saved=saved, empower=empower, maximize=maximize,
# concealment and critical threat included). crit=crit, ranged_touch=ranged_touch, attack_penalty=attack_penalty,
hit, proj_crit = self._spell_ranged_touch_attack_hits( )
caster, target, if dmg is None:
attack_penalty=attack_penalty, deals_damage=True, continue # this ray missed — no damage, no stat/reaction
)
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
caster_live.damage_dealt += dmg caster_live.damage_dealt += dmg
target_live.damage_taken += dmg target_live.damage_taken += dmg
self._check_reactions( self._check_reactions(
@@ -5882,8 +5924,83 @@ class CombatEngine:
# are lost (the engine's existing multi-attack convention). # are lost (the engine's existing multi-attack convention).
if not target.active: if not target.active:
break 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" 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( def _apply_condition_effect(
self, self,
effect: ConditionEffect, effect: ConditionEffect,
+54
View File
@@ -93,6 +93,29 @@ class ProjectileScaling:
return min(self.max_projectiles, self.base + extra) 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) @dataclass(frozen=True)
class DamageEffect: class DamageEffect:
formula: DiceExpr formula: DiceExpr
@@ -113,6 +136,10 @@ class DamageEffect:
# fires `count_for(caster_level)` projectiles, each rolling its own damage # fires `count_for(caster_level)` projectiles, each rolling its own damage
# and (for a ray) its own attack. Absent = a single projectile. # and (for a ray) its own attack. Absent = a single projectile.
projectiles: ProjectileScaling | None = None 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: def effective_formula(self, caster_level: int) -> DiceExpr:
"""The damage formula resolved for a caster of ``caster_level``. """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: def _parse_effect(data: Json) -> SpellEffect:
effect = _as_dict(data) effect = _as_dict(data)
effect_type = _as_str(effect["type"]) 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) scaling_flat = None if flat_raw is None else _parse_flat_scaling(flat_raw)
proj_raw = effect.get("projectiles") proj_raw = effect.get("projectiles")
projectiles = None if proj_raw is None else _parse_projectiles(proj_raw) 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( return DamageEffect(
formula=formula, formula=formula,
types=tuple(_as_str(t) for t in _as_list(effect["types"])), 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=scaling,
scaling_flat=scaling_flat, scaling_flat=scaling_flat,
projectiles=projectiles, projectiles=projectiles,
recurring=recurring,
) )
if effect_type == "condition": if effect_type == "condition":
return ConditionEffect( return ConditionEffect(
+244
View File
@@ -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}
]