de88543c5c
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.
106 lines
3.7 KiB
Python
106 lines
3.7 KiB
Python
"""Unit tests for the Phase-2 objective primitives (objectives.py).
|
|
|
|
Synthetic RunOutcome tuples with hand-computed expectations: the metrics
|
|
are pure frequencies/means over per-run summaries, so every number below is
|
|
verifiable by eye. No engine, no RNG.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from pf1e_simulator.objectives import (
|
|
danger_metrics,
|
|
expected_hp_lost,
|
|
expected_rounds,
|
|
p_any_death,
|
|
p_survives,
|
|
p_tpk,
|
|
worst_decile_party_hp,
|
|
)
|
|
from pf1e_simulator.runner import CombatantOutcome, RunOutcome
|
|
|
|
|
|
def _pc(cid: str, hp: int, hp_max: int = 10) -> CombatantOutcome:
|
|
dead = hp <= -12
|
|
return CombatantOutcome(id=cid, side="players", hp=hp, hp_max=hp_max, dead=dead)
|
|
|
|
|
|
def _mob(cid: str, hp: int, hp_max: int = 10) -> CombatantOutcome:
|
|
dead = hp <= -12
|
|
return CombatantOutcome(id=cid, side="monsters", hp=hp, hp_max=hp_max, dead=dead)
|
|
|
|
|
|
def _run(rounds: int, *combatants: CombatantOutcome) -> RunOutcome:
|
|
return RunOutcome(winner=None, rounds=rounds, combatants=combatants)
|
|
|
|
|
|
def test_empty_batch_returns_zero() -> None:
|
|
assert p_survives((), "players") == 0.0
|
|
assert p_tpk((), "players") == 0.0
|
|
assert p_any_death((), "players") == 0.0
|
|
assert expected_hp_lost((), "players") == 0.0
|
|
assert expected_rounds(()) == 0.0
|
|
assert worst_decile_party_hp((), "players") == 0.0
|
|
|
|
|
|
def test_survival_tpk_and_death_rates() -> None:
|
|
# run 1: both players clean (hp 10/10, 8/10) -> survives, no death
|
|
# run 2: one player dying (hp -1, NOT dead at -CON 12), one at 3 hp
|
|
# -> survives (nobody dead), no death counted
|
|
# run 3: one player dead (hp -12), one at 0 hp -> any death, not TPK
|
|
# run 4: both dead (hp -13, -12) -> TPK
|
|
runs = (
|
|
_run(2, _pc("a", 10), _pc("b", 8), _mob("m", 5)),
|
|
_run(3, _pc("a", -1), _pc("b", 3)),
|
|
_run(4, _pc("a", -12), _pc("b", 0)),
|
|
_run(5, _pc("a", -13), _pc("b", -12)),
|
|
)
|
|
assert p_survives(runs, "players") == 2 / 4
|
|
assert p_any_death(runs, "players") == 2 / 4
|
|
assert p_tpk(runs, "players") == 1 / 4
|
|
# monsters absent from a run: vacuously survived, never TPK
|
|
assert p_survives(runs, "monsters") == 1.0
|
|
assert p_tpk(runs, "monsters") == 0.0
|
|
|
|
|
|
def test_expected_hp_lost_floors_per_member() -> None:
|
|
# run 1: a lost 2, b lost 7 (total 9). Run 2: a at -12 counts as the
|
|
# full 10 lost (clamped), b at 10 lost nothing. Mean of the two totals
|
|
# is 9.5.
|
|
runs = (
|
|
_run(1, _pc("a", 8), _pc("b", 3)),
|
|
_run(2, _pc("a", -12), _pc("b", 10)),
|
|
)
|
|
assert expected_hp_lost(runs, "players") == 9.5
|
|
assert expected_rounds(runs) == 1.5
|
|
|
|
|
|
def test_worst_decile_takes_the_tail_not_the_mean() -> None:
|
|
# 20 runs: 19 clean (hp lost 0) + 1 disaster (both dead, 20 lost).
|
|
# Mean hp lost = 1.0 but the worst decile (2 runs: disaster + one clean)
|
|
# averages 10 hp remaining - the tail is visible.
|
|
clean = _run(1, _pc("a", 10), _pc("b", 10))
|
|
disaster = _run(9, _pc("a", -12), _pc("b", -13))
|
|
runs = (disaster, *([clean] * 19))
|
|
assert expected_hp_lost(runs, "players") == 1.0
|
|
assert worst_decile_party_hp(runs, "players") == (0 + 20) / 2
|
|
|
|
|
|
def test_worst_decile_minimum_one_run() -> None:
|
|
runs = (_run(1, _pc("a", 4)),)
|
|
assert worst_decile_party_hp(runs, "players") == 4
|
|
|
|
|
|
def test_danger_metrics_aggregates_every_primitive() -> None:
|
|
runs = (
|
|
_run(2, _pc("a", 10), _mob("m", 0)),
|
|
_run(4, _pc("a", -12), _mob("m", -13)),
|
|
)
|
|
m = danger_metrics(runs, "players")
|
|
assert m.runs == 2
|
|
assert m.p_survives == 0.5
|
|
assert m.p_tpk == 0.5
|
|
assert m.p_any_death == 0.5
|
|
assert m.expected_hp_lost == 5.0
|
|
assert m.expected_rounds == 3.0
|
|
assert m.worst_decile_party_hp == 0.0
|