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
+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.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,