Files
pf1e-simulator/tests/test_runner.py
T
ctan a21e234b41 feat(runner): Monte Carlo runner, closed-form metrics and balance CLI
Adds the S6 layer on top of the deterministic combat engine:
- metrics.py: closed-form binomial statistics (win rate, standard error,
  3-sigma confidence band) with clamped degenerate proportions.
- runner.py: EncounterSpec/Side definitions, per-run SeededRng streams,
  global duplicate-id disambiguation, attrition averages per combatant.
- cli.py: pf1e-sim entry point producing a French balance report with
  win rates, 3-sigma bands, draws, rounds, and attrition.
- Movement fix: greedy straight-line heuristic could oscillate at walls;
  _step_toward now follows the true shortest path via an unbounded
  Dijkstra cost field from the target (Grid.reachable budget=None).
- Regression tests: melee unit routes around a wall and engages; grid
  unbounded-budget coverage.
2026-08-17 22:49:50 +02:00

198 lines
6.6 KiB
Python

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