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