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
+7
View File
@@ -7,6 +7,9 @@ dependencies = [
"pyyaml",
]
[project.scripts]
pf1e-sim = "pf1e_simulator.cli:main"
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
@@ -38,6 +41,8 @@ ignore = [
# CPY001: missing copyright notice — we don't use copyright headers
"CPY001",
]
# RUF001: sigma in the CLI balance report is intentional, not a confusable
allowed-confusables = ["σ"]
[tool.ruff.lint.per-file-ignores]
# Tests: assert is expected, magic numbers are fine, annotations are noisy,
@@ -45,6 +50,8 @@ ignore = [
"tests/**" = ["S101", "PLR2004", "ANN", "PLR0913", "PLR0917"]
# rng.py: `random.Random` is used for reproducible Monte Carlo streams, not cryptography
"src/pf1e_simulator/rng.py" = ["S311"]
# cli.py: stdout printing is the point of a CLI
"src/pf1e_simulator/cli.py" = ["T201"]
# ── Basedpyright ──────────────────────────────────────────────────────────────
[tool.basedpyright]
+119
View File
@@ -0,0 +1,119 @@
"""Command-line balance simulator: load a map and sides, run Monte Carlo, report.
Usage: pf1e-sim --map MAP.yaml --side NAME FILE.json [FILE.json ...] [--runs N]
Side names must match the map's `deployment` section (zone letters). Each side
may repeat the same monster JSON file; the runner disambiguates the ids.
"""
from __future__ import annotations
import argparse
import sys
from pathlib import Path
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from pf1e_simulator.runner import EncounterReport, EncounterSpec
from pf1e_simulator.loaders import MonsterLoadError, SheetLoadError, load_monster
from pf1e_simulator.map import MapValidationError, load_map
from pf1e_simulator.runner import EncounterSpec, Side, run_encounter
class CliError(Exception):
"""User-facing CLI error (bad arguments or unusable files)."""
def _build_spec(args: argparse.Namespace) -> EncounterSpec:
map_spec = load_map(args.map)
provided = [tuple(raw) for raw in args.side]
provided_names = {raw[0] for raw in provided}
missing = sorted(set(map_spec.deployment) - provided_names)
if missing:
msg = f"sides in map deployment without --side: {', '.join(missing)}"
raise CliError(msg)
unknown = sorted(provided_names - set(map_spec.deployment))
if unknown:
msg = f"--side names not in map deployment: {', '.join(unknown)}"
raise CliError(msg)
sides: list[Side] = []
for raw in provided:
name = raw[0]
files = raw[1:]
if not files:
msg = f"side {name!r}: at least one monster JSON file is required"
raise CliError(msg)
combatants = tuple(load_monster(Path(path)) for path in files)
sides.append(Side(name=name, combatants=combatants, zone=map_spec.deployment[name]))
return EncounterSpec(map=map_spec, sides=tuple(sides), round_cap=args.round_cap)
def format_report(report: EncounterReport, spec: EncounterSpec) -> str:
"""Render a human-readable French balance report."""
lines = ["=== Rapport d'équilibrage ===", f"Carte : {spec.map.name}"]
lines.extend(f"{side.name} : {len(side.combatants)} combatants" for side in spec.sides)
lines.append(
f"{report.runs} combats simulés (seed {report.seed}, plafond {spec.round_cap} rounds)"
)
lines.append("")
for side in spec.sides:
wins = report.wins.get(side.name, 0)
rate = report.win_rate(side.name)
low, high = report.win_rate_band(side.name)
lines.append(
f"Victoires {side.name} : {rate:.1%} ({wins}) — "
f"bande 3σ : [{low:.1%}, {high:.1%}]"
)
lines.append(f"Nuls : {report.draws / report.runs:.1%} ({report.draws})")
lines.append(f"Rounds moyens : {report.avg_rounds:.1f}")
lines.append("")
lines.append("Attrition moyenne par combatant :")
lines.extend(
f" {cid} : {attrition.hits:.2f} touches · {attrition.crits:.2f} critiques · "
f"{attrition.damage_dealt:.2f} dégâts infligés · {attrition.damage_taken:.2f} subis"
for cid, attrition in report.attrition.items()
)
return "\n".join(lines)
def main(argv: list[str] | None = None) -> int:
"""CLI entry point; returns the process exit code."""
parser = argparse.ArgumentParser(
prog="pf1e-sim",
description="Run Monte Carlo battles on a map and print a balance report.",
)
parser.add_argument("--map", required=True, type=Path, help="map YAML file")
parser.add_argument(
"--side",
action="append",
nargs="+",
metavar=("NAME", "FILE"),
help="side NAME with one or more monster JSON files (repeatable)",
)
parser.add_argument("--runs", type=int, default=1000, help="number of battles (default: 1000)")
parser.add_argument("--seed", type=int, default=1, help="RNG seed (default: 1)")
parser.add_argument(
"--round-cap",
type=int,
default=100,
help="max rounds per battle (default: 100)",
)
args = parser.parse_args(argv)
if not args.side:
parser.error("at least one --side is required")
try:
spec = _build_spec(args)
except CliError as exc:
print(f"pf1e-sim: {exc}", file=sys.stderr)
return 2
except (MapValidationError, MonsterLoadError, SheetLoadError, OSError) as exc:
print(f"pf1e-sim: {exc}", file=sys.stderr)
return 2
try:
report = run_encounter(spec, runs=args.runs, seed=args.seed)
except ValueError as exc:
print(f"pf1e-sim: {exc}", file=sys.stderr)
return 2
print(format_report(report, spec))
return 0
+10 -5
View File
@@ -7,8 +7,8 @@ Phase 0 documented deviations from PF1e (conventions):
- DR applies once, after crit multiplication; any bypassing type defeats DR.
- Death when hp < min(-10, -CON); hp <= 0 cannot act.
- Initiative ties: higher initiative_mod first, then list order (no re-roll).
- Movement: greedy single step toward the nearest enemy minimizing
accumulated cost + remaining grid distance; ties keep delta order.
- Movement: one step per move action toward the nearest enemy, following the
true shortest path (Dijkstra cost field from the target); ties keep delta order.
- Ranged attacks ignore cover and range penalties in Phase 0.
"""
@@ -301,15 +301,20 @@ class CombatEngine:
if speed_cells <= 0:
return None
blocked = frozenset(s.pos for s in self._states if s is not state and s.active)
costs = self._grid.reachable(state.pos, speed_cells, blocked)
to_target = self._grid.reachable(target.pos, None, blocked)
if state.pos not in to_target:
return None
best: tuple[int, Pos] | None = None
row, col = state.pos
for d_row, d_col in _STEP_DELTAS:
nxt = (row + d_row, col + d_col)
cost = costs.get(nxt)
cost = to_target.get(nxt)
if cost is None:
continue
score = cost + self._grid.distance(nxt, target.pos)
step = self._grid.step_cost(state.pos, nxt, 0)
if step > speed_cells:
continue
score = step + cost
if best is None or score < best[0]:
best = (score, nxt)
if best is None:
+3 -2
View File
@@ -87,11 +87,12 @@ class Grid:
flank_b = (to[0], frm[1])
return self.passable(flank_a) and self.passable(flank_b)
def reachable(self, start: Pos, budget: int, blocked: frozenset[Pos]) -> dict[Pos, int]:
def reachable(self, start: Pos, budget: int | None, blocked: frozenset[Pos]) -> dict[Pos, int]:
"""Cheapest movement cost per reachable square, capped at budget.
`blocked` holds creature-occupied squares: unenterable, but never
affecting diagonal_allowed. The start square is included at cost 0.
budget=None disables the cap (full cost field).
"""
best_per_state: dict[tuple[Pos, int], int] = {(start, 0): 0}
results: dict[Pos, int] = {start: 0}
@@ -109,7 +110,7 @@ class Grid:
if is_diagonal and not self.diagonal_allowed(pos, nxt):
continue
new_cost = cost + self.step_cost(pos, nxt, parity)
if new_cost > budget:
if budget is not None and new_cost > budget:
continue
new_parity = (parity + 1) % 2 if is_diagonal else parity
state = (nxt, new_parity)
+32
View File
@@ -0,0 +1,32 @@
"""Closed-form statistics for Monte Carlo balance reports.
Proportions follow the binomial model: p = wins/n with standard error
sqrt(p(1-p)/n). The 3-sigma band is the default confidence interval used in
the CLI report; clamp keeps degenerate proportions (0 or 1) exact.
"""
from __future__ import annotations
import math
def win_rate(wins: int, runs: int) -> float:
"""Fraction of runs won: wins / runs."""
if runs <= 0:
msg = "runs must be positive"
raise ValueError(msg)
return wins / runs
def win_rate_sigma(p: float, runs: int) -> float:
"""Standard error of a proportion: sqrt(p(1-p)/n)."""
if runs <= 0:
msg = "runs must be positive"
raise ValueError(msg)
return math.sqrt(p * (1 - p) / runs)
def win_rate_band(p: float, runs: int, *, sigma_count: float = 3.0) -> tuple[float, float]:
"""p +/- sigma_count standard errors, clamped to [0, 1]."""
sigma = win_rate_sigma(p, runs)
return (max(0.0, p - sigma_count * sigma), min(1.0, p + sigma_count * sigma))
+171
View File
@@ -0,0 +1,171 @@
"""Monte Carlo runner: many deterministic battles aggregated into balance metrics.
Each run builds fresh combatant states (duplicate ids get -N suffixes), places
each side inside its deployment zone, and plays one battle with a dedicated
SeededRng stream derived from the encounter seed. Results are aggregated into
an EncounterReport: wins per side, draws, average rounds, and per-combatant
attrition averages. All statistical helpers live in metrics.py.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from pf1e_simulator.combat import Policy
from pf1e_simulator.grid import Grid
from pf1e_simulator.map import MapSpec
from pf1e_simulator.models import Combatant
from pf1e_simulator.combat import CombatantState, CombatantStats, CombatEngine
from pf1e_simulator.grid import Grid
from pf1e_simulator.map import zone_cells
from pf1e_simulator.metrics import win_rate, win_rate_band, win_rate_sigma
from pf1e_simulator.rng import SeededRng
@dataclass(frozen=True)
class Side:
"""One faction in an encounter: combatants deployed inside one zone letter."""
name: str
combatants: tuple[Combatant, ...]
zone: str
@dataclass(frozen=True)
class EncounterSpec:
"""A full encounter definition: map, sides, and a battle round cap."""
map: MapSpec
sides: tuple[Side, ...]
round_cap: int = 100
@dataclass(frozen=True)
class CombatantAttrition:
"""Average per-combatant outcomes over a Monte Carlo run."""
hits: float
crits: float
damage_dealt: float
damage_taken: float
@dataclass(frozen=True)
class EncounterReport:
"""Aggregated results of a Monte Carlo encounter."""
runs: int
seed: int
wins: dict[str, int]
draws: int
avg_rounds: float
attrition: dict[str, CombatantAttrition]
def win_rate(self, side: str) -> float:
"""Fraction of runs won by `side` (closed form, see metrics)."""
return win_rate(self.wins.get(side, 0), self.runs)
def win_rate_sigma(self, side: str) -> float:
"""Standard error of the side's win rate: sqrt(p(1-p)/n)."""
return win_rate_sigma(self.win_rate(side), self.runs)
def win_rate_band(self, side: str) -> tuple[float, float]:
"""3-sigma confidence band for the side's win rate."""
return win_rate_band(self.win_rate(side), self.runs)
def build_states(spec: EncounterSpec) -> list[CombatantState]:
"""Fresh states for one battle; duplicate combatant ids get -N suffixes.
Combatants of a side fill the zone's cells in (row, col) order. A side with
more combatants than zone cells, or a zone with no cells, is a spec error.
"""
states: list[CombatantState] = []
seen: dict[str, int] = {}
for side in spec.sides:
cells = zone_cells(spec.map, side.zone)
if not cells:
msg = f"side {side.name!r}: zone {side.zone!r} has no cells"
raise ValueError(msg)
if len(cells) < len(side.combatants):
msg = (
f"side {side.name!r}: zone {side.zone!r} has {len(cells)} cells "
f"for {len(side.combatants)} combatants"
)
raise ValueError(msg)
for combatant, pos in zip(side.combatants, cells, strict=False):
count = seen.get(combatant.id, 0) + 1
seen[combatant.id] = count
cid = combatant.id if count == 1 else f"{combatant.id}-{count}"
effective = (
combatant
if cid == combatant.id
else combatant.model_copy(update={"id": cid})
)
states.append(
CombatantState(
combatant=effective,
side=side.name,
pos=pos,
hp=effective.hp_max,
)
)
return states
def run_encounter(
spec: EncounterSpec,
runs: int,
seed: int,
*,
policy: Policy | None = None,
) -> EncounterReport:
"""Run `runs` battles (one SeededRng per run) and aggregate balance metrics."""
if runs <= 0:
msg = "runs must be positive"
raise ValueError(msg)
grid = Grid.from_spec(spec.map)
states = build_states(spec)
ids = [s.combatant.id for s in states]
wins = {side.name: 0 for side in spec.sides}
draws = 0
total_rounds = 0
totals = {cid: CombatantStats() for cid in ids}
for index in range(runs):
if index > 0:
states = build_states(spec)
rng = SeededRng(seed + index)
result = CombatEngine(rng, grid, states, round_cap=spec.round_cap, policy=policy).run()
if result.winner is None:
draws += 1
else:
wins[result.winner] = wins.get(result.winner, 0) + 1
total_rounds += result.rounds
for cid, stats in result.stats.items():
current = totals[cid]
totals[cid] = CombatantStats(
hits=current.hits + stats.hits,
crits=current.crits + stats.crits,
damage_dealt=current.damage_dealt + stats.damage_dealt,
damage_taken=current.damage_taken + stats.damage_taken,
)
attrition = {
cid: CombatantAttrition(
hits=stats.hits / runs,
crits=stats.crits / runs,
damage_dealt=stats.damage_dealt / runs,
damage_taken=stats.damage_taken / runs,
)
for cid, stats in totals.items()
}
return EncounterReport(
runs=runs,
seed=seed,
wins=wins,
draws=draws,
avg_rounds=total_rounds / runs,
attrition=attrition,
)
+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)