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:
@@ -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]
|
||||
|
||||
@@ -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
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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