Files
pf1e-simulator/tests/test_recurring_damage.py

267 lines
9.6 KiB
Python

"""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 ADDITIONAL rounds = 7 total at 18th)
When: resolved for caster levels 1 / 3 / 18
Then: it is 1 / 2 / 7 rounds (SRD spells/acid-arrow.md: "1 round + 1 round
per three levels [...] to a maximum of 6 additional rounds at 18th level"
- the cap counts ADDITIONAL rounds, so the total is 1 + 6 = 7)."""
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_seven_total(self) -> None:
assert _acid_rounds(18) == 7
assert _acid_rounds(20) == 7
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}
]
class TestRecurringTicksRecordDamageTaken:
"""Given: a target burning from Acid Arrow
When: each recurring tick lands
Then: attrition's damage_taken grows by the tick (README "dégâts subis");
the initial spell hit was already counted at cast time."""
def test_each_tick_counts_as_damage_taken(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 engine._stats["victim"].damage_taken == 6
assert engine._stats["caster"].damage_dealt == 6
engine._apply_recurring_damage(v)
assert engine._stats["victim"].damage_taken == 10
engine._apply_recurring_damage(v)
assert engine._stats["victim"].damage_taken == 18