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
+78 -1
View File
@@ -22,7 +22,13 @@ from pf1e_simulator.models import (
DamageComponent,
Saves,
)
from pf1e_simulator.runner import EncounterSpec, Side, build_states, run_encounter
from pf1e_simulator.runner import (
EncounterReport,
EncounterSpec,
Side,
build_states,
run_encounter,
)
def make_combatant(
@@ -391,3 +397,74 @@ def test_run_encounter_reports_healing_done() -> None:
report = run_encounter(spec, runs=5, seed=11, policy=_self_healing_policy)
assert report.attrition["healer"].healing_done > 0.0
assert report.attrition["healer"].damage_taken > 0.0
def _duel_spec() -> EncounterSpec:
return EncounterSpec(
map=make_map(),
sides=(
Side(name="players", combatants=(make_combatant("p1", hp=20),), zone="A"),
Side(name="monsters", combatants=(make_combatant("m1", hp=20),), zone="B"),
),
round_cap=20,
)
def test_run_encounter_default_unchanged_without_collect_runs() -> None:
"""Without collect_runs the call still returns the bare EncounterReport."""
result = run_encounter(_duel_spec(), runs=5, seed=3)
assert isinstance(result, EncounterReport)
def test_run_encounter_collect_runs_returns_report_and_summaries() -> None:
"""collect_runs=True returns (report, per-run summaries) with consistent aggregates."""
spec = _duel_spec()
report, outcomes = run_encounter(spec, runs=10, seed=3, collect_runs=True)
assert isinstance(report, EncounterReport)
assert len(outcomes) == 10
wins = sum(1 for o in outcomes if o.winner == "players")
draws = sum(1 for o in outcomes if o.winner is None)
assert wins == report.wins["players"]
assert draws == report.draws
assert sum(o.rounds for o in outcomes) / 10 == report.avg_rounds
for o in outcomes:
assert {c.id for c in o.combatants} == {"p1", "m1"}
by_id = {c.id: c for c in o.combatants}
assert by_id["p1"].side == "players"
assert by_id["m1"].side == "monsters"
assert by_id["p1"].hp_max == 20
# dead is the engine death condition: hp <= -CON (con 12 here)
for c in o.combatants:
assert c.dead == (c.hp <= -12)
def test_run_encounter_collect_runs_deterministic() -> None:
"""Same seed -> identical per-run summaries."""
spec = _duel_spec()
_, a = run_encounter(spec, runs=8, seed=9, collect_runs=True)
_, b = run_encounter(spec, runs=8, seed=9, collect_runs=True)
assert a == b
def test_run_encounter_collect_runs_dead_matches_winner() -> None:
"""A side that never wins still ends runs with its member dead in the summaries."""
spec = EncounterSpec(
map=make_map(),
sides=(
Side(name="players", combatants=(make_combatant("weak", hp=4),), zone="A"),
Side(
name="monsters",
combatants=(make_combatant("strong", hp=40, attack_bonus=10, damage="2d6"),) * 2,
zone="B",
),
),
round_cap=20,
)
report, outcomes = run_encounter(spec, runs=5, seed=5, collect_runs=True)
assert report.wins["monsters"] == 5
for o in outcomes:
weak = next(c for c in o.combatants if c.id == "weak")
# PF1e: the battle ends when weak is INACTIVE (hp < 0), not necessarily
# dead - dead must mirror the engine condition hp <= -CON, not the loss.
assert weak.hp < 0
assert weak.dead == (weak.hp <= -12)