diff --git a/pyproject.toml b/pyproject.toml
index 1e302b6..f8e0353 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -58,6 +58,9 @@ allowed-confusables = ["σ"]
"src/pf1e_simulator/runner.py" = ["PLR0913"]
# client.py: LLMClient/FakeLLMClient need the full safeguard knobs (cache, budget, pricing, retry, breaker)
"src/pf1e_simulator/client.py" = ["PLR0913"]
+# robustness.py: the cartography API takes the full knob set (scenario, specs,
+# side, runs, seed, fractions, target, precision, budget) as keyword parameters
+"src/pf1e_simulator/robustness.py" = ["PLR0913"]
# ── Basedpyright ──────────────────────────────────────────────────────────────
[tool.basedpyright]
diff --git a/src/pf1e_simulator/adversary.py b/src/pf1e_simulator/adversary.py
new file mode 100644
index 0000000..8fdc22d
--- /dev/null
+++ b/src/pf1e_simulator/adversary.py
@@ -0,0 +1,124 @@
+"""Adversary spec and compilation (docs/phase-2-llm-architecture.md section 10, P4.1).
+
+The adversary-facing mirror of strategy.py: ``AdversarySpec`` is a
+field-for-field copy of ``StrategySpec`` (same knobs, same validation) so the
+enemy side of a matchup carries its own spec type without touching the party
+search space. ``compile_adversary`` has identity semantics — it rebuilds the
+mirrored ``StrategySpec`` and delegates to ``compile_strategy``, so the
+all-default spec compiles to the module-level ``default_policy`` object
+itself. ``compile_matchup`` glues the two sides into ONE ``Policy`` that
+routes per combatant side: the engine invokes a single policy for every
+turn (``CombatEngine._take_turn`` → ``_policy(self, state)``), so the policy
+branches on ``state.side``, following the ``phase2_spike.focus_fire_policy``
+pattern. Deterministic throughout: no RNG, no LLM.
+"""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+
+from pf1e_simulator.combat import (
+ Action,
+ CombatantState,
+ CombatEngine,
+ Policy,
+ default_policy,
+)
+from pf1e_simulator.strategy import (
+ SpellPolicy,
+ StrategySpec,
+ TargetPriority,
+ compile_strategy,
+)
+
+
+@dataclass(frozen=True)
+class AdversarySpec:
+ """Field-for-field mirror of ``StrategySpec`` (same defaults, same validation)."""
+
+ name: str = "default"
+ target_priority: TargetPriority = "nearest"
+ retreat_hp_fraction: float = 0.0 # 0.0 = never retreat
+ spell_policy: SpellPolicy = "default"
+ use_charges: bool = True
+ use_class_abilities: bool = True
+
+ def __post_init__(self) -> None:
+ if not self.name:
+ msg = "name must be a non-empty string"
+ raise ValueError(msg)
+ if not 0.0 <= self.retreat_hp_fraction <= 1.0:
+ msg = "retreat_hp_fraction must be within [0, 1]"
+ raise ValueError(msg)
+
+
+def adversary_name(spec: AdversarySpec) -> str:
+ """A deterministic label for `spec` from its non-default levers.
+
+ Local mirror of ``search.strategy_name`` (same field order: target_priority,
+ spell_policy, use_charges, use_class_abilities, retreat_hp_fraction; joined
+ with ", "; the `name` field never appears; all-default is "default"), kept
+ local so this module never imports search.py.
+ """
+ levers: list[str] = []
+ if spec.target_priority != "nearest":
+ levers.append(spec.target_priority)
+ if spec.spell_policy != "default":
+ levers.append(f"spell_policy={spec.spell_policy}")
+ if not spec.use_charges:
+ levers.append("use_charges=false")
+ if not spec.use_class_abilities:
+ levers.append("use_class_abilities=false")
+ if spec.retreat_hp_fraction != 0.0:
+ levers.append(f"retreat={spec.retreat_hp_fraction}")
+ if not levers:
+ return "default"
+ return ", ".join(levers)
+
+
+def compile_adversary(spec: AdversarySpec) -> Policy:
+ """Compile an ``AdversarySpec`` into a ``Policy`` (identity semantics).
+
+ Rebuilds the mirrored ``StrategySpec`` and delegates to
+ ``compile_strategy``, so ``compile_adversary(AdversarySpec()) is
+ default_policy`` holds exactly like the strategy-side identity.
+ """
+ return compile_strategy(
+ StrategySpec(
+ name=spec.name,
+ target_priority=spec.target_priority,
+ retreat_hp_fraction=spec.retreat_hp_fraction,
+ spell_policy=spec.spell_policy,
+ use_charges=spec.use_charges,
+ use_class_abilities=spec.use_class_abilities,
+ )
+ )
+
+
+def compile_matchup(
+ party: StrategySpec, adversary: AdversarySpec, *, party_side: str = "players"
+) -> Policy:
+ """One ``Policy`` that routes every turn by ``state.side`` (P4.1).
+
+ Turns of ``party_side`` delegate to ``compile_strategy(party)``; every
+ other side delegates to ``compile_adversary(adversary)`` — the engine
+ invokes ONE policy for all combatants, so side routing lives inside the
+ policy (the ``phase2_spike.focus_fire_policy`` pattern, generalised to
+ per-side compiled strategies). When both specs compile to
+ ``default_policy``, the default object itself is returned (identity, no
+ wrapper). An empty ``party_side`` is a ValueError.
+ """
+ if not party_side:
+ msg = "party_side must be a non-empty string"
+ raise ValueError(msg)
+ party_policy = compile_strategy(party)
+ adversary_policy = compile_adversary(adversary)
+ if party_policy is default_policy and adversary_policy is default_policy:
+ return default_policy
+
+ def policy(engine: CombatEngine, state: CombatantState) -> tuple[Action, ...]:
+ if state.side == party_side:
+ return party_policy(engine, state)
+ return adversary_policy(engine, state)
+
+ return policy
diff --git a/src/pf1e_simulator/robustness.py b/src/pf1e_simulator/robustness.py
new file mode 100644
index 0000000..8a36c03
--- /dev/null
+++ b/src/pf1e_simulator/robustness.py
@@ -0,0 +1,682 @@
+"""Robustness layer: alternate min-max and danger cartography
+(docs/phase-2-llm-architecture.md sections 3.2 and 10; P4.2 and P4.3).
+
+A party's danger is only meaningful against an adversary that plays to
+win (D12). This module implements the two robustness layers on top of
+the compiled Etage-A pipeline:
+
+- P4.2 - alternate min-max (section 3.2: max_PJ . min_monstres):
+ ``adversarial_analysis`` alternately fixes one side and best-responds
+ the other (the party maximizes its own ``DangerMetrics``, then the
+ adversary minimizes the party's), every evaluation being a
+ ``matchup_benchmark`` of one party ``StrategySpec`` vs one adversary
+ ``AdversarySpec`` over a ``ScenarioSpec``. The same measure gives both
+ senses: danger for the party AND danger for the monsters.
+- P4.3 - danger cartography (non exhaustive, D14): ``danger_map`` sweeps
+ a grid of per-side initial-HP fractions (``SideSpec.hp_fraction``) and
+ reports both sides' danger per cell, ``frontier`` bisects the party HP
+ fraction where a danger criterion crosses a threshold, and
+ ``sensitivity`` ranks which single adversary lever moves the party
+ danger the most.
+
+Deterministic throughout: no RNG, no LLM, no network. The engine's
+``SeededRng`` under ``run_encounter`` is the only randomness and it is
+seeded, so identical inputs produce bit-identical frozen results.
+"""
+
+from __future__ import annotations
+
+import math
+from dataclasses import dataclass
+from typing import TYPE_CHECKING
+
+if TYPE_CHECKING:
+ from collections.abc import Sequence
+
+ from pf1e_simulator.objectives import DangerMetrics
+ from pf1e_simulator.runner import EncounterReport
+ from pf1e_simulator.strategy import StrategySpec
+
+from pf1e_simulator.adversary import AdversarySpec, adversary_name, compile_matchup
+from pf1e_simulator.objectives import danger_metrics
+from pf1e_simulator.runner import run_encounter
+from pf1e_simulator.scenarios import ScenarioSpec, SideSpec, build_encounter
+
+_CRITERIA = ("p_survives", "p_tpk", "p_any_death", "expected_hp_lost")
+_MATCHUP_SIDES = 2 # a matchup is exactly one arena pair (two sides)
+
+
+def _check_criterion(criterion: str) -> None:
+ if criterion not in _CRITERIA:
+ msg = (
+ "criterion must be one of ('p_survives', 'p_tpk', 'p_any_death', "
+ f"'expected_hp_lost'), got {criterion!r}"
+ )
+ raise ValueError(msg)
+
+
+def _danger_value(danger: DangerMetrics, criterion: str) -> float:
+ """The `criterion` value of a danger profile (explicit dispatch, no getattr)."""
+ if criterion == "p_survives":
+ return danger.p_survives
+ if criterion == "p_tpk":
+ return danger.p_tpk
+ if criterion == "p_any_death":
+ return danger.p_any_death
+ return danger.expected_hp_lost
+
+
+@dataclass(frozen=True)
+class MatchupBenchmark:
+ """One (party strategy x adversary spec) matchup over a scenario.
+
+ ``report`` aggregates all runs; ``party_danger`` is the danger of the
+ ``party_side`` side and ``adversary_danger`` the danger of the other
+ side (same measure, both senses: PJ and monsters).
+ """
+
+ scenario: str
+ party: str
+ adversary: str
+ runs: int
+ seed: int
+ report: EncounterReport
+ party_danger: DangerMetrics
+ adversary_danger: DangerMetrics
+ party_side: str
+
+
+def _other_side(scenario: ScenarioSpec, party_side: str) -> str:
+ """The unique side different from ``party_side`` (exactly-2-sides contract)."""
+ names = tuple(side.name for side in scenario.sides)
+ if len(names) != _MATCHUP_SIDES:
+ msg = f"matchup requires exactly 2 sides, got {len(names)}"
+ raise ValueError(msg)
+ if party_side not in names:
+ msg = f"party_side {party_side!r} is not a side of the scenario"
+ raise ValueError(msg)
+ (other,) = (name for name in names if name != party_side)
+ return other
+
+
+def matchup_benchmark(
+ scenario: ScenarioSpec,
+ party: StrategySpec,
+ adversary: AdversarySpec,
+ *,
+ party_side: str = "players",
+ runs: int = 100,
+ seed: int = 1,
+) -> MatchupBenchmark:
+ """Compile one matchup and measure both sides' danger over `runs` battles.
+
+ Deterministic: same inputs -> bit-identical ``MatchupBenchmark``
+ (frozen). Exactly two sides are required (one arena pair), the party
+ side name must be non-empty and be one of them, and ``runs`` >= 1.
+ """
+ if not party_side:
+ msg = "party_side must be a non-empty string"
+ raise ValueError(msg)
+ adversary_side = _other_side(scenario, party_side)
+ if runs < 1:
+ msg = "runs must be >= 1"
+ raise ValueError(msg)
+ encounter = build_encounter(scenario)
+ policy = compile_matchup(party, adversary, party_side=party_side)
+ report, outcomes = run_encounter(
+ encounter, runs, seed, policy=policy, collect_runs=True
+ )
+ return MatchupBenchmark(
+ scenario=scenario.name,
+ party=party.name,
+ adversary=adversary.name,
+ runs=runs,
+ seed=seed,
+ report=report,
+ party_danger=danger_metrics(outcomes, party_side),
+ adversary_danger=danger_metrics(outcomes, adversary_side),
+ party_side=party_side,
+ )
+
+
+@dataclass(frozen=True)
+class RobustnessEntry:
+ """One turn of the alternating min-max: the pair kept after that turn."""
+
+ iteration: int
+ party: StrategySpec
+ adversary: AdversarySpec
+ benchmark: MatchupBenchmark
+
+
+def _party_rank(benchmark: MatchupBenchmark) -> tuple[float, float, str]:
+ """Key: the party best-response prefers high survival, low HP lost, then name."""
+ danger = benchmark.party_danger
+ return (danger.p_survives, -danger.expected_hp_lost, benchmark.party)
+
+
+def _adversary_rank(benchmark: MatchupBenchmark) -> tuple[float, float, str]:
+ """Key: the adversary best-response prefers low party survival, high HP lost."""
+ danger = benchmark.party_danger
+ return (danger.p_survives, -danger.expected_hp_lost, benchmark.adversary)
+
+
+def adversarial_analysis(
+ scenario: ScenarioSpec,
+ *,
+ party_candidates: Sequence[StrategySpec],
+ adversary_candidates: Sequence[AdversarySpec],
+ party_side: str = "players",
+ runs: int = 100,
+ seed: int = 1,
+ iterations: int = 3,
+ max_total_runs: int | None = None,
+) -> tuple[RobustnessEntry, ...]:
+ """Alternate (party, adversary) best responses over `iterations` turns.
+
+ Each turn re-evaluates every party candidate against the current
+ adversary and keeps the best for the party (max own survival), then
+ every adversary candidate against the kept party and keeps the worst
+ for the party (min own survival, max party HP lost) -- the min-max
+ alternation of section 3.2. The kept pair is reported with its
+ benchmark.
+
+ Budget: at most ``iterations * (party + adversary candidates) * runs``
+ battles. When ``max_total_runs`` is set and the budget exceeds it, no
+ battle is fought and a ValueError is raised.
+ """
+ if not party_candidates:
+ msg = "party_candidates must be non-empty"
+ raise ValueError(msg)
+ if not adversary_candidates:
+ msg = "adversary_candidates must be non-empty"
+ raise ValueError(msg)
+ if iterations < 1:
+ msg = f"iterations must be >= 1, got {iterations}"
+ raise ValueError(msg)
+ if runs < 1:
+ msg = f"runs must be >= 1, got {runs}"
+ raise ValueError(msg)
+ cost = iterations * (len(party_candidates) + len(adversary_candidates)) * runs
+ if max_total_runs is not None and cost > max_total_runs:
+ msg = (
+ f"robustness budget exceeded: {cost} runs > "
+ f"{max_total_runs} max_total_runs"
+ )
+ raise ValueError(msg)
+
+ party = party_candidates[0]
+ adversary = adversary_candidates[0]
+ entries: list[RobustnessEntry] = []
+ for iteration in range(1, iterations + 1):
+ party_pairs: list[tuple[StrategySpec, MatchupBenchmark]] = [
+ (
+ candidate,
+ matchup_benchmark(
+ scenario,
+ candidate,
+ adversary,
+ party_side=party_side,
+ runs=runs,
+ seed=seed,
+ ),
+ )
+ for candidate in party_candidates
+ ]
+ party = max(party_pairs, key=lambda pair: _party_rank(pair[1]))[0]
+ adversary_pairs: list[tuple[AdversarySpec, MatchupBenchmark]] = [
+ (
+ candidate,
+ matchup_benchmark(
+ scenario,
+ party,
+ candidate,
+ party_side=party_side,
+ runs=runs,
+ seed=seed,
+ ),
+ )
+ for candidate in adversary_candidates
+ ]
+ adversary, benchmark = min(
+ adversary_pairs, key=lambda pair: _adversary_rank(pair[1])
+ )
+ entries.append(
+ RobustnessEntry(
+ iteration=iteration,
+ party=party,
+ adversary=adversary,
+ benchmark=benchmark,
+ )
+ )
+ return tuple(entries)
+
+
+@dataclass(frozen=True)
+class DangerCell:
+ """One (party HP x adversary HP) cell of the danger map."""
+
+ party_hp_fraction: float
+ adversary_hp_fraction: float
+ benchmark: MatchupBenchmark
+
+
+def _side_variant(
+ scenario: ScenarioSpec,
+ *,
+ party_side: str,
+ party_hp_fraction: float,
+ adversary_hp_fraction: float,
+) -> ScenarioSpec:
+ """A deterministic clone of `scenario` with per-side initial-HP fractions.
+
+ The `party_side` side carries `party_hp_fraction`; every other side
+ carries `adversary_hp_fraction` (the matchup contract is exactly 2
+ sides). Name: " party_hp= adv_hp=" so cells stay distinct.
+ """
+ sides = tuple(
+ SideSpec(
+ name=side.name,
+ zone=side.zone,
+ combatant_files=side.combatant_files,
+ hp_fraction=(
+ party_hp_fraction if side.name == party_side else adversary_hp_fraction
+ ),
+ aware=side.aware,
+ )
+ for side in scenario.sides
+ )
+ return ScenarioSpec(
+ name=(
+ f"{scenario.name} party_hp={party_hp_fraction} "
+ f"adv_hp={adversary_hp_fraction}"
+ ),
+ map_file=scenario.map_file,
+ sides=sides,
+ round_cap=scenario.round_cap,
+ )
+
+
+def danger_map(
+ scenario: ScenarioSpec,
+ party: StrategySpec,
+ adversary: AdversarySpec,
+ *,
+ party_side: str = "players",
+ fractions: Sequence[float] = (0.5, 0.7, 1.0),
+ runs: int = 100,
+ seed: int = 1,
+) -> tuple[DangerCell, ...]:
+ """The grid of party HP x adversary HP danger cells (non exhaustive, D14).
+
+ One ``DangerCell`` per combination, party-major (party fraction
+ outermost), each with a distinct scenario name
+ " party_hp= adv_hp=". Every fraction must lie in (0, 1].
+ """
+ if not fractions:
+ msg = "fractions must be non-empty"
+ raise ValueError(msg)
+ for fraction in fractions:
+ if not 0.0 < fraction <= 1.0:
+ msg = f"fraction must be in (0, 1], got {fraction}"
+ raise ValueError(msg)
+ return tuple(
+ DangerCell(
+ party_hp_fraction=party_frac,
+ adversary_hp_fraction=adv_frac,
+ benchmark=matchup_benchmark(
+ _side_variant(
+ scenario,
+ party_side=party_side,
+ party_hp_fraction=party_frac,
+ adversary_hp_fraction=adv_frac,
+ ),
+ party,
+ adversary,
+ party_side=party_side,
+ runs=runs,
+ seed=seed,
+ ),
+ )
+ for party_frac in fractions
+ for adv_frac in fractions
+ )
+
+
+def _validate_frontier(
+ criterion: str,
+ target: float,
+ min_fraction: float,
+ max_fraction: float,
+ precision: float,
+) -> None:
+ """Validate frontier parameters (keeps ``frontier`` itself flat)."""
+ _check_criterion(criterion)
+ if criterion == "expected_hp_lost":
+ if target < 0:
+ msg = f"target must be >= 0, got {target}"
+ raise ValueError(msg)
+ elif not 0.0 < target < 1.0:
+ msg = f"target must be in (0, 1), got {target}"
+ raise ValueError(msg)
+ if min_fraction <= 0:
+ msg = f"min_fraction must be > 0, got {min_fraction}"
+ raise ValueError(msg)
+ if max_fraction > 1:
+ msg = f"max_fraction must be <= 1, got {max_fraction}"
+ raise ValueError(msg)
+ if min_fraction > max_fraction:
+ msg = (
+ f"min_fraction must be <= max_fraction, "
+ f"got min={min_fraction} max={max_fraction}"
+ )
+ raise ValueError(msg)
+ if precision <= 0:
+ msg = f"precision must be > 0, got {precision}"
+ raise ValueError(msg)
+
+
+def frontier(
+ scenario: ScenarioSpec,
+ party: StrategySpec,
+ adversary: AdversarySpec,
+ *,
+ party_side: str = "players",
+ criterion: str = "p_tpk",
+ target: float = 0.2,
+ min_fraction: float = 0.25,
+ max_fraction: float = 1.0,
+ precision: float = 0.05,
+ runs: int = 100,
+ seed: int = 1,
+) -> float | None:
+ """The smallest party HP fraction where `criterion` crosses `target`.
+
+ The adversary HP fraction stays at 1.0. Crossing convention (section
+ 3.2): ``value >= target`` for p_tpk, p_any_death and expected_hp_lost;
+ ``value <= target`` for p_survives. Monotone danger (less party HP is
+ strictly worse): a crossing at ``min_fraction`` already yields the
+ smallest probed fraction, ceiled to the ``precision`` grid; otherwise
+ bisection converges to the smallest crossing fraction and returns
+ ``ceil(hi / precision) * precision`` (ceiling keeps the fraction on
+ the crossing side of the threshold). None when not even
+ ``max_fraction`` crosses. Probabilities require ``0 < target < 1``;
+ expected_hp_lost requires ``target >= 0``.
+ """
+ _validate_frontier(criterion, target, min_fraction, max_fraction, precision)
+
+ def _crossed(fraction: float) -> bool:
+ cell = matchup_benchmark(
+ _side_variant(
+ scenario,
+ party_side=party_side,
+ party_hp_fraction=fraction,
+ adversary_hp_fraction=1.0,
+ ),
+ party,
+ adversary,
+ party_side=party_side,
+ runs=runs,
+ seed=seed,
+ )
+ value = _danger_value(cell.party_danger, criterion)
+ if criterion == "p_survives":
+ return value <= target
+ return value >= target
+
+ if _crossed(min_fraction):
+ return math.ceil(min_fraction / precision) * precision
+ if not _crossed(max_fraction):
+ return None
+ lo = min_fraction
+ hi = max_fraction
+ while hi - lo > precision:
+ mid = (lo + hi) / 2.0
+ if _crossed(mid):
+ hi = mid
+ else:
+ lo = mid
+ return math.ceil(hi / precision) * precision
+
+
+@dataclass(frozen=True)
+class SensitivityEntry:
+ """One adversary lever raised from the baseline, with its danger delta."""
+
+ levers: str
+ spec: AdversarySpec
+ benchmark: MatchupBenchmark
+ delta: float
+
+
+def sensitivity(
+ scenario: ScenarioSpec,
+ party: StrategySpec,
+ *,
+ party_side: str = "players",
+ criterion: str = "expected_hp_lost",
+ runs: int = 100,
+ seed: int = 1,
+ max_total_runs: int | None = None,
+) -> tuple[SensitivityEntry, ...]:
+ """Rank which single adversary lever moves the party danger the most.
+
+ Baseline is the all-default ``AdversarySpec()``; each of the 7
+ candidate levers varies exactly one knob (target_priority x3,
+ spell_policy=none, retreat x2, use_charges=false). ``delta`` is the
+ signed change of the party ``criterion`` value vs the baseline
+ (positive = more dangerous). Sorted by |delta| descending, ties by
+ lever label ascending. Budget is exactly 8 x runs (baseline + 7
+ levers), enforced before any battle.
+ """
+ _check_criterion(criterion)
+ cost = 8 * runs
+ if max_total_runs is not None and cost > max_total_runs:
+ msg = (
+ f"robustness budget exceeded: {cost} runs > "
+ f"{max_total_runs} max_total_runs"
+ )
+ raise ValueError(msg)
+ levers = (
+ AdversarySpec(target_priority="weakest_ac"),
+ AdversarySpec(target_priority="lowest_hp"),
+ AdversarySpec(target_priority="healer"),
+ AdversarySpec(spell_policy="none"),
+ AdversarySpec(retreat_hp_fraction=0.25),
+ AdversarySpec(retreat_hp_fraction=0.5),
+ AdversarySpec(use_charges=False),
+ )
+ baseline = matchup_benchmark(
+ scenario,
+ party,
+ AdversarySpec(),
+ party_side=party_side,
+ runs=runs,
+ seed=seed,
+ )
+ base = _danger_value(baseline.party_danger, criterion)
+ entries: list[SensitivityEntry] = []
+ for spec in levers:
+ benchmark = matchup_benchmark(
+ scenario, party, spec, party_side=party_side, runs=runs, seed=seed
+ )
+ entries.append(
+ SensitivityEntry(
+ levers=adversary_name(spec),
+ spec=spec,
+ benchmark=benchmark,
+ delta=_danger_value(benchmark.party_danger, criterion) - base,
+ )
+ )
+ entries.sort(key=lambda entry: (-abs(entry.delta), entry.levers))
+ return tuple(entries)
+
+
+def _render_line(values: Sequence[str], widths: Sequence[int]) -> str:
+ """One aligned row: every cell ljust-padded to its column width."""
+ return " ".join(value.ljust(width) for value, width in zip(values, widths, strict=True))
+
+
+def _render_table(headers: Sequence[str], rows: Sequence[Sequence[str]]) -> str:
+ """A plain-text grid: header, a full-width dash separator, data rows.
+
+ Column widths are the max width over header and rows (same algorithm
+ as ``sweep.format_sweep``), so every line has identical length and the
+ separator spans the whole header line.
+ """
+ columns: tuple[Sequence[str], ...] = (headers, *rows)
+ widths = tuple(
+ max(len(row[column]) for row in columns) for column in range(len(headers))
+ )
+ header = _render_line(headers, widths)
+ lines = [header, "-" * len(header)]
+ lines.extend(_render_line(row, widths) for row in rows)
+ return "\n".join(lines)
+
+
+def format_matchup(benchmark: MatchupBenchmark) -> str:
+ """Render one matchup: per-side win rate, danger profile and rounds."""
+ headers = (
+ "Scenario",
+ "Runs",
+ "Side",
+ "Win%",
+ "Survive",
+ "TPK",
+ ">=1-death",
+ "HP-lost",
+ "Rounds",
+ )
+ adversary_side = next(
+ side for side in benchmark.report.wins if side != benchmark.party_side
+ )
+ party_row = (
+ benchmark.scenario,
+ str(benchmark.runs),
+ "party",
+ f"{benchmark.report.win_rate(benchmark.party_side):.0%}",
+ f"{benchmark.party_danger.p_survives:.0%}",
+ f"{benchmark.party_danger.p_tpk:.0%}",
+ f"{benchmark.party_danger.p_any_death:.0%}",
+ f"{benchmark.party_danger.expected_hp_lost:.1f}",
+ f"{benchmark.report.avg_rounds:.1f}",
+ )
+ adversary_row = (
+ "",
+ "",
+ "adversary",
+ f"{benchmark.report.win_rate(adversary_side):.0%}",
+ f"{benchmark.adversary_danger.p_survives:.0%}",
+ f"{benchmark.adversary_danger.p_tpk:.0%}",
+ f"{benchmark.adversary_danger.p_any_death:.0%}",
+ f"{benchmark.adversary_danger.expected_hp_lost:.1f}",
+ f"{benchmark.report.avg_rounds:.1f}",
+ )
+ return _render_table(headers, (party_row, adversary_row))
+
+
+def format_robustness(entries: Sequence[RobustnessEntry]) -> str:
+ """Render the alternating min-max log, one aligned line per turn."""
+ headers = (
+ "Iter",
+ "Party",
+ "Adversary",
+ "P(party-survive)",
+ "P(party-TPK)",
+ "Party-HP",
+ "P(adv-survive)",
+ "P(adv-TPK)",
+ "Adv-HP",
+ )
+ rows = tuple(
+ (
+ str(entry.iteration),
+ entry.party.name,
+ entry.adversary.name,
+ f"{entry.benchmark.party_danger.p_survives:.0%}",
+ f"{entry.benchmark.party_danger.p_tpk:.0%}",
+ f"{entry.benchmark.party_danger.expected_hp_lost:.1f}",
+ f"{entry.benchmark.adversary_danger.p_survives:.0%}",
+ f"{entry.benchmark.adversary_danger.p_tpk:.0%}",
+ f"{entry.benchmark.adversary_danger.expected_hp_lost:.1f}",
+ )
+ for entry in entries
+ )
+ return _render_table(headers, rows)
+
+
+def format_danger_map(cells: Sequence[DangerCell]) -> str:
+ """Render the danger grid: rows = party HP, columns = adversary HP.
+
+ Each cell shows party survive% / adversary survive%, both as 0%
+ percentages (order preserved from ``danger_map``, hence the
+ dict.fromkeys dedupe instead of a set).
+ """
+ party_fractions: tuple[float, ...] = tuple(
+ dict.fromkeys(cell.party_hp_fraction for cell in cells)
+ )
+ adversary_fractions: tuple[float, ...] = tuple(
+ dict.fromkeys(cell.adversary_hp_fraction for cell in cells)
+ )
+ by_cell: dict[tuple[float, float], DangerCell] = {
+ (cell.party_hp_fraction, cell.adversary_hp_fraction): cell for cell in cells
+ }
+ headers = (
+ "Party\\Adv",
+ *(f"adv={fraction}" for fraction in adversary_fractions),
+ )
+ rows: list[tuple[str, ...]] = []
+ for fraction in party_fractions:
+ row: list[str] = [f"party={fraction}"]
+ for adv in adversary_fractions:
+ cell = by_cell[(fraction, adv)]
+ row.append(
+ f"{cell.benchmark.party_danger.p_survives:.0%}/"
+ f"{cell.benchmark.adversary_danger.p_survives:.0%}"
+ )
+ rows.append(tuple(row))
+ return _render_table(headers, rows)
+
+
+def format_frontier(
+ criterion: str,
+ target: float,
+ fraction: float | None,
+ *,
+ min_fraction: float,
+ max_fraction: float,
+) -> str:
+ """Render a frontier result (None -> "no frontier" message)."""
+ if fraction is None:
+ return (
+ f"no frontier for {criterion} {target} "
+ f"in [{min_fraction:.2f}, {max_fraction:.2f}]"
+ )
+ return (
+ f"frontier {criterion} {target}: "
+ f"party hp_fraction = {fraction:.2f}"
+ )
+
+
+def _format_criterion_value(danger: DangerMetrics, criterion: str) -> str:
+ """One danger criterion as text: 0% for probabilities, 1 decimal for HP."""
+ value = _danger_value(danger, criterion)
+ if criterion == "expected_hp_lost":
+ return f"{value:.1f}"
+ return f"{value:.0%}"
+
+
+def format_sensitivity(entries: Sequence[SensitivityEntry], criterion: str) -> str:
+ """Render the lever ranking: rank, levers, party criterion value, delta."""
+ headers = ("Rank", "Levers", "Value", "Delta")
+ probability = criterion != "expected_hp_lost"
+ rows = tuple(
+ (
+ str(index),
+ entry.levers,
+ _format_criterion_value(entry.benchmark.party_danger, criterion),
+ f"{entry.delta:+.0%}" if probability else f"{entry.delta:+.1f}",
+ )
+ for index, entry in enumerate(entries, start=1)
+ )
+ return _render_table(headers, rows)
diff --git a/src/pf1e_simulator/scenarios.py b/src/pf1e_simulator/scenarios.py
index 47b18b7..e2f6ca2 100644
--- a/src/pf1e_simulator/scenarios.py
+++ b/src/pf1e_simulator/scenarios.py
@@ -35,6 +35,7 @@ class SideSpec:
zone: str # deployment zone letter, e.g. "A"
combatant_files: tuple[Path, ...] # monster/PJ JSON files, loaded via load_combatant
hp_fraction: float = 1.0 # initial HP as a fraction of hp_max, in (0, 1]
+ aware: bool | None = None # SRD "Surprise" override; None keeps Combatant.aware (True)
@dataclass(frozen=True)
@@ -82,6 +83,19 @@ def _apply_hp_fraction(combatant: Combatant, fraction: float) -> Combatant:
)
+def _apply_aware(combatant: Combatant, *, aware: bool | None) -> Combatant:
+ """Apply a side's SRD "Surprise" awareness override via model_copy.
+
+ None keeps the loaded combatant unchanged (Combatant.aware defaults to
+ True, models.py:416-420). SRD "Surprise": only some combatants aware
+ means a surprise round precedes round 1; unaware combatants do not act
+ in it.
+ """
+ if aware is None:
+ return combatant
+ return combatant.model_copy(update={"aware": aware})
+
+
def build_encounter(scenario: ScenarioSpec) -> EncounterSpec:
"""Compile a ScenarioSpec into a runner EncounterSpec (deterministic).
@@ -89,6 +103,12 @@ def build_encounter(scenario: ScenarioSpec) -> EncounterSpec:
file with load_combatant, then apply hp_fraction to hp_max via
pydantic model_copy(update={"hp_max": max(1, round(hp_max * fraction))})
(same model_copy pattern as runner.build_states).
+ - When a side's aware is not None, it is applied to that side's
+ combatants via model_copy(update={"aware": ...}) at the same point
+ where hp_fraction is applied (SRD "Surprise" round semantics: only
+ some aware -> surprise round; Combatant.aware defaults to True, so
+ aware=None keeps the loaded default and is bit-identical to the
+ behaviour without the field).
- Duplicate combatant ids are NOT disambiguated here: build_states
(called later by run_encounter) handles that. Just list each loaded
combatant once per occurrence in combatant_files.
@@ -126,7 +146,10 @@ def build_encounter(scenario: ScenarioSpec) -> EncounterSpec:
Side(
name=side.name,
combatants=tuple(
- _apply_hp_fraction(load_combatant(path), side.hp_fraction)
+ _apply_aware(
+ _apply_hp_fraction(load_combatant(path), side.hp_fraction),
+ aware=side.aware,
+ )
for path in side.combatant_files
),
zone=side.zone,
diff --git a/src/pf1e_simulator/strategy.py b/src/pf1e_simulator/strategy.py
index 31929d0..f0d351d 100644
--- a/src/pf1e_simulator/strategy.py
+++ b/src/pf1e_simulator/strategy.py
@@ -35,7 +35,7 @@ from pf1e_simulator.combat import (
default_policy,
)
-TargetPriority = Literal["nearest", "weakest_ac", "lowest_hp"]
+TargetPriority = Literal["nearest", "weakest_ac", "lowest_hp", "healer"]
SpellPolicy = Literal["default", "none"]
@@ -83,6 +83,16 @@ def select_target(
)
case "lowest_hp":
return min(enemies, key=lambda s: (s.hp, engine.states.index(s)))
+ case "healer":
+ # Prefer the caster/healer (non-empty combatant.spells); among
+ # several, min by (ac, hp, list order). No active caster on the
+ # enemy side -> identical to weakest_ac.
+ casters = [s for s in enemies if s.combatant.spells]
+ pool = casters or enemies
+ return min(
+ pool,
+ key=lambda s: (s.combatant.ac.total, s.hp, engine.states.index(s)),
+ )
def _melee_actions(
diff --git a/tests/test_phase2_adversary.py b/tests/test_phase2_adversary.py
new file mode 100644
index 0000000..dcc4612
--- /dev/null
+++ b/tests/test_phase2_adversary.py
@@ -0,0 +1,249 @@
+"""Phase-2 adversary compilation tests (docs/phase-2-llm-architecture.md 10 P4.1).
+
+Deterministic, offline: no network, no LLM. Pins the ``AdversarySpec`` /
+``compile_adversary`` / ``compile_matchup`` contract (default-policy identity,
+per-side routing on a real ``CombatEngine``, empty ``party_side`` rejection),
+the additive ``"healer"`` target priority (prefer the enemy caster, fall back
+to ``weakest_ac``), and ``SideSpec.aware`` landing on the right side's
+combatants plus its SRD "Surprise" round effect. Conventions follow
+tests/test_phase2_search.py: small n (runs <= 5), fixed seed, ROOT-relative
+path constants.
+"""
+
+from __future__ import annotations
+
+from pathlib import Path
+from typing import TYPE_CHECKING
+
+import pytest
+
+from pf1e_simulator.adversary import (
+ AdversarySpec,
+ adversary_name,
+ compile_adversary,
+ compile_matchup,
+)
+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.rng import SeededRng
+from pf1e_simulator.runner import build_states, run_encounter
+from pf1e_simulator.scenarios import ScenarioSpec, SideSpec, build_encounter
+from pf1e_simulator.strategy import StrategySpec, compile_strategy, select_target
+
+if TYPE_CHECKING:
+ from pf1e_simulator.models import Combatant
+ from pf1e_simulator.runner import EncounterSpec
+
+ROOT = Path(__file__).resolve().parents[1]
+MAP = ROOT / "data" / "maps" / "sample_arena.yaml"
+GOBLIN = ROOT / "data" / "monsters" / "goblin.json"
+ORC = ROOT / "data" / "monsters" / "orc.json"
+NAIRDA = ROOT / "data" / "pcs" / "nairda.json"
+
+RUNS = 5
+SEED = 7
+ROUND_CAP = 20
+PARTY = "players"
+
+_MAP_SPEC = load_map(MAP)
+
+
+def _c(path: Path) -> Combatant:
+ return load_combatant(path)
+
+
+def _scenario(*, monsters_aware: bool | None = None) -> ScenarioSpec:
+ """3 goblins (zone A) vs 2 orcs (zone B), optionally with side awareness."""
+ return ScenarioSpec(
+ name="goblins-vs-orcs",
+ map_file=MAP,
+ sides=(
+ SideSpec(
+ name=PARTY,
+ zone="A",
+ combatant_files=(GOBLIN, GOBLIN, GOBLIN),
+ ),
+ SideSpec(
+ name="monsters",
+ zone="B",
+ combatant_files=(ORC, ORC),
+ aware=monsters_aware,
+ ),
+ ),
+ round_cap=ROUND_CAP,
+ )
+
+
+def _encounter() -> EncounterSpec:
+ return build_encounter(_scenario())
+
+
+def _state(
+ combatant: Combatant, side: str, pos: tuple[int, int], *, hp: int | None = None
+) -> CombatantState:
+ """A bare runtime state over a loaded combatant (no battle run)."""
+ return CombatantState(
+ combatant=combatant,
+ side=side,
+ pos=pos,
+ hp=combatant.hp_max if hp is None else hp,
+ resources_left={},
+ )
+
+
+def _mini_engine(states: list[CombatantState]) -> CombatEngine:
+ """A live engine over the sample arena with the given states (no battle run)."""
+ return CombatEngine(SeededRng(SEED), Grid.from_spec(_MAP_SPEC), states)
+
+
+# --- identity ---------------------------------------------------------------
+
+
+def test_compile_adversary_default_is_default_policy() -> None:
+ """The all-default AdversarySpec compiles to default_policy by identity."""
+ assert compile_adversary(AdversarySpec()) is default_policy
+
+
+def test_compile_matchup_defaults_is_default_policy() -> None:
+ """Two all-default specs return default_policy itself, not a wrapper."""
+ assert compile_matchup(StrategySpec(), AdversarySpec()) is default_policy
+
+
+def test_adversary_name_mirrors_strategy_name() -> None:
+ """Labels render non-default levers in field order; the name field is excluded."""
+ assert adversary_name(AdversarySpec()) == "default"
+ assert adversary_name(AdversarySpec(target_priority="healer")) == "healer"
+ assert (
+ adversary_name(AdversarySpec(name="custom", spell_policy="none"))
+ == "spell_policy=none"
+ )
+ assert (
+ adversary_name(
+ AdversarySpec(target_priority="weakest_ac", retreat_hp_fraction=0.5)
+ )
+ == "weakest_ac, retreat=0.5"
+ )
+
+
+# --- validation ------------------------------------------------------------
+
+
+def test_compile_matchup_empty_party_side_raises() -> None:
+ """An empty party_side is a ValueError (pre-assigned msg, EM101/EM102)."""
+ with pytest.raises(ValueError, match="party_side"):
+ compile_matchup(StrategySpec(), AdversarySpec(), party_side="")
+
+
+# --- routing ----------------------------------------------------------------
+
+
+def test_compile_matchup_routes_by_side() -> None:
+ """On a real engine, each side of the matchup delegates to its own policy."""
+ party_spec = StrategySpec(name="party-focus", target_priority="weakest_ac")
+ adversary_spec = AdversarySpec(name="adv-focus", target_priority="lowest_hp")
+ matchup = compile_matchup(party_spec, adversary_spec)
+ assert matchup is not default_policy
+
+ encounter = _encounter()
+ states = build_states(encounter)
+ engine = CombatEngine(SeededRng(SEED), Grid.from_spec(encounter.map), states)
+ party_state = next(s for s in states if s.side == PARTY)
+ monster_state = next(s for s in states if s.side == "monsters")
+
+ assert matchup(engine, party_state) == compile_strategy(party_spec)(
+ engine, party_state
+ )
+ assert matchup(engine, monster_state) == compile_adversary(adversary_spec)(
+ engine, monster_state
+ )
+
+
+def test_matchup_and_healer_strategy_run_deterministically() -> None:
+ """A healer spec compiles and runs evaluate-style cells bit-identically."""
+ healer = StrategySpec(name="healer-party", target_priority="healer")
+ policy = compile_strategy(healer)
+ assert policy is not default_policy
+ matchup = compile_matchup(healer, AdversarySpec(target_priority="weakest_ac"))
+
+ spec = _encounter()
+ _, first = run_encounter(spec, runs=RUNS, seed=SEED, policy=policy, collect_runs=True)
+ _, again = run_encounter(spec, runs=RUNS, seed=SEED, policy=policy, collect_runs=True)
+ assert first == again
+ _, matchup_a = run_encounter(
+ spec, runs=RUNS, seed=SEED, policy=matchup, collect_runs=True
+ )
+ _, matchup_b = run_encounter(
+ spec, runs=RUNS, seed=SEED, policy=matchup, collect_runs=True
+ )
+ assert matchup_a == matchup_b
+
+
+# --- healer targeting -------------------------------------------------------
+
+
+def test_healer_prefers_the_enemy_caster() -> None:
+ """With a monster-side caster (nairda) among non-casters, healer picks her."""
+ actor = _state(_c(GOBLIN), PARTY, (1, 1))
+ nairda = _state(_c(NAIRDA), "monsters", (1, 8))
+ orc = _state(_c(ORC), "monsters", (1, 16))
+ assert nairda.combatant.spells # caster precondition (nairda.json)
+ engine = _mini_engine([actor, nairda, orc])
+
+ assert select_target(engine, actor, "healer") is nairda
+ # Discriminator: weakest_ac sees equal AC 13 and prefers the wounded orc.
+ assert select_target(engine, actor, "weakest_ac") is orc
+
+
+def test_healer_falls_back_to_weakest_ac_without_casters() -> None:
+ """No active caster on the enemy side -> identical to weakest_ac."""
+ actor = _state(_c(GOBLIN), PARTY, (1, 1))
+ orc = _state(_c(ORC), "monsters", (1, 8))
+ goblin_enemy = _state(_c(GOBLIN), "monsters", (1, 16))
+ assert not orc.combatant.spells
+ assert not goblin_enemy.combatant.spells
+ engine = _mini_engine([actor, orc, goblin_enemy])
+
+ healer = select_target(engine, actor, "healer")
+ assert healer is select_target(engine, actor, "weakest_ac")
+ assert healer is orc # AC 13 < goblin AC 16
+
+
+# --- SideSpec.aware (SRD "Surprise") ---------------------------------------
+
+
+def test_side_spec_aware_applies_per_side() -> None:
+ """aware=False lands on that side's combatants only; the other side keeps True."""
+ encounter = build_encounter(_scenario(monsters_aware=False))
+ players, monsters = encounter.sides
+ assert all(c.aware is True for c in players.combatants)
+ assert all(c.aware is False for c in monsters.combatants)
+
+ states = build_states(encounter)
+ assert all(s.combatant.aware is True for s in states if s.side == PARTY)
+ assert all(s.combatant.aware is False for s in states if s.side == "monsters")
+
+ untouched = build_encounter(_scenario())
+ assert all(c.aware is True for side in untouched.sides for c in side.combatants)
+
+
+def test_side_spec_aware_triggers_surprise_round() -> None:
+ """Only some aware -> a surprise round runs; the default (aware=None) has none."""
+ surprise = _encounter_states_and_run(_scenario(monsters_aware=False))
+ assert "surprise round" in surprise
+
+ default = _encounter_states_and_run(_scenario())
+ assert "surprise round" not in default
+
+
+def _encounter_states_and_run(scenario: ScenarioSpec) -> tuple[str, ...]:
+ """Run one seeded battle of `scenario` and return its transcript."""
+ encounter = build_encounter(scenario)
+ engine = CombatEngine(
+ SeededRng(SEED),
+ Grid.from_spec(encounter.map),
+ build_states(encounter),
+ round_cap=ROUND_CAP,
+ )
+ return engine.run().transcript
diff --git a/tests/test_phase2_robustness.py b/tests/test_phase2_robustness.py
new file mode 100644
index 0000000..6f74f18
--- /dev/null
+++ b/tests/test_phase2_robustness.py
@@ -0,0 +1,599 @@
+"""Tests for the robustness layer (P4.2/P4.3, docs section 10)."""
+
+from __future__ import annotations
+
+import itertools
+from pathlib import Path
+
+import pytest
+
+from pf1e_simulator import robustness
+from pf1e_simulator.adversary import AdversarySpec
+from pf1e_simulator.robustness import (
+ DangerCell,
+ MatchupBenchmark,
+ RobustnessEntry,
+ SensitivityEntry,
+ adversarial_analysis,
+ danger_map,
+ format_danger_map,
+ format_frontier,
+ format_matchup,
+ format_robustness,
+ format_sensitivity,
+ frontier,
+ matchup_benchmark,
+ sensitivity,
+)
+from pf1e_simulator.scenarios import ScenarioSpec, SideSpec
+from pf1e_simulator.strategy import StrategySpec
+
+ROOT = Path(__file__).resolve().parent.parent
+RUNS = 5
+SEED = 7
+ROUND_CAP = 20
+PARTY = "players"
+
+ARENA = ROOT / "data" / "maps" / "sample_arena.yaml"
+GOBLIN = ROOT / "data" / "monsters" / "goblin.json"
+ORC = ROOT / "data" / "monsters" / "orc.json"
+
+
+def _scenario(
+ *, name: str = "goblins-vs-orcs", sides: tuple[SideSpec, ...] | None = None
+) -> ScenarioSpec:
+ return ScenarioSpec(
+ name=name,
+ map_file=ARENA,
+ sides=sides
+ or (
+ SideSpec(PARTY, "A", (GOBLIN, GOBLIN, GOBLIN)),
+ SideSpec("monsters", "B", (ORC, ORC)),
+ ),
+ round_cap=ROUND_CAP,
+ )
+
+
+def _party(*, name: str = "default") -> StrategySpec:
+ return StrategySpec(name=name)
+
+
+def _three_side_scenario() -> ScenarioSpec:
+ return ScenarioSpec(
+ name="three-sides",
+ map_file=ARENA,
+ sides=(
+ SideSpec(PARTY, "A", (GOBLIN,)),
+ SideSpec("monsters", "B", (ORC,)),
+ SideSpec("spectators", "C", (GOBLIN,)),
+ ),
+ round_cap=ROUND_CAP,
+ )
+
+
+def test_matchup_requires_exactly_two_sides() -> None:
+ with pytest.raises(ValueError, match="exactly 2 sides"):
+ matchup_benchmark(
+ _three_side_scenario(), _party(), AdversarySpec(), runs=RUNS, seed=SEED
+ )
+
+
+def test_matchup_requires_valid_party_side() -> None:
+ scenario = _scenario()
+ with pytest.raises(ValueError, match="party_side"):
+ matchup_benchmark(
+ scenario, _party(), AdversarySpec(), party_side="", runs=RUNS, seed=SEED
+ )
+ with pytest.raises(ValueError, match="is not a side"):
+ matchup_benchmark(
+ scenario,
+ _party(),
+ AdversarySpec(),
+ party_side="spectators",
+ runs=RUNS,
+ seed=SEED,
+ )
+
+
+def test_matchup_reports_both_dangers_and_is_reproducible() -> None:
+ scenario = _scenario()
+ party = _party()
+ adversary = AdversarySpec()
+ first = matchup_benchmark(scenario, party, adversary, runs=RUNS, seed=SEED)
+ assert isinstance(first, MatchupBenchmark)
+ assert first.scenario == "goblins-vs-orcs"
+ assert first.party == "default"
+ assert first.adversary == "default"
+ assert first.runs == RUNS
+ assert first.seed == SEED
+ assert first.party_side == PARTY
+ assert first.report.runs == RUNS
+ for danger in (first.party_danger, first.adversary_danger):
+ assert 0.0 <= danger.p_survives <= 1.0
+ assert 0.0 <= danger.p_tpk <= 1.0
+ assert 0.0 <= danger.p_any_death <= 1.0
+ assert danger.expected_hp_lost >= 0.0
+ second = matchup_benchmark(scenario, party, adversary, runs=RUNS, seed=SEED)
+ assert first == second
+ assert second.party_danger == first.party_danger
+
+
+def test_adversarial_analysis_alternates_and_converges() -> None:
+ scenario = _scenario()
+ party = _party()
+ specs = (
+ AdversarySpec(name="retreat", retreat_hp_fraction=0.5),
+ AdversarySpec(name="plain"),
+ )
+ entries = adversarial_analysis(
+ scenario,
+ party_candidates=[party],
+ adversary_candidates=specs,
+ runs=RUNS,
+ seed=SEED,
+ iterations=2,
+ )
+ assert len(entries) == 2
+ assert all(isinstance(entry, RobustnessEntry) for entry in entries)
+ assert [entry.iteration for entry in entries] == [1, 2]
+ # the adversary best response is the candidate with the worst party outlook
+ benchmarks = {
+ spec.name: matchup_benchmark(
+ scenario, party, spec, runs=RUNS, seed=SEED
+ )
+ for spec in specs
+ }
+ expected = min(
+ benchmarks.values(),
+ key=lambda b: (
+ b.party_danger.p_survives,
+ -b.party_danger.expected_hp_lost,
+ b.adversary,
+ ),
+ )
+ assert all(entry.benchmark == expected for entry in entries)
+ assert entries[0].adversary.name == expected.adversary
+
+
+def test_adversarial_party_best_response() -> None:
+ scenario = _scenario()
+ parties = (
+ StrategySpec(),
+ StrategySpec(name="bully", target_priority="lowest_hp"),
+ )
+ adversary = AdversarySpec()
+ entries = adversarial_analysis(
+ scenario,
+ party_candidates=parties,
+ adversary_candidates=[adversary],
+ runs=RUNS,
+ seed=SEED,
+ iterations=1,
+ )
+ benchmarks = {
+ spec.name: matchup_benchmark(
+ scenario, spec, adversary, runs=RUNS, seed=SEED
+ )
+ for spec in parties
+ }
+ expected = max(
+ benchmarks.values(),
+ key=lambda b: (
+ b.party_danger.p_survives,
+ -b.party_danger.expected_hp_lost,
+ b.party,
+ ),
+ )
+ assert entries[0].benchmark == expected
+
+
+def test_adversarial_candidates_required() -> None:
+ scenario = _scenario()
+ party = _party()
+ with pytest.raises(ValueError, match="party_candidates must be non-empty"):
+ adversarial_analysis(
+ scenario,
+ party_candidates=[],
+ adversary_candidates=[AdversarySpec()],
+ runs=RUNS,
+ seed=SEED,
+ )
+ with pytest.raises(ValueError, match="adversary_candidates must be non-empty"):
+ adversarial_analysis(
+ scenario,
+ party_candidates=[party],
+ adversary_candidates=[],
+ runs=RUNS,
+ seed=SEED,
+ )
+
+
+def test_adversarial_budget_is_enforced_before_any_battle() -> None:
+ scenario = _scenario()
+ party = _party()
+ specs = (
+ AdversarySpec(name="retreat", retreat_hp_fraction=0.5),
+ AdversarySpec(name="plain"),
+ )
+ cost = 2 * (1 + len(specs)) * RUNS
+ with pytest.raises(ValueError, match="robustness budget exceeded"):
+ adversarial_analysis(
+ scenario,
+ party_candidates=[party],
+ adversary_candidates=specs,
+ runs=RUNS,
+ seed=SEED,
+ iterations=2,
+ max_total_runs=cost - 1,
+ )
+ entries = adversarial_analysis(
+ scenario,
+ party_candidates=[party],
+ adversary_candidates=specs,
+ runs=RUNS,
+ seed=SEED,
+ iterations=2,
+ max_total_runs=cost,
+ )
+ assert len(entries) == 2
+
+
+def test_danger_map_is_3x3_party_major_with_variant_names() -> None:
+ scenario = _scenario()
+ cells = danger_map(scenario, _party(), AdversarySpec(), runs=RUNS, seed=SEED)
+ assert len(cells) == 9
+ assert all(isinstance(cell, DangerCell) for cell in cells)
+ order = [(cell.party_hp_fraction, cell.adversary_hp_fraction) for cell in cells]
+ assert order == [(p, a) for p in (0.5, 0.7, 1.0) for a in (0.5, 0.7, 1.0)]
+ assert cells[0].benchmark.scenario == "goblins-vs-orcs party_hp=0.5 adv_hp=0.5"
+ assert cells[-1].benchmark.scenario == "goblins-vs-orcs party_hp=1.0 adv_hp=1.0"
+ for cell in cells:
+ assert 0.0 <= cell.benchmark.party_danger.p_survives <= 1.0
+
+
+def test_danger_map_rejects_out_of_range_fractions() -> None:
+ scenario = _scenario()
+ with pytest.raises(ValueError, match="fraction must be in"):
+ danger_map(
+ scenario, _party(), AdversarySpec(), fractions=(0.0,), runs=RUNS, seed=SEED
+ )
+ with pytest.raises(ValueError, match="fraction must be in"):
+ danger_map(
+ scenario, _party(), AdversarySpec(), fractions=(1.5,), runs=RUNS, seed=SEED
+ )
+ with pytest.raises(ValueError, match="fractions must be non-empty"):
+ danger_map(
+ scenario, _party(), AdversarySpec(), fractions=(), runs=RUNS, seed=SEED
+ )
+
+
+def test_danger_map_is_reproducible() -> None:
+ scenario = _scenario()
+ first = danger_map(scenario, _party(), AdversarySpec(), runs=RUNS, seed=SEED)
+ second = danger_map(scenario, _party(), AdversarySpec(), runs=RUNS, seed=SEED)
+ assert first == second
+
+
+def test_frontier_finds_reached_threshold() -> None:
+ scenario = _scenario()
+ result = frontier(
+ scenario,
+ _party(),
+ AdversarySpec(),
+ criterion="expected_hp_lost",
+ target=0.0,
+ min_fraction=0.25,
+ max_fraction=1.0,
+ precision=0.05,
+ runs=RUNS,
+ seed=SEED,
+ )
+ # expected hp lost is always >= 0, so the threshold is crossed everywhere:
+ # bisection converges to the smallest probed fraction, rounded to precision
+ assert result == pytest.approx(0.25)
+
+
+def test_frontier_returns_none_when_threshold_unreachable() -> None:
+ scenario = _scenario()
+ result = frontier(
+ scenario,
+ _party(),
+ AdversarySpec(),
+ criterion="expected_hp_lost",
+ target=1e9,
+ min_fraction=0.25,
+ max_fraction=1.0,
+ precision=0.05,
+ runs=RUNS,
+ seed=SEED,
+ )
+ assert result is None
+
+
+def test_frontier_is_reproducible() -> None:
+ scenario = _scenario()
+ first = frontier(
+ scenario,
+ _party(),
+ AdversarySpec(),
+ criterion="expected_hp_lost",
+ target=0.0,
+ runs=RUNS,
+ seed=SEED,
+ )
+ second = frontier(
+ scenario,
+ _party(),
+ AdversarySpec(),
+ criterion="expected_hp_lost",
+ target=0.0,
+ runs=RUNS,
+ seed=SEED,
+ )
+ assert first == second
+
+
+def test_frontier_returns_precision_multiple() -> None:
+ scenario = _scenario()
+ result = frontier(
+ scenario,
+ _party(),
+ AdversarySpec(),
+ criterion="expected_hp_lost",
+ target=0.0,
+ min_fraction=0.3,
+ max_fraction=1.0,
+ precision=0.1,
+ runs=RUNS,
+ seed=SEED,
+ )
+ assert result is not None
+ # bisection stops within precision of min_fraction; the result is the
+ # nearest precision multiple in [min_fraction, min_fraction + precision]
+ assert 0.3 <= result <= 0.4
+ assert result == pytest.approx(round(result / 0.1) * 0.1)
+
+
+def test_frontier_p_survives_uses_inverse_threshold() -> None:
+ scenario = _scenario()
+ result = frontier(
+ scenario,
+ _party(),
+ AdversarySpec(),
+ criterion="p_survives",
+ target=0.9999,
+ min_fraction=0.5,
+ max_fraction=1.0,
+ precision=0.25,
+ runs=RUNS,
+ seed=SEED,
+ )
+ # convention: p_survives crosses when value <= target; a present result
+ # is a precision multiple within the probed range
+ if result is not None:
+ assert 0.5 <= result <= 0.75
+ assert result == pytest.approx(round(result / 0.25) * 0.25)
+
+
+def test_frontier_validates_arguments() -> None:
+ scenario = _scenario()
+ with pytest.raises(ValueError, match="criterion must be one of"):
+ frontier(
+ scenario, _party(), AdversarySpec(), criterion="bogus", runs=RUNS, seed=SEED
+ )
+ with pytest.raises(ValueError, match="target must be in"):
+ frontier(
+ scenario,
+ _party(),
+ AdversarySpec(),
+ criterion="p_tpk",
+ target=0.0,
+ runs=RUNS,
+ seed=SEED,
+ )
+ with pytest.raises(ValueError, match="target must be in"):
+ frontier(
+ scenario,
+ _party(),
+ AdversarySpec(),
+ criterion="p_survives",
+ target=1.0,
+ runs=RUNS,
+ seed=SEED,
+ )
+ with pytest.raises(ValueError, match="target must be >= 0"):
+ frontier(
+ scenario,
+ _party(),
+ AdversarySpec(),
+ criterion="expected_hp_lost",
+ target=-1.0,
+ runs=RUNS,
+ seed=SEED,
+ )
+ with pytest.raises(ValueError, match="min_fraction must be > 0"):
+ frontier(
+ scenario,
+ _party(),
+ AdversarySpec(),
+ criterion="expected_hp_lost",
+ target=0.0,
+ min_fraction=0.0,
+ runs=RUNS,
+ seed=SEED,
+ )
+ with pytest.raises(ValueError, match="max_fraction must be <= 1"):
+ frontier(
+ scenario,
+ _party(),
+ AdversarySpec(),
+ criterion="expected_hp_lost",
+ target=0.0,
+ max_fraction=1.5,
+ runs=RUNS,
+ seed=SEED,
+ )
+ with pytest.raises(ValueError, match="min_fraction must be <= max_fraction"):
+ frontier(
+ scenario,
+ _party(),
+ AdversarySpec(),
+ criterion="expected_hp_lost",
+ target=0.0,
+ min_fraction=0.9,
+ max_fraction=0.5,
+ runs=RUNS,
+ seed=SEED,
+ )
+ with pytest.raises(ValueError, match="precision must be > 0"):
+ frontier(
+ scenario,
+ _party(),
+ AdversarySpec(),
+ criterion="expected_hp_lost",
+ target=0.0,
+ precision=0.0,
+ runs=RUNS,
+ seed=SEED,
+ )
+
+
+def test_sensitivity_ranks_levers_and_reports_signed_deltas() -> None:
+ scenario = _scenario()
+ party = _party()
+ entries = sensitivity(scenario, party, runs=RUNS, seed=SEED)
+ assert len(entries) == 7
+ assert all(isinstance(entry, SensitivityEntry) for entry in entries)
+ assert {entry.levers for entry in entries} == {
+ "weakest_ac",
+ "lowest_hp",
+ "healer",
+ "spell_policy=none",
+ "retreat=0.25",
+ "retreat=0.5",
+ "use_charges=false",
+ }
+ for previous, current in itertools.pairwise(entries):
+ assert abs(previous.delta) >= abs(current.delta)
+ if abs(previous.delta) == abs(current.delta):
+ assert previous.levers <= current.levers
+ # delta is the signed change vs the all-default baseline
+ baseline = matchup_benchmark(
+ scenario, party, AdversarySpec(), runs=RUNS, seed=SEED
+ )
+ base = baseline.party_danger.expected_hp_lost
+ for entry in entries:
+ assert entry.delta == pytest.approx(
+ entry.benchmark.party_danger.expected_hp_lost - base
+ )
+
+
+def test_sensitivity_rejects_unknown_criterion() -> None:
+ scenario = _scenario()
+ with pytest.raises(ValueError, match="criterion must be one of"):
+ sensitivity(scenario, _party(), criterion="bogus", runs=RUNS, seed=SEED)
+
+
+def test_sensitivity_budget_is_enforced() -> None:
+ scenario = _scenario()
+ party = _party()
+ with pytest.raises(ValueError, match="robustness budget exceeded"):
+ sensitivity(
+ scenario, party, runs=RUNS, seed=SEED, max_total_runs=8 * RUNS - 1
+ )
+ entries = sensitivity(
+ scenario, party, runs=RUNS, seed=SEED, max_total_runs=8 * RUNS
+ )
+ assert len(entries) == 7
+
+
+def test_format_matchup_ascii_aligned() -> None:
+ benchmark_ = matchup_benchmark(
+ _scenario(), _party(), AdversarySpec(), runs=RUNS, seed=SEED
+ )
+ text = format_matchup(benchmark_)
+ lines = text.splitlines()
+ assert lines[0].split() == [
+ "Scenario",
+ "Runs",
+ "Side",
+ "Win%",
+ "Survive",
+ "TPK",
+ ">=1-death",
+ "HP-lost",
+ "Rounds",
+ ]
+ assert lines[1] == "-" * len(lines[0])
+ assert "party" in lines[2]
+ assert "adversary" in lines[3]
+ assert all(len(line) == len(lines[0]) for line in lines)
+ assert text.isascii()
+
+
+def test_format_robustness_ascii_aligned() -> None:
+ scenario = _scenario()
+ entries = adversarial_analysis(
+ scenario,
+ party_candidates=[_party()],
+ adversary_candidates=[AdversarySpec()],
+ runs=RUNS,
+ seed=SEED,
+ iterations=2,
+ )
+ text = format_robustness(entries)
+ lines = text.splitlines()
+ assert lines[0].split() == [
+ "Iter",
+ "Party",
+ "Adversary",
+ "P(party-survive)",
+ "P(party-TPK)",
+ "Party-HP",
+ "P(adv-survive)",
+ "P(adv-TPK)",
+ "Adv-HP",
+ ]
+ assert len(lines) == 2 + 2
+ assert all(len(line) == len(lines[0]) for line in lines)
+ assert text.isascii()
+
+
+def test_format_danger_map_ascii_aligned() -> None:
+ cells = danger_map(_scenario(), _party(), AdversarySpec(), runs=RUNS, seed=SEED)
+ text = format_danger_map(cells)
+ lines = text.splitlines()
+ assert lines[0].startswith("Party\\Adv")
+ assert "adv=0.5" in lines[0]
+ assert "party=0.5" in lines[2]
+ assert "party=1.0" in lines[4]
+ assert "/" in lines[2]
+ assert all(len(line) == len(lines[0]) for line in lines)
+ assert text.isascii()
+
+
+def test_format_frontier_messages() -> None:
+ assert (
+ format_frontier("p_tpk", 0.2, None, min_fraction=0.25, max_fraction=1.0)
+ == "no frontier for p_tpk 0.2 in [0.25, 1.00]"
+ )
+ assert (
+ format_frontier("p_tpk", 0.2, 0.35, min_fraction=0.25, max_fraction=1.0)
+ == "frontier p_tpk 0.2: party hp_fraction = 0.35"
+ )
+
+
+def test_format_sensitivity_ascii_aligned() -> None:
+ entries = sensitivity(_scenario(), _party(), runs=RUNS, seed=SEED)
+ text = format_sensitivity(entries, "expected_hp_lost")
+ lines = text.splitlines()
+ assert lines[0].split() == ["Rank", "Levers", "Value", "Delta"]
+ assert len(lines) == 2 + 7
+ assert lines[1].split()[-1].startswith(("+", "-"))
+ assert all(len(line) == len(lines[0]) for line in lines)
+ assert text.isascii()
+
+
+def test_robustness_module_does_not_import_search() -> None:
+ source = Path(robustness.__file__).resolve().read_text(encoding="utf-8")
+ assert "search" not in source