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.
121 lines
3.8 KiB
Python
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}
|