feat(phase2): strategy compiler and sweep runner (P2)
This commit is contained in:
@@ -0,0 +1,231 @@
|
||||
"""Phase-2 Etage A strategy compiler tests (docs/phase-2-llm-architecture.md 10 P2.1).
|
||||
|
||||
Deterministic, offline: no network, no LLM. Pins the ``StrategySpec`` /
|
||||
``compile_strategy`` contract: the all-default spec compiles to
|
||||
``default_policy`` by identity, ``select_target`` honours each priority with
|
||||
deterministic tie-breaks, non-default specs drive real battles through
|
||||
``run_encounter`` bit-identically under a fixed seed, and invalid specs are
|
||||
rejected at construction.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import pytest
|
||||
|
||||
from pf1e_simulator.combat import (
|
||||
Action,
|
||||
CombatantState,
|
||||
CombatEngine,
|
||||
default_policy,
|
||||
)
|
||||
from pf1e_simulator.grid import Grid
|
||||
from pf1e_simulator.loaders import load_combatant
|
||||
from pf1e_simulator.map import load_map
|
||||
from pf1e_simulator.rng import SeededRng
|
||||
from pf1e_simulator.runner import EncounterSpec, Side, run_encounter
|
||||
from pf1e_simulator.spells import SPELLS_DIR, load_spell_registry
|
||||
from pf1e_simulator.strategy import StrategySpec, compile_strategy, select_target
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pf1e_simulator.models import Combatant
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
MAP = ROOT / "data" / "maps" / "sample_arena.yaml"
|
||||
_MAP_SPEC = load_map(MAP)
|
||||
_SPELLS = load_spell_registry(SPELLS_DIR)
|
||||
|
||||
|
||||
def _c(path: str) -> Combatant:
|
||||
return load_combatant(ROOT / "data" / path)
|
||||
|
||||
|
||||
def _spec() -> EncounterSpec:
|
||||
"""A fast 2v2 (goblin archer + orc vs orc + goblin) on the sample arena."""
|
||||
return EncounterSpec(
|
||||
map=_MAP_SPEC,
|
||||
round_cap=20,
|
||||
sides=(
|
||||
Side(
|
||||
name="players",
|
||||
combatants=(_c("monsters/goblin.json"), _c("monsters/orc.json")),
|
||||
zone="A",
|
||||
),
|
||||
Side(
|
||||
name="monsters",
|
||||
combatants=(_c("monsters/orc.json"), _c("monsters/goblin.json")),
|
||||
zone="B",
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _state(
|
||||
cid: str, side: str, pos: tuple[int, int], hp: int = 10, path: str = "monsters/orc.json"
|
||||
) -> CombatantState:
|
||||
"""A bare runtime state over a loaded combatant (no battle run)."""
|
||||
combatant = _c(path).model_copy(update={"id": cid})
|
||||
return CombatantState(combatant=combatant, side=side, pos=pos, hp=hp, resources_left={})
|
||||
|
||||
|
||||
def _mini_engine(states: list[CombatantState]) -> CombatEngine:
|
||||
"""A live engine over the 2v2 map with the given states (no battle run)."""
|
||||
return CombatEngine(SeededRng(1), Grid.from_spec(_MAP_SPEC), states, spell_registry=_SPELLS)
|
||||
|
||||
|
||||
def test_compile_strategy_default_is_default_policy() -> None:
|
||||
"""The all-default spec compiles to ``default_policy`` by identity."""
|
||||
assert compile_strategy(StrategySpec()) is default_policy
|
||||
|
||||
|
||||
def test_select_target_nearest() -> None:
|
||||
"""nearest picks the closest active enemy (ties keep list order)."""
|
||||
orc = _state("orc1", "monsters", (1, 1))
|
||||
goblin = _state("goblin1", "players", (1, 2), hp=6, path="monsters/goblin.json")
|
||||
far_orc = _state("orc2", "players", (1, 16), hp=6)
|
||||
engine = _mini_engine([orc, goblin, far_orc])
|
||||
assert select_target(engine, orc, "nearest") is goblin
|
||||
|
||||
|
||||
def test_select_target_weakest_ac() -> None:
|
||||
"""weakest_ac picks the lowest total AC (goblin 16 > orc 13)."""
|
||||
orc = _state("orc1", "monsters", (1, 1))
|
||||
goblin = _state("goblin1", "players", (1, 2), hp=6, path="monsters/goblin.json")
|
||||
far_orc = _state("orc2", "players", (1, 16), hp=6)
|
||||
engine = _mini_engine([orc, goblin, far_orc])
|
||||
assert select_target(engine, orc, "weakest_ac") is far_orc
|
||||
|
||||
|
||||
def test_select_target_lowest_hp() -> None:
|
||||
"""lowest_hp picks the most wounded active enemy."""
|
||||
orc = _state("orc1", "monsters", (1, 1))
|
||||
healthy = _state("goblin1", "players", (1, 2), hp=6, path="monsters/goblin.json")
|
||||
wounded = _state("goblin2", "players", (1, 16), hp=1, path="monsters/goblin.json")
|
||||
engine = _mini_engine([orc, healthy, wounded])
|
||||
assert select_target(engine, orc, "lowest_hp") is wounded
|
||||
|
||||
|
||||
def test_select_target_no_active_enemy() -> None:
|
||||
"""No active enemy -> None for every priority."""
|
||||
orc = _state("orc1", "monsters", (1, 1))
|
||||
dying = _state("goblin1", "players", (1, 2), hp=-5, path="monsters/goblin.json")
|
||||
engine = _mini_engine([orc, dying])
|
||||
assert select_target(engine, orc, "nearest") is None
|
||||
assert select_target(engine, orc, "weakest_ac") is None
|
||||
assert select_target(engine, orc, "lowest_hp") is None
|
||||
|
||||
|
||||
def test_compiled_policy_melee_ladder() -> None:
|
||||
"""A non-default spec still resolves the melee ladder on a live engine."""
|
||||
spec = StrategySpec(name="no-charge", use_charges=False)
|
||||
policy = compile_strategy(spec)
|
||||
assert policy is not default_policy
|
||||
orc = _state("orc1", "monsters", (1, 1))
|
||||
goblin = _state("goblin1", "players", (1, 2), hp=6, path="monsters/goblin.json")
|
||||
engine = _mini_engine([orc, goblin])
|
||||
assert policy(engine, orc) == (Action(kind="full_attack", target_id="goblin1"),)
|
||||
|
||||
|
||||
def test_compiled_policy_charge_disabled() -> None:
|
||||
"""use_charges=False skips the charge and falls to step + full-attack."""
|
||||
spec = StrategySpec(name="no-charge", use_charges=False)
|
||||
policy = compile_strategy(spec)
|
||||
# The celestial wolf is melee-only (Bite, reach 5 ft): at 2 cells no
|
||||
# weapon_for short-circuit, so the ladder reaches the charge branch. The
|
||||
# orc sits DIAGONALLY at (2, 3): straight ahead at (1, 3) the only
|
||||
# attackable charge end is 1 cell away (below the 2-cell minimum charge
|
||||
# move), while from (1, 1) the diagonal end (1, 3) is a legal 2-cell
|
||||
# straight line AND a single 5-foot step to (1, 2) still enters reach.
|
||||
wolf = _state("wolf1", "players", (1, 1), hp=13, path="monsters/celestial_wolf.json")
|
||||
orc = _state("orc1", "monsters", (2, 3))
|
||||
engine = _mini_engine([wolf, orc])
|
||||
assert default_policy(engine, wolf) == (Action(kind="charge", target_id="orc1"),)
|
||||
assert policy(engine, wolf) == (
|
||||
Action(kind="5foot_step", target_id="orc1"),
|
||||
Action(kind="full_attack", target_id="orc1"),
|
||||
)
|
||||
|
||||
|
||||
def test_compiled_policy_retreats_below_threshold() -> None:
|
||||
"""retreat_hp_fraction withdraws below the threshold, not at/above it."""
|
||||
spec = StrategySpec(name="coward", retreat_hp_fraction=0.5)
|
||||
policy = compile_strategy(spec)
|
||||
orc = _state("orc1", "monsters", (1, 1), hp=2) # 2 < 0.5 * 6 = 3
|
||||
goblin = _state("goblin1", "players", (1, 2), hp=6, path="monsters/goblin.json")
|
||||
engine = _mini_engine([orc, goblin])
|
||||
assert policy(engine, orc) == (Action(kind="withdraw", target_id="goblin1"),)
|
||||
orc_ok = _state("orc2", "monsters", (1, 1), hp=3) # 3 is not below 3
|
||||
engine_ok = _mini_engine([orc_ok, goblin])
|
||||
assert all(a.kind != "withdraw" for a in policy(engine_ok, orc_ok))
|
||||
|
||||
|
||||
def test_compiled_policy_spell_policy_none_skips_casting() -> None:
|
||||
"""spell_policy="none" never proposes a cast even for a caster with slots."""
|
||||
spec = StrategySpec(name="no-spells", spell_policy="none")
|
||||
policy = compile_strategy(spec)
|
||||
oni = _c("pcs/oni.json")
|
||||
oni_state = CombatantState(
|
||||
combatant=oni,
|
||||
side="players",
|
||||
pos=(1, 1),
|
||||
hp=oni.hp_max,
|
||||
resources_left={},
|
||||
spell_slots_left={1: 4, 2: 3},
|
||||
)
|
||||
goblin = _state("goblin1", "monsters", (1, 2), hp=6, path="monsters/goblin.json")
|
||||
engine = _mini_engine([oni_state, goblin])
|
||||
# With the default spell policy the same setup casts (Shield of Faith,
|
||||
# slot 1): no ally is below 50% HP and Sound Burst is an area spell, so
|
||||
# _find_offensive_spell (top-level DamageEffect only) skips it.
|
||||
assert default_policy(engine, oni_state) == (
|
||||
Action(kind="cast_spell", target_id="oni", spell_name="Shield of Faith"),
|
||||
)
|
||||
# With spell_policy="none" the melee ladder runs instead.
|
||||
actions = policy(engine, oni_state)
|
||||
assert all(a.kind != "cast_spell" for a in actions)
|
||||
|
||||
|
||||
def test_compiled_policy_class_abilities_disabled() -> None:
|
||||
"""use_class_abilities=False skips channel/hex even when they would fire."""
|
||||
spec = StrategySpec(name="no-class", use_class_abilities=False)
|
||||
policy = compile_strategy(spec)
|
||||
oni = _c("pcs/oni.json")
|
||||
oni_state = CombatantState(
|
||||
combatant=oni,
|
||||
side="players",
|
||||
pos=(1, 1),
|
||||
hp=oni.hp_max,
|
||||
resources_left={"channel_energy": 6},
|
||||
spell_slots_left={1: 4, 2: 3},
|
||||
)
|
||||
hurt = _state("hurt", "players", (1, 3), hp=1, path="monsters/goblin.json")
|
||||
goblin = _state("goblin1", "monsters", (1, 2), hp=6, path="monsters/goblin.json")
|
||||
engine = _mini_engine([oni_state, hurt, goblin])
|
||||
# With class abilities enabled the channel fires (ally below 50% hp).
|
||||
assert default_policy(engine, oni_state) == (
|
||||
Action(kind="channel_energy", target_id="goblin1", channel_mode="heal"),
|
||||
)
|
||||
# Disabled: the channel is skipped entirely.
|
||||
actions = policy(engine, oni_state)
|
||||
assert all(a.kind != "channel_energy" for a in actions)
|
||||
|
||||
|
||||
def test_compiled_policy_deterministic_runs() -> None:
|
||||
"""A compiled policy drives bit-identical battles under a fixed seed."""
|
||||
spec = StrategySpec(name="focus", target_priority="weakest_ac")
|
||||
policy = compile_strategy(spec)
|
||||
_, runs_a = run_encounter(_spec(), runs=3, seed=7, policy=policy, collect_runs=True)
|
||||
_, runs_b = run_encounter(_spec(), runs=3, seed=7, policy=policy, collect_runs=True)
|
||||
assert runs_a == runs_b
|
||||
|
||||
|
||||
def test_strategy_spec_validation() -> None:
|
||||
"""Invalid specs are rejected at construction."""
|
||||
with pytest.raises(ValueError, match="non-empty"):
|
||||
StrategySpec(name="")
|
||||
with pytest.raises(ValueError, match="within"):
|
||||
StrategySpec(retreat_hp_fraction=1.5)
|
||||
with pytest.raises(ValueError, match="within"):
|
||||
StrategySpec(retreat_hp_fraction=-0.1)
|
||||
Reference in New Issue
Block a user