fix(combat): summoned creatures act and expire
This commit is contained in:
@@ -0,0 +1,193 @@
|
||||
"""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 SpellSpec, SummonEffect
|
||||
|
||||
|
||||
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}"
|
||||
)
|
||||
|
||||
|
||||
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"
|
||||
)
|
||||
Reference in New Issue
Block a user