feat(phase2): danger-cartography spike - per-run outcomes, objectives, focus-fire adversary

run_encounter gains an opt-in collect_runs flag returning one RunOutcome
per battle (winner, rounds, per-combatant id/side/hp/hp_max/dead) next to
the EncounterReport; default behavior is unchanged. objectives.py provides
the pure optimizer primitives: P(survives all), P(TPK), P(>=1 death),
expected hp lost, expected rounds and a worst-decile (CVaR-style) tail
metric. phase2_spike.py holds the spike-level strengthened monster policy
(focus fire on the lowest-AC enemy, ties to the wounded; party side stays
on default_policy). tests/test_phase2_danger_map.py prints the danger-map
sketch: warband and lich scenarios measured default vs focus-fire at
100 runs / seed 11, pinning the delta deterministically.
This commit is contained in:
2026-09-21 12:07:23 +02:00
parent e5c4892a57
commit de88543c5c
7 changed files with 734 additions and 27 deletions
+135
View File
@@ -0,0 +1,135 @@
"""Objective primitives for the Phase-2 optimizer and danger cartography.
Pure functions over the per-run summaries produced by
``run_encounter(..., collect_runs=True)`` (docs/phase-2-llm-architecture.md
sections 3.1 and 3.2). These are the metrics an optimizer maximizes (party
survival) and a danger map reports per scenario - no engine access, no
state, no RNG: given the same ``RunOutcome`` tuple they always return the
same numbers, which is what makes them safe as a search objective.
Definitions (all probabilities are empirical frequencies over the runs;
``party`` is the side name being measured, e.g. ``"players"``):
- ``p_survives`` : P(party survives all) - no party member dead.
- ``p_tpk`` : P(total party kill) - every party member dead.
- ``p_any_death`` : P(at least one party member death).
- ``expected_hp_lost``: mean total party hit points lost (hp_max - hp,
clamped to [0, hp_max] per member: healing above max never counts
negative, and overkill on a corpse is not counted as extra loss).
- ``expected_rounds`` : mean battle length.
- ``worst_decile_party_hp`` : TAIL metric - the mean party hp *remaining*
over the worst decile of runs (the 10% with the largest hp loss, at
least 1 run): a CVaR-style measure so rare disasters are not averaged
away. A dead or dying member contributes 0 hp remaining.
A side with no members in a run (should not happen in a normal encounter)
is treated vacuously: survives all, no TPK, 0 hp lost.
"""
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.runner import CombatantOutcome, RunOutcome
@dataclass(frozen=True)
class DangerMetrics:
"""The full danger profile of one party over a batch of run outcomes."""
runs: int
p_survives: float
p_tpk: float
p_any_death: float
expected_hp_lost: float
expected_rounds: float
worst_decile_party_hp: float
def _party_members(outcome: RunOutcome, party: str) -> tuple[CombatantOutcome, ...]:
return tuple(c for c in outcome.combatants if c.side == party)
def _hp_lost(outcome: RunOutcome, party: str) -> int:
return sum(
min(c.hp_max, max(0, c.hp_max - c.hp))
for c in _party_members(outcome, party)
)
def _hp_remaining(outcome: RunOutcome, party: str) -> int:
return sum(max(0, c.hp) for c in _party_members(outcome, party))
def p_survives(outcomes: Sequence[RunOutcome], party: str) -> float:
"""P(no member of `party` is dead at the end of a run)."""
if not outcomes:
return 0.0
hits = sum(1 for o in outcomes if not any(c.dead for c in _party_members(o, party)))
return hits / len(outcomes)
def p_tpk(outcomes: Sequence[RunOutcome], party: str) -> float:
"""P(every member of `party` is dead at the end of a run)."""
if not outcomes:
return 0.0
hits = 0
for o in outcomes:
members = _party_members(o, party)
if members and all(c.dead for c in members):
hits += 1
return hits / len(outcomes)
def p_any_death(outcomes: Sequence[RunOutcome], party: str) -> float:
"""P(at least one member of `party` is dead at the end of a run)."""
if not outcomes:
return 0.0
hits = sum(1 for o in outcomes if any(c.dead for c in _party_members(o, party)))
return hits / len(outcomes)
def expected_hp_lost(outcomes: Sequence[RunOutcome], party: str) -> float:
"""Mean total `party` hit points lost per run (clamped to [0, hp_max] per member)."""
if not outcomes:
return 0.0
return sum(_hp_lost(o, party) for o in outcomes) / len(outcomes)
def expected_rounds(outcomes: Sequence[RunOutcome]) -> float:
"""Mean number of rounds per run (draws count their full round cap)."""
if not outcomes:
return 0.0
return sum(o.rounds for o in outcomes) / len(outcomes)
def worst_decile_party_hp(outcomes: Sequence[RunOutcome], party: str) -> float:
"""Mean `party` hp remaining over the worst-decile runs (tail/CVaR metric).
The worst decile is the ``max(1, ceil(10%))`` runs with the largest hp
loss; ties keep list order. Complements ``expected_hp_lost``: the mean
hides tail disasters, this number does not.
"""
if not outcomes:
return 0.0
tail = max(1, math.ceil(len(outcomes) / 10))
worst = sorted(outcomes, key=lambda o: _hp_lost(o, party), reverse=True)[:tail]
return sum(_hp_remaining(o, party) for o in worst) / tail
def danger_metrics(outcomes: Sequence[RunOutcome], party: str) -> DangerMetrics:
"""Compute the whole danger profile in one call (the optimizer's objective)."""
return DangerMetrics(
runs=len(outcomes),
p_survives=p_survives(outcomes, party),
p_tpk=p_tpk(outcomes, party),
p_any_death=p_any_death(outcomes, party),
expected_hp_lost=expected_hp_lost(outcomes, party),
expected_rounds=expected_rounds(outcomes),
worst_decile_party_hp=worst_decile_party_hp(outcomes, party),
)
+86
View File
@@ -0,0 +1,86 @@
"""Phase-2 spike: a strengthened monster policy for danger cartography.
Spike-level, not the final ``adversary.py`` (docs/phase-2-llm-architecture.md
sections 3.2 and 10, P4.1): the danger a party faces only exists against an
adversary that plays to win, and ``default_policy`` is a weak baseline - each
monster attacks its own nearest enemy, so the party never concentrates
punishment on one member. This module provides the minimal upgrade that
proves the point: FOCUS FIRE. Every monster on the designated side
concentrates on the same party member - the active enemy with the lowest AC
(ties: lowest current hp, then initiative list order) - instead of its
nearest one.
The party side (any side other than the designated monster side) delegates
to ``default_policy`` unchanged, so one policy object drives the whole
battle and the danger-map comparison isolates exactly the monster-side
change. Spellcasting, class abilities and maneuvers stay on the default
flow: the spike measures what target selection alone is worth.
"""
from __future__ import annotations
from pf1e_simulator.combat import (
Action,
CombatantState,
CombatEngine,
Policy,
default_policy,
)
def focus_target(engine: CombatEngine, state: CombatantState) -> CombatantState | None:
"""The enemy the whole monster side concentrates on: lowest AC, then hp, then order.
AC is the combatant's current total AC as exposed on the frozen profile
(the spike does not re-derive buff/condition modifiers); hp is current
hit points (a dying target ranks below a healthy one). Deterministic:
ties break on the engine's initiative list order.
"""
enemies = [s for s in engine.states if s.side != state.side and s.active]
if not enemies:
return None
return min(enemies, key=lambda s: (s.combatant.ac.total, s.hp, engine.states.index(s)))
def _strike(
engine: CombatEngine, state: CombatantState, target: CombatantState
) -> tuple[Action, ...]:
"""Attack the focus target with the default melee/ranged action ladder."""
if engine.weapon_for(state, target) is not None:
return (Action(kind="full_attack", target_id=target.combatant.id),)
charge = engine.can_charge(state, target)
if charge is not None and charge.weapon is not None:
return (Action(kind="charge", target_id=target.combatant.id),)
if engine.can_step5_to_attack(state, target):
return (
Action(kind="5foot_step", target_id=target.combatant.id),
Action(kind="full_attack", target_id=target.combatant.id),
)
return (
Action(kind="move", target_id=target.combatant.id),
Action(kind="attack", target_id=target.combatant.id),
)
def focus_fire_policy(monster_side: str = "monsters") -> Policy:
"""A ``Policy`` where the monster side focus-fires; every other side plays default.
Pass to ``run_encounter(policy=focus_fire_policy("monsters"))``. The
party side delegates to ``default_policy``, so the only behavioural
change is monster target concentration.
"""
def policy(engine: CombatEngine, state: CombatantState) -> tuple[Action, ...]:
if state.side != monster_side:
return default_policy(engine, state)
if engine.has_condition_flag(state, "must_flee"):
target = engine.nearest_enemy(state)
if target is None:
return (Action(kind="wait"),)
return (Action(kind="withdraw", target_id=target.combatant.id),)
target = focus_target(engine, state)
if target is None:
return (Action(kind="wait"),)
return _strike(engine, state, target)
return policy
+125 -24
View File
@@ -10,7 +10,7 @@ attrition averages. All statistical helpers live in metrics.py.
from __future__ import annotations
from dataclasses import dataclass
from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, Literal, overload
if TYPE_CHECKING:
from pf1e_simulator.combat import Policy
@@ -24,6 +24,7 @@ from pf1e_simulator.combat import (
CombatantState,
CombatantStats,
CombatEngine,
CombatResult,
)
from pf1e_simulator.grid import Grid
from pf1e_simulator.map import Pos, zone_cells
@@ -61,6 +62,39 @@ class CombatantAttrition:
healing_done: float
@dataclass(frozen=True)
class CombatantOutcome:
"""Final state of one combatant at the end of one battle (per-run record).
``dead`` is the engine's own death condition (``CombatantState.dead``:
hp at or below the combatant's death threshold - CON for living PCs, 0
for undead/constructs and under the simple monster-death rule). A dying
PC (hp < 0 but above -CON) is NOT dead here. ``hp`` may be negative;
``hp_max`` is carried so objectives can compute hit points lost.
"""
id: str
side: str
hp: int
hp_max: int
dead: bool
@dataclass(frozen=True)
class RunOutcome:
"""Minimal per-battle outcome for the danger/optimization objectives.
Collected only when ``run_encounter`` is called with
``collect_runs=True``; the aggregate ``EncounterReport`` stays the
default (cheap) output. ``combatants`` covers every creature that
existed at battle end, including summons.
"""
winner: str | None
rounds: int
combatants: tuple[CombatantOutcome, ...]
@dataclass(frozen=True)
class EncounterReport:
"""Aggregated results of a Monte Carlo encounter.
@@ -169,6 +203,50 @@ def build_states(spec: EncounterSpec) -> list[CombatantState]:
return states
def _accumulate_stats(totals: dict[str, CombatantStats], result: CombatResult) -> None:
"""Add one battle's per-combatant stats into the running totals, in place.
Accumulate whatever ids the engine reports: summoned creatures act in the
battle and appear in result.stats, so seeding from the initial states
only would KeyError on them. Ids seen for the first time get a fresh
accumulator. Duplicate summons of the same creature are disambiguated by
the engine the same way build_states does (celestial_wolf,
celestial_wolf-2, ...), so each summoned creature owns its own slot and
their attrition is split, not merged.
"""
for cid, stats in result.stats.items():
current = totals.get(cid)
if current is None:
totals[cid] = stats
continue
totals[cid] = CombatantStats(
hits=current.hits + stats.hits,
crits=current.crits + stats.crits,
damage_dealt=current.damage_dealt + stats.damage_dealt,
damage_taken=current.damage_taken + stats.damage_taken,
healing_done=current.healing_done + stats.healing_done,
)
def _run_outcome(engine: CombatEngine, result: CombatResult) -> RunOutcome:
"""One battle's per-run summary, read from the engine's final live states."""
return RunOutcome(
winner=result.winner,
rounds=result.rounds,
combatants=tuple(
CombatantOutcome(
id=state.combatant.id,
side=state.side,
hp=state.hp,
hp_max=state.combatant.hp_max,
dead=state.dead,
)
for state in engine.states
),
)
@overload
def run_encounter(
spec: EncounterSpec,
runs: int,
@@ -178,7 +256,35 @@ def run_encounter(
spell_registry: dict[str, SpellSpec] | None = None,
capture_first_transcript: bool = False,
monsters_die_at_zero: bool = False,
) -> EncounterReport:
collect_runs: Literal[False] = False,
) -> EncounterReport: ...
@overload
def run_encounter(
spec: EncounterSpec,
runs: int,
seed: int,
*,
policy: Policy | None = None,
spell_registry: dict[str, SpellSpec] | None = None,
capture_first_transcript: bool = False,
monsters_die_at_zero: bool = False,
collect_runs: Literal[True],
) -> tuple[EncounterReport, tuple[RunOutcome, ...]]: ...
def run_encounter(
spec: EncounterSpec,
runs: int,
seed: int,
*,
policy: Policy | None = None,
spell_registry: dict[str, SpellSpec] | None = None,
capture_first_transcript: bool = False,
monsters_die_at_zero: bool = False,
collect_runs: bool = False,
) -> EncounterReport | tuple[EncounterReport, tuple[RunOutcome, ...]]:
"""Run `runs` battles (one SeededRng per run) and aggregate balance metrics.
The spell registry is loaded once (from ``data/spells/`` unless overridden)
@@ -190,6 +296,12 @@ def run_encounter(
``monsters_die_at_zero`` enables the optional simple monster-death rule
(CLI ``--simple-monster-death``): monsters die at hp <= 0; PCs keep the
PF1e hp <= -CON rule. Default False keeps behavior bit-identical.
``collect_runs`` (Phase 2, danger cartography) additionally returns a
tuple ``(report, run_outcomes)`` where ``run_outcomes`` holds one
``RunOutcome`` per battle (winner, rounds, final per-combatant
id/side/hp/dead). Default False keeps the signature and behavior of
every existing caller unchanged (the report alone).
"""
if runs <= 0:
msg = "runs must be positive"
@@ -201,20 +313,14 @@ def run_encounter(
wins = {side.name: 0 for side in spec.sides}
draws = 0
total_rounds = 0
# Accumulate whatever ids the engine reports: summoned creatures act in the
# battle and appear in result.stats, so seeding from the initial states
# only would KeyError on them. Ids seen for the first time get a fresh
# accumulator. Duplicate summons of the same creature are disambiguated by
# the engine the same way build_states does (celestial_wolf,
# celestial_wolf-2, ...), so each summoned creature owns its own slot and
# their attrition is split, not merged.
totals: dict[str, CombatantStats] = {}
transcript: tuple[str, ...] | None = None
outcomes: list[RunOutcome] = []
for index in range(runs):
if index > 0:
states = build_states(spec)
rng = SeededRng(seed + index)
result = CombatEngine(
engine = CombatEngine(
rng,
grid,
states,
@@ -222,7 +328,10 @@ def run_encounter(
policy=policy,
spell_registry=spell_registry,
monsters_die_at_zero=monsters_die_at_zero,
).run()
)
result = engine.run()
if collect_runs:
outcomes.append(_run_outcome(engine, result))
if capture_first_transcript and index == 0:
transcript = result.transcript
if result.winner is None:
@@ -230,18 +339,7 @@ def run_encounter(
else:
wins[result.winner] = wins.get(result.winner, 0) + 1
total_rounds += result.rounds
for cid, stats in result.stats.items():
current = totals.get(cid)
if current is None:
totals[cid] = stats
continue
totals[cid] = CombatantStats(
hits=current.hits + stats.hits,
crits=current.crits + stats.crits,
damage_dealt=current.damage_dealt + stats.damage_dealt,
damage_taken=current.damage_taken + stats.damage_taken,
healing_done=current.healing_done + stats.healing_done,
)
_accumulate_stats(totals, result)
attrition = {
cid: CombatantAttrition(
hits=stats.hits / runs,
@@ -252,7 +350,7 @@ def run_encounter(
)
for cid, stats in totals.items()
}
return EncounterReport(
report = EncounterReport(
runs=runs,
seed=seed,
wins=wins,
@@ -261,3 +359,6 @@ def run_encounter(
attrition=attrition,
transcript=transcript,
)
if collect_runs:
return report, tuple(outcomes)
return report