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:
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user