"""Tests for the Monte Carlo runner and its closed-form statistics. The runner aggregates many deterministic battles (one SeededRng per run) into balance metrics: win rates per side, draws, average rounds, and per-combatant attrition. Statistical assertions use the closed-form binomial standard error sqrt(p(1-p)/n): symmetric matchups must land within the 3-sigma band. """ from __future__ import annotations import pytest from pf1e_simulator.dice import parse_dice from pf1e_simulator.map import MapSpec, TerrainType from pf1e_simulator.metrics import win_rate, win_rate_band, win_rate_sigma from pf1e_simulator.models import ( AbilityScores, ACProfile, AttackSpec, Combatant, DamageComponent, Saves, ) from pf1e_simulator.runner import EncounterSpec, Side, build_states, run_encounter def make_combatant( cid: str, *, hp: int = 6, ac: int = 13, attack_bonus: int = 2, damage: str = "1d4", initiative_mod: int = 0, speed: int = 30, ) -> Combatant: attack = AttackSpec( id=f"{cid}-w", name="short sword", kind="melee", attack_bonus=attack_bonus, damage=[DamageComponent(formula=parse_dice(damage), types=["slashing"])], ) return Combatant( id=cid, name=cid, level=1, size="Medium", abilities=AbilityScores( str_score=10, dex_score=10, con_score=12, int_score=10, wis_score=10, cha_score=10, ), hp_max=hp, ac=ACProfile(total=ac, touch=ac, flat_footed=ac), bab=attack_bonus, initiative_mod=initiative_mod, speed_land_ft=speed, saves=Saves(fort=0, ref=0, will=0), attacks=[attack], ) def make_map() -> MapSpec: legend = {".": TerrainType(type="floor", move_cost=1)} return MapSpec( name="test", terrain=("........", "........"), legend=legend, zones=("AA..BB..", "........"), deployment={"players": "A", "monsters": "B"}, ) def test_metrics_closed_form() -> None: """Proportion stats use the binomial closed form: p, sqrt(p(1-p)/n), 3-sigma band.""" assert win_rate(50, 100) == pytest.approx(0.5) assert win_rate_sigma(0.5, 100) == pytest.approx(0.05) assert win_rate_band(0.5, 100) == (pytest.approx(0.35), pytest.approx(0.65)) assert win_rate(0, 100) == 0.0 assert win_rate_band(0.0, 100) == (0.0, 0.0) assert win_rate_band(1.0, 100) == (1.0, 1.0) with pytest.raises(ValueError, match="runs must be positive"): win_rate(1, 0) def test_symmetric_1v1_win_rate_within_3sigma() -> None: """Identical combatants on mirror zones: players win rate stays inside 3 sigma of 0.5. Long battles (hp 20, 1d4) keep the first-strike initiative edge small, so the observed proportion must fall in [0.5 - 3*sqrt(0.25/1000), 0.5 + 3*...]. """ c = make_combatant("solo", hp=20, damage="1d4") spec = EncounterSpec( map=make_map(), sides=( Side(name="players", combatants=(c,), zone="A"), Side(name="monsters", combatants=(c,), zone="B"), ), ) report = run_encounter(spec, runs=1000, seed=1) p = report.win_rate("players") band = report.win_rate_band("players") assert band[0] <= p <= band[1] assert report.wins["players"] + report.wins["monsters"] + report.draws == report.runs def test_stalemate_draws_every_run_at_round_cap() -> None: """Immobile sides never meet: every run draws at the round cap with win rate 0.""" slow = make_combatant("slow", speed=0) spec = EncounterSpec( map=make_map(), sides=( Side(name="players", combatants=(slow,), zone="A"), Side(name="monsters", combatants=(slow,), zone="B"), ), round_cap=3, ) report = run_encounter(spec, runs=50, seed=7) assert report.draws == 50 assert report.wins == {"players": 0, "monsters": 0} assert report.avg_rounds == pytest.approx(3.0) assert report.win_rate("players") == 0.0 assert report.win_rate_band("players") == (0.0, 0.0) def test_attrition_averages_every_combatant() -> None: """Attrition reports per-combatant averages with physically plausible bounds.""" spec = EncounterSpec( map=make_map(), sides=( Side( name="players", combatants=(make_combatant("p1"), make_combatant("p2")), zone="A", ), Side( name="monsters", combatants=(make_combatant("m1"), make_combatant("m2")), zone="B", ), ), ) report = run_encounter(spec, runs=20, seed=3) assert set(report.attrition) == {"p1", "p2", "m1", "m2"} for attrition in report.attrition.values(): assert attrition.hits >= 0 assert attrition.crits >= 0 assert attrition.damage_dealt >= 0 assert attrition.damage_taken >= 0 # hp 6, max single hit 4 (1d4): a killing blow lands from hp >= 1, so damage_taken <= 6 + 3 assert all(a.damage_taken <= 9.0 for a in report.attrition.values()) # Symmetric 2v2 in a two-cell corridor: both sides lose some runs, nobody escapes untouched. assert all(a.damage_taken > 0 for a in report.attrition.values()) assert all(a.damage_dealt > 0 for a in report.attrition.values()) def test_build_states_disambiguates_duplicate_ids() -> None: """Duplicate combatant ids (same monster file twice) get global -N suffixes.""" spec = EncounterSpec( map=make_map(), sides=( Side( name="players", combatants=(make_combatant("gob"), make_combatant("gob")), zone="A", ), Side(name="monsters", combatants=(make_combatant("gob"),), zone="B"), ), ) states = build_states(spec) assert [s.combatant.id for s in states] == ["gob", "gob-2", "gob-3"] assert [s.pos for s in states] == [(0, 0), (0, 1), (0, 4)] assert all(s.hp == s.combatant.hp_max for s in states) def test_build_states_rejects_too_many_combatants_for_zone() -> None: """A zone with fewer cells than combatants is a spec error, not a runtime surprise.""" spec = EncounterSpec( map=make_map(), sides=( Side( name="players", combatants=( make_combatant("a"), make_combatant("b"), make_combatant("c"), ), zone="A", ), ), ) with pytest.raises(ValueError, match="has 2 cells"): build_states(spec)