247 lines
8.7 KiB
Python
247 lines
8.7 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}
|
|
]
|