"""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