Files
pf1e-simulator/tests/test_grid.py
T
ctan a21e234b41 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.
2026-08-17 22:49:50 +02:00

121 lines
3.8 KiB
Python

"""Tests for the square grid: distance, step costs, corner rule, Dijkstra."""
from __future__ import annotations
from pf1e_simulator.grid import Grid
from pf1e_simulator.map import MapSpec, TerrainType
def make_grid(rows: list[str]) -> Grid:
legend = {
"#": TerrainType(type="wall", move_cost=None, blocks_los=True),
".": TerrainType(type="floor", move_cost=1),
"T": TerrainType(type="rubble", move_cost=2),
"C": TerrainType(type="pillar", move_cost=None, cover=True),
}
spec = MapSpec(name="test", terrain=tuple(rows), legend=legend)
return Grid.from_spec(spec)
def test_distance_diagonal_table() -> None:
grid = make_grid([".....", ".....", ".....", ".....", "....."])
assert grid.distance((0, 0), (1, 1)) == 1
assert grid.distance((0, 0), (2, 2)) == 3
assert grid.distance((0, 0), (3, 2)) == 4
assert grid.distance((0, 0), (0, 3)) == 3
assert grid.distance((2, 1), (0, 0)) == 2
def test_step_cost_orthogonal_uses_entered_square() -> None:
grid = make_grid([".T."])
assert grid.step_cost((0, 0), (0, 1), 0) == 2
assert grid.step_cost((0, 1), (0, 2), 0) == 1
def test_step_cost_diagonal_alternates() -> None:
grid = make_grid(["...", "...", "..."])
assert grid.step_cost((0, 0), (1, 1), 0) == 1
assert grid.step_cost((1, 1), (2, 2), 1) == 2
assert grid.step_cost((1, 1), (2, 2), 2) == 1
def test_step_cost_diagonal_into_difficult_is_flat_three() -> None:
grid = make_grid(["..", ".T"])
assert grid.step_cost((0, 0), (1, 1), 0) == 3
assert grid.step_cost((0, 0), (1, 1), 1) == 3
def test_diagonal_forbidden_past_wall_corner() -> None:
grid = make_grid([
".#.",
".#.",
"...",
])
assert grid.diagonal_allowed((2, 1), (1, 0)) is False
assert grid.diagonal_allowed((2, 1), (1, 2)) is False
def test_diagonal_allowed_with_both_flanks_clear() -> None:
grid = make_grid([
".#.",
"...",
"...",
])
assert grid.diagonal_allowed((2, 1), (1, 0)) is True
def test_reachable_open_floor_costs() -> None:
grid = make_grid([".....", ".....", ".....", ".....", "....."])
reached = grid.reachable((0, 0), 10, frozenset())
assert reached[(0, 0)] == 0
assert reached[(1, 1)] == 1
assert reached[(2, 2)] == 3
assert reached[(3, 3)] == 4
assert reached[(4, 4)] == 6
assert reached[(0, 4)] == 4
def test_reachable_respects_budget() -> None:
grid = make_grid([".....", ".....", ".....", ".....", "....."])
reached = grid.reachable((0, 0), 2, frozenset())
assert (1, 1) in reached
assert (2, 2) not in reached
def test_reachable_diagonal_into_rubble_costs_three() -> None:
grid = make_grid(["...", ".T.", "..."])
reached = grid.reachable((0, 0), 6, frozenset())
assert reached[(1, 1)] == 3
def test_reachable_through_rubble_cheaper_than_around() -> None:
grid = make_grid(["...", ".T.", "..."])
reached = grid.reachable((1, 0), 6, frozenset())
assert reached[(1, 2)] == 3
def test_reachable_creatures_block_entry_not_diagonals() -> None:
grid = make_grid(["...", "...", "..."])
reached = grid.reachable((1, 1), 1, frozenset({(1, 0)}))
assert (1, 0) not in reached
assert reached[(0, 2)] == 1
def test_reachable_paths_around_creatures() -> None:
grid = make_grid(["...", ".#.", "..."])
reached = grid.reachable((1, 0), 6, frozenset())
assert (1, 1) not in reached
assert reached[(1, 2)] == 4
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}