feat(grid): map YAML schema, 5-10-5 grid, Dijkstra movement, corner LoS/cover

This commit is contained in:
2026-08-17 22:49:50 +02:00
parent 6ad37ae9ec
commit 21698d76c6
7 changed files with 780 additions and 0 deletions
+114
View File
@@ -0,0 +1,114 @@
"""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
+90
View File
@@ -0,0 +1,90 @@
"""Tests for corner-rule line of effect and cover (los.py).
Geometry convention: a line is blocked when its relative interior touches a
blocking square (interior, border, or vertex); lines that merely start or end
on a blocking square's vertex do not count as passing through it.
"""
from __future__ import annotations
from pf1e_simulator.grid import Grid
from pf1e_simulator.los import has_cover, has_line_of_effect
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_los_open_field() -> None:
grid = make_grid(["...", "...", "..."])
assert has_line_of_effect(grid, (0, 0), (2, 2)) is True
def test_los_same_square() -> None:
grid = make_grid(["."])
assert has_line_of_effect(grid, (0, 0), (0, 0)) is True
def test_los_blocked_by_wall_between() -> None:
grid = make_grid([".#.", "..."])
assert has_line_of_effect(grid, (0, 0), (0, 2)) is False
def test_los_through_doorway_gap() -> None:
grid = make_grid(["##.##", "##.##"])
assert has_line_of_effect(grid, (0, 2), (1, 2)) is True
def test_los_pinched_vertex_stays_open() -> None:
grid = make_grid([".#.", "#.."])
assert has_line_of_effect(grid, (0, 0), (1, 1)) is True
def test_los_blocked_past_wall_line() -> None:
grid = make_grid(["...#.", "....."])
assert has_line_of_effect(grid, (0, 0), (0, 4)) is False
def test_cover_open_field() -> None:
grid = make_grid([".....", "....."])
assert has_cover(grid, (0, 0), (0, 4), ranged=True) is False
def test_cover_pillar_between() -> None:
grid = make_grid([".....", "..C..", "....."])
assert has_cover(grid, (1, 0), (1, 4), ranged=True) is True
def test_no_cover_when_pillar_off_axis() -> None:
grid = make_grid(["..C..", "....."])
assert has_cover(grid, (1, 0), (1, 4), ranged=True) is False
def test_no_cover_adjacent_with_wall_behind_target() -> None:
grid = make_grid(["..#"])
assert has_cover(grid, (0, 0), (0, 1), ranged=True) is False
def test_cover_diagonal_past_wall_vertex() -> None:
grid = make_grid([".#.", "#.."])
assert has_cover(grid, (0, 0), (1, 1), ranged=True) is True
def test_cover_melee_uses_same_corner_rule_in_phase0() -> None:
grid = make_grid([".#.", "#.."])
assert has_cover(grid, (0, 0), (1, 1), ranged=False) is True
open_grid = make_grid(["...", "..."])
assert has_cover(open_grid, (0, 0), (1, 1), ranged=False) is False
def test_cover_same_square_is_false() -> None:
grid = make_grid(["."])
assert has_cover(grid, (0, 0), (0, 0), ranged=True) is False
+93
View File
@@ -0,0 +1,93 @@
"""Tests for the map YAML schema (map.py)."""
from __future__ import annotations
from pathlib import Path
import pytest
from pf1e_simulator.map import MapValidationError, load_map, zone_cells
MAPS_DIR = Path(__file__).resolve().parents[1] / "data" / "maps"
def _write(tmp_path: Path, body: str) -> Path:
target = tmp_path / "map.yaml"
target.write_text(body, encoding="utf-8")
return target
def _minimal_map() -> str:
return """
name: "Test"
terrain: |
###
#.#
###
legend:
"#": { type: wall, blocks_los: true }
".": { type: floor, move_cost: 1 }
"""
def test_sample_arena_loads() -> None:
spec = load_map(MAPS_DIR / "sample_arena.yaml")
assert spec.square_size_ft == 5
assert len(spec.terrain) == 8
assert all(len(row) == 20 for row in spec.terrain)
assert spec.legend["#"].move_cost is None
assert spec.legend["#"].blocks_los is True
assert spec.legend["C"].cover is True
assert spec.legend["T"].move_cost == 2
assert spec.deployment == {"players": "A", "monsters": "B"}
assert spec.markers["autel"] == (1, 16)
def test_sample_arena_zones_and_elevation() -> None:
spec = load_map(MAPS_DIR / "sample_arena.yaml")
assert spec.elevation is not None
assert spec.elevation[1][2] == "1"
cells_a = zone_cells(spec, "A")
assert cells_a == [
(1, 1), (1, 2), (1, 3), (1, 4),
(2, 1), (2, 2), (2, 3), (2, 4),
]
assert len(zone_cells(spec, "B")) == 8
def test_minimal_map_without_optional_layers(tmp_path: Path) -> None:
spec = load_map(_write(tmp_path, _minimal_map()))
assert spec.elevation is None
assert spec.zones is None
assert spec.markers == {}
assert zone_cells(spec, "A") == []
def test_rejects_ragged_rows(tmp_path: Path) -> None:
body = _minimal_map().replace(" #.#\n", " #.##\n")
with pytest.raises(MapValidationError):
load_map(_write(tmp_path, body))
def test_rejects_char_missing_from_legend(tmp_path: Path) -> None:
body = _minimal_map().replace(" #.#\n", " #x#\n")
with pytest.raises(MapValidationError):
load_map(_write(tmp_path, body))
def test_rejects_elevation_dimension_mismatch(tmp_path: Path) -> None:
body = _minimal_map() + "elevation: |\n 000\n 00\n"
with pytest.raises(MapValidationError):
load_map(_write(tmp_path, body))
def test_rejects_marker_out_of_bounds(tmp_path: Path) -> None:
body = _minimal_map() + "markers:\n autel: [9, 1]\n"
with pytest.raises(MapValidationError):
load_map(_write(tmp_path, body))
def test_rejects_deployment_without_zone(tmp_path: Path) -> None:
body = _minimal_map() + "zones: |\n ...\n .A.\n ...\ndeployment:\n players: B\n"
with pytest.raises(MapValidationError):
load_map(_write(tmp_path, body))