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:
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user