feat(grid): map YAML schema, 5-10-5 grid, Dijkstra movement, corner LoS/cover
This commit is contained in:
@@ -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
|
||||
@@ -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
|
||||
@@ -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))
|
||||
Reference in New Issue
Block a user