diff --git a/pyproject.toml b/pyproject.toml index 009632c..fd0d7f3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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] diff --git a/src/pf1e_simulator/cli.py b/src/pf1e_simulator/cli.py new file mode 100644 index 0000000..7195021 --- /dev/null +++ b/src/pf1e_simulator/cli.py @@ -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 diff --git a/src/pf1e_simulator/combat.py b/src/pf1e_simulator/combat.py index 2127b5a..4ab195d 100644 --- a/src/pf1e_simulator/combat.py +++ b/src/pf1e_simulator/combat.py @@ -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: diff --git a/src/pf1e_simulator/grid.py b/src/pf1e_simulator/grid.py index a37eb4e..0b52b7d 100644 --- a/src/pf1e_simulator/grid.py +++ b/src/pf1e_simulator/grid.py @@ -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) diff --git a/src/pf1e_simulator/metrics.py b/src/pf1e_simulator/metrics.py new file mode 100644 index 0000000..31ae107 --- /dev/null +++ b/src/pf1e_simulator/metrics.py @@ -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)) diff --git a/src/pf1e_simulator/runner.py b/src/pf1e_simulator/runner.py new file mode 100644 index 0000000..7d3ac92 --- /dev/null +++ b/src/pf1e_simulator/runner.py @@ -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, + ) diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..c0f2e99 --- /dev/null +++ b/tests/test_cli.py @@ -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 diff --git a/tests/test_combat.py b/tests/test_combat.py index 1bdb44f..e68b25a 100644 --- a/tests/test_combat.py +++ b/tests/test_combat.py @@ -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 diff --git a/tests/test_grid.py b/tests/test_grid.py index 642ffd9..2f06069 100644 --- a/tests/test_grid.py +++ b/tests/test_grid.py @@ -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} diff --git a/tests/test_runner.py b/tests/test_runner.py new file mode 100644 index 0000000..0742119 --- /dev/null +++ b/tests/test_runner.py @@ -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)