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.
This commit is contained in:
2026-08-17 22:49:50 +02:00
parent 577aec33c4
commit a21e234b41
10 changed files with 625 additions and 7 deletions
+59
View File
@@ -0,0 +1,59 @@
"""Tests for the pf1e-sim command-line interface."""
from __future__ import annotations
from pathlib import Path
from typing import TYPE_CHECKING
if TYPE_CHECKING:
import pytest
from pf1e_simulator.cli import main
ROOT = Path(__file__).resolve().parents[1]
MAP = ROOT / "data" / "maps" / "sample_arena.yaml"
GOBLIN = ROOT / "data" / "monsters" / "goblin.json"
ORC = ROOT / "data" / "monsters" / "orc.json"
def test_cli_prints_balance_report(capsys: pytest.CaptureFixture[str]) -> None:
rc = main(
[
"--map",
str(MAP),
"--side",
"players",
str(GOBLIN),
str(GOBLIN),
str(GOBLIN),
"--side",
"monsters",
str(ORC),
str(ORC),
"--runs",
"50",
"--seed",
"1",
]
)
out = capsys.readouterr().out
assert rc == 0
assert "Rapport d'équilibrage" in out
assert "Victoires players" in out
assert "Victoires monsters" in out
assert "bande 3σ" in out
assert "Nuls" in out
assert "Rounds moyens" in out
assert "Attrition moyenne" in out
def test_cli_rejects_side_not_in_deployment(capsys: pytest.CaptureFixture[str]) -> None:
rc = main(["--map", str(MAP), "--side", "bogus", str(GOBLIN), "--runs", "10"])
assert rc == 2
assert "deployment" in capsys.readouterr().err
def test_cli_rejects_missing_deployment_side(capsys: pytest.CaptureFixture[str]) -> None:
rc = main(["--map", str(MAP), "--side", "players", str(GOBLIN), "--runs", "10"])
assert rc == 2
assert "monsters" in capsys.readouterr().err
+21
View File
@@ -394,3 +394,24 @@ def test_nearest_enemy_targeting_and_full_rounds() -> None:
"round 4 gob: orc-2 down",
"battle over: players win in 4 rounds",
)
def test_melee_routes_around_wall() -> None:
"""Melee unit follows the true shortest path instead of oscillating at a wall."""
legend = {
".": TerrainType(type="floor", move_cost=1),
"#": TerrainType(type="wall", move_cost=None, blocks_los=True),
}
spec = MapSpec(
name="wall-test",
terrain=("....#...", "....#...", "........"),
legend=legend,
)
grid = Grid.from_spec(spec)
mover = make_combatant("mover", hp=20, speed=30)
target = make_combatant("target", speed=0)
states = [make_state(mover, "players", (1, 1)), make_state(target, "monsters", (1, 5))]
engine = CombatEngine(SeededRng(42), grid, states)
result = engine.run()
assert result.winner == "players"
assert result.stats["mover"].hits > 0
+6
View File
@@ -112,3 +112,9 @@ def test_reachable_cannot_enter_wall_squares() -> None:
grid = make_grid(["..", ".C"])
reached = grid.reachable((0, 0), 9, frozenset())
assert (1, 1) not in reached
def test_reachable_unbounded_budget_covers_whole_map() -> None:
grid = make_grid([".#.", "..."])
reached = grid.reachable((0, 0), None, frozenset())
assert reached == {(0, 0): 0, (1, 0): 1, (1, 1): 2, (1, 2): 3, (0, 2): 4}
+197
View File
@@ -0,0 +1,197 @@
"""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)