Files
ctan de88543c5c 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.
2026-09-21 12:07:23 +02:00

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