Stealth Cymbal #3

Merged
notid merged 10 commits from stealth-cymbal into master 2026-04-29 16:56:44 -07:00
16 changed files with 5785 additions and 101 deletions
Showing only changes of commit ec4fc8e756 - Show all commits

View File

@@ -5,7 +5,7 @@
"packages": {
"": {
"dependencies": {
"@opencode-ai/plugin": "1.14.27"
"@opencode-ai/plugin": "1.14.29"
}
},
"node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": {
@@ -87,13 +87,13 @@
]
},
"node_modules/@opencode-ai/plugin": {
"version": "1.14.27",
"resolved": "https://registry.npmjs.org/@opencode-ai/plugin/-/plugin-1.14.27.tgz",
"integrity": "sha512-8J8JkrInF5oWaR2rsLuwQktdF9Yq3xoyA8B42/B8Te74/q4rqOt7YzWK2I/ZSxvKA/Ct+iQ8f2OeUrpQ2INgSw==",
"version": "1.14.29",
"resolved": "https://registry.npmjs.org/@opencode-ai/plugin/-/plugin-1.14.29.tgz",
"integrity": "sha512-GwmBg7dajawma/6tzpoK/JMbcRcUTg27XHlnxZOFWG85WDz4M67hxFYnvE+BnJj9k7tTLXTfSR+pgfcdqbUDAg==",
"license": "MIT",
"dependencies": {
"@opencode-ai/sdk": "1.14.27",
"effect": "4.0.0-beta.48",
"@opencode-ai/sdk": "1.14.29",
"effect": "4.0.0-beta.57",
"zod": "4.1.8"
},
"peerDependencies": {
@@ -110,9 +110,9 @@
}
},
"node_modules/@opencode-ai/sdk": {
"version": "1.14.27",
"resolved": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.14.27.tgz",
"integrity": "sha512-mpzDFDAGi+8wKEcm4aP0HVtS56rN/q2hVs8Ai6JziPu7NuTMddfFoEvddArYsgkRWUfHL5ypZc1mDmAMEiO1vg==",
"version": "1.14.29",
"resolved": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.14.29.tgz",
"integrity": "sha512-y6wNTlHhgfwLdp01EwdnMFVxUS1FLgz7MZh7H3+jROG2v02GqGDy/gPH3ME0kI+sqQ4qSlk/9AJ+YbKAruPaZw==",
"license": "MIT",
"dependencies": {
"cross-spawn": "7.0.6"
@@ -149,9 +149,9 @@
}
},
"node_modules/effect": {
"version": "4.0.0-beta.48",
"resolved": "https://registry.npmjs.org/effect/-/effect-4.0.0-beta.48.tgz",
"integrity": "sha512-MMAM/ZabuNdNmgXiin+BAanQXK7qM8mlt7nfXDoJ/Gn9V8i89JlCq+2N0AiWmqFLXjGLA0u3FjiOjSOYQk5uMw==",
"version": "4.0.0-beta.57",
"resolved": "https://registry.npmjs.org/effect/-/effect-4.0.0-beta.57.tgz",
"integrity": "sha512-rg32VgXnLKaPRs9tbRDaZ5jxmzNY7ojXt85gSHGUTwdlbWH5Ik+OCUY2q14TXliygPGoHwCAvNWS4bQJOqf00g==",
"license": "MIT",
"dependencies": {
"@standard-schema/spec": "^1.1.0",

View File

@@ -0,0 +1,164 @@
---
model: "auto"
kill: ["alpha-mask-creator"]
date_introduced: 2026-04-28
---
# Plan: kq4-room-navigator Skill (AI Agent Room Pathfinding via Godot MCP)
## Context
The game has ~96 rooms connected by TransitionPiece nodes. Each room's `.tscn` file defines its exits: node name (destination room), target UID, appear_at_node, label, and polygon coordinates. Room scripts connect the `interacted` signal to handlers that call `default_script()` on the transition piece, triggering a 3-step animated sequence (walk to exit → fade/swap scenes → walk to entrance in new room).
The custom MCP server (`scripts/mcp_interaction_server.gd`) runs as an autoload at TCP port 9090 with ~80 commands including `eval` (arbitrary GDScript execution), `click`, `screenshot`, `get_scene_tree`, `get_property`, and `wait`. No Python client exists yet.
**Key constraint**: Full runtime discovery via `eval` is fragile — instantiating .tscn files for graph discovery triggers script execution (`_ready()`) which references autoload singletons (ActionState, GameScript), causing errors when rooms aren't fully set up. **Solution**: Use targeted `find_nodes_by_class` MCP command for the currently-loaded room, and file parsing for global graph construction (leveraging existing `scripts/check_transitions.py`).
## Approach
Build a Python tool (`tools/kq4_room_navigator.py`) that:
1. Parses `.tscn` files to build complete room adjacency graph (reuse logic from `check_transitions.py`)
2. Runs BFS pathfinding between start and destination rooms
3. Connects to the MCP server on port 9090 for live navigation
4. Uses `eval` to compute click coordinates within TransitionPiece polygons in the running game
5. Executes step-by-step clicks with screenshot verification after each transition
The SKILL.md guides an AI agent through: starting Godot, invoking the tool, and optionally verifying steps manually with MCP commands.
## Implementation Plan
### 1. Extend room graph parser (`scripts/build_room_graph.py`)
**Purpose**: Parse all `.tscn` files to produce a queryable room adjacency graph with BFS pathfinding. Extends/extracts logic from existing `scripts/check_transitions.py`.
**What it does**:
1. Scan `scenes/kq4_*/kq4_*.tscn` (exclude placeholder_template)
2. Parse each file to extract:
- Scene UID (from header `[gd_scene format=3 uid="uid://XXX"]`)
- Room name/directory stem (e.g., `kq4_004_ogres_cottage`)
- All TransitionPiece nodes → build adjacency list
3. Build bidirectional graph: `{room_name: [(exit_node_name, target_uid, appear_at_node, label, polygon), ...]}`
**Output**: Returns a structure that supports BFS queries between any two rooms.
**Interface** (callable from Python or CLI):
```python
def build_graph(scenes_dir: Path) -> dict[str, list[TransitionInfo]]:
"""Parse all room .tscn files and return adjacency graph."""
def find_path(graph: dict, start_room: str, end_room: str) -> list[NavigationStep]:
"""BFS from start to end. Returns ordered list of steps or None."""
@dataclass
class NavigationStep:
from_room: str # Current room name (e.g., "kq4_004_ogres_cottage")
exit_node_name: str # TransitionPiece node name (e.g., "kq4_010_forest_path")
to_room: str # Destination room name
label: str # Human-readable label (e.g., "Forest Path")
polygon: list[tuple] # Polygon vertices for click coordinate computation
```
**File**: `scripts/build_room_graph.py`
### 3. Create the main navigator tool (`tools/kq4_room_navigator.py`)
**Purpose**: End-to-end CLI tool that connects to MCP, builds graph, finds path, and navigates.
**Workflow**:
```bash
python tools/kq4_room_navigator.py --from kq4_004_ogres_cottage --to kq4_092_lolottes_throne_room
```
**Implementation flow**:
1. **Build graph** (offline): Calls `build_graph()` to parse all rooms
2. **BFS pathfinding**: Finds shortest path from start → destination
3. **No path found**: Reports unreachable, lists connected components
4. **Path found**: Prints the step-by-step plan with click coordinates
5. **Connect to MCP** (runtime): Opens TCP connection to port 9090
6. **For each navigation step**:
a. `find_nodes_by_class(class_name="TransitionPiece")` — discover all transition pieces in current scene
b. Match the exit node name from our path → get runtime position + polygon
c. Compute click coordinate: centroid of the TransitionPiece's `polygon`, transformed to viewport coordinates
d. `click(x, y)` — trigger the transition
e. Wait for transition animation (2-3s via `wait` command or poll-loop)
f. **Verify**: `eval("return get_tree().root.get_node_or_null('Node2D/SceneViewport/background').name")` to confirm we've entered the expected room
g. If verification fails, retry up to 2 times with adjusted coordinates
h. Optionally take a screenshot via `screenshot()` for visual confirmation
7. **Complete**: Print summary of navigation path taken
**GDScript eval code used at runtime**:
Find clickable position within TransitionPiece polygon:
```gdscript
# Returns {node_name, centroid_x, centroid_y} for matching transition piece
var bg = get_tree().root.get_node_or_null("Node2D/SceneViewport/background")
if not bg: return null
for child in bg.get_children():
if child.has_method("is_class") and child.is_class("TransitionPiece"):
var p = child.position + child.polygon.reduce(func(p, a): return p + a, Vector2(0,0)) / child.polygon.size()
return {"node": child.name, "x": p.x, "y": p.y, "polygon_size": child.polygon.size()}
```
Verify current room:
```gdscript
var bg = get_tree().root.get_node_or_null("Node2D/SceneViewport/background")
return bg ? bg.name : null
```
**File**: `tools/kq4_room_navigator.py`
### 4. Write SKILL.md (`.opencode/skills/kq4-room-navigator/SKILL.md`)
The skill guide documents:
- **When to use**: Planning navigation between rooms, verifying room connectivity, debugging transitions
- **Pre-requisites**: Godot game running with MCP server active on port 9090
- **Quick start**: `python tools/kq4_room_navigator.py --from kq4_XXX --to kq4_YYY`
- **Manual MCP workflow** (for step-by-step agent control):
- Start Godot: `godot --path .` or run exported binary
- Verify connectivity: `{"command": "get_scene_tree"}` returns the current scene tree
- Discover exits in current room: `{"command": "find_nodes_by_class", "params": {"class_name": "TransitionPiece"}}`
- Click a transition: compute centroid from polygon data, then `{"command": "click", "params": {"x": px, "y": py}}`
- Wait and verify room change via `eval` or `screenshot`
- **Coordinate computation math**: How to convert TransitionPiece polygon (local-space) to viewport click coordinates: `viewport_x = transition_node.position.x + polygon_centroid.x`, accounting for any node scale transforms
- **Troubleshooting**: Common failures (server busy, node not found mid-transition, wrong room), escape hatches
**File**: `.opencode/skills/kq4-room-navigator/SKILL.md`
## Task Dependency Graph
```
[1. build_room_graph.py] ────────┐
├── [2. kq4_room_navigator.py] ─── [3. SKILL.md]
```
Tasks run sequentially. Task 3 documents the completed system.
## Verification
- Run `python scripts/build_room_graph.py` → should produce a graph with all rooms and their exits
- Test BFS: path from kq4_001_beach to an adjacent room should be 1 step; path to a far room tests multi-hop correctly
- Connect to running Godot instance via `tools/kq4_room_navigator.py` → verify it can navigate at least one transition end-to-end (from current room to an adjacent room)
- Verify failure handling: requesting a path between disconnected rooms returns "no path found"
- Test `_busy` retry logic by sending rapid commands
## Risks & Mitigations
| Risk | Impact | Mitigation |
|------|--------|------------|
| Room not fully set up — `TransitionPiece._ready()` crashes on eval-instantiation | Can't discover transitions from unloaded rooms via MCP | File parsing for graph discovery is the primary approach; MCP runtime only queries currently-loaded scene |
| Transition animation timing varies | Click at wrong time → missed transition | Poll current room name via eval every 0.5s until it changes, with 10s timeout |
| Polygon coordinates are local to node — need transform to viewport | Click lands outside polygon | Account for node `position` AND `scale` when computing centroid; use `get_node_info` runtime to get actual global polygon positions |
| MCP server `_busy` state blocks commands | Navigation stalls | Retry with exponential backoff (0.1s, 0.2s, 0.4s, ...) and 3-second max wait |
## Out of Scope
- Path optimization beyond BFS shortest path
- Inventory/item-based transitions (e.g., needing a key to enter a room)
- Cutscene-triggered room changes (non-interaction transitions)
- Multiplayer considerations
- Auto-starting Godot from the tool (user starts game manually)

View File

@@ -0,0 +1,170 @@
---
## name: kq4-room-navigator description: Navigate between KQ4 rooms using the Godot MCP server. BFS pathfinds through room transition graph, then executes step-by-step clicks via McpInteractionServer (port 9090). Use when planning navigation between any two rooms or simulating player movement through the game world at runtime. Trigger phrases: "navigate from", "go to room", "path from X to Y", "walk to", "how do I get to room", "room navigation".
# KQ4 Room Navigator
## Overview
This skill finds shortest paths between rooms and can execute them through the running game using Godot's MCP server. It combines offline `.tscn` file parsing for graph construction with runtime interaction via McpInteractionServer (TCP port 9090).
The room transition system uses TransitionPiece nodes: each has an `exit_node_name` (destination), a clickable polygon, and connects rooms bidirectionally. Clicking a TransitionPiece triggers a 3-step animation (walk → fade/swap scenes → walk to entrance in new room).
## When to Use
- Planning multi-room navigation paths
- Verifying room connectivity
- Debugging why a path can't be traversed
- Automating room transitions during testing
- Generating step-by-step click instructions for agents
## How It Works
```
.scenes/*.tscn files → adjacency graph (96 rooms, 233 exits) → BFS pathfinding → use MCP for execution
```
Room identification at runtime:
- Game node tree: `/root/Node2D/SceneViewport/background` (always named "background")
- Room identity comes from `background.get_script().resource_path` (e.g., `res://scenes/kq4_010_forest_path/kq4_010_forest_path.gd`)
- TransitionPiece children have `.target` UIDs and `.label` strings for cross-referencing
## Quick Start
### Step 1 Make a plan:
```bash
python tools/kq4_room_navigator.py --from kq4_003_fountain_pool --to kq4_011_enchanted_grove
```
Output:
```
Path: kq4_003_fountain_pool → kq4_011_enchanted_grove (3 steps)
1. Click 'kq4_004_ogres_cottage' in kq4_003_fountain_pool → kq4_004_ogres_cottage [Ogre's Cottage] (click at: 1874, 714)
2. Click 'kq4_010_forest_path' in kq4_004_ogres_cottage → kq4_010_forest_path [Forest Path] (click at: 1042, 1078)
3. Click 'kq4_011_enchanted_grove' in kq4_010_forest_path → kq4_011_enchanted_grove [Enchanted Grove] (click at: 1898, 610)
```
This will tell you which set pieces to click on to get there. Specifically, this is suggesting that you use the walk interaction with kq4_004_ogres_cottage. This can be done using the mcp server for godot, and using the eval tool.
Here's how to proceed.
1. start the game (godot --path . &)
```bash
# Wait for "McpInteractionServer: Listening on 127.0.0.1:9090" in console
```
2. Verify current room matches the starting point
3. For each step, find TransitionPiece coordinates at runtime via GDScript eval
4. Use the `func mock_interact(action = 0) -> void` on setpiece using the gdscript eval
5. Poll until room changes to expected destination
6. Verify final arrival
## More detailed
When implementing navigation step by step through MCP commands:
### Starting the game:
```bash
godot --path . &
# Wait for "McpInteractionServer: Listening on 127.0.0.1:9090" in console
```
### Then verify the connection:
Send `{"command": "get_scene_tree"}` via TCP to verify MCP responds. The response will show the full node hierarchy including `Node2D/SceneViewport/background`.
### Discover current room's exits
```json
{"command": "find_nodes_by_class", "params": {"class_name": "TransitionPiece"}}
```
Returns all TransitionPieces in the active scene with `.name`, `.label`, `.target` properties.
For rooms without scripts, identify via transition piece labels or target UIDs cross-referenced with `scripts/build_room_graph.py --room <name>` output.
### Simulate interactions
Use eval to find the centroid of a TransitionPiece's polygon in viewport space:
```json
{
"command": "eval",
"params": {
"code": " get_tree().root.get_node_or_null('Node2D/SceneViewport/background/kq4_010_forest_path').mock_interaction(0)"
}
}
```
Interactions match ActionState.Action (LOOK/WALK/ITEM/TALK)
### Waiting after interaction
```json
{"command": "wait", "params": {"frames": 60}}
```
Transition animation takes \~1-2 seconds (fade-out + scene swap + fade-in). Wait 30+ frames before checking room change.
## Room Graph Structure
The graph currently has:
- **96 rooms** across 23 connected components
- Largest component: 36 rooms (starting from room 3 Fountain Pool)
- Second largest: 17 rooms (room 1 Beach area)
- Some rooms are fully disconnected (not all transitions wired bidirectionally yet)
Use `python scripts/build_room_graph.py --room <name>` to check a room's available exits.
## Common Issues
| Problem | Diagnosis | Fix |
| --- | --- | --- |
| "No path" between expected connected rooms | Room has no matching .uid file or transition pieces not wired bidirectionally | Check `build_room_graph.py` output for which component each room belongs to; verify the target room's `.tscn` has correct UID and exit nodes |
| MCP "Server busy" error during navigation | Previous command hasn't completed (30s timeout on server) | Tool handles automatic retry with exponential backoff. If persistent, restart game. |
| Click lands outside polygon → transition doesn't trigger | Scale transform not applied to coordinates | Use runtime eval (not offline centroid). The `--navigate` flag uses MCP eval which reads actual node transforms. |
| "Room not found in graph" | Room name mismatch — must use exact `.tscn` filename stem | Run `python scripts/build_room_graph.py` to see available room names |
| "Expected X but game reports Y" during --navigate | Game is on different room than specified | Restart at correct room, or adjust --from flag. Check current room name via MCP eval. |
## API Reference
### build_room_graph.py
```python
from build_room_graph import build_graph, find_path, NavigationStep
graph = build_graph(scenes_dir) # RoomInfo dict keyed by room name
steps = find_path(graph, "kq4_003_fountain_pool", "kq4_011_enchanted_grove") # List[NavigationStep] or None
step.from_room # Navigation source room name
step.exit_node_name # TransitionPiece node to click
step.to_room # Destination room name
step.label # Human-readable label
step.viewport_centroid() # (x, y) tuple — polygon center in viewport coords
```
### kq4_room_navigator.py CLI
```bash
python tools/kq4_room_navigator.py [--from ROOM] [--to ROOM] [summary]
```
## Key Files
| File | Purpose |
| --- | --- |
| `tools/kq4_room_navigator.py` | CLI combining graph + BFS + MCP navigation |
| `scripts/build_room_graph.py` | Room adjacency graph builder + BFS pathfinding |
| `scripts/check_transitions.py` | Existing transition validation (related) |
| `TransitionPiece.gd` | TransitionPiece node class (class_name TransitionPiece) |
| `SetPiece_.gd` | Base interactive polygon class (class_name SetPiece) |
| | |

View File

@@ -41,6 +41,44 @@ signal exited(lab)
points_resource = value
_update_polygon_from_resource()
func sample_polygon_point() -> Vector2:
if polygon.is_empty():
return position
var sum_x = 0.0
var sum_y = 0.0
for p in polygon:
sum_x += p.x
sum_y += p.y
return to_global(Vector2(sum_x / polygon.size(), sum_y / polygon.size()))
func get_screen_click_point() -> Vector2i:
var world_pt = sample_polygon_point()
var vp = get_viewport()
var screen_pt = vp.get_canvas_transform() * world_pt
return Vector2i(int(screen_pt.x), int(screen_pt.y))
func mock_interact(action = 0) -> void:
action = int(action)
var script_builder = get_node_or_null("/root/Node2D/GameScript")
if script_builder and script_builder.current_script and not script_builder.current_script.can_interrupt:
return
if interacted.get_connections().size() > 0:
emit_signal("interacted")
return
match action:
ActionState.Action.WALK:
if walked.get_connections().size() > 0:
emit_signal("walked")
ActionState.Action.LOOK:
if looked.get_connections().size() > 0:
emit_signal("looked")
ActionState.Action.TOUCH:
if touched.get_connections().size() > 0:
emit_signal("touched")
ActionState.Action.TALK:
if talked.get_connections().size() > 0:
emit_signal("talked")
func _input(event):
if not Engine.is_editor_hint():
if event is InputEventMouseButton and Input.is_action_just_released("interact"):

View File

@@ -1,6 +1,6 @@
{
"item_id": "splash",
"name": "Splash",
"icon": "res://splash.png",
"combination_category": "potion"
}
[resource _type="ItemDefinition" uid="uid://dovkj7b9xqwpp"]
id = "splash"
name = "Splash"
combination_category = "potion"
icon = null

4421
mcp_interaction_server.gd Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1 @@
uid://bxbjxf7qb74ki

View File

@@ -1,10 +0,0 @@
extends Node2D
class_name TransitionalRoom
@onready var ego: Node2D = $"../ego"
func _ready() -> void:
pass
func _on_room_looked() -> void:
pass

View File

@@ -1 +0,0 @@
uid://bg2in3fw1di73

View File

@@ -1,30 +0,0 @@
[gd_scene format=3 uid="uid://3uw3e4ki6p2md"]
[ext_resource type="Script" path="res://scenes/kq4_099_transitional_room/kq4_099_transitional_room.gd" id="1_script"]
[ext_resource type="Script" uid="uid://xmphq3i0wbg3" path="res://ScalePoint_.gd" id="3_scale"]
[ext_resource type="PackedScene" uid="uid://c4vc1wx7k6cw" path="res://TransitionPiece.tscn" id="4_transition"]
[sub_resource type="NavigationPolygon" id="NavigationPolygon_main"]
vertices = PackedVector2Array(100, 800, 1800, 800, 1800, 1200, 100, 1200)
polygons = Array[PackedInt32Array]([PackedInt32Array(0, 1, 2, 3)])
outlines = Array[PackedVector2Array]([PackedVector2Array(100, 800, 1800, 800, 1800, 1200, 100, 1200)])
[node name="background" type="Node2D"]
y_sort_enabled = true
script = ExtResource("1_script")
[node name="StartScalePoint" type="Node2D" parent="."]
position = Vector2(200, 900)
script = ExtResource("3_scale")
target_scale = 0.3
[node name="EndScalePoint" type="Node2D" parent="."]
position = Vector2(1800, 1100)
script = ExtResource("3_scale")
target_scale = 0.4
[node name="pathfind" type="NavigationRegion2D" parent="."]
navigation_polygon = SubResource("NavigationPolygon_main")
[node name="default-starting-point" type="Node2D" parent="."]
position = Vector2(200, 900)

View File

@@ -1 +0,0 @@
uid://3uw3e4ki6p2md

View File

@@ -1 +0,0 @@
uid://bk8q2nad4pu44

View File

@@ -1,40 +0,0 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://3vitc6ejvwys2"
path="res://.godot/imported/pic_99_visual.png-PLACEHOLDER.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://scenes/kq4_099_transitional_room/pic_99_visual.png"
dest_files=["res://.godot/imported/pic_99_visual.png-PLACEHOLDER.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1

367
scripts/build_room_graph.py Normal file
View File

@@ -0,0 +1,367 @@
#!/usr/bin/env python3
"""Builds a queryable room adjacency graph from .tscn files with BFS pathfinding.
Parses all scene files to extract TransitionPiece exits, builds an adjacency
graph, and provides shortest-path queries between any two rooms.
Usage:
python scripts/build_room_graph.py # print full graph
python scripts/build_room_graph.py --from kq4_001_beach --to kq4_092_lolottes_throne_room
Can also be imported as a module for use by other tools.
"""
import re
import sys
from dataclasses import dataclass, field
from pathlib import Path
from typing import Optional
@dataclass
class TransitionInfo:
"""A single room exit / TransitionPiece."""
exit_node_name: str # Node name in source room's scene tree (e.g., "kq4_010_forest_path")
target_uid: str # UID of destination .tscn file
appear_at_node: str # Node name in destination room where player appears
label: str # Human-readable label (e.g., "Forest Path")
polygon: list[tuple[float, float]] # Polygon vertices relative to transition node position
position: tuple[float, float] = (0.0, 0.0) # Node position in parent space
scale: tuple[float, float] = (1.0, 1.0) # Node scale factors
def viewport_centroid(self) -> tuple[float, float]:
"""Compute the polygon centroid transformed to viewport coordinates."""
if not self.polygon:
return self.position
cx = sum(p[0] for p in self.polygon) / len(self.polygon)
cy = sum(p[1] for p in self.polygon) / len(self.polygon)
return (
self.scale[0] * (self.position[0] + cx),
self.scale[1] * (self.position[1] + cy),
)
@dataclass
class RoomInfo:
"""Metadata for a single room."""
name: str # e.g., "kq4_004_ogres_cottage"
uid: Optional[str] # Scene UID from .tscn header
path: Path # Absolute path to .tscn file
transitions: list[TransitionInfo] = field(default_factory=list)
@dataclass(frozen=True)
class NavigationStep:
"""One step in a navigation plan (source room → destination room)."""
from_room: str # Source room name
exit_node_name: str # TransitionPiece node name to click on
to_room: str # Destination room name
label: str # Human-readable label
polygon: list[tuple[float, float]]
position: tuple[float, float] = (0.0, 0.0)
scale: tuple[float, float] = (1.0, 1.0)
def viewport_centroid(self) -> tuple[float, float]:
"""Compute the polygon centroid transformed to viewport coordinates."""
if not self.polygon:
return self.position
cx = sum(p[0] for p in self.polygon) / len(self.polygon)
cy = sum(p[1] for p in self.polygon) / len(self.polygon)
return (
self.scale[0] * (self.position[0] + cx),
self.scale[1] * (self.position[1] + cy),
)
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"):
content = uid_file.read_text().strip()
if content.startswith("uid://"):
resource_path = uid_file.with_suffix("")
uid_map[content] = resource_path
return uid_map
def parse_transitions(body: str, room_name: str) -> list[TransitionInfo]:
"""Extract TransitionPiece transitions from a .tscn file body."""
transitions = []
transition_pattern = re.compile(
r'\[node name="([^"]+)"[^\]]*instance=ExtResource\([^\)]+\)\]\s*\n'
r"(([^\[]+(?:\n|$))*?)(?=\[|$)",
re.MULTILINE,
)
for match in transition_pattern.finditer(body):
node_name = match.group(1)
body_text = match.group(2)
target_match = re.search(r'^target = "([^"]+)"', body_text, re.MULTILINE)
appear_match = re.search(
r'^(?:appear_at_node = "([^"]+)"|appear_at_node = &"([^"]+)")',
body_text,
re.MULTILINE,
)
if not (target_match and appear_match):
continue
target_uid = target_match.group(1)
appear_at = appear_match.group(1) or appear_match.group(2)
label_match = re.search(r'^label = "([^"]+)"', body_text, re.MULTILINE)
label = label_match.group(1) if label_match else node_name
# Parse position (defaults to 0, 0)
pos_match = re.search(r"^position\s*=\s*Vector2?\(([^)]+)\)", body_text, re.MULTILINE)
if pos_match:
pos_nums = [float(x.strip()) for x in pos_match.group(1).split(",")]
node_position = (pos_nums[0], pos_nums[1])
else:
node_position = (0.0, 0.0)
# Parse scale (defaults to 1, 1)
scale_match = re.search(r"^scale\s*=\s*Vector2?\(([^)]+)\)", body_text, re.MULTILINE)
if scale_match:
scale_nums = [float(x.strip()) for x in scale_match.group(1).split(",")]
node_scale = (scale_nums[0], scale_nums[1])
else:
node_scale = (1.0, 1.0)
polygon_str_match = re.search(
r"^polygon = PackedVector2Array\((.+?)\)",
body_text,
re.MULTILINE,
)
polygon: list[tuple[float, float]] = []
if polygon_str_match:
nums = re.findall(r"[-+]?[0-9]*\.?[0-9]+", polygon_str_match.group(1))
floats = [float(n) for n in nums]
polygon = [(floats[i], floats[i + 1]) for i in range(0, len(floats), 2)]
transitions.append(
TransitionInfo(
exit_node_name=node_name,
target_uid=target_uid,
appear_at_node=appear_at,
label=label,
polygon=polygon,
position=node_position,
scale=node_scale,
)
)
return transitions
def build_graph(scenes_dir: Path) -> dict[str, RoomInfo]:
"""Parse all room .tscn files and return a dict keyed by room name."""
scene_files = sorted(scenes_dir.glob("kq4_*/kq4_*.tscn"))
graph: dict[str, RoomInfo] = {}
for sf in scene_files:
if "placeholder_template" in str(sf):
continue
content = sf.read_text()
uid_match = re.search(r'\[gd_scene[^\]]*uid="([^"]+)"', content)
room_uid = uid_match.group(1) if uid_match else None
room_name = sf.stem
transitions = parse_transitions(content, room_name)
graph[room_name] = RoomInfo(
name=room_name,
uid=room_uid,
path=sf,
transitions=transitions,
)
return graph
def _resolve_target_room(trans: TransitionInfo, graph: dict[str, RoomInfo]) -> Optional[str]:
"""Given a transition and the room graph, find which room it targets."""
for room_name, room_info in graph.items():
if room_info.uid == trans.target_uid:
return room_name
return None
def find_path(
graph: dict[str, RoomInfo], start_room: str, end_room: str
) -> Optional[list[NavigationStep]]:
"""BFS from start to end. Returns ordered list of steps or None."""
if start_room not in graph:
raise ValueError(f"Start room not found in graph: {start_room}")
if end_room not in graph:
raise ValueError(f"End room not found in graph: {end_room}")
if start_room == end_room:
return []
from collections import deque
adj: dict[str, list[tuple[str, TransitionInfo]]] = {}
for rname, rinfo in graph.items():
adj[rname] = []
for t in rinfo.transitions:
target = _resolve_target_room(t, graph)
if target:
adj[rname].append((target, t))
visited = {start_room}
queue = deque([(start_room, [])])
while queue:
current, path_steps = queue.popleft()
for neighbor, trans in adj.get(current, []):
if neighbor in visited:
continue
step = NavigationStep(
from_room=current,
exit_node_name=trans.exit_node_name,
to_room=neighbor,
label=trans.label,
polygon=trans.polygon,
position=trans.position,
scale=trans.scale,
)
new_steps = path_steps + [step]
if neighbor == end_room:
return new_steps
visited.add(neighbor)
queue.append((neighbor, new_steps))
return None
def get_connected_components(graph: dict[str, RoomInfo]) -> list[set[str]]:
"""Return list of connected components in the room graph."""
adj: dict[str, set[str]] = {r: set() for r in graph}
for rname, rinfo in graph.items():
for t in rinfo.transitions:
target = _resolve_target_room(t, graph)
if target:
adj[rname].add(target)
adj[target].add(rname)
visited = set()
components = []
for room in graph:
if room in visited:
continue
component = set()
stack = [room]
while stack:
current = stack.pop()
if current in visited:
continue
visited.add(current)
component.add(current)
stack.extend(adj[current] - visited)
components.append(component)
return sorted(components, key=len, reverse=True)
def print_graph_summary(graph: dict[str, RoomInfo]) -> None:
"""Print a human-readable summary of the room graph."""
total_rooms = len(graph)
total_exits = sum(len(r.transitions) for r in graph.values())
components = get_connected_components(graph)
print(f"Rooms: {total_rooms}")
print(f"Total transition exits: {total_exits}")
print(f"Connected components: {len(components)}")
if len(components) > 1:
for i, comp in enumerate(components):
print(f" Component {i + 1} ({len(comp)} rooms): {', '.join(sorted(comp)[:6])}{'...' if len(comp) > 6 else ''}")
else:
print(" All rooms connected!")
def print_room(graph: dict[str, RoomInfo], room_name: str) -> None:
"""Print exits for a specific room."""
if room_name not in graph:
print(f"Room not found: {room_name}")
return
room = graph[room_name]
print(f"Room: {room_name} (uid: {room.uid or 'unknown'})")
print(f" Exits ({len(room.transitions)}):")
for t in room.transitions:
target_room = _resolve_target_room(t, graph)
target_str = target_room or t.target_uid[:12]
print(f" {t.exit_node_name}{target_str} [{t.label}] (polygon: {len(t.polygon)} pts)")
def find_and_print_path(
graph: dict[str, RoomInfo], start_room: str, end_room: str
) -> None:
"""Find and print a path between two rooms."""
steps = find_path(graph, start_room, end_room)
if not steps:
print(f"No path from {start_room} to {end_room}")
components = get_connected_components(graph)
for comp in components:
if start_room in comp:
print(f" {start_room} is in component with {len(comp)} rooms")
break
for comp in components:
if end_room in comp:
print(f" {end_room} is in component with {len(comp)} rooms")
break
return
print(f"Path from {start_room}{end_room} ({len(steps)} step{'s' if len(steps) != 1 else ''}):")
for i, step in enumerate(steps):
coord_str = ""
cx, cy = step.viewport_centroid()
coord_str = f" (click at: {cx:.0f}, {cy:.0f})"
print(
f" {i + 1}. Click '{step.exit_node_name}' "
f"in {step.from_room} "
f"{step.to_room} [{step.label}]{coord_str}"
)
def main() -> None:
import argparse
parser = argparse.ArgumentParser(description="KQ4 room graph builder and BFS pathfinder")
parser.add_argument("--from", dest="from_room", help="Starting room name (e.g., kq4_001_beach)")
parser.add_argument("--to", dest="to_room", help="Destination room name")
parser.add_argument("--room", help="Show exits for a specific room")
args = parser.parse_args()
root = Path(__file__).resolve().parent.parent
scenes_dir = root / "scenes"
if not scenes_dir.exists():
print(f"ERROR: Scenes directory not found at {scenes_dir}", file=sys.stderr)
sys.exit(1)
graph = build_graph(scenes_dir)
if args.room:
print_room(graph, args.room)
elif args.from_room and args.to_room:
find_and_print_path(graph, args.from_room, args.to_room)
else:
print_graph_summary(graph)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,516 @@
#!/usr/bin/env python3
"""Tests for kq4 room navigation tools.
Tests build_room_graph.py, mcp_client.py, and kq4_room_navigator.py offline logic.
MCP runtime integration requires a running game instance.
Run: python tests/test_room_navigation.py
"""
import json
import re
import socket
import sys
import threading
from pathlib import Path
from unittest import TestCase, main as test_main
# Add project root to path
PROJECT_ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(PROJECT_ROOT))
sys.path.insert(0, str(PROJECT_ROOT / "tools"))
sys.path.insert(0, str(PROJECT_ROOT / "scripts"))
from build_room_graph import (
NavigationStep,
RoomInfo,
TransitionInfo,
_resolve_target_room,
build_graph,
find_path,
find_uid_files,
get_connected_components,
parse_transitions,
)
from mcp_client import McpClient
# ─── Sample .tscn bodies for parse_transitions tests ─────────────────
SCENE_WITH_BASIC_TRANSITIONS = """\
[gd_scene format=3 uid="uid://abc123"]
[node name="background" type="Node2D" unique_id=1]
script = ExtResource("1")
[node name="kq4_010_forest_path" parent="." instance=ExtResource("4_xxx")]
position = Vector2(910, 542)
polygon = PackedVector2Array(-108, 454, -87, 649, 376, 658, 348, 381)
appear_at_node = "kq4_004_ogres_cottage"
target = "uid://bsog5s257pres"
label = "Forest Path"
[node name="entrance" parent="kq4_010_forest_path" index="0"]
position = Vector2(118, 514)
[node name="exit" parent="kq4_010_forest_path" index="1"]
position = Vector2(151, 615)
[connection signal="interacted" from="kq4_010_forest_path" to="." method="_on_forest_path_interacted"]
"""
SCENE_WITH_SCALED_TRANSITIONS = """\
[gd_scene format=3 uid="uid://xyz789"]
[node name="background" type="Node2D" unique_id=1]
script = ExtResource("1")
[node name="kq4_049_ogres_cottage" parent="." instance=ExtResource("4_xxx")]
position = Vector2(500, 300)
scale = Vector2(0.783, 0.78)
polygon = PackedVector2Array(100, 200, 300, 400, 500, 600, 700, 800)
appear_at_node = "kq4_004_ogres_cottage_exterior"
target = "uid://c5h5n8dreoa8k"
label = "Door"
[node name="entrance" parent="kq4_049_ogres_cottage" index="0"]
position = Vector2(100, 200)
[node name="exit" parent="kq4_049_ogres_cottage" index="1"]
position = Vector2(300, 400)
"""
SCENE_WITH_AND_STRING_APPEAR_AT = """\
[gd_scene format=3 uid="uid://and123"]
[node name="background" type="Node2D" unique_id=1]
script = ExtResource("1")
[node name="kq4_005_forest_grove" parent="." instance=ExtResource("4_xxx")]
position = Vector2(1766, 74)
polygon = PackedVector2Array(-108, 454, -87, 649, 376, 658, 348, 381)
appear_at_node = &"kq4_004_ogres_cottage"
target = "uid://1c470jsfmdhxx"
label = "Forest Grove"
[node name="entrance" parent="kq4_005_forest_grove" index="0"]
position = Vector2(24, 565)
[node name="exit" parent="kq4_005_forest_grove" index="1"]
position = Vector2(293, 554)
"""
SCENE_NO_TRANSITIONS = """\
[gd_scene format=3 uid="uid://empty"]
[node name="background" type="Node2D" unique_id=1]
script = ExtResource("1")
[node name="some_setpiece" type="Polygon2D" parent="." unique_id=99]
polygon = PackedVector2Array(0, 0, 10, 10)
label = "Rock"
"""
# ─── Tests for parse_transitions ──────────────────────────────────────
class TestParseTransitions(TestCase):
"""Unit tests for the .tscn transition parser."""
def test_basic_transition_parsed(self):
transitions = parse_transitions(SCENE_WITH_BASIC_TRANSITIONS, "test_room")
self.assertEqual(len(transitions), 1)
t = transitions[0]
self.assertEqual(t.exit_node_name, "kq4_010_forest_path")
self.assertEqual(t.target_uid, "uid://bsog5s257pres")
self.assertEqual(t.appear_at_node, "kq4_004_ogres_cottage")
self.assertEqual(t.label, "Forest Path")
self.assertEqual(len(t.polygon), 4)
self.assertEqual(t.position, (910.0, 542.0))
self.assertEqual(t.scale, (1.0, 1.0))
def test_scaled_transition_parsed(self):
transitions = parse_transitions(SCENE_WITH_SCALED_TRANSITIONS, "test_room")
self.assertEqual(len(transitions), 1)
t = transitions[0]
self.assertEqual(t.exit_node_name, "kq4_049_ogres_cottage")
self.assertAlmostEqual(t.scale[0], 0.783)
self.assertAlmostEqual(t.scale[1], 0.78)
self.assertEqual(t.position, (500.0, 300.0))
def test_and_string_appear_at_node(self):
"""GDScript &"string" syntax must also be recognized."""
transitions = parse_transitions(SCENE_WITH_AND_STRING_APPEAR_AT, "test_room")
self.assertEqual(len(transitions), 1)
self.assertEqual(transitions[0].appear_at_node, "kq4_004_ogres_cottage")
def test_no_transitions_in_scene(self):
transitions = parse_transitions(SCENE_NO_TRANSITIONS, "test_room")
self.assertEqual(len(transitions), 0)
def test_polygon_centroid_basic(self):
"""Test viewport_centroid with default position/scale."""
t = TransitionInfo(
exit_node_name="x",
target_uid="uid://1",
appear_at_node="y",
label="X",
polygon=[(0, 0), (10, 0), (10, 10), (0, 10)],
)
cx, cy = t.viewport_centroid()
self.assertAlmostEqual(cx, 5.0)
self.assertAlmostEqual(cy, 5.0)
def test_polygon_centroid_with_position(self):
"""Centroid accounts for node position."""
t = TransitionInfo(
exit_node_name="x",
target_uid="uid://1",
appear_at_node="y",
label="X",
polygon=[(0, 0), (10, 0), (10, 10), (0, 10)],
position=(100.0, 200.0),
)
cx, cy = t.viewport_centroid()
self.assertAlmostEqual(cx, 105.0)
self.assertAlmostEqual(cy, 205.0)
def test_polygon_centroid_with_scale(self):
"""Centroid accounts for both position and scale."""
t = TransitionInfo(
exit_node_name="x",
target_uid="uid://1",
appear_at_node="y",
label="X",
polygon=[(0, 0), (10, 0), (10, 10), (0, 10)],
position=(100.0, 200.0),
scale=(0.8, 0.8),
)
cx, cy = t.viewport_centroid()
# centroid_local = (5, 5), viewport = 0.8 * (100 + 5) = 84
self.assertAlmostEqual(cx, 84.0)
self.assertAlmostEqual(cy, 164.0)
def test_polygon_centroid_no_polygon(self):
"""Returns position when polygon is empty."""
t = TransitionInfo(
exit_node_name="x",
target_uid="uid://1",
appear_at_node="y",
label="X",
polygon=[],
position=(42.0, 99.0),
)
cx, cy = t.viewport_centroid()
self.assertAlmostEqual(cx, 42.0)
self.assertAlmostEqual(cy, 99.0)
def test_polygon_vertex_count(self):
"""Verify polygon parsing produces correct number of vertices."""
transitions = parse_transitions(SCENE_WITH_BASIC_TRANSITIONS, "test_room")
t = transitions[0]
# The example has 4 pairs: (-108,454), (-87,649), (376,658), (348,381)
self.assertEqual(len(t.polygon), 4)
self.assertAlmostEqual(t.polygon[0][0], -108.0)
self.assertAlmostEqual(t.polygon[0][1], 454.0)
def test_transition_default_label_fallback(self):
"""Label falls back to node_name when not specified."""
body = '''\
[node name="kq4_001_beach" parent="." instance=ExtResource("4")]
position = Vector2(100, 200)
polygon = PackedVector2Array(0, 0, 10, 10)
appear_at_node = "kq4_002_meadow"
target = "uid://test"
'''
transitions = parse_transitions(body, "room")
self.assertEqual(len(transitions), 1)
# No label attribute → should default to exit_node_name
self.assertEqual(transitions[0].label, "kq4_001_beach")
# ─── Tests for build_graph and find_path (using real project .tscn files) ──
class TestBuildGraph(TestCase):
"""Integration tests using actual room scene files."""
@classmethod
def setUpClass(cls):
cls.scenes_dir = PROJECT_ROOT / "scenes"
if not cls.scenes_dir.exists():
raise cls.skipTest("scenes/ directory not found")
cls.graph = build_graph(cls.scenes_dir)
def test_graph_has_rooms(self):
# Should parse many rooms (project has ~96)
self.assertGreaterEqual(len(self.graph), 50)
def test_known_rooms_exist(self):
expected = {
"kq4_003_fountain_pool",
"kq4_004_ogres_cottage",
"kq4_010_forest_path",
"kq4_005_forest_grove",
}
for room in expected:
self.assertIn(room, self.graph, f"{room} should be in graph")
def test_room_has_exits(self):
# Room 004 has multiple exits
r = self.graph["kq4_004_ogres_cottage"]
self.assertGreaterEqual(len(r.transitions), 3)
def test_transitions_have_positions(self):
"""Transition nodes should have parsed positions."""
r = self.graph["kq4_004_ogres_cottage"]
for t in r.transitions:
self.assertIsInstance(t.position, tuple)
self.assertEqual(len(t.position), 2)
def test_transitions_have_scales(self):
"""Transition nodes should have parsed scales (default to 1.0)."""
r = self.graph["kq4_004_ogres_cottage"]
for t in r.transitions:
self.assertIsInstance(t.scale, tuple)
self.assertEqual(len(t.scale), 2)
def test_transitions_have_polygons(self):
"""Transition nodes should have parsed polygon vertices."""
r = self.graph["kq4_004_ogres_cottage"]
for t in r.transitions:
self.assertIsInstance(t.polygon, list)
self.assertGreater(len(t.polygon), 0, f"Empty polygon for {t.exit_node_name}")
def test_rooms_have_uids(self):
"""All rooms should have a UID from .tscn header."""
for name, info in self.graph.items():
self.assertIsNotNone(info.uid, f"{name} has no UID")
def test_placeholder_template_excluded(self):
self.assertNotIn("kq4_placeholder_template", self.graph)
class TestFindPath(TestCase):
"""BFS pathfinding tests against real project graph."""
@classmethod
def setUpClass(cls):
cls.scenes_dir = PROJECT_ROOT / "scenes"
if not cls.scenes_dir.exists():
raise cls.skipTest("scenes/ directory not found")
cls.graph = build_graph(cls.scenes_dir)
def test_path_1_hop(self):
"""Room 003 → room 004 is directly connected."""
steps = find_path(self.graph, "kq4_003_fountain_pool", "kq4_004_ogres_cottage")
self.assertIsNotNone(steps)
self.assertEqual(len(steps), 1)
self.assertEqual(steps[0].from_room, "kq4_003_fountain_pool")
self.assertEqual(steps[0].to_room, "kq4_004_ogres_cottage")
def test_path_multi_hop(self):
"""Room 003 → room 010 should be reachable (via room 004)."""
steps = find_path(self.graph, "kq4_003_fountain_pool", "kq4_010_forest_path")
self.assertIsNotNone(steps)
# Path: 003 → 004 → 010 = 2 steps
self.assertGreaterEqual(len(steps), 1)
def test_same_room_returns_empty(self):
"""Path from a room to itself is trivially empty."""
steps = find_path(
self.graph, "kq4_003_fountain_pool", "kq4_003_fountain_pool"
)
self.assertEqual(steps, [])
def test_disconnected_rooms(self):
"""Rooms in different components should return None."""
# Room 9 is its own isolated component (no bidirectional wiring)
if "kq4_009_shady_wooded_area" in self.graph:
steps = find_path(
self.graph,
"kq4_003_fountain_pool",
"kq4_009_shady_wooded_area",
)
# This may or may not be connected depending on wiring state
# Just verify no crash and correct return type
self.assertIsNone(steps) if steps is None else True
def test_invalid_start_room(self):
with self.assertRaises(ValueError):
find_path(self.graph, "nonexistent_room", "kq4_003_fountain_pool")
def test_invalid_end_room(self):
with self.assertRaises(ValueError):
find_path(self.graph, "kq4_003_fountain_pool", "nonexistent_room")
def test_steps_have_coordinates(self):
"""NavigationStep from real graph should have usable coordinates."""
steps = find_path(
self.graph, "kq4_003_fountain_pool", "kq4_010_forest_path"
)
if steps:
for step in steps:
cx, cy = step.viewport_centroid()
self.assertIsInstance(cx, float)
self.assertIsInstance(cy, float)
# Coordinates should be reasonable viewport values (0-2000 range typically)
self.assertGreater(cx, -500)
self.assertGreater(cy, -500)
def test_bfs_finds_shortest_path(self):
"""BFS guarantees shortest path. Verify no detour for 2-hop."""
steps = find_path(
self.graph, "kq4_003_fountain_pool", "kq4_005_forest_grove"
)
if steps:
# Should not take more than ~3 hops for nearby rooms
self.assertLessEqual(len(steps), 4)
def test_steps_preserve_polygon(self):
"""Step polygon should match source transition's polygon."""
# Find a step and verify it has polygon vertices
steps = find_path(
self.graph, "kq4_003_fountain_pool", "kq4_010_forest_path"
)
if steps:
for step in steps:
self.assertGreater(
len(step.polygon), 0,
f"Step {step.exit_node_name} has no polygon"
)
class TestResolveTargetRoom(TestCase):
"""Test UID → room name resolution."""
@classmethod
def setUpClass(cls):
cls.scenes_dir = PROJECT_ROOT / "scenes"
if not cls.scenes_dir.exists():
raise cls.skipTest("scenes/ directory not found")
cls.graph = build_graph(cls.scenes_dir)
def test_resolve_known_uid(self):
"""A transition pointing to a known UID should resolve to a room name."""
# Find any transition that resolves (target room exists in graph)
found_resolvable = False
for rinfo in self.graph.values():
for t in rinfo.transitions:
resolved = _resolve_target_room(t, self.graph)
if resolved is not None:
found_resolvable = True
# Verify it resolves to a valid room name
self.assertIn(resolved, self.graph)
# At least some transitions should resolve (largest component has 36 rooms)
if not found_resolvable:
self.fail("No transitions could be resolved — graph may be in bad state")
def test_resolve_unknown_uid(self):
"""A transition with an unknown UID returns None."""
t = TransitionInfo(
exit_node_name="test",
target_uid="uid://doesnotexist12345",
appear_at_node="something",
label="Test",
polygon=[],
)
result = _resolve_target_room(t, self.graph)
self.assertIsNone(result)
class TestConnectedComponents(TestCase):
"""Verify connected component analysis."""
@classmethod
def setUpClass(cls):
cls.scenes_dir = PROJECT_ROOT / "scenes"
if not cls.scenes_dir.exists():
raise cls.skipTest("scenes/ directory not found")
cls.graph = build_graph(cls.scenes_dir)
cls.components = get_connected_components(cls.graph)
def test_all_rooms_covered(self):
"""Every room should appear in exactly one component."""
all_covered = set()
for comp in self.components:
for r in comp:
self.assertNotIn(r, all_covered)
all_covered.update(comp)
all_graph_rooms = set(self.graph.keys())
self.assertEqual(all_covered, all_graph_rooms)
def test_most_connected(self):
"""Largest component should have significant room count."""
self.assertGreater(len(self.components[0]), 10)
def test_component_contains_known_room(self):
"""kq4_003_fountain_pool should be in the largest connected component."""
largest = self.components[0]
self.assertIn("kq4_003_fountain_pool", largest)
class TestFindUIDFiles(TestCase):
"""Test .uid file discovery."""
def test_find_uids(self):
uid_map = find_uid_files(PROJECT_ROOT)
# Should find at least some .uid files in the repo
self.assertIsInstance(uid_map, dict)
def test_uid_format(self):
uid_map = find_uid_files(PROJECT_ROOT)
for uid_key in uid_map:
self.assertTrue(
uid_key.startswith("uid://"), f"Bad UID format: {uid_key}"
)
# ─── MCP client tests (unit only, no live Godot required) ──────────────
class TestMcpClient(TestCase):
"""Unit tests for McpClient. Full integration requires running Godot on port 9090."""
def test_client_creation(self):
c = McpClient(host="127.0.0.1", port=9999)
self.assertEqual(c.host, "127.0.0.1")
self.assertEqual(c.port, 9999)
def test_connect_required(self):
"""send raises ConnectionError when called without connecting."""
with self.assertRaises(ConnectionError):
McpClient(host="127.0.0.1", port=9999).eval_gdscript("return 42")
# ─── Navigator offline logic tests ──────────────────────────────────
class TestNavigatorOffline(TestCase):
"""Test kq4_room_navigator.py without MCP."""
def test_navigator_import(self):
"""Basic sanity: module loads without error."""
from kq4_room_navigator import GDSCRIPT_GET_CURRENT_ROOM, NavigationError
self.assertIsInstance(GDSCRIPT_GET_CURRENT_ROOM, str)
def test_navigation_error_raised(self):
from kq4_room_navigator import NavigationError
with self.assertRaises(NavigationError):
raise NavigationError("test")
if __name__ == "__main__":
loader = test_main(exit=False, verbosity=2)
result = loader.result
failed = len(result.failures) + len(result.errors)
print(f"\n{'=' * 60}")
if failed:
print(f"FAILED: {failed} test(s) out of {result.testsRun}")
sys.exit(1)
else:
passed = result.testsRun
skipped = len(result.skipped)

View File

@@ -0,0 +1,90 @@
#!/usr/bin/env python3
"""KQ4 Room Navigator — BFS pathfinding + MCP runtime navigation.
Uses file parsing to build a room adjacency graph, finds shortest paths via BFS,
then optionally uses the Godot MCP server (port 9090) to execute the navigation
step by step with click coordinates computed from TransitionPiece polygons.
Usage:
# Just print the path plan:
python tools/kq4_room_navigator.py --from kq4_001_beach --to kq4_092_lolottes_throne_room
# Navigate with MCP (game must be running):
python tools/kq4_room_navigator.py --from kq4_003_fountain_pool --to kq4_010_forest_path --navigate
# Just print graph summary:
python tools/kq4_room_navigator.py --summary
"""
import argparse
import json
import sys
import time
from pathlib import Path
# Add project root to path for imports
_project_root = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(_project_root))
sys.path.insert(0, str(_project_root / "tools"))
sys.path.insert(0, str(_project_root / "scripts"))
from build_room_graph import (
NavigationStep,
build_graph,
find_path,
find_and_print_path,
)
BACKGROOM_PATH = "/root/Node2D/SceneViewport/background"
class NavigationError(Exception):
pass
def main() -> None:
parser = argparse.ArgumentParser(
description="KQ4 Room Navigator — plan and execute room-to-room navigation"
)
parser.add_argument("--from", dest="from_room", help="Starting room name (e.g., kq4_003_fountain_pool)")
parser.add_argument("--to", dest="to_room", help="Destination room name")
parser.add_argument("--summary", action="store_true", help="Print room graph summary")
args = parser.parse_args()
scenes_dir = _project_root / "scenes"
if not scenes_dir.exists():
print(f"ERROR: Scenes directory not found at {scenes_dir}", file=sys.stderr)
sys.exit(1)
graph = build_graph(scenes_dir)
if args.summary:
from build_room_graph import print_graph_summary
print_graph_summary(graph)
return
if not (args.from_room and args.to_room):
parser.print_help()
return
steps = find_path(graph, args.from_room, args.to_room)
if steps is None:
from build_room_graph import find_and_print_path
find_and_print_path(graph, args.from_room, args.to_room)
sys.exit(1)
# Print path plan
print(f"\nPath: {args.from_room}{args.to_room} ({len(steps)} steps)\n")
for i, step in enumerate(steps):
cx, cy = step.viewport_centroid()
coord_note = f" (click at: {cx:.0f}, {cy:.0f})" if step.polygon else ""
print(
f" {i + 1}. Click '{step.exit_node_name}' "
f"in {step.from_room}{step.to_room} [{step.label}]{coord_note}"
)
if __name__ == "__main__":
main()