381 lines
14 KiB
Python
381 lines
14 KiB
Python
"""Point-targeted spell areas (Package U7b).
|
|
|
|
``Action(kind="cast_spell", target_pos=(row, col))`` centers a spell's
|
|
top-level ``AreaEffect`` on that cell instead of on the target creature's
|
|
position. Factories are reused from ``tests/test_observation_enriched.py``
|
|
(``make_combatant``/``make_state``/``make_grid`` — an open 8x8 floor grid);
|
|
spell registries are hand-built like ``tests/test_spell_resistance.py``.
|
|
Casts go through ``engine._execute`` (the full action path: missing-target
|
|
gate, point validation, dispatch) and every roll is scripted — initiative is
|
|
never rolled because ``run`` is never called. A queue that is too short
|
|
raises ``RngExhaustedError``, so the scripted sequences also pin the EXACT
|
|
number of rolls (no primary save on point casts, per-creature saves only).
|
|
|
|
Roll vocabulary for the Fireball-like spell (8d6 fire, ref DC 13 — caster
|
|
level 1, wis 10): one d20 per saving throw, then one d6 per damage die.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from pf1e_simulator.combat import ACTION_RESULT_REASONS, Action, 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.rng import ScriptedRng
|
|
from pf1e_simulator.spells import AreaEffect, DamageEffect, SpellSpec
|
|
from tests.test_observation_enriched import make_combatant, make_grid, make_state
|
|
|
|
_FIREBALL = SpellSpec(
|
|
name="Fireball",
|
|
level=3,
|
|
school="evocation",
|
|
range="long",
|
|
save="ref",
|
|
base_dc=10,
|
|
effects=(
|
|
AreaEffect(
|
|
radius_ft=20,
|
|
effects=(
|
|
DamageEffect(
|
|
formula=parse_dice("8d6"),
|
|
types=("fire",),
|
|
half_on_save=True,
|
|
),
|
|
),
|
|
save_for_half=True,
|
|
),
|
|
),
|
|
)
|
|
|
|
_SPARK_BURST = SpellSpec(
|
|
name="Spark Burst",
|
|
level=2,
|
|
school="evocation",
|
|
range="long",
|
|
save="none",
|
|
base_dc=10,
|
|
effects=(
|
|
AreaEffect(
|
|
radius_ft=20,
|
|
effects=(
|
|
DamageEffect(
|
|
formula=parse_dice("8d6"),
|
|
types=("fire",),
|
|
half_on_save=False,
|
|
),
|
|
),
|
|
save_for_half=False,
|
|
),
|
|
),
|
|
)
|
|
|
|
_MAGIC_MISSILE = SpellSpec(
|
|
name="Magic Missile",
|
|
level=1,
|
|
school="evocation",
|
|
range="medium",
|
|
save="none",
|
|
base_dc=10,
|
|
effects=(
|
|
DamageEffect(
|
|
formula=parse_dice("1d4+1"),
|
|
types=("force",),
|
|
),
|
|
),
|
|
)
|
|
|
|
_REGISTRY = {
|
|
"Fireball": _FIREBALL,
|
|
"Spark Burst": _SPARK_BURST,
|
|
"Magic Missile": _MAGIC_MISSILE,
|
|
}
|
|
|
|
|
|
def _engine(
|
|
states: list[CombatantState], rolls: list[int], grid: Grid | None = None
|
|
) -> CombatEngine:
|
|
return CombatEngine(
|
|
ScriptedRng(rolls), grid or make_grid(), states, spell_registry=_REGISTRY
|
|
)
|
|
|
|
|
|
def _cast_scene(
|
|
rolls: list[int],
|
|
) -> tuple[CombatEngine, CombatantState, CombatantState, CombatantState]:
|
|
"""Caster at (7,0); foe_in at (0,4) and foe_out at (4,7).
|
|
|
|
A burst centered on (0,4) (radius 20 ft = 4 cells) covers foe_in
|
|
(distance 0) but not foe_out (distance 5) nor the caster (distance 9).
|
|
"""
|
|
caster = make_state(
|
|
make_combatant(
|
|
"caster", hp=100, spells=["Fireball"], spell_slots={1: 9, 2: 9, 3: 9}
|
|
), "players", (7, 0)
|
|
)
|
|
foe_in = make_state(make_combatant("foe_in", hp=100), "monsters", (0, 4))
|
|
foe_out = make_state(make_combatant("foe_out", hp=100), "monsters", (4, 7))
|
|
return _engine([caster, foe_in, foe_out], rolls), caster, foe_in, foe_out
|
|
|
|
|
|
def _walled_grid() -> Grid:
|
|
"""Open 8x8 floor with a blocking wall column at col 4."""
|
|
legend = {
|
|
".": TerrainType(type="floor", move_cost=1),
|
|
"#": TerrainType(type="wall", move_cost=None, blocks_los=True),
|
|
}
|
|
spec = MapSpec(name="walled", terrain=tuple(["....#..."] * 8), legend=legend)
|
|
return Grid.from_spec(spec)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# (a) a Fireball-like area spell cast at a point hits exactly the area
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_point_cast_hits_only_creatures_in_radius() -> None:
|
|
"""Given: caster (7,0), foe_in (0,4), foe_out (4,7), 9 scripted rolls
|
|
When: cast_spell with target_pos=(0,4) and NO target_id (point cast)
|
|
Then: ok; only foe_in takes damage — its own save fails (d20=2) and 8d6
|
|
all 3s deal 24; nobody else is touched and no roll is wasted."""
|
|
eng, caster, foe_in, foe_out = _cast_scene([2] + [3] * 8)
|
|
result = eng._execute(
|
|
caster, Action(kind="cast_spell", spell_name="Fireball", target_pos=(0, 4))
|
|
)
|
|
assert result.kind == "cast_spell"
|
|
assert result.ok
|
|
assert result.reason == "ok"
|
|
assert foe_in.hp == 100 - 24
|
|
assert foe_out.hp == 100
|
|
assert caster.hp == 100
|
|
assert any("cast Fireball at (0,4)" in line for line in eng._transcript)
|
|
|
|
|
|
def test_point_cast_successful_save_halves_damage() -> None:
|
|
"""Given: the same scene, scripted natural 20 for the in-area creature
|
|
When: the same point cast
|
|
Then: 8d6 all 3s = 24 halved to 12 on the saved creature."""
|
|
eng, caster, foe_in, foe_out = _cast_scene([20] + [3] * 8)
|
|
eng._execute(
|
|
caster, Action(kind="cast_spell", spell_name="Fireball", target_pos=(0, 4))
|
|
)
|
|
assert foe_in.hp == 100 - 12
|
|
assert foe_out.hp == 100
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# (b) invalid destinations are rejected without casting
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_point_cast_out_of_bounds_rejected_no_rolls_no_damage() -> None:
|
|
"""Given: an empty roll queue and target_pos=(9,9) on the 8x8 map
|
|
When: cast_spell at that point
|
|
Then: invalid_destination rejection, nothing cast, NO die rolled
|
|
(any roll would raise RngExhaustedError) and no hp moved."""
|
|
eng, caster, foe_in, foe_out = _cast_scene([])
|
|
result = eng._execute(
|
|
caster, Action(kind="cast_spell", spell_name="Fireball", target_pos=(9, 9))
|
|
)
|
|
assert result.kind == "cast_spell"
|
|
assert not result.ok
|
|
assert result.reason == "invalid_destination"
|
|
assert foe_in.hp == 100
|
|
assert foe_out.hp == 100
|
|
assert any("cast_spell rejected (invalid_destination)" in line for line in eng._transcript)
|
|
|
|
|
|
def test_point_cast_without_line_of_effect_rejected() -> None:
|
|
"""Given: a wall column between caster (0,0) and the point (0,6)
|
|
When: cast_spell at that unseen point
|
|
Then: invalid_destination rejection without casting."""
|
|
caster = make_state(
|
|
make_combatant(
|
|
"caster", hp=100, spells=["Fireball"], spell_slots={1: 9, 2: 9, 3: 9}
|
|
), "players", (0, 0)
|
|
)
|
|
foe = make_state(make_combatant("foe", hp=100), "monsters", (0, 6))
|
|
eng = _engine([caster, foe], [], grid=_walled_grid())
|
|
result = eng._execute(
|
|
caster, Action(kind="cast_spell", spell_name="Fireball", target_pos=(0, 6))
|
|
)
|
|
assert not result.ok
|
|
assert result.reason == "invalid_destination"
|
|
assert foe.hp == 100
|
|
|
|
|
|
def test_invalid_destination_is_in_reason_vocabulary() -> None:
|
|
"""Given: the normalized ActionResult reason vocabulary
|
|
When: checked for the U7b reason
|
|
Then: invalid_destination is declared."""
|
|
assert "invalid_destination" in ACTION_RESULT_REASONS
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# (c) point cast with no primary target: no crash, per-creature saves
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_point_cast_rolls_a_save_per_creature_in_area() -> None:
|
|
"""Given: two foes inside the burst (0,4) and (0,5), 18 scripted rolls
|
|
When: a target-less point cast at (0,4)
|
|
Then: no primary save is rolled (queue would shift otherwise) and EACH
|
|
creature rolls its own: foe_a fails (2) full 24, foe_b saves (20) half 12."""
|
|
caster = make_state(make_combatant(
|
|
"caster", hp=100, spells=["Fireball"], spell_slots={1: 9, 2: 9, 3: 9}
|
|
), "players", (7, 0))
|
|
foe_a = make_state(make_combatant("foe_a", hp=100), "monsters", (0, 4))
|
|
foe_b = make_state(make_combatant("foe_b", hp=100), "monsters", (0, 5))
|
|
eng = _engine([caster, foe_a, foe_b], [2] + [3] * 8 + [20] + [3] * 8)
|
|
result = eng._execute(
|
|
caster, Action(kind="cast_spell", spell_name="Fireball", target_pos=(0, 4))
|
|
)
|
|
assert result.ok
|
|
assert foe_a.hp == 100 - 24
|
|
assert foe_b.hp == 100 - 12
|
|
|
|
|
|
def test_point_cast_no_save_area_spell_rolls_damage_dice_only() -> None:
|
|
"""Given: a no-save area spell (save_for_half=False) and 8 scripted d6
|
|
When: a target-less point cast
|
|
Then: no saving throw is rolled at all — only the 8 damage dice."""
|
|
caster = make_state(
|
|
make_combatant(
|
|
"caster", hp=100, spells=["Spark Burst"], spell_slots={1: 9, 2: 9, 3: 9}
|
|
), "players", (7, 0)
|
|
)
|
|
foe = make_state(make_combatant("foe", hp=100), "monsters", (0, 4))
|
|
eng = _engine([caster, foe], [3] * 8)
|
|
result = eng._execute(
|
|
caster, Action(kind="cast_spell", spell_name="Spark Burst", target_pos=(0, 4))
|
|
)
|
|
assert result.ok
|
|
assert foe.hp == 100 - 24
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# (d) target_pos=None pins the creature-centered behaviour
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_creature_centered_cast_transcript_pinned() -> None:
|
|
"""Given: caster (7,0) and foe (0,4), primary save scripted to fail
|
|
When: cast_spell with target_id and NO target_pos (the legacy path)
|
|
Then: the transcript is the pinned creature-centered line — primary save
|
|
rolled once, reused for the target in the area, 8d6 all 3s."""
|
|
caster = make_state(make_combatant(
|
|
"caster", hp=100, spells=["Fireball"], spell_slots={1: 9, 2: 9, 3: 9}
|
|
), "players", (7, 0))
|
|
foe = make_state(make_combatant("foe", hp=100), "monsters", (0, 4))
|
|
eng = _engine([caster, foe], [2] + [3] * 8)
|
|
result = eng._execute(
|
|
caster, Action(kind="cast_spell", target_id="foe", spell_name="Fireball")
|
|
)
|
|
assert result.ok
|
|
assert result.reason == "ok"
|
|
assert eng._transcript == [
|
|
(
|
|
"round 0 caster: cast Fireball vs foe DC13 FAILED "
|
|
"-> area: 1 targets, 24 total damage, 0 conditions, 0 buffs (100->76)"
|
|
)
|
|
]
|
|
assert foe.hp == 76
|
|
|
|
|
|
def test_target_pos_at_target_cell_matches_creature_centered() -> None:
|
|
"""Given: the same scene, twice — once creature-centered, once with
|
|
target_pos set to the target's own cell (target_id kept)
|
|
When: both casts resolve with identical scripted rolls
|
|
Then: transcripts and hp outcomes are byte-identical."""
|
|
caster = make_state(make_combatant(
|
|
"caster", hp=100, spells=["Fireball"], spell_slots={1: 9, 2: 9, 3: 9}
|
|
), "players", (7, 0))
|
|
foe = make_state(make_combatant("foe", hp=100), "monsters", (0, 4))
|
|
plain = _engine([caster, foe], [2] + [3] * 8)
|
|
plain._execute(
|
|
caster, Action(kind="cast_spell", target_id="foe", spell_name="Fireball")
|
|
)
|
|
caster2 = make_state(make_combatant(
|
|
"caster", hp=100, spells=["Fireball"], spell_slots={1: 9, 2: 9, 3: 9}
|
|
), "players", (7, 0))
|
|
foe2 = make_state(make_combatant("foe", hp=100), "monsters", (0, 4))
|
|
pointed = _engine([caster2, foe2], [2] + [3] * 8)
|
|
pointed._execute(
|
|
caster2,
|
|
Action(kind="cast_spell", target_id="foe", spell_name="Fireball", target_pos=(0, 4)),
|
|
)
|
|
assert pointed._transcript == plain._transcript
|
|
assert foe2.hp == foe.hp
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# (e) non-area spells ignore target_pos and resolve on their target
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_non_area_spell_with_target_pos_resolves_on_target() -> None:
|
|
"""Given: Magic Missile (no AreaEffect) cast with target_id AND target_pos
|
|
When: the cast resolves
|
|
Then: target_pos is ignored — the missile hits its creature (1d4+1 = 4)
|
|
and the transcript is the ordinary creature-centered line."""
|
|
caster = make_state(
|
|
make_combatant(
|
|
"caster", hp=100, spells=["Magic Missile"], spell_slots={1: 9, 2: 9, 3: 9}
|
|
), "players", (0, 0)
|
|
)
|
|
foe = make_state(make_combatant("foe", hp=100), "monsters", (0, 2))
|
|
eng = _engine([caster, foe], [3])
|
|
result = eng._execute(
|
|
caster,
|
|
Action(kind="cast_spell", target_id="foe", spell_name="Magic Missile", target_pos=(1, 1)),
|
|
)
|
|
assert result.ok
|
|
assert result.reason == "ok"
|
|
assert foe.hp == 96
|
|
assert eng._transcript == [
|
|
"round 0 caster: cast Magic Missile vs foe DC11 NO SAVE -> 4 damage (100->96)"
|
|
]
|
|
|
|
|
|
def test_non_area_point_cast_without_target_is_harmless_no_op() -> None:
|
|
"""Given: Magic Missile (no area) with target_pos but NO target_id
|
|
When: the action is dispatched with an empty roll queue
|
|
Then: nothing crashes, nothing is cast, no die is rolled."""
|
|
caster = make_state(
|
|
make_combatant(
|
|
"caster", hp=100, spells=["Magic Missile"], spell_slots={1: 9, 2: 9, 3: 9}
|
|
), "players", (0, 0)
|
|
)
|
|
foe = make_state(make_combatant("foe", hp=100), "monsters", (0, 2))
|
|
eng = _engine([caster, foe], [])
|
|
result = eng._execute(
|
|
caster, Action(kind="cast_spell", spell_name="Magic Missile", target_pos=(1, 1))
|
|
)
|
|
assert result.kind == "cast_spell"
|
|
assert foe.hp == 100
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# (f) the missing-target relaxation covers cast_spell only
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_cast_spell_without_target_or_pos_still_missing_target() -> None:
|
|
"""Given: cast_spell with neither target_id nor target_pos
|
|
When: executed with an empty roll queue
|
|
Then: the U1 missing_target rejection still fires."""
|
|
eng, caster, foe_in, _ = _cast_scene([])
|
|
result = eng._execute(caster, Action(kind="cast_spell", spell_name="Fireball"))
|
|
assert not result.ok
|
|
assert result.reason == "missing_target"
|
|
assert foe_in.hp == 100
|
|
|
|
|
|
def test_other_kinds_not_relaxed_by_target_pos() -> None:
|
|
"""Given: an attack carrying target_pos but no target_id
|
|
When: executed
|
|
Then: missing_target still fires — only cast_spell is relaxed."""
|
|
eng, caster, _, _ = _cast_scene([])
|
|
result = eng._execute(caster, Action(kind="attack", target_pos=(0, 4)))
|
|
assert not result.ok
|
|
assert result.reason == "missing_target"
|