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
+54
View File
@@ -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(