"""Phase-2 danger-cartography spike (docs/phase-2-llm-architecture.md 3.1/3.2, 10 P3/P4). Proof of value WITHOUT any LLM: the same party measured against the default monster policy and against the strengthened focus-fire policy (spike module pf1e_simulator.phase2_spike). A danger only exists against an adversary that plays to win, so the DELTA between the two columns is the danger the default Monte Carlo report hides. Prints a compact per-scenario block (party win rate +/- 3 sigma, P(TPK), P(>=1 death), expected hp lost, rounds, worst decile hp) and pins loose assertions: everything is deterministic under a fixed seed, so the pinned inequalities are exact reproductions, not noise. Scenarios (sample arena, 100 runs, fixed seed): warband - 4 level-4 PCs vs a thrune agent + 2 orcs + a goblin: the party "wins" either way, but focus fire nearly doubles P(>=1 death). lich - 2 level-2/4 PCs vs the lich + an orc: the party loses both ways, but against the default policy nobody ever actually DIES (downed at hp<0, above -CON); focus fire converts defeat into real deaths and a nonzero TPK rate. """ from __future__ import annotations import sys from pathlib import Path from typing import TYPE_CHECKING from pf1e_simulator.combat import 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.objectives import DangerMetrics, danger_metrics from pf1e_simulator.phase2_spike import focus_fire_policy from pf1e_simulator.rng import SeededRng from pf1e_simulator.runner import EncounterSpec, RunOutcome, Side, run_encounter if TYPE_CHECKING: import pytest from pf1e_simulator.models import Combatant ROOT = Path(__file__).resolve().parents[1] MAP = ROOT / "data" / "maps" / "sample_arena.yaml" RUNS = 100 SEED = 11 ROUND_CAP = 20 def _c(path: str) -> Combatant: return load_combatant(ROOT / "data" / path) def _spec(pcs: list[str], monsters: list[str]) -> EncounterSpec: return EncounterSpec( map=load_map(MAP), round_cap=ROUND_CAP, sides=( Side(name="players", combatants=tuple(_c(p) for p in pcs), zone="A"), Side(name="monsters", combatants=tuple(_c(p) for p in monsters), zone="B"), ), ) WARBAND = _spec( ["pcs/jeanne.json", "pcs/tammara.json", "pcs/harvie.json", "pcs/ierlieth.json"], ["monsters/thrune_agent.json", "monsters/orc.json", "monsters/orc.json", "monsters/goblin.json"], ) LICH = _spec( ["pcs/harvie.json", "pcs/ierlieth.json"], ["monsters/lich.json", "monsters/orc.json"], ) def _emit(line: str) -> None: sys.stdout.write(line + "\n") def _mini_engine(states: list[CombatantState]) -> CombatEngine: return CombatEngine(SeededRng(1), Grid.from_spec(WARBAND.map), states) def _state(cid: str, side: str, pos: tuple[int, int], hp: int = 10) -> CombatantState: c = _c("monsters/orc.json").model_copy(update={"id": cid}) return CombatantState(combatant=c, side=side, pos=pos, hp=hp, resources_left={}) def test_focus_fire_concentrates_on_lowest_ac_not_nearest() -> None: """The monster ignores the adjacent tough target and converges on the squishy one.""" policy = focus_fire_policy("monsters") orc = _state("orc1", "monsters", (1, 1)) tough = _c("pcs/jeanne.json") # AC 23, adjacent to the orc squishy = _c("pcs/nairda.json") # AC 13, far away tough_state = CombatantState( combatant=tough, side="players", pos=(1, 2), hp=tough.hp_max, resources_left={} ) squishy_state = CombatantState( combatant=squishy, side="players", pos=(1, 16), hp=squishy.hp_max, resources_left={} ) engine = _mini_engine([orc, tough_state, squishy_state]) actions = policy(engine, orc) assert all(a.target_id == "nairda" for a in actions), actions def test_focus_fire_tie_break_prefers_wounded() -> None: """Equal AC: the monster focuses the member with the lower current hp.""" policy = focus_fire_policy("monsters") orc = _state("orc1", "monsters", (1, 1)) fresh = _c("monsters/goblin.json").model_copy(update={"id": "fresh"}) hurt = _c("monsters/goblin.json").model_copy(update={"id": "hurt"}) fresh_state = CombatantState( combatant=fresh, side="players", pos=(5, 5), hp=fresh.hp_max, resources_left={} ) hurt_state = CombatantState( combatant=hurt, side="players", pos=(5, 10), hp=1, resources_left={} ) engine = _mini_engine([orc, fresh_state, hurt_state]) actions = policy(engine, orc) assert all(a.target_id == "hurt" for a in actions), actions def test_focus_fire_leaves_the_party_side_on_default_policy() -> None: """Only the monster side changes: a party-side turn gets default_policy verbatim.""" policy = focus_fire_policy("monsters") pc = _state("p", "players", (1, 1)) mob = _state("m", "monsters", (1, 8)) engine = _mini_engine([pc, mob]) assert policy(engine, pc) == default_policy(engine, pc) def _report(name: str, m: DangerMetrics, win: float, sigma: float) -> None: _emit( f" {name:<9} win={win:6.1%} +/-{3 * sigma:4.1%} surv={m.p_survives:5.1%} " f"tpk={m.p_tpk:5.1%} >=1-death={m.p_any_death:5.1%} " f"hp-lost={m.expected_hp_lost:6.1f} rounds={m.expected_rounds:4.1f} " f"tail-hp={m.worst_decile_party_hp:5.1f}" ) def _measure( spec: EncounterSpec, *, strengthened: bool ) -> tuple[float, float, DangerMetrics, tuple[RunOutcome, ...]]: policy = focus_fire_policy("monsters") if strengthened else None report, outcomes = run_encounter( spec, runs=RUNS, seed=SEED, policy=policy, collect_runs=True ) win = report.win_rate("players") return win, report.win_rate_sigma("players"), danger_metrics(outcomes, "players"), outcomes def test_danger_map_default_vs_strengthened(capsys: pytest.CaptureFixture[str]) -> None: """Print the danger map sketch and pin the delta (deterministic, fixed seed).""" _emit("=== PHASE2 SUMMARY (danger cartography spike) ===") deltas: dict[str, tuple[float, float]] = {} metrics: dict[str, tuple[DangerMetrics, DangerMetrics]] = {} for name, spec in (("warband", WARBAND), ("lich", LICH)): _emit(f"scenario {name}: {len(spec.sides[0].combatants)} PCs vs " f"{len(spec.sides[1].combatants)} monsters, runs={RUNS}, seed={SEED}") w0, s0, m0, _ = _measure(spec, strengthened=False) w1, s1, m1, _ = _measure(spec, strengthened=True) _report("default", m0, w0, s0) _report("focusfire", m1, w1, s1) _emit( f" delta death-rate {m1.p_any_death - m0.p_any_death:+.1%} " f"hp-lost {m1.expected_hp_lost - m0.expected_hp_lost:+.1f}" ) deltas[name] = (m0.p_any_death, m1.p_any_death) metrics[name] = (m0, m1) out = capsys.readouterr().out assert "PHASE2 SUMMARY" in out # Loose, deterministic pins: the strengthened adversary never hides danger. for m0, m1 in metrics.values(): assert 0.0 <= m1.p_any_death <= 1.0 assert m1.p_tpk >= m0.p_tpk - 0.05 assert m1.p_survives <= m0.p_survives + 0.05 # warband: focus fire must strictly raise the death rate d0, d1 = deltas["warband"] assert d1 > d0 # lich: default policy shows ZERO real deaths; focus fire shows some assert metrics["lich"][0].p_any_death == 0.0 assert metrics["lich"][1].p_any_death > 0.0 assert metrics["lich"][1].p_tpk > 0.0 def test_danger_map_is_reproducible() -> None: """Same seed, same policy -> byte-identical per-run outcome tuples.""" a = _measure(LICH, strengthened=True)[3] b = _measure(LICH, strengthened=True)[3] assert a == b