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:
+149
-32
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user