a21e234b41
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.
198 lines
6.6 KiB
Python
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)
|