338 lines
12 KiB
Python
338 lines
12 KiB
Python
"""Tests for summoning: summoned creatures act in combat and expire after duration."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from pf1e_simulator.combat import (
|
|
Action,
|
|
CombatantState,
|
|
CombatEngine,
|
|
default_policy,
|
|
)
|
|
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,
|
|
Saves,
|
|
)
|
|
from pf1e_simulator.rng import SeededRng
|
|
from pf1e_simulator.spells import SPELLS_DIR, SpellSpec, SummonEffect, load_spell
|
|
|
|
|
|
def _make_caster() -> Combatant:
|
|
attack = AttackSpec(
|
|
id="caster-w",
|
|
name="dagger",
|
|
kind="melee",
|
|
attack_bonus=2,
|
|
damage=[DamageComponent(formula=parse_dice("1d4"), types=["piercing"])],
|
|
)
|
|
return Combatant(
|
|
id="caster",
|
|
name="caster",
|
|
level=5,
|
|
size="Medium",
|
|
abilities=AbilityScores(
|
|
str_score=10, dex_score=14, con_score=12,
|
|
int_score=18, wis_score=12, cha_score=10,
|
|
),
|
|
hp_max=50,
|
|
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",
|
|
spells=["Summon Monster II"],
|
|
spell_slots={1: 3, 2: 3},
|
|
)
|
|
|
|
|
|
def _make_enemy() -> Combatant:
|
|
attack = AttackSpec(
|
|
id="enemy-w",
|
|
name="club",
|
|
kind="melee",
|
|
attack_bonus=1,
|
|
damage=[DamageComponent(formula=parse_dice("1d6"), types=["bludgeoning"])],
|
|
)
|
|
return Combatant(
|
|
id="enemy",
|
|
name="enemy",
|
|
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=200,
|
|
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=[attack],
|
|
)
|
|
|
|
|
|
def _make_summon_spell(duration_rounds: int = 2) -> SpellSpec:
|
|
return SpellSpec(
|
|
name="Summon Monster II",
|
|
level=2,
|
|
school="conjuration",
|
|
range="close",
|
|
save="none",
|
|
base_dc=10,
|
|
effects=(
|
|
SummonEffect(
|
|
creature="data/monsters/celestial_wolf.json",
|
|
duration_rounds=duration_rounds,
|
|
),
|
|
),
|
|
)
|
|
|
|
|
|
def _summon_policy(
|
|
engine: CombatEngine, state: CombatantState
|
|
) -> tuple[Action, ...]:
|
|
"""Force the caster to summon on first turn with slots available."""
|
|
if (
|
|
state.combatant.id == "caster"
|
|
and state.combatant.spells
|
|
and state.spell_slots_left.get(2, 0) > 0
|
|
):
|
|
target = engine.nearest_enemy(state)
|
|
if target is not None:
|
|
return (
|
|
Action(
|
|
kind="cast_spell",
|
|
target_id=target.combatant.id,
|
|
spell_name="Summon Monster II",
|
|
),
|
|
)
|
|
return default_policy(engine, state)
|
|
|
|
|
|
def _build_engine(seed: int, duration_rounds: int = 2) -> CombatEngine:
|
|
caster = _make_caster()
|
|
enemy = _make_enemy()
|
|
c = CombatantState(
|
|
combatant=caster, side="players", pos=(0, 0), hp=caster.hp_max,
|
|
)
|
|
e = CombatantState(
|
|
combatant=enemy, side="monsters", pos=(5, 0), hp=enemy.hp_max,
|
|
)
|
|
legend = {".": TerrainType(type="floor", move_cost=1)}
|
|
spec = MapSpec(name="test", terrain=tuple(["." * 12] * 12), legend=legend)
|
|
grid = Grid.from_spec(spec)
|
|
reg = {"Summon Monster II": _make_summon_spell(duration_rounds)}
|
|
return CombatEngine(
|
|
SeededRng(seed), grid, [c, e],
|
|
policy=_summon_policy,
|
|
spell_registry=reg,
|
|
)
|
|
|
|
|
|
class TestSummonedCreatureActs:
|
|
"""Given: a caster who summons a celestial wolf
|
|
When: combat runs for several rounds
|
|
Then: the summoned creature takes at least one turn (acts)."""
|
|
|
|
def test_summoned_creature_acts_in_combat(self) -> None:
|
|
engine = _build_engine(seed=42)
|
|
result = engine.run()
|
|
|
|
wolf_lines = [
|
|
line for line in result.transcript if "celestial_wolf" in line
|
|
]
|
|
action_keywords = (
|
|
"charge", "attack", "move", "full_attack", "bite",
|
|
"5-foot", "wait",
|
|
)
|
|
action_lines = [
|
|
line for line in wolf_lines
|
|
if any(kw in line for kw in action_keywords)
|
|
]
|
|
assert len(action_lines) >= 1, (
|
|
f"Summoned creature never acted. All wolf lines: {wolf_lines}"
|
|
)
|
|
|
|
|
|
def _build_engine_with_walls(seed: int = 42, duration_rounds: int = 2) -> CombatEngine:
|
|
"""Caster at (1,1) under a walled row 0, like sample_arena's border walls.
|
|
|
|
The spawn search must skip wall cells and pick the first passable one.
|
|
"""
|
|
caster = _make_caster()
|
|
enemy = _make_enemy()
|
|
c = CombatantState(
|
|
combatant=caster, side="players", pos=(1, 1), hp=caster.hp_max,
|
|
)
|
|
e = CombatantState(
|
|
combatant=enemy, side="monsters", pos=(9, 1), hp=enemy.hp_max,
|
|
)
|
|
legend = {
|
|
".": TerrainType(type="floor", move_cost=1),
|
|
"#": TerrainType(type="wall", move_cost=None),
|
|
}
|
|
terrain = tuple(
|
|
"#" * 12 if r == 0 else "#..........." if r == 1 else "." * 12
|
|
for r in range(12)
|
|
)
|
|
spec = MapSpec(name="test", terrain=terrain, legend=legend)
|
|
grid = Grid.from_spec(spec)
|
|
reg = {"Summon Monster II": _make_summon_spell(duration_rounds)}
|
|
return CombatEngine(
|
|
SeededRng(seed), grid, [c, e],
|
|
policy=_summon_policy,
|
|
spell_registry=reg,
|
|
)
|
|
|
|
|
|
class TestSummonSpawnsOnPassableCell:
|
|
"""Given: a caster whose adjacent ring contains wall cells (arena border)
|
|
When: a summons resolves
|
|
Then: the creature spawns on a passable cell and acts (not just waits)."""
|
|
|
|
def test_summon_spawns_on_passable_cell_and_acts(self) -> None:
|
|
engine = _build_engine_with_walls(seed=42)
|
|
result = engine.run()
|
|
|
|
wolf_lines = [
|
|
line for line in result.transcript if "celestial_wolf" in line
|
|
]
|
|
spawn_line = next(
|
|
(ln for ln in wolf_lines if "summon celestial_wolf at" in ln),
|
|
None,
|
|
)
|
|
assert spawn_line is not None, (
|
|
f"Wolf was never summoned. Wolf lines: {wolf_lines}"
|
|
)
|
|
# "summon celestial_wolf at (r, c) (2r)" — parse the spawn position.
|
|
pos_str = spawn_line.split("at (")[1].split(")")[0]
|
|
spawn_pos = tuple(int(part) for part in pos_str.split(", "))
|
|
assert engine._grid.passable(spawn_pos), (
|
|
f"Wolf spawned on impassable cell {spawn_pos}: {spawn_line!r}"
|
|
)
|
|
|
|
action_keywords = (
|
|
"charge", "attack", "move", "full_attack", "bite", "5-foot",
|
|
)
|
|
acted_lines = [
|
|
line for line in wolf_lines
|
|
if any(kw in line for kw in action_keywords)
|
|
]
|
|
assert len(acted_lines) >= 1, (
|
|
f"Wolf never acted (only waited). Wolf lines: {wolf_lines}"
|
|
)
|
|
|
|
|
|
class TestSummonedCreatureExpires:
|
|
"""Given: a caster who summons with duration 2
|
|
When: enough rounds pass
|
|
Then: the summoned creature expires and the transcript logs it."""
|
|
|
|
def test_summoned_creature_expires_after_duration(self) -> None:
|
|
engine = _build_engine(seed=42, duration_rounds=2)
|
|
result = engine.run()
|
|
|
|
expire_lines = [
|
|
line for line in result.transcript
|
|
if "summon expired" in line.lower()
|
|
]
|
|
summon_lines = [
|
|
ln for ln in result.transcript
|
|
if "celestial" in ln.lower() or "summon" in ln.lower()
|
|
]
|
|
assert len(expire_lines) >= 1, (
|
|
f"Summoned creature never expired. "
|
|
f"Transcript excerpt: {summon_lines}"
|
|
)
|
|
wolf_states = [
|
|
s for s in engine._states if s.combatant.id == "celestial_wolf"
|
|
]
|
|
if wolf_states:
|
|
assert not wolf_states[0].active, (
|
|
"Wolf should be inactive after expiration"
|
|
)
|
|
|
|
|
|
class TestSummonDurationScalesWithCasterLevel:
|
|
"""SRD spells/summon-monster-1.md: "Duration 1 round/level (D)" - the
|
|
summon's rounds resolve at cast time from the caster's level, mirroring
|
|
the ConditionEffect per-level duration convention."""
|
|
|
|
def _cast(self, caster_level: int, *, per_level: int) -> CombatEngine:
|
|
caster = _make_caster()
|
|
caster = caster.model_copy(update={"level": caster_level})
|
|
enemy = _make_enemy()
|
|
c = CombatantState(combatant=caster, side="players", pos=(0, 0), hp=caster.hp_max)
|
|
e = CombatantState(combatant=enemy, side="monsters", pos=(5, 0), hp=enemy.hp_max)
|
|
spell = SpellSpec(
|
|
name="Summon Monster II",
|
|
level=2,
|
|
school="conjuration",
|
|
range="close",
|
|
effects=(
|
|
SummonEffect(
|
|
creature="data/monsters/celestial_wolf.json",
|
|
duration_rounds=1,
|
|
duration_rounds_per_level=per_level,
|
|
),
|
|
),
|
|
)
|
|
legend = {".": TerrainType(type="floor", move_cost=1)}
|
|
spec = MapSpec(name="test", terrain=tuple(["." * 12] * 12), legend=legend)
|
|
engine = CombatEngine(
|
|
SeededRng(1), Grid.from_spec(spec), [c, e],
|
|
spell_registry={"Summon Monster II": spell},
|
|
)
|
|
engine._cast_spell(c, e, "Summon Monster II")
|
|
return engine
|
|
|
|
def test_duration_equals_caster_level_rounds(self) -> None:
|
|
# Given/When: a level 5 caster summons with 1 round/level
|
|
# Then: the wolf lives for 5 rounds.
|
|
engine = self._cast(5, per_level=1)
|
|
wolf = next(s for s in engine._states if s.combatant.id == "celestial_wolf")
|
|
assert wolf.summon_duration == 5
|
|
|
|
def test_duration_scales_for_a_higher_caster(self) -> None:
|
|
# Given: a level 9 caster
|
|
# Then: 9 rounds - the duration is not the fixed JSON value.
|
|
engine = self._cast(9, per_level=1)
|
|
wolf = next(s for s in engine._states if s.combatant.id == "celestial_wolf")
|
|
assert wolf.summon_duration == 9
|
|
|
|
def test_fixed_duration_still_used_without_per_level_field(self) -> None:
|
|
# Given: a summon declaring only a fixed duration (per_level = 0)
|
|
# Then: the fixed value applies unchanged (pre-conformance fallback).
|
|
caster = _make_caster()
|
|
enemy = _make_enemy()
|
|
c = CombatantState(combatant=caster, side="players", pos=(0, 0), hp=caster.hp_max)
|
|
e = CombatantState(combatant=enemy, side="monsters", pos=(5, 0), hp=enemy.hp_max)
|
|
legend = {".": TerrainType(type="floor", move_cost=1)}
|
|
spec = MapSpec(name="test", terrain=tuple(["." * 12] * 12), legend=legend)
|
|
reg = {"Summon Monster II": _make_summon_spell(duration_rounds=3)}
|
|
engine = CombatEngine(
|
|
SeededRng(1), Grid.from_spec(spec), [c, e], spell_registry=reg
|
|
)
|
|
engine._cast_spell(c, e, "Summon Monster II")
|
|
wolf = next(s for s in engine._states if s.combatant.id == "celestial_wolf")
|
|
assert wolf.summon_duration == 3
|
|
|
|
def test_shipped_summon_spells_declare_per_level_duration(self) -> None:
|
|
# Given: the shipped spell data
|
|
# When: Summon Monster I/II are loaded
|
|
# Then: they carry the SRD 1 round/level duration (no fixed value).
|
|
for name in ("summon_monster_i.json", "summon_monster_ii.json"):
|
|
spell = load_spell(SPELLS_DIR / name)
|
|
effect = spell.effects[0]
|
|
assert isinstance(effect, SummonEffect)
|
|
assert effect.duration_rounds_per_level == 1
|