Files
wehub-resource-sync aa1a5d5763
CI / Release hygiene (push) Has been cancelled
CI / Python targeted tests (push) Has been cancelled
CI / Frontend build (push) Has been cancelled
Pages / Build and deploy public pages (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:18:17 +08:00

227 lines
8.2 KiB
Python

#!/usr/bin/env python3
"""Audit A* routes between semantic locations in a GOD map package."""
from __future__ import annotations
import argparse
import heapq
import json
from pathlib import Path
from typing import Any
import yaml
Tile = tuple[int, int]
DEFAULT_ROUTES: dict[str, list[tuple[str, str]]] = {
"the_ville": [("park", "cafe"), ("home", "market"), ("school", "library")],
"pku": [
("west_gate", "centennial_hall"),
("dormitory", "library"),
("weiming_lake", "teaching_building"),
],
}
def repo_root() -> Path:
return Path(__file__).resolve().parents[1]
def load_structured(path: Path) -> dict[str, Any]:
text = path.read_text(encoding="utf-8")
if path.suffix.lower() in {".yaml", ".yml"}:
data = yaml.safe_load(text)
else:
data = json.loads(text)
if not isinstance(data, dict):
raise ValueError(f"Expected object in {path}")
return data
def manifest_path_for(path: Path) -> Path:
if path.is_file():
return path
for filename in ("map.yaml", "map.yml", "town.yaml", "town.yml", "map.json", "town.json"):
candidate = path / filename
if candidate.exists():
return candidate
raise FileNotFoundError(f"No map manifest found under {path}")
def load_map(manifest_path: Path) -> tuple[dict[str, Any], dict[str, Any], Path]:
manifest = load_structured(manifest_path)
map_path = manifest_path.parent / str(manifest.get("tiled_map_path") or "visuals/map.json")
return manifest, load_structured(map_path), map_path
def walkable_tiles(tiled_map: dict[str, Any]) -> tuple[set[Tile], int, int]:
width = int(tiled_map.get("width") or 0)
height = int(tiled_map.get("height") or 0)
collisions = next(
(
layer
for layer in tiled_map.get("layers", []) or []
if isinstance(layer, dict)
and layer.get("name") == "Collisions"
and layer.get("type") == "tilelayer"
),
None,
)
if not collisions or not isinstance(collisions.get("data"), list):
raise ValueError("Map must include a tile layer named Collisions")
return {
(index % width, index // width)
for index, gid in enumerate(collisions["data"])
if int(gid or 0) == 0
}, width, height
def nearest_walkable(tile: Tile, walkable: set[Tile]) -> Tile:
if tile in walkable or not walkable:
return tile
return min(walkable, key=lambda item: abs(item[0] - tile[0]) + abs(item[1] - tile[1]))
def find_path(start: Tile, goal: Tile, walkable: set[Tile], width: int, height: int) -> list[Tile] | None:
start = nearest_walkable(start, walkable)
goal = nearest_walkable(goal, walkable)
frontier: list[tuple[int, Tile]] = [(0, start)]
came_from: dict[Tile, Tile | None] = {start: None}
cost_so_far: dict[Tile, int] = {start: 0}
while frontier:
_, current = heapq.heappop(frontier)
if current == goal:
break
x, y = current
for neighbor in ((x + 1, y), (x - 1, y), (x, y + 1), (x, y - 1)):
if not (0 <= neighbor[0] < width and 0 <= neighbor[1] < height and neighbor in walkable):
continue
next_cost = cost_so_far[current] + 1
if neighbor not in cost_so_far or next_cost < cost_so_far[neighbor]:
cost_so_far[neighbor] = next_cost
priority = next_cost + abs(goal[0] - neighbor[0]) + abs(goal[1] - neighbor[1])
heapq.heappush(frontier, (priority, neighbor))
came_from[neighbor] = current
if goal not in came_from:
return None
path = [goal]
current = goal
while came_from[current] is not None:
current = came_from[current] # type: ignore[assignment]
path.append(current)
path.reverse()
return path
def location_tiles(manifest: dict[str, Any], walkable: set[Tile]) -> dict[str, Tile]:
result: dict[str, Tile] = {}
for location in manifest.get("locations") or []:
if not isinstance(location, dict):
continue
anchor = location.get("anchor_tile") or {}
try:
tile = (int(anchor["x"]), int(anchor["y"]))
except Exception:
continue
result[str(location.get("id") or "")] = nearest_walkable(tile, walkable)
return result
def parse_route(raw: str) -> tuple[str, str]:
if ":" in raw:
left, right = raw.split(":", 1)
elif "->" in raw:
left, right = raw.split("->", 1)
else:
raise argparse.ArgumentTypeError("Routes must look like from:to or from->to")
return left.strip(), right.strip()
def write_overlay(
target: Path,
width: int,
height: int,
walkable: set[Tile],
paths: list[tuple[str, str, list[Tile]]],
) -> None:
try:
from PIL import Image, ImageDraw
except Exception as exc: # pragma: no cover - depends on optional local tooling
raise RuntimeError("PNG overlay requires Pillow") from exc
scale = 6
image = Image.new("RGBA", (width * scale, height * scale), (247, 250, 252, 255))
draw = ImageDraw.Draw(image)
for y in range(height):
for x in range(width):
if (x, y) not in walkable:
draw.rectangle(
[x * scale, y * scale, (x + 1) * scale - 1, (y + 1) * scale - 1],
fill=(17, 24, 39, 180),
)
colors = [(239, 68, 68, 255), (37, 99, 235, 255), (22, 163, 74, 255), (217, 119, 6, 255)]
for index, (_, _, path) in enumerate(paths):
color = colors[index % len(colors)]
points = [(x * scale + scale // 2, y * scale + scale // 2) for x, y in path]
if len(points) >= 2:
draw.line(points, fill=color, width=max(2, scale // 2))
for x, y in (path[0], path[-1]):
draw.ellipse(
[x * scale, y * scale, (x + 1) * scale - 1, (y + 1) * scale - 1],
fill=color,
)
target.parent.mkdir(parents=True, exist_ok=True)
image.save(target)
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("map", help="Map package directory, manifest path, or map id under custom/maps.")
parser.add_argument("--route", action="append", type=parse_route, help="Route to audit, as from:to.")
parser.add_argument("--overlay", type=Path, help="Optional PNG path for a compact route overlay.")
args = parser.parse_args()
target = Path(args.map).expanduser()
if not target.is_absolute():
maps_candidate = repo_root() / "custom" / "maps" / str(args.map)
target = maps_candidate if maps_candidate.exists() else (repo_root() / target)
manifest_path = manifest_path_for(target.resolve())
manifest, tiled_map, _ = load_map(manifest_path)
map_id = str(manifest.get("map_id") or manifest_path.parent.name)
routes = args.route or DEFAULT_ROUTES.get(map_id, [])
if not routes:
raise SystemExit(f"No default routes for map_id={map_id}; pass --route from:to")
walkable, width, height = walkable_tiles(tiled_map)
locations = location_tiles(manifest, walkable)
resolved_paths: list[tuple[str, str, list[Tile]]] = []
errors: list[str] = []
for start_id, goal_id in routes:
if start_id not in locations or goal_id not in locations:
errors.append(f"{start_id}->{goal_id}: missing location id")
continue
path = find_path(locations[start_id], locations[goal_id], walkable, width, height)
if path is None:
errors.append(f"{start_id}->{goal_id}: unreachable")
continue
resolved_paths.append((start_id, goal_id, path))
preview = path[:4] + ([(-1, -1)] if len(path) > 10 else []) + path[-4:]
preview_text = " ".join("..." if item == (-1, -1) else f"({item[0]},{item[1]})" for item in preview)
print(f"OK {start_id}->{goal_id} length={len(path)} preview={preview_text}")
if args.overlay and resolved_paths:
write_overlay(args.overlay.expanduser().resolve(), width, height, walkable, resolved_paths)
print(f"overlay={args.overlay.expanduser().resolve()}")
if errors:
print("\n".join(f"ERROR {error}" for error in errors))
return 1
return 0
if __name__ == "__main__":
raise SystemExit(main())