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:
@@ -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),
|
||||
)
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user