136 lines
4.1 KiB
Python
Executable File
136 lines
4.1 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Validates transition nodes in KQ4 room scenes."""
|
|
|
|
import re
|
|
import sys
|
|
from pathlib import Path
|
|
from dataclasses import dataclass
|
|
from typing import Optional
|
|
|
|
|
|
@dataclass
|
|
class Transition:
|
|
exit_node_name: str
|
|
target_uid: str
|
|
appear_at_node: str
|
|
label: str
|
|
source_room: str
|
|
|
|
|
|
@dataclass
|
|
class SceneInfo:
|
|
uid: str
|
|
path: Path
|
|
node_names: set[str]
|
|
|
|
|
|
def find_uid_files(root: Path) -> dict[str, Path]:
|
|
"""Build a mapping of UID -> file path from all .uid files."""
|
|
uid_map = {}
|
|
for uid_file in root.rglob("*.uid"):
|
|
uid_content = uid_file.read_text().strip()
|
|
if uid_content.startswith("uid://"):
|
|
resource_path = uid_file.with_suffix("")
|
|
if resource_path.exists():
|
|
uid_map[uid_content] = resource_path
|
|
else:
|
|
uid_map[uid_content] = uid_file.with_suffix("")
|
|
return uid_map
|
|
|
|
|
|
def parse_scene_file(tscn_path: Path) -> tuple[Optional[str], set[str], list[Transition]]:
|
|
"""Parse a .tscn file to extract UID, node names, and transitions."""
|
|
content = tscn_path.read_text()
|
|
|
|
scene_uid = None
|
|
uid_match = re.search(r'\[gd_scene[^\]]*uid="([^"]+)"', content)
|
|
if uid_match:
|
|
scene_uid = uid_match.group(1)
|
|
|
|
node_names = set(re.findall(r'^\[node name="([^"]+)"', content, re.MULTILINE))
|
|
|
|
transitions = []
|
|
room_name = tscn_path.stem
|
|
|
|
transition_pattern = re.compile(
|
|
r'\[node name="([^"]+)"[^\]]*instance=ExtResource\([^\)]+\)\]\s*\n'
|
|
r'((?:[^\[]+\n)*)',
|
|
re.MULTILINE
|
|
)
|
|
|
|
for match in transition_pattern.finditer(content):
|
|
node_name = match.group(1)
|
|
body = match.group(2)
|
|
|
|
target_match = re.search(r'^target = "([^"]+)"', body, re.MULTILINE)
|
|
appear_match = re.search(r'^appear_at_node = "([^"]+)"', body, re.MULTILINE)
|
|
label_match = re.search(r'^label = "([^"]+)"', body, re.MULTILINE)
|
|
|
|
if target_match and appear_match:
|
|
transitions.append(Transition(
|
|
exit_node_name=node_name,
|
|
target_uid=target_match.group(1),
|
|
appear_at_node=appear_match.group(1),
|
|
label=label_match.group(1) if label_match else node_name,
|
|
source_room=room_name
|
|
))
|
|
|
|
return scene_uid, node_names, transitions
|
|
|
|
|
|
def main():
|
|
root = Path(__file__).parent.parent
|
|
scenes_dir = root / "scenes"
|
|
|
|
uid_map = find_uid_files(root)
|
|
|
|
scene_files = list(scenes_dir.glob("kq4_*/kq4_*.tscn"))
|
|
scene_files = [f for f in scene_files if "placeholder_template" not in str(f)]
|
|
|
|
scenes_by_uid: dict[str, SceneInfo] = {}
|
|
all_transitions: list[Transition] = []
|
|
|
|
for scene_file in scene_files:
|
|
scene_uid, node_names, transitions = parse_scene_file(scene_file)
|
|
if scene_uid:
|
|
scenes_by_uid[scene_uid] = SceneInfo(
|
|
uid=scene_uid,
|
|
path=scene_file,
|
|
node_names=node_names
|
|
)
|
|
all_transitions.extend(transitions)
|
|
|
|
errors = 0
|
|
|
|
for t in all_transitions:
|
|
source_room = t.source_room
|
|
target_scene = scenes_by_uid.get(t.target_uid)
|
|
|
|
if target_scene is None:
|
|
print(f"ERROR: {source_room} exit '{t.exit_node_name}' -> target UID '{t.target_uid}' NOT FOUND")
|
|
errors += 1
|
|
continue
|
|
|
|
target_room_name = target_scene.path.stem
|
|
|
|
if t.appear_at_node not in target_scene.node_names:
|
|
print(f"ERROR: {source_room} exit '{t.exit_node_name}' -> {target_room_name} node '{t.appear_at_node}' NOT FOUND")
|
|
errors += 1
|
|
continue
|
|
|
|
print(f"OK: {source_room} exit '{t.exit_node_name}' -> {target_room_name} (node: {t.appear_at_node})")
|
|
|
|
print(f"\n{'='*60}")
|
|
print(f"Checked {len(all_transitions)} transitions across {len(scene_files)} rooms")
|
|
|
|
if errors:
|
|
print(f"FOUND {errors} ERROR(S)")
|
|
sys.exit(1)
|
|
else:
|
|
print("All transitions valid!")
|
|
sys.exit(0)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|