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:
@@ -961,6 +961,22 @@ Règles modélisées :
|
||||
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)
|
||||
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
|
||||
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
|
||||
@@ -1340,7 +1356,10 @@ Règles modélisées :
|
||||
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 —
|
||||
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,
|
||||
range_penalty, can_charge, can_step5_to_attack, has_line_of_effect,
|
||||
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
|
||||
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
|
||||
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
|
||||
|
||||
@@ -12,8 +12,15 @@
|
||||
"type": "damage",
|
||||
"formula": "2d4",
|
||||
"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
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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}
|
||||
]
|
||||
Reference in New Issue
Block a user