feat(phase2): adversary and robustness cartography (P4)

This commit is contained in:
2026-09-25 21:34:11 +02:00
parent 016d6e1459
commit cd69312ff4
7 changed files with 1692 additions and 2 deletions
+124
View File
@@ -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
+682
View File
@@ -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: "<base> party_hp=<f> adv_hp=<g>" 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
"<base> party_hp=<f> adv_hp=<g>". 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)
+24 -1
View File
@@ -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,
+11 -1
View File
@@ -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(