Merge pull request 'Stealth Cymbal' (#3) from stealth-cymbal into master

Reviewed-on: #3
This commit was merged in pull request #3.
This commit is contained in:
2026-04-29 16:56:44 -07:00
86 changed files with 1711 additions and 230 deletions

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,202 @@
---
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. Verify starting room: `eval("return get_node('/root/Node2D').get_current_room_name()")` returns the current room's node name (e.g., `"kq4_003_fountain_pool"`). This convenience function on MainGame simplifies verification compared to traversing the scene tree.
b. `find_nodes_by_class(class_name="TransitionPiece")` — discover all transition pieces in current scene
c. Match the exit node name from our path → get runtime position + polygon
d. Compute click coordinate: centroid of the TransitionPiece's `polygon`, transformed to viewport coordinates
e. `click(x, y)` — trigger the transition
f. Wait for transition animation (2-3s via `wait` command or poll-loop)
g. **Verify arrival**: `eval("return get_node('/root/Node2D').get_current_room_name()")` to confirm we've entered the expected room. Compare against the `to_room` from our BFS path. Returns empty string if no scene loaded.
h. 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`
- **Room verification helper** (`MainGame.get_current_room_name()`):
The root node (`/root/Node2D`, script: `MainGame.gd`) provides a convenience function for testing:
```gdscript
# Via MCP eval — returns node name like "kq4_003_fountain_pool", or "" if no scene loaded
return get_node("/root/Node2D").get_current_room_name()
```
Use this after each transition click to verify you arrived at the expected room. Compare against the `to_room` field from your BFS path.
- **Manual MCP workflow** (for step-by-step agent control):
- Start Godot: `godot --path .` or run exported binary
- Check current room: `{"command": "eval", "params": {"code": "return get_node('/root/Node2D').get_current_room_name()"}}` → returns `"kq4_003_fountain_pool"` style string
- 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}}`
- Verify current room: `{"command": "eval", "params": {"code": "return get_node('/root/Node2D').get_current_room_name()"}}` (returns node name like `"kq4_010_forest_path"`). Alternatively use screenshot for visual confirmation.
- **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]
### Using MainGame.get_current_room_name() for Room Verification
The `MainGame.gd` script (attached to `/root/Node2D`) provides a convenience function:
```gdscript
# Returns the node name of the current scene, e.g., "kq4_003_fountain_pool"
func get_current_room_name() -> String:
var scene = get_scene()
if scene:
return scene.name
return ""
```
**Via MCP eval for runtime verification**:
```bash
curl http://localhost:9090 -d '{"command": "eval", "params": {"code": "return get_node(\"/root/Node2D\").get_current_room_name()"}}'
```
Returns the room node name string (e.g., `"kq4_003_fountain_pool"`), or empty string if no scene is loaded. This is more reliable than traversing the scene tree manually since it uses the same `$SceneViewport/background` reference that MainGame's own logic uses.
**When to use**:
- After clicking a transition, call to confirm arrival at expected room in `to_room`
- Before navigation starts, verify you're actually in the intended `from_room`
- Compare against BFS path nodes during automated step-by-step execution
```
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,194 @@
---
name: kq4-room-navigator
description: Navigate between KQ4 rooms using the Godot MCP server. BFS pathfinds through room transition graph, then the agent navigates 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 polygon, and connects rooms typically 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
## 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
### Getting the current room name at runtime
The canonical way is via `MainGame.get_current_room_name()`:
Use `godot_game_call_method` to call it directly on the node path:
```json
Call method get_current_room_name on /root/Node2D
```
Returns a string like `"kq4_010_forest_path"`. The implementation extracts the filename from the scene script's resource path (`script_path.trim_suffix(".gd").get_file()`), NOT the node name (which is always `"background"`).
## 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)
```
The click coordinates are not used — the `exit_node_name` is what matters.
### Step 2 — Launch and navigate
1. Use the Godot MCP to start the game (`godot_run_project`)
2. Poll room name to verify starting room:
```
get_tree().root.get_node('Node2D').get_current_room_name()
```
3. For each step, call **`mock_interact(0)`** on the TransitionPiece node (see exact method name below)
4. Wait and poll until `get_current_room_name()` returns the expected destination
5. Repeat for next step
## Detailed Navigation Protocol
### Identify current room
```json
{"command": "eval", "params": {"code": "get_tree().root.get_node('Node2D').get_current_room_name()"}}
```
Returns `"kq4_0xx_room_name"`, confirming both MCP connectivity and which room you're in.
### Discover available exits from current room
Use the `set-piece` group to find all interactive polygons (TransitionPieces are automatically added):
```json
{"command": "get_nodes_in_group", "params": {"group": "set-piece"}}
```
Filter results for node names starting with `kq4_0` — these are transition exits.
### Trigger a room transition
**Critical: The method name is `mock_interact`, NOT `mock_interaction`.**
Use `godot_game_call_method` to call it directly on the node path:
```
Call method mock_interact(0) on /root/Node2D/SceneViewport/background/<exit_node_name>
```
Argument `0` = ActionState.Action.WALK (1=LOOK, 2=TOUCH, 3=TALK).
### Waiting — MCP busy protocol
**IMPORTANT**: Walk animations block the McpInteractionServer for ~30 seconds. During this time:
- `eval`, `wait`, `call_method` all fail with "timed out after 30s"
- The server has a built-in `_busy` flag auto-reset after ~30s ("_busy flag stuck for 30.Xs, force-resetting")
**Robust polling pattern**:
1. Call `mock_interact(0)` on the target node (returns immediately, animation starts)
2. Try `wait(frames=30)` then poll room name
3. If both timeout (~30s elapsed), retry the room name poll once more
4. The server will auto-reset and the next poll will succeed
Expected timing per transition: **~45-75 seconds total** (walk ~15s, MCP block ~30s, fade-in ~5s, confirm ~2s)
### Finding transitions by script handler
In room `.gd` files, handlers like `_on_ogres_cottage_interacted()` reference the TransitionPiece node (`$kq4_004_ogres_cottage.default_script(self)`). The handler's parameter name matches the node name you need to call `mock_interact(0)` on.
## 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)
- Two additional 36-room components that are disconnected due to UID mismatches
Use `python scripts/build_room_graph.py --from A --to B` to find a path. If "no path" is returned but rooms feel like they should connect, the cause is likely a stale UID (see Common Issues).
### UID mismatch example
Room `kq4_018_cemetery` has UID `uid://b3fjmiaribbrl`, but nearby rooms point to it with stale UIDs (`uid://35amqvpjgpf2x`). Runtime navigation works fine (Transitions use file paths), but the graph can't find the connection.
## Common Issues
| Problem | Diagnosis | Fix |
| --- | --- | --- |
| `mock_interaction` method not found | Wrong method name | Use **`mock_interact(0)`** — no trailing "tion" |
| MCP commands time out during transition | Walk animation blocks server (~30s) | Wait and retry; server auto-resets after ~30s |
| "No path" between adjacent rooms at graph level | Target UID in source `.tscn` doesn't match destination room's `.uid` file | Runtime still works. Fix the target uid in the source tscn or update the destination room's .uid |
| `get_current_room_name()` returns `"background"` | Old implementation used `scene.name` | Updated to use `script_path.trim_suffix(".gd").get_file()` |
## Verified Navigation Example
Successfully tested navigating from kq4_003_fountain_pool to kq4_018_cemetery via 5 transitions:
```
kq4_003_fountain_pool → mock_interact(kq4_004_ogres_cottage) → kq4_004_ogres_cottage ✓
kq4_004_ogres_cottage → mock_interact(kq4_010_forest_path) → kq4_010_forest_path ✓
kq4_010_forest_path → mock_interact(kq4_011_enchanted_grove) → kq4_011_enchanted_grove ✓
kq4_011_enchanted_grove → mock_interact(kq4_017_spooky_house_exterior) → kq4_017_spooky_house_exterior ✓
kq4_017_spooky_house_exterior → mock_interact(kq4_018_cemetery) → kq4_018_cemetery ✓
```
## API Reference
### build_room_graph.py module
```python
from pathlib import Path
from scripts.build_room_graph import build_graph, find_path, NavigationStep
graph = build_graph(Path('scenes')) # RoomInfo dict keyed by room name
steps = find_path(graph, "kq4_003_fountain_pool", "kq4_018_cemetery") # List[NavigationStep] or None
step.from_room # Source room name
step.exit_node_name # TransitionPiece node to call mock_interact(0) on
step.to_room # Destination room name
step.label # Human-readable label (e.g., "Ogre's Cottage")
```
### kq4_room_navigator.py CLI
```bash
python tools/kq4_room_navigator.py --from ROOM --to ROOM
# For graph summary:
python tools/kq4_room_navigator.py summary
```
## Key Files
| File | Purpose |
| --- | --- |
| `MainGame.gd` | Root game node; `get_current_room_name()` returns room from script path |
| `tools/kq4_room_navigator.py` | CLI combining graph + BFS + MCP navigation |
| `scripts/build_room_graph.py` | Room adjacency graph builder + BFS pathfinding |
| `TransitionPiece.gd` | Exit nodes; `mock_interact(action)` triggers default_script() |
| `SetPiece_.gd` | Base interactive polygon class (class_name SetPiece) |
| `.opencode/skills/kq4-room-creator/SKILL.md` | Related: creating new rooms with transitions |

View File

@@ -10,6 +10,14 @@ var is_cursor_locked: bool = false # When true, hourglass is shown and cursor c
func get_scene() -> Scene:
return $SceneViewport/background
func get_current_room_name() -> String:
var scene = get_scene()
if scene and scene.get_script():
var script_path = scene.get_script().resource_path
if script_path.begins_with("res://scenes/"):
return script_path.trim_suffix(".gd").get_file()
return ""
# Called when the node enters the scene tree for the first time.
func _ready():
# get_scene().connect("transitioned", Callable($SceneViewport/label, "_on_transitioned"))

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"):

135
docs/code-review-pr3.md Normal file
View File

@@ -0,0 +1,135 @@
## Code Review: PR 3 - "Stealth Cymbal"
### Scope
- **Branch:** stealth-cymbal
- **Base:** master
- **Files Changed:** 86 files (~1576 insertions, ~230 deletions)
- **Key Changes:** KQ4 Room Navigator skill, UID repair tools, MCP interaction server, room scene updates
### Review Team
-**ce-correctness-reviewer** - Logic errors, edge cases, state bugs
-**ce-testing-reviewer** - Coverage gaps, weak assertions, brittle tests
-**ce-maintainability-reviewer** - Coupling, complexity, naming, dead code
-**ce-project-standards-reviewer** - CLAUDE.md and AGENTS.md compliance
-**ce-agent-native-reviewer** - Agent-accessible design patterns
-**ce-learnings-researcher** - Past issues in docs/solutions/
---
### P1 -- High
| # | File | Issue | Reviewer | Confidence | Route |
|---|------|------|----------|------------|-------|
| 1 | `scenes/kq4_004_ogres_cottage/kq4_004_ogres_cottage.tscn:46` | UID mismatch - target UID for kq4_028_mine_entrance points to non-existent room | correctness | 75% | manual → downstream-resolver |
| 2 | `scripts/mcp_interaction_server.gd` | No runtime tests for 80+ TCP command handlers (171KB script) | testing | 50% | manual → downstream-resolver |
---
### P2 -- Moderate
| # | File | Issue | Reviewer | Confidence | Route |
|---|------|------|----------|------------|-------|
| 3 | `scenes/kq4_098_transitional_room/kq4_098_transitional_room.tscn` | Missing .uid companion file | correctness | 75% | manual → review-fixer |
| 4 | `scripts/build_room_graph.py:184` | `_resolve_target_room` returns None without explicit handling in callers | correctness | 75% | safe_auto → review-fixer |
| 5 | `tools/repair_uids.py:322` | Code duplication in fix_stale_target with inconsistent error handling | correctness | 75% | safe_auto → review-fixer |
| 6 | `tools/repair_uids.py` | No tests for file mutation operations (UID sync, target replacement) | testing | 75% | manual → downstream-resolver |
| 7 | `scripts/build_room_graph.py` | No tests for BFS edge cases (disconnected components, malformed UIDs) | testing | 75% | manual → downstream-resolver |
| 8 | `MainGame.gd`, `SetPiece_.gd` | No tests for signal emission logic and cursor action routing | testing | 50% | manual → downstream-resolver |
| 9 | `SetPiece_.gd:mock_interact()` | Unnecessary indirection - duplicates `_input()` logic | maintainability | 75% | gated_auto → downstream-resolver |
| 10 | `SetPiece_.gd` | Unused `entered/exited` signals with unclear `lab` param | maintainability | 75% | gated_auto → downstream-resolver |
| 11 | `tools/kq4_room_navigator.py` | sys.path manipulation creates coupling | maintainability | 50% | gated_auto → downstream-resolver |
---
### P3 -- Low
| # | File | Issue | Reviewer | Confidence | Route |
|---|------|------|----------|------------|-------|
| 12 | `MainGame.gd:13` | `get_current_room_name()` returns empty string on failure - silent failure mode | correctness | 50% | advisory → downstream-resolver |
| 13 | `scripts/mcp_interaction_server.gd:42` | Fixed 30-second timeout could mask real hangs | correctness | 50% | advisory → downstream-resolver |
| 14 | `SetPiece_.gd:60` | `mock_interact()` doesn't validate target room exists | correctness | 50% | advisory → downstream-resolver |
| 15 | `tools/kq4_room_navigator.py` | No tests for CLI tool | testing | 75% | advisory → human |
| 16 | `MainGame.gd` | Placeholder comment in `_ready()` - dead code | maintainability | 100% | advisory → human |
| 17 | `MainGame.gd` | Commented-out code with typo - dead code | maintainability | 100% | advisory → human |
| 18 | `scripts/mcp_interaction_server.gd` | Potentially incomplete implementation | maintainability | 50% | advisory → human |
| 19 | `.opencode/skills/kq4-room-navigator/SKILL.md:38-41` | Code block formatting changed from JSON to plain text | project-standards | 75% | advisory → human |
| 20 | `tools/kq4_room_navigator.py` | Dataclass overhead for Mismatch in CLI tool | maintainability | 50% | advisory → human |
| 21 | `.opencode/skills/kq4-room-navigator/SKILL.md` | Missing explicit retry guidance for `_busy` protocol | agent-native | 75% | advisory → human |
| 22 | `.opencode/skills/kq4-room-navigator/SKILL.md` | `mock_interact` vs `mock_interaction` typo in error messages | agent-native | 75% | safe_auto → review-fixer |
| 23 | `.opencode/skills/kq4-room-navigator/SKILL.md` | `--navigate` flag documented but not implemented in CLI | agent-native | 50% | gated_auto → downstream-resolver |
---
### Requirements Completeness
**No plan document found** for this PR. The PR title "Stealth Cymbal" is ambiguous and doesn't reference a specific plan.
---
### Pre-existing Issues
None identified in this review.
---
### Learnings & Past Solutions
**No institutional learnings found.** The `docs/solutions/` directory does not exist in this repository. Consider using `/ce-compound` skill after significant work to document:
- UID generation patterns (make_uid.py + .uid files)
- Navigation/pathfinding setup (NavigationServer2D)
- MCP server integration learnings
- Room transition wiring patterns
---
### Agent-Native Gaps
1. **Offline-Only Graph Discovery** - Agents can't dynamically discover room connectivity at runtime
2. **Manual Verification** - Navigation completion requires polling instead of signals
3. **No Path Validation** - Agents can attempt invalid navigation without guardrails
4. **Incomplete CLI** - The `--navigate` flag is documented but not implemented
---
### Residual Risks
1. **UID mismatch risk** - Room graph connectivity depends on UID consistency across all .tscn and .uid files. If UIDs drift from manual edits, pathfinding will fail even though runtime transitions work.
- **Mitigation:** Run `tools/repair_uids.py --fix` before each significant room update. Consider integrating UID validation into CI pipeline.
2. **Timeout risk** - The MCP server's `_busy` flag uses a fixed 30-second timeout. If navigation animations or other operations take longer, commands may be prematurely reset.
- **Mitigation:** Monitor `_busy` timeout warnings in logs. Consider making configurable or implementing per-command timeout overrides.
3. **Arbitrary code execution** - MCP eval command allows arbitrary code execution without guardrails.
4. **Heuristic risk** - UID auto-resolution in repair_uids.py may produce incorrect fixes based on heuristics.
---
### Testing Gaps
1. **No automated tests for UID consistency** across room transitions
2. **No tests for pathfinding edge cases** (disconnected rooms, single-room graphs, self-loops)
3. **No tests for MCP server command timeout** behavior under load
4. **No validation** that all TransitionPiece targets point to existing rooms
5. **No unit tests** for UID mismatch detection in repair_uids.py
6. **No tests** for BFS pathfinding edge cases in build_room_graph.py
7. **No tests** for SetPiece signal emission logic
8. **No tests** for busy-state timeout recovery in MCP server
---
### Coverage
- **Findings:** 23 total (2 P1, 9 P2, 12 P3)
- **Safe-auto (fixable now):** 3 findings
- **Manual (needs handoff):** 4 findings
- **Gated-auto (needs review):** 4 findings
- **Advisory (report-only):** 12 findings
- **Failed reviewers:** 0 of 6
---
### Verdict
**Ready with fixes**
**Fix order:**
1. **Critical:** Fix UID mismatch in kq4_004_ogres_cottage.tscn (finding #1) - this breaks navigation
2. **High:** Add missing .uid file for kq4_098_transitional_room (finding #3)
3. **Medium:** Fix None handling in build_room_graph.py (finding #4)
4. **Medium:** Consolidate code duplication in repair_uids.py (finding #5)
5. **Low:** Address dead code and formatting issues (findings #12-23)
**Before merge:** Run `tools/repair_uids.py --fix` to synchronize all UIDs and verify the UID mismatch is resolved.

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

View File

@@ -6,9 +6,17 @@
"command": [
"npx",
"@playwright/mcp@latest",
"--executable-path",
"/snap/bin/chromium",
"--isolated"
"--executable-path",
"/snap/bin/chromium",
"--isolated"
],
"enabled": true
},
"godot": {
"type": "local",
"command": [
"node",
"/home/noti/.superset/worktrees/ai-game-2/stealth-cymbal/godot-mcp/build/index.js"
],
"enabled": true
}

View File

@@ -54,7 +54,7 @@ position = Vector2(174, 519)
position = Vector2(1766, 74)
polygon = PackedVector2Array(-108, 454, -87, 649, 376, 658, 348, 381)
appear_at_node = "kq4_001_beach"
target = "uid://1489d4oh9twtu"
target = "uid://dxs1tr5yvmoba"
label = "Meadow"
[node name="entrance" parent="kq4_002_meadow" index="0"]

View File

@@ -1 +1 @@
uid://1rwfkejhz94hp
uid://dlg6010ym2uw4

View File

@@ -58,7 +58,7 @@ position = Vector2(174, 519)
position = Vector2(1766, 74)
polygon = PackedVector2Array(-79, 729, -86, 873, 261, 904, 249, 694)
appear_at_node = "kq4_002_meadow"
target = "uid://151dbn9bybiwx"
target = "uid://dyk4rcqsk3aed"
label = "Fountain Pool"
[node name="entrance" parent="kq4_003_fountain_pool" index="0"]
@@ -71,7 +71,7 @@ position = Vector2(227, 790)
position = Vector2(910, 542)
polygon = PackedVector2Array(-108, 454, -87, 649, 376, 658, 348, 381)
appear_at_node = "kq4_002_meadow"
target = "uid://bncmzju9ibkv"
target = "uid://bncm0jvaibkv"
label = "Back of Fisherman's Shack"
[node name="entrance" parent="kq4_008_back_of_fishermans_shack" index="0"]
@@ -83,7 +83,7 @@ position = Vector2(151, 615)
[node name="kq4_001_beach" parent="." unique_id=1117747814 instance=ExtResource("4_abc")]
polygon = PackedVector2Array(-108, 454, -87, 649, 376, 658, 348, 381)
appear_at_node = "kq4_002_meadow"
target = "uid://1rwfkejhz94hp"
target = "uid://dlg6010ym2uw4"
label = "Beach"
[node name="entrance" parent="kq4_001_beach" index="0"]

View File

@@ -1 +1 @@
uid://1489d4oh9twtu
uid://dxs1tr5yvmoba

View File

@@ -48,7 +48,7 @@ position = Vector2(194, 819)
position = Vector2(-150, 100)
polygon = PackedVector2Array(-108, 454, -87, 649, 376, 658, 348, 381)
appear_at_node = "kq4_003_fountain_pool"
target = "uid://1489d4oh9twtu"
target = "uid://dxs1tr5yvmoba"
label = "Meadow"
[node name="entrance" parent="kq4_002_meadow" index="0"]
@@ -75,7 +75,7 @@ position = Vector2(66, 71)
position = Vector2(910, 542)
polygon = PackedVector2Array(-108, 454, -87, 649, 376, 658, 348, 381)
appear_at_node = "kq4_003_fountain_pool"
target = "uid://1hkplw2a78b1y"
target = "uid://da4h2ljrt02ie"
label = "Shady Wooded Forest"
[node name="entrance" parent="kq4_009_shady_wooded_area" index="0"]
@@ -89,7 +89,7 @@ position = Vector2(500, 100)
color = Color(1, 1, 1, 1)
polygon = PackedVector2Array(349, 278, 398, 417, 593, 411, 650, 277)
appear_at_node = "kq4_003_fountain_pool"
target = "uid://1fpyosj18xls7"
target = "uid://c8aq5g1juqdam"
label = "Forest Path"
[node name="entrance" parent="kq4_027_forest_path" index="0"]

View File

@@ -1 +1 @@
uid://151dbn9bybiwx
uid://dyk4rcqsk3aed

View File

@@ -43,7 +43,7 @@ position = Vector2(194, 819)
position = Vector2(910, -213)
polygon = PackedVector2Array(-108, 454, -87, 649, 376, 658, 348, 381)
appear_at_node = "kq4_004_ogres_cottage"
target = "uid://qkcwifq2lb9m"
target = "uid://qkcwifq2lcam"
label = "Mine Entrance"
[node name="entrance" parent="kq4_028_mine_entrance" index="0"]
@@ -57,7 +57,7 @@ position = Vector2(5, 888)
color = Color(1, 1, 1, 1)
polygon = PackedVector2Array(-85, -86, -65, 128, 264, 167, 260, -78)
appear_at_node = "kq4_004_ogres_cottage"
target = "uid://151dbn9bybiwx"
target = "uid://dyk4rcqsk3aed"
label = "Fountain Pool"
[node name="entrance" parent="kq4_003_fountain_pool" index="0"]

View File

@@ -1 +1 @@
uid://1nxmm3b1kcdm1
uid://dhie3qsi5333g

View File

@@ -46,7 +46,7 @@ position = Vector2(194, 819)
position = Vector2(910, -213)
polygon = PackedVector2Array(-108, 454, -87, 649, 376, 658, 348, 381)
appear_at_node = "kq4_005_forest_grove"
target = "uid://1sfzaldfq5kn1"
target = "uid://dlyrp8twdxb4g"
label = "Dense Forest"
[node name="entrance" parent="kq4_029_dense_forest" index="0"]
@@ -84,7 +84,7 @@ position = Vector2(151, 615)
[node name="kq4_004_ogres_cottage" parent="." unique_id=1117747814 instance=ExtResource("4_abc")]
polygon = PackedVector2Array(-108, 454, -87, 649, 376, 658, 348, 381)
appear_at_node = "kq4_005_forest_grove"
target = "uid://1nxmm3b1kcdm1"
target = "uid://dhie3qsi5333g"
label = "Ogre's Cottage"
[node name="entrance" parent="kq4_004_ogres_cottage" index="0"]

View File

@@ -46,7 +46,7 @@ position = Vector2(194, 819)
position = Vector2(910, -213)
polygon = PackedVector2Array(-108, 454, -87, 649, 376, 658, 348, 381)
appear_at_node = "kq4_007_fishermans_shack"
target = "uid://1rwfkejhz94hp"
target = "uid://dlg6010ym2uw4"
label = "Beach"
[node name="entrance" parent="kq4_001_beach" index="0"]
@@ -59,7 +59,7 @@ position = Vector2(174, 519)
position = Vector2(1766, 74)
polygon = PackedVector2Array(-108, 454, -87, 649, 376, 658, 348, 381)
appear_at_node = "kq4_007_fishermans_shack"
target = "uid://bncmzju9ibkv"
target = "uid://bncm0jvaibkv"
label = "Back of Fisherman's Shack"
[node name="entrance" parent="kq4_008_back_of_fishermans_shack" index="0"]
@@ -72,7 +72,7 @@ position = Vector2(293, 554)
position = Vector2(910, 542)
polygon = PackedVector2Array(-108, 454, -87, 649, 376, 658, 348, 381)
appear_at_node = "kq4_007_fishermans_shack"
target = "uid://2bqawc9w4uu59"
target = "uid://d4a2d0rfqnmmo"
label = "Beach"
[node name="entrance" parent="kq4_013_beach" index="0"]

View File

@@ -45,7 +45,7 @@ z_index = 4
position = Vector2(1766, 74)
polygon = PackedVector2Array(-108, 454, -87, 649, 376, 658, 348, 381)
appear_at_node = "kq4_008_back_of_fishermans_shack"
target = "uid://1hkplw2a78b1y"
target = "uid://da4h2ljrt02ie"
label = "Shady Wooded Area"
[node name="entrance" parent="kq4_009_shady_wooded_area" index="0"]
@@ -59,7 +59,7 @@ z_index = 2
position = Vector2(910, -213)
polygon = PackedVector2Array(-631, 454, -539, 610, 376, 658, 769, 610, 748, 475, 348, 381, -594, 362)
appear_at_node = "kq4_008_back_of_fishermans_shack"
target = "uid://1489d4oh9twtu"
target = "uid://dxs1tr5yvmoba"
label = "Meadow"
[node name="entrance" parent="kq4_002_meadow" index="0"]

View File

@@ -1 +1 @@
uid://bncmzju9ibkv
uid://bncm0jvaibkv

View File

@@ -45,7 +45,7 @@ position = Vector2(194, 819)
position = Vector2(910, -213)
polygon = PackedVector2Array(-108, 454, -87, 649, 376, 658, 348, 381)
appear_at_node = "kq4_009_shady_wooded_area"
target = "uid://151dbn9bybiwx"
target = "uid://dyk4rcqsk3aed"
label = "Fountain Pool"
[node name="entrance" parent="kq4_003_fountain_pool" index="0"]
@@ -58,7 +58,7 @@ position = Vector2(174, 519)
position = Vector2(1766, 74)
polygon = PackedVector2Array(-108, 454, -87, 649, 376, 658, 348, 381)
appear_at_node = "kq4_009_shady_wooded_area"
target = "uid://3ujj97iw54vo5"
target = "uid://bsog5s257pres"
label = "Forest Path"
[node name="entrance" parent="kq4_010_forest_path" index="0"]
@@ -71,7 +71,7 @@ position = Vector2(293, 554)
position = Vector2(-200, 100)
polygon = PackedVector2Array(-108, 454, -87, 649, 376, 658, 348, 381)
appear_at_node = "kq4_009_shady_wooded_area"
target = "uid://bncmzju9ibkv"
target = "uid://bncm0jvaibkv"
label = "Back of Fisherman's Shack"
[node name="entrance" parent="kq4_008_back_of_fishermans_shack" index="0"]
@@ -84,7 +84,7 @@ position = Vector2(100, 480)
position = Vector2(910, 542)
polygon = PackedVector2Array(-108, 454, -87, 649, 376, 658, 348, 381)
appear_at_node = "kq4_009_shady_wooded_area"
target = "uid://2zga29mwl2ced"
target = "uid://xk6xu65nm620"
label = "Frog Pond"
[node name="entrance" parent="kq4_015_frog_pond" index="0"]

View File

@@ -1 +1 @@
uid://1hkplw2a78b1y
uid://da4h2ljrt02ie

View File

@@ -44,7 +44,7 @@ position = Vector2(194, 819)
position = Vector2(910, -213)
polygon = PackedVector2Array(-108, 454, -87, 649, 376, 658, 348, 381)
appear_at_node = "kq4_010_forest_path"
target = "uid://151dbn9bybiwx"
target = "uid://dyk4rcqsk3aed"
label = "Fountain Pool"
[node name="entrance" parent="kq4_003_fountain_pool" index="0"]
@@ -57,7 +57,7 @@ position = Vector2(174, 519)
position = Vector2(910, -213)
polygon = PackedVector2Array(-108, 454, -87, 649, 376, 658, 348, 381)
appear_at_node = "kq4_010_forest_path"
target = "uid://1nxmm3b1kcdm1"
target = "uid://dhie3qsi5333g"
label = "Ogre's Cottage"
[node name="entrance" parent="kq4_004_ogres_cottage" index="0"]
@@ -83,7 +83,7 @@ position = Vector2(293, 554)
position = Vector2(910, 542)
polygon = PackedVector2Array(-108, 454, -87, 649, 376, 658, 348, 381)
appear_at_node = "kq4_010_forest_path"
target = "uid://27b2k6gky3afg"
target = "uid://5gygr0s1n433"
label = "Graveyard"
[node name="entrance" parent="kq4_016_graveyard" index="0"]
@@ -96,7 +96,7 @@ position = Vector2(151, 615)
position = Vector2(-150, 100)
polygon = PackedVector2Array(-108, 454, -87, 649, 376, 658, 348, 381)
appear_at_node = "kq4_010_forest_path"
target = "uid://1hkplw2a78b1y"
target = "uid://da4h2ljrt02ie"
label = "Shady Wooded Area"
[node name="entrance" parent="kq4_009_shady_wooded_area" index="0"]

View File

@@ -1 +1 @@
uid://3ujj97iw54vo5
uid://bsog5s257pres

View File

@@ -69,7 +69,7 @@ position = Vector2(293, 554)
position = Vector2(910, 542)
polygon = PackedVector2Array(-108, 454, -87, 649, 376, 658, 348, 381)
appear_at_node = "kq4_011_enchanted_grove"
target = "uid://1kz9yo5f1tpc6"
target = "uid://dek2gdmwnmgsl"
label = "Spooky House Exterior"
[node name="entrance" parent="kq4_017_spooky_house_exterior" index="0"]
@@ -81,7 +81,7 @@ position = Vector2(151, 615)
[node name="kq4_010_forest_path" parent="." unique_id=1117747814 instance=ExtResource("4_abc")]
polygon = PackedVector2Array(-108, 454, -87, 649, 376, 658, 348, 381)
appear_at_node = "kq4_011_enchanted_grove"
target = "uid://3ujj97iw54vo5"
target = "uid://bsog5s257pres"
label = "Forest Path"
[node name="entrance" parent="kq4_010_forest_path" index="0"]

View File

@@ -1 +1 @@
uid://2bqawc9w4uu59
uid://d4a2d0rfqnmmo

View File

@@ -46,7 +46,7 @@ position = Vector2(194, 819)
position = Vector2(910, -213)
polygon = PackedVector2Array(-108, 454, -87, 649, 376, 658, 348, 381)
appear_at_node = "kq4_014_green_meadow"
target = "uid://bncmzju9ibkv"
target = "uid://bncm0jvaibkv"
label = "Back of Fisherman's Shack"
[node name="entrance" parent="kq4_008_back_of_fishermans_shack" index="0"]
@@ -59,7 +59,7 @@ position = Vector2(174, 519)
position = Vector2(1766, 74)
polygon = PackedVector2Array(-108, 454, -87, 649, 376, 658, 348, 381)
appear_at_node = "kq4_014_green_meadow"
target = "uid://2zga29mwl2ced"
target = "uid://xk6xu65nm620"
label = "Frog Pond"
[node name="entrance" parent="kq4_015_frog_pond" index="0"]
@@ -72,7 +72,7 @@ position = Vector2(293, 554)
position = Vector2(910, 542)
polygon = PackedVector2Array(-108, 454, -87, 649, 376, 658, 348, 381)
appear_at_node = "kq4_014_green_meadow"
target = "uid://2yy1t1lic39gp"
target = "uid://w4xpm5qeo45d"
label = "Meadow"
[node name="entrance" parent="kq4_020_meadow" index="0"]
@@ -84,7 +84,7 @@ position = Vector2(151, 615)
[node name="kq4_013_beach" parent="." unique_id=1117747814 instance=ExtResource("4_abc")]
polygon = PackedVector2Array(-108, 454, -87, 649, 376, 658, 348, 381)
appear_at_node = "kq4_014_green_meadow"
target = "uid://2bqawc9w4uu59"
target = "uid://d4a2d0rfqnmmo"
label = "Beach"
[node name="entrance" parent="kq4_013_beach" index="0"]

View File

@@ -45,7 +45,7 @@ position = Vector2(194, 819)
position = Vector2(910, -213)
polygon = PackedVector2Array(-108, 454, -87, 649, 376, 658, 348, 381)
appear_at_node = "kq4_015_frog_pond"
target = "uid://1hkplw2a78b1y"
target = "uid://da4h2ljrt02ie"
label = "Shady Wooded Area"
[node name="entrance" parent="kq4_009_shady_wooded_area" index="0"]
@@ -58,7 +58,7 @@ position = Vector2(174, 519)
position = Vector2(1766, 74)
polygon = PackedVector2Array(-108, 454, -87, 649, 376, 658, 348, 381)
appear_at_node = "kq4_015_frog_pond"
target = "uid://27b2k6gky3afg"
target = "uid://5gygr0s1n433"
label = "Graveyard"
[node name="entrance" parent="kq4_016_graveyard" index="0"]
@@ -84,7 +84,7 @@ position = Vector2(300, 554)
position = Vector2(910, 542)
polygon = PackedVector2Array(-108, 454, -87, 649, 376, 658, 348, 381)
appear_at_node = "kq4_015_frog_pond"
target = "uid://3uxipzjekijqc"
target = "uid://bs3fll3ml3ffy"
label = "Bridge over Stream"
[node name="entrance" parent="kq4_021_bridge_over_stream" index="0"]

View File

@@ -1 +1 @@
uid://2zga29mwl2ced
uid://xk6xu65nm620

View File

@@ -44,7 +44,7 @@ position = Vector2(194, 819)
position = Vector2(-200, 100)
polygon = PackedVector2Array(-108, 454, -87, 649, 376, 658, 348, 381)
appear_at_node = "kq4_016_graveyard"
target = "uid://2zga29mwl2ced"
target = "uid://xk6xu65nm620"
label = "Frog Pond"
[node name="entrance" parent="kq4_015_frog_pond" index="0"]
@@ -57,7 +57,7 @@ position = Vector2(100, 480)
position = Vector2(910, -213)
polygon = PackedVector2Array(-108, 454, -87, 649, 376, 658, 348, 381)
appear_at_node = "kq4_016_graveyard"
target = "uid://3ujj97iw54vo5"
target = "uid://bsog5s257pres"
label = "Forest Path"
[node name="entrance" parent="kq4_010_forest_path" index="0"]
@@ -70,7 +70,7 @@ position = Vector2(174, 519)
position = Vector2(910, 542)
polygon = PackedVector2Array(-108, 454, -87, 649, 376, 658, 348, 381)
appear_at_node = "kq4_016_graveyard"
target = "uid://3oq4x3exoimdb"
target = "uid://bmv1tox6p3h1x"
label = "Gnomes Cottage"
[node name="entrance" parent="kq4_022_gnomes_cottage" index="0"]
@@ -83,7 +83,7 @@ position = Vector2(174, 519)
position = Vector2(1766, 74)
polygon = PackedVector2Array(-108, 454, -87, 649, 376, 658, 348, 381)
appear_at_node = "kq4_016_graveyard"
target = "uid://1kz9yo5f1tpc6"
target = "uid://dek2gdmwnmgsl"
label = "Spooky House Exterior"
[node name="entrance" parent="kq4_017_spooky_house_exterior" index="0"]

View File

@@ -1 +1 @@
uid://27b2k6gky3afg
uid://5gygr0s1n433

View File

@@ -56,7 +56,7 @@ position = Vector2(174, 519)
position = Vector2(1766, 74)
polygon = PackedVector2Array(-108, 454, -87, 649, 376, 658, 348, 381)
appear_at_node = "kq4_017_spooky_house_exterior"
target = "uid://35amqvpjgpf2x"
target = "uid://b3fjmiaribbrl"
label = "Cemetery"
[node name="entrance" parent="kq4_018_cemetery" index="0"]
@@ -81,7 +81,7 @@ position = Vector2(151, 615)
[node name="kq4_016_graveyard" parent="." unique_id=1117747814 instance=ExtResource("4_abc")]
polygon = PackedVector2Array(-108, 454, -87, 649, 376, 658, 348, 381)
appear_at_node = "kq4_017_spooky_house_exterior"
target = "uid://27b2k6gky3afg"
target = "uid://5gygr0s1n433"
label = "Graveyard"
[node name="entrance" parent="kq4_016_graveyard" index="0"]

View File

@@ -1 +1 @@
uid://1kz9yo5f1tpc6
uid://dek2gdmwnmgsl

View File

@@ -67,7 +67,7 @@ position = Vector2(151, 615)
[node name="kq4_017_spooky_house_exterior" parent="." unique_id=1117747814 instance=ExtResource("4_abc")]
polygon = PackedVector2Array(-108, 454, -87, 649, 376, 658, 348, 381)
appear_at_node = "kq4_018_cemetery"
target = "uid://1kz9yo5f1tpc6"
target = "uid://dek2gdmwnmgsl"
label = "Spooky House Exterior"
[node name="entrance" parent="kq4_017_spooky_house_exterior" index="0"]

View File

@@ -1 +1 @@
uid://35amqvpjgpf2x
uid://b3fjmiaribbrl

View File

@@ -22,7 +22,7 @@ navigation_polygon = SubResource("NavigationPolygon_mt6rs")
[node name="kq4_020_meadow" parent="." index="5" instance=ExtResource("4_8xjvi")]
polygon = PackedVector2Array(1821, 506, 1756, 1110, 2122, 1104, 2067, 511)
appear_at_node = "kq4_019_coastal_cliffs"
target = "uid://2yy1t1lic39gp"
target = "uid://w4xpm5qeo45d"
label = "Meadow"
[node name="entrance" parent="kq4_020_meadow" index="0"]
@@ -46,7 +46,7 @@ position = Vector2(1538, 1274)
[node name="kq4_013_beach" parent="." index="7" instance=ExtResource("4_8xjvi")]
polygon = PackedVector2Array(1200, 100, 1300, 200, 1500, 200, 1600, 100)
appear_at_node = "kq4_019_coastal_cliffs"
target = "uid://2bqawc9w4uu59"
target = "uid://d4a2d0rfqnmmo"
label = "Beach"
[node name="entrance" parent="kq4_013_beach" index="0"]

View File

@@ -59,7 +59,7 @@ position = Vector2(174, 519)
position = Vector2(1766, 74)
polygon = PackedVector2Array(-108, 454, -87, 649, 376, 658, 348, 381)
appear_at_node = "kq4_020_meadow"
target = "uid://3uxipzjekijqc"
target = "uid://bs3fll3ml3ffy"
label = "Bridge over Stream"
[node name="entrance" parent="kq4_021_bridge_over_stream" index="0"]
@@ -84,7 +84,7 @@ position = Vector2(-64, 534)
position = Vector2(910, 542)
polygon = PackedVector2Array(-108, 454, -87, 649, 376, 658, 348, 381)
appear_at_node = "kq4_020_meadow"
target = "uid://10p7miv0a14l7"
target = "uid://dtay26ehvtu2m"
label = "River Meadow"
[node name="entrance" parent="kq4_026_river_meadow" index="0"]

View File

@@ -1 +1 @@
uid://2yy1t1lic39gp
uid://w4xpm5qeo45d

View File

@@ -44,7 +44,7 @@ position = Vector2(194, 819)
position = Vector2(910, -213)
polygon = PackedVector2Array(-108, 454, -87, 649, 376, 658, 348, 381)
appear_at_node = "kq4_021_bridge_over_stream"
target = "uid://2zga29mwl2ced"
target = "uid://xk6xu65nm620"
label = "Frog Pond"
[node name="entrance" parent="kq4_015_frog_pond" index="0"]
@@ -57,7 +57,7 @@ position = Vector2(174, 519)
position = Vector2(1766, 74)
polygon = PackedVector2Array(-108, 454, -87, 649, 376, 658, 348, 381)
appear_at_node = "kq4_021_bridge_over_stream"
target = "uid://3oq4x3exoimdb"
target = "uid://bmv1tox6p3h1x"
label = "Gnome's Cottage"
[node name="entrance" parent="kq4_022_gnomes_cottage" index="0"]
@@ -70,7 +70,7 @@ position = Vector2(293, 554)
position = Vector2(910, 542)
polygon = PackedVector2Array(-108, 454, -87, 649, 376, 658, 348, 381)
appear_at_node = "kq4_021_bridge_over_stream"
target = "uid://1fpyosj18xls7"
target = "uid://c8aq5g1juqdam"
label = "Forest Path"
[node name="entrance" parent="kq4_027_forest_path" index="0"]
@@ -83,7 +83,7 @@ position = Vector2(151, 615)
position = Vector2(-200, 74)
polygon = PackedVector2Array(-108, 454, -87, 649, 376, 658, 348, 381)
appear_at_node = "kq4_021_bridge_over_stream"
target = "uid://2yy1t1lic39gp"
target = "uid://w4xpm5qeo45d"
label = "Meadow"
[node name="entrance" parent="kq4_020_meadow" index="0"]

View File

@@ -1 +1 @@
uid://3uxipzjekijqc
uid://bs3fll3ml3ffy

View File

@@ -43,7 +43,7 @@ position = Vector2(194, 819)
position = Vector2(910, -213)
polygon = PackedVector2Array(-108, 454, -87, 649, 376, 658, 348, 381)
appear_at_node = "kq4_022_gnomes_cottage"
target = "uid://27b2k6gky3afg"
target = "uid://5gygr0s1n433"
label = "Graveyard"
[node name="entrance" parent="kq4_016_graveyard" index="0"]
@@ -56,7 +56,7 @@ position = Vector2(174, 519)
position = Vector2(910, 542)
polygon = PackedVector2Array(-108, 454, -87, 649, 376, 658, 348, 381)
appear_at_node = "kq4_022_gnomes_cottage"
target = "uid://qkcwifq2lb9m"
target = "uid://qkcwifq2lcam"
label = "South Exit"
[node name="entrance" parent="kq4_028_mine_entrance" index="0"]
@@ -81,7 +81,7 @@ position = Vector2(293, 554)
[node name="kq4_021_bridge_over_stream" parent="." unique_id=1117747814 instance=ExtResource("4_abc")]
polygon = PackedVector2Array(-108, 454, -87, 649, 376, 658, 348, 381)
appear_at_node = "kq4_022_gnomes_cottage"
target = "uid://3uxipzjekijqc"
target = "uid://bs3fll3ml3ffy"
label = "West Exit"
[node name="entrance" parent="kq4_021_bridge_over_stream" index="0"]

View File

@@ -1 +1 @@
uid://3oq4x3exoimdb
uid://bmv1tox6p3h1x

View File

@@ -43,7 +43,7 @@ position = Vector2(194, 819)
position = Vector2(910, -213)
polygon = PackedVector2Array(-108, 454, -87, 649, 376, 658, 348, 381)
appear_at_node = "kq4_023_forest_path_with_cottage"
target = "uid://1kz9yo5f1tpc6"
target = "uid://dek2gdmwnmgsl"
label = "Spooky House Exterior"
[node name="entrance" parent="kq4_017_spooky_house_exterior" index="0"]
@@ -69,7 +69,7 @@ position = Vector2(293, 554)
position = Vector2(910, 542)
polygon = PackedVector2Array(-108, 454, -87, 649, 376, 658, 348, 381)
appear_at_node = "kq4_023_forest_path_with_cottage"
target = "uid://1sfzaldfq5kn1"
target = "uid://dlyrp8twdxb4g"
label = "Dense Forest"
[node name="entrance" parent="kq4_029_dense_forest" index="0"]
@@ -81,7 +81,7 @@ position = Vector2(151, 615)
[node name="kq4_022_gnomes_cottage" parent="." unique_id=1117747814 instance=ExtResource("4_abc")]
polygon = PackedVector2Array(-108, 454, -87, 649, 376, 658, 348, 381)
appear_at_node = "kq4_023_forest_path_with_cottage"
target = "uid://3oq4x3exoimdb"
target = "uid://bmv1tox6p3h1x"
label = "Gnome's Cottage"
[node name="entrance" parent="kq4_022_gnomes_cottage" index="0"]

View File

@@ -44,7 +44,7 @@ position = Vector2(194, 819)
position = Vector2(910, -213)
polygon = PackedVector2Array(-108, 454, -87, 649, 376, 658, 348, 381)
appear_at_node = "kq4_024_waterfall_and_pool"
target = "uid://35amqvpjgpf2x"
target = "uid://b3fjmiaribbrl"
label = "Cemetery"
[node name="entrance" parent="kq4_018_cemetery" index="0"]

View File

@@ -65,7 +65,7 @@ position = Vector2(1535, 302)
position = Vector2(1800, 400)
polygon = PackedVector2Array(1700, 300, 1900, 300, 1900, 500, 1700, 500)
appear_at_node = "kq4_025_beach_at_river_delta"
target = "uid://10p7miv0a14l7"
target = "uid://dtay26ehvtu2m"
label = "River Meadow"
[node name="entrance" parent="kq4_026_river_meadow" index="0"]
@@ -91,7 +91,7 @@ position = Vector2(50, 350)
position = Vector2(910, 542)
polygon = PackedVector2Array(-108, 454, -87, 649, 376, 658, 348, 381)
appear_at_node = "kq4_025_beach_at_river_delta"
target = "uid://1rwfkejhz94hp"
target = "uid://dlg6010ym2uw4"
label = "Beach"
[node name="entrance" parent="kq4_001_beach" index="0"]

View File

@@ -41,7 +41,7 @@ position = Vector2(194, 819)
position = Vector2(910, -213)
polygon = PackedVector2Array(-108, 454, -87, 649, 376, 658, 348, 381)
appear_at_node = "kq4_026_river_meadow"
target = "uid://2yy1t1lic39gp"
target = "uid://w4xpm5qeo45d"
label = "Meadow"
priority = 1
@@ -55,7 +55,7 @@ position = Vector2(174, 519)
position = Vector2(1766, 74)
polygon = PackedVector2Array(-108, 454, -87, 649, 376, 658, 348, 381)
appear_at_node = "kq4_026_river_meadow"
target = "uid://1fpyosj18xls7"
target = "uid://c8aq5g1juqdam"
label = "Forest Path"
[node name="entrance" parent="kq4_027_forest_path" index="0"]
@@ -68,7 +68,7 @@ position = Vector2(293, 554)
position = Vector2(910, 542)
polygon = PackedVector2Array(-108, 454, -87, 649, 376, 658, 348, 381)
appear_at_node = "kq4_026_river_meadow"
target = "uid://1489d4oh9twtu"
target = "uid://dxs1tr5yvmoba"
label = "Meadow"
[node name="entrance" parent="kq4_002_meadow" index="0"]

View File

@@ -1 +1 @@
uid://10p7miv0a14l7
uid://dtay26ehvtu2m

View File

@@ -43,7 +43,7 @@ position = Vector2(194, 819)
position = Vector2(910, -213)
polygon = PackedVector2Array(-108, 454, -87, 649, 376, 658, 348, 381)
appear_at_node = "kq4_027_forest_path"
target = "uid://3uxipzjekijqc"
target = "uid://bs3fll3ml3ffy"
label = "Bridge over Stream"
[node name="entrance" parent="kq4_021_bridge_over_stream" index="0"]
@@ -56,7 +56,7 @@ position = Vector2(174, 519)
position = Vector2(1766, 74)
polygon = PackedVector2Array(-108, 454, -87, 649, 376, 658, 348, 381)
appear_at_node = "kq4_027_forest_path"
target = "uid://qkcwifq2lb9m"
target = "uid://qkcwifq2lcam"
label = "Mine Entrance"
[node name="entrance" parent="kq4_028_mine_entrance" index="0"]
@@ -69,7 +69,7 @@ position = Vector2(293, 554)
position = Vector2(910, 542)
polygon = PackedVector2Array(-108, 454, -87, 649, 376, 658, 348, 381)
appear_at_node = "kq4_027_forest_path"
target = "uid://151dbn9bybiwx"
target = "uid://dyk4rcqsk3aed"
label = "Fountain Pool"
[node name="entrance" parent="kq4_003_fountain_pool" index="0"]
@@ -81,7 +81,7 @@ position = Vector2(151, 615)
[node name="kq4_026_river_meadow" parent="." unique_id=1117747814 instance=ExtResource("4_abc")]
polygon = PackedVector2Array(-108, 454, -87, 649, 376, 658, 348, 381)
appear_at_node = "kq4_027_forest_path"
target = "uid://10p7miv0a14l7"
target = "uid://dtay26ehvtu2m"
label = "River Meadow"
[node name="entrance" parent="kq4_026_river_meadow" index="0"]

View File

@@ -1 +1 @@
uid://1fpyosj18xls7
uid://c8aq5g1juqdam

View File

@@ -43,7 +43,7 @@ position = Vector2(194, 819)
position = Vector2(910, -213)
polygon = PackedVector2Array(-108, 454, -87, 649, 376, 658, 348, 381)
appear_at_node = "kq4_028_mine_entrance"
target = "uid://3oq4x3exoimdb"
target = "uid://bmv1tox6p3h1x"
label = "Gnome's Cottage"
[node name="entrance" parent="kq4_022_gnomes_cottage" index="0"]
@@ -56,7 +56,7 @@ position = Vector2(174, 519)
position = Vector2(1766, 74)
polygon = PackedVector2Array(-108, 454, -87, 649, 376, 658, 348, 381)
appear_at_node = "kq4_028_mine_entrance"
target = "uid://1sfzaldfq5kn1"
target = "uid://dlyrp8twdxb4g"
label = "Dense Forest"
[node name="entrance" parent="kq4_029_dense_forest" index="0"]
@@ -69,7 +69,7 @@ position = Vector2(293, 554)
position = Vector2(910, 542)
polygon = PackedVector2Array(-108, 454, -87, 649, 376, 658, 348, 381)
appear_at_node = "kq4_028_mine_entrance"
target = "uid://1nxmm3b1kcdm1"
target = "uid://dhie3qsi5333g"
label = "Ogre's Cottage"
[node name="entrance" parent="kq4_004_ogres_cottage" index="0"]
@@ -81,7 +81,7 @@ position = Vector2(151, 615)
[node name="kq4_027_forest_path" parent="." unique_id=1117747814 instance=ExtResource("4_abc")]
polygon = PackedVector2Array(-108, 454, -87, 649, 376, 658, 348, 381)
appear_at_node = "kq4_028_mine_entrance"
target = "uid://1fpyosj18xls7"
target = "uid://c8aq5g1juqdam"
label = "Forest Path"
[node name="entrance" parent="kq4_027_forest_path" index="0"]

View File

@@ -1 +1 @@
uid://qkcwifq2lb9m
uid://qkcwifq2lcam

View File

@@ -81,7 +81,7 @@ position = Vector2(151, 615)
[node name="kq4_028_mine_entrance" parent="." unique_id=1117747814 instance=ExtResource("4_abc")]
polygon = PackedVector2Array(-108, 454, -87, 649, 376, 658, 348, 381)
appear_at_node = "kq4_029_dense_forest"
target = "uid://qkcwifq2lb9m"
target = "uid://qkcwifq2lcam"
label = "Mine Entrance"
[node name="entrance" parent="kq4_028_mine_entrance" index="0"]

View File

@@ -1 +1 @@
uid://1sfzaldfq5kn1
uid://dlyrp8twdxb4g

View File

@@ -67,7 +67,7 @@ position = Vector2(293, 554)
[node name="kq4_029_dense_forest" parent="." unique_id=1117747814 instance=ExtResource("4_abc")]
polygon = PackedVector2Array(-108, 454, -87, 649, 376, 658, 348, 381)
appear_at_node = "kq4_030_mountain_pass"
target = "uid://1sfzaldfq5kn1"
target = "uid://dlyrp8twdxb4g"
label = "Dense Forest"
[node name="entrance" parent="kq4_029_dense_forest" index="0"]

View File

@@ -43,7 +43,7 @@ position = Vector2(194, 819)
position = Vector2(910, -213)
polygon = PackedVector2Array(-108, 454, -87, 649, 376, 658, 348, 381)
appear_at_node = "kq4_031_open_ocean"
target = "uid://1rwfkejhz94hp"
target = "uid://dlg6010ym2uw4"
label = "Beach"
[node name="entrance" parent="kq4_001_beach" index="0"]
@@ -82,7 +82,7 @@ position = Vector2(174, 519)
position = Vector2(1500, 200)
polygon = PackedVector2Array(-108, 454, -87, 649, 376, 658, 348, 381)
appear_at_node = "kq4_031_open_ocean"
target = "uid://2bqawc9w4uu59"
target = "uid://d4a2d0rfqnmmo"
label = "Beach"
[node name="entrance" parent="kq4_013_beach" index="0"]

View File

@@ -0,0 +1 @@
uid://2f7c49hpkducc

View File

@@ -41,7 +41,7 @@ position = Vector2(194, 819)
position = Vector2(910, -213)
polygon = PackedVector2Array(-703, 472, -696, 1033, -462, 953, -471, 562)
appear_at_node = "kq4_049_ogres_cottage"
target = "uid://1nxmm3b1kcdm1"
target = "uid://dhie3qsi5333g"
label = "Ogre's Cottage Exterior"
[node name="entrance" parent="kq4_004_ogres_cottage_exterior" index="0"]

View File

@@ -1 +1 @@
uid://1cxd7kvarvjr5
uid://c5h5n8dreoa8k

View File

@@ -41,7 +41,7 @@ position = Vector2(194, 819)
position = Vector2(1766, 74)
polygon = PackedVector2Array(-108, 454, -87, 649, 376, 658, 348, 381)
appear_at_node = "kq4_051_ogres_closet"
target = "uid://1cxd7kvarvjr5"
target = "uid://c5h5n8dreoa8k"
label = "Ogre's Cottage"
[node name="entrance" parent="kq4_049_ogres_cottage" index="0"]

View File

@@ -39,7 +39,7 @@ position = Vector2(200, 900)
position = Vector2(1766, 600)
polygon = PackedVector2Array(-108, 454, -87, 649, 376, 658, 348, 381)
appear_at_node = "kq4_053_seven_dwarfs_bedroom"
target = "uid://3opp6zygwkh7x"
target = "uid://bmum2mjox5dwl"
label = "054 Seven Dwarfs Cottage"
[node name="entrance" parent="kq4_054_seven_dwarfs_cottage" index="0"]

View File

@@ -52,7 +52,7 @@ position = Vector2(188, 628)
position = Vector2(801, 597)
polygon = PackedVector2Array(-108, 454, -87, 649, 376, 658, 348, 381)
appear_at_node = "kq4_054_seven_dwarfs_cottage"
target = "uid://3oq4x3exoimdb"
target = "uid://bmv1tox6p3h1x"
label = "022 Gnomes Cottage"
[node name="entrance" parent="kq4_022_gnomes_cottage" index="0"]

View File

@@ -1 +1 @@
uid://3opp6zygwkh7x
uid://bmum2mjox5dwl

View File

@@ -52,7 +52,7 @@ position = Vector2(174, 519)
position = Vector2(50, 600)
polygon = PackedVector2Array(-108, 454, -87, 649, 376, 658, 348, 381)
appear_at_node = "kq4_055_seven_dwarfs_diamond_mine"
target = "uid://qkcwifq2lb9m"
target = "uid://qkcwifq2lcam"
label = "028 Mine Entrance"
[node name="entrance" parent="kq4_028_mine_entrance" index="0"]

View File

@@ -39,7 +39,7 @@ position = Vector2(200, 900)
position = Vector2(1452, 159)
polygon = PackedVector2Array(62, 224, 50, 647, 277, 662, 295, 181)
appear_at_node = "kq4_059_baby_nursery"
target = "uid://368r91sorjxs0"
target = "uid://b5eo5ndws4tin"
label = "062 Bedroom"
[node name="entrance" parent="kq4_062_bedroom" index="0"]

View File

@@ -1 +1 @@
uid://15wiem5l9oi69
uid://dyhaubm3vhano

View File

@@ -39,7 +39,7 @@ position = Vector2(200, 900)
position = Vector2(967, 527)
polygon = PackedVector2Array(-108, 454, -87, 649, 376, 658, 348, 381)
appear_at_node = "kq4_060_bedroom"
target = "uid://3hb2kqpkpvmnj"
target = "uid://bfgygdasrhic6"
label = "068 The Foyer"
[node name="entrance" parent="kq4_068_the_foyer" index="0"]

View File

@@ -1 +1 @@
uid://abd2plmdre4f
uid://bd2plmdre4f

View File

@@ -40,7 +40,7 @@ position = Vector2(200, 900)
position = Vector2(877, 491)
polygon = PackedVector2Array(-108, 454, -87, 649, 376, 658, 348, 381)
appear_at_node = "kq4_062_bedroom"
target = "uid://3hb2kqpkpvmnj"
target = "uid://bfgygdasrhic6"
label = "068 The Foyer"
[node name="entrance" parent="kq4_068_the_foyer" index="0"]
@@ -53,7 +53,7 @@ position = Vector2(130, 684)
position = Vector2(212, -39)
polygon = PackedVector2Array(46, 377, 108, 814, 214, 811, 221, 419)
appear_at_node = "kq4_062_bedroom"
target = "uid://15wiem5l9oi69"
target = "uid://dyhaubm3vhano"
label = "059 Baby Nursery"
[node name="entrance" parent="kq4_059_baby_nursery" index="0"]

View File

@@ -1 +1 @@
uid://368r91sorjxs0
uid://b5eo5ndws4tin

View File

@@ -41,7 +41,7 @@ position = Vector2(200, 900)
position = Vector2(351, -41)
polygon = PackedVector2Array(-108, 454, -102, 844, 78, 811, 108, 429)
appear_at_node = "kq4_064_old_dining_room"
target = "uid://3hb2kqpkpvmnj"
target = "uid://bfgygdasrhic6"
label = "068 The Foyer"
[node name="entrance" parent="kq4_068_the_foyer" index="0"]
@@ -54,7 +54,7 @@ position = Vector2(-97, 787)
position = Vector2(1217, -17)
polygon = PackedVector2Array(-49, 358, -18, 700, 179, 703, 181, 351)
appear_at_node = "kq4_064_old_dining_room"
target = "uid://3am8ohkla4wr9"
target = "uid://7r5j24tcpshw"
label = "065 Old Kitchen"
[node name="entrance" parent="kq4_065_old_kitchen" index="0"]

View File

@@ -1 +1 @@
uid://2y0sti59qypsl
uid://w5po4qisklh8

View File

@@ -39,7 +39,7 @@ position = Vector2(200, 900)
position = Vector2(1103, 522)
polygon = PackedVector2Array(-108, 454, -87, 649, 376, 658, 348, 381)
appear_at_node = "kq4_065_old_kitchen"
target = "uid://2y0sti59qypsl"
target = "uid://w5po4qisklh8"
label = "064 Old Dining Room"
[node name="entrance" parent="kq4_064_old_dining_room" index="0"]

View File

@@ -1 +1 @@
uid://3am8ohkla4wr9
uid://7r5j24tcpshw

View File

@@ -39,7 +39,7 @@ position = Vector2(200, 900)
position = Vector2(50, 600)
polygon = PackedVector2Array(-108, 454, -87, 649, 376, 658, 348, 381)
appear_at_node = "kq4_067_the_parlor"
target = "uid://3hb2kqpkpvmnj"
target = "uid://bfgygdasrhic6"
label = "068 The Foyer"
[node name="entrance" parent="kq4_068_the_foyer" index="0"]

View File

@@ -68,7 +68,7 @@ position = Vector2(7, 747)
position = Vector2(910, 200)
polygon = PackedVector2Array(-396, -99, -479, 209, -147, 228, -161, -61)
appear_at_node = "kq4_068_the_foyer"
target = "uid://abd2plmdre4f"
target = "uid://bd2plmdre4f"
label = "060 Bedroom"
[node name="entrance" parent="kq4_060_bedroom" index="0"]
@@ -81,7 +81,7 @@ position = Vector2(-311, 176)
position = Vector2(973, -346)
polygon = PackedVector2Array(58, 451, 108, 830, 393, 832, 348, 381)
appear_at_node = "kq4_068_the_foyer"
target = "uid://368r91sorjxs0"
target = "uid://b5eo5ndws4tin"
label = "062 Bedroom"
metadata/_edit_lock_ = true
@@ -95,7 +95,7 @@ position = Vector2(207, 737)
position = Vector2(50, 600)
polygon = PackedVector2Array(1448, -51, 1404, 401, 1680, 390, 1668, 85)
appear_at_node = "kq4_068_the_foyer"
target = "uid://2y0sti59qypsl"
target = "uid://w5po4qisklh8"
label = "064 Old Dining Room"
[node name="entrance" parent="kq4_064_old_dining_room" index="0"]

View File

@@ -1 +1 @@
uid://3hb2kqpkpvmnj
uid://bfgygdasrhic6

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

@@ -14,9 +14,18 @@ const BUSY_TIMEOUT: float = 30.0
var _key_map: Dictionary
var _held_keys: Dictionary = {}
var _debugger_safe: bool = false
func _ready() -> void:
# Ensure MCP server keeps processing even when game is paused
process_mode = Node.PROCESS_MODE_ALWAYS
if EngineDebugger.is_active():
EngineDebugger.send_message("core:set_skip_breakpoints", [true])
EngineDebugger.send_message("core:set_ignore_error_breaks", [true])
_debugger_safe = EngineDebugger.is_skipping_breakpoints()
if _debugger_safe:
print("McpInteractionServer: Debugger breakpoints safely disabled")
else:
push_warning("McpInteractionServer: Could not disable debugger breakpoints (LocalDebugger?). Eval with invalid code may hang the game.")
_init_key_map()
_server = TCPServer.new()
var err: int = _server.listen(PORT, "127.0.0.1")
@@ -544,17 +553,18 @@ func _cmd_eval(params: Dictionary) -> void:
_send_response({"error": "No code provided"})
return
# Wrap user code in a function so we can capture the return value
var script_source: String = """extends Node
var script_source: String = _build_eval_script(code)
func execute():
var __result = null
__result = await _run()
return __result
if EngineDebugger.is_active() and not _debugger_safe:
var ext_validation: Dictionary = _validate_script_external(script_source)
if not ext_validation.get("valid", false):
_send_response({"error": "Script validation failed: %s" % ext_validation.get("error", "unknown error")})
return
func _run():
%s
""" % [_indent_code(code)]
var validation: Dictionary = _validate_script_source(script_source)
if not validation.get("valid", false):
_send_response({"error": "Script validation failed: %s" % validation.get("error", "unknown error"), "error_code": validation.get("error_code", -1)})
return
var script: GDScript = GDScript.new()
script.source_code = script_source
@@ -565,7 +575,6 @@ func _run():
var temp_node: Node = Node.new()
temp_node.set_script(script)
# Allow eval to work even when game is paused
temp_node.process_mode = Node.PROCESS_MODE_ALWAYS
add_child(temp_node)
@@ -577,6 +586,69 @@ func _run():
_send_response({"success": true, "result": _variant_to_json(result)})
func _build_eval_script(code: String) -> String:
return """extends Node
func execute():
var __result = null
__result = await _run()
return __result
func _run():
%s
""" % [_indent_code(code)]
func _validate_script_source(source: String) -> Dictionary:
var test_script: GDScript = GDScript.new()
test_script.source_code = source
var err: int = test_script.reload()
if err != OK:
var error_name: String = ""
match err:
ERR_PARSE_ERROR:
error_name = "Parse error"
ERR_COMPILATION_FAILED:
error_name = "Compilation failed"
_:
error_name = "Error code %d" % err
return {"valid": false, "error": error_name, "error_code": err}
return {"valid": true}
func _validate_script_external(source: String) -> Dictionary:
var temp_path: String = "user://_mcp_eval_validate_%d.gd" % Time.get_ticks_msec()
var file: FileAccess = FileAccess.open(temp_path, FileAccess.WRITE)
if file == null:
return {"valid": true}
file.store_string(source)
file.close()
var godot_path: String = OS.get_executable_path()
var project_path: String = ProjectSettings.globalize_path("res://")
var global_temp: String = ProjectSettings.globalize_path(temp_path)
var output: Array = []
var exit_code: int = OS.execute(godot_path, [
"--headless",
"--check-only",
"--script", global_temp,
"--path", project_path
], output)
DirAccess.remove_absolute(global_temp)
if exit_code != 0:
var error_detail: String = ""
for line in output:
error_detail += line + "\n"
error_detail = error_detail.strip_edges()
var msg: String = "Syntax error in eval code (external validation)"
if not error_detail.is_empty():
msg += ": " + error_detail
return {"valid": false, "error": msg}
return {"valid": true}
func _indent_code(code: String) -> String:
var lines: PackedStringArray = code.split("\n")
var indented: String = ""

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()

449
tools/repair_uids.py Normal file
View File

@@ -0,0 +1,449 @@
#!/usr/bin/env python3
"""Scans and repairs UID mismatches across all KQ4 room scenes.
Fixes two types of drift:
1. .tscn.headers have a different UID than the .uid companion file
2. Source rooms reference stale target UIDs from .uid companions instead of
the current .tscn header UID
After repair, check_transitions.py and build_room_graph.py report consistent results.
Usage:
# Detect (show what's wrong)
python tools/repair_uids.py --detect
# Dry run (show what would change)
python tools/repair_uids.py --fix --dry-run
# Apply fixes
python tools/repair_uids.py --fix
# Fix a specific room only
python tools/repair_uids.py --fix --room kq4_018_cemetery
# Verbose output
python tools/repair_uids.py --detect -v
"""
import re
import sys
import hashlib
import json
from pathlib import Path
from dataclasses import dataclass, field
from typing import Optional
def generate_godot_uid() -> str:
"""Generate a fresh Godot-compatible UID (base36 encoded 64-bit random)."""
ALPHABET = "0123456789abcdefghijklmnopqrstuvwxyz"
def to_base36(num: int) -> str:
if num == 0:
return "0"
chars = []
while num:
num, rem = divmod(num, 36)
chars.append(ALPHABET[rem])
return "".join(reversed(chars))
import secrets
value = secrets.randbits(64)
return f"uid://{to_base36(value)}"
@dataclass
class RoomData:
"""Parsed data for a single room scene."""
name: str
path: Path
header_uid: Optional[str] # uid from .tscn [gd_scene ...] line
companion_uid: Optional[str] # uid from .uid file (may be stale or missing)
target_uids: list[dict] # [{"exit_node": str, "target_uid": str}, ...]
@dataclass
class Mismatch:
"""A single detected UID problem."""
category: str # "companion_mismatch" | "missing_companion" | "stale_target"
room_name: str
detail: str
fixable: bool = True
fix_uid: Optional[str] = None
source_file: Optional[Path] = None
def parse_all_rooms(scenes_dir: Path) -> dict[str, RoomData]:
"""Parse every kq4_* .tscn scene and return data keyed by room name."""
rooms: dict[str, RoomData] = {}
for tscn in sorted(scenes_dir.glob("kq4_*/kq4_*.tscn")):
if "placeholder" in str(tscn):
continue
content = tscn.read_text()
# Extract header UID
header_match = re.search(r'\[gd_scene[^\]]*uid="([^"]+)"', content)
header_uid = header_match.group(1) if header_match else None
# Extract companion .uid file
uid_file = Path(str(tscn) + ".uid")
companion_uid = uid_file.read_text().strip() if uid_file.exists() else None
# Extract target UIDs from TransitionPiece nodes
target_uids = []
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)
if target_match:
target_uids.append({
"exit_node": node_name,
"target_uid": target_match.group(1),
})
rooms[tscn.stem] = RoomData(
name=tscn.stem, path=tscn,
header_uid=header_uid, companion_uid=companion_uid, target_uids=target_uids,
)
return rooms
def detect_mismatches(rooms: dict[str, RoomData]) -> list[Mismatch]:
"""Find all UID problems and return actionable mismatch records."""
mismatches: list[Mismatch] = []
# Build lookup: companion_uid -> room_name (for stale-target detection)
companion_to_room: dict[str, str] = {}
for rdata in rooms.values():
if rdata.companion_uid:
companion_to_room[rdata.companion_uid] = rdata.name
for rname, rd in rooms.items():
# 1. Companion doesn't match header (both exist but differ)
if rd.header_uid and rd.companion_uid and rd.header_uid != rd.companion_uid:
mismatches.append(Mismatch(
category="companion_mismatch",
room_name=rname,
detail=f".uid={rd.companion_uid} .tscn header={rd.header_uid}",
fix_uid=rd.header_uid, source_file=Path(str(rd.path) + ".uid"),
))
# 2. Missing companion file
if rd.header_uid and not rd.companion_uid:
mismatches.append(Mismatch(
category="missing_companion",
room_name=rname,
detail=f"No .uid file for header UID {rd.header_uid}",
fix_uid=rd.header_uid, source_file=Path(str(rd.path) + ".uid"),
))
# 3. Stale target references
for tgt in rd.target_uids:
uid = tgt["target_uid"]
if not uid.startswith("uid://"):
continue
# Target exists in some header -> check companion matches
target_room = None
for other_name, other_rd in rooms.items():
if other_rd.header_uid == uid:
target_room = other_name
break
if target_room is None:
# Check if the stale UID matches a companion that diverged from header
mapped_companion = companion_to_room.get(uid)
if mapped_companion and mapped_companion != rooms[mapped_companion].name or \
(mapped_companion and rooms[mapped_companion].companion_uid == uid):
# Stale companion lookup — the target room exists but source
# references its OLD companion UID instead of current header
correct_uid = rooms[mapped_companion].header_uid
if uid != correct_uid:
mismatches.append(Mismatch(
category="stale_target",
room_name=rname,
detail=f"{tgt['exit_node']} -> {uid} (correct: {correct_uid} for {mapped_companion})",
fix_uid=correct_uid, source_file=rd.path,
))
elif target_room is None and uid not in companion_to_room:
# Totally orphaned UID — try to auto-resolve via exit node name
suggested = suggest_target_from_node(tgt["exit_node"], rooms)
if suggested:
sugg_uid = rooms[suggested].header_uid
mismatches.append(Mismatch(
category="stale_target",
room_name=rname,
detail=f"{tgt['exit_node']} -> {uid} (not found, likely → {suggested}: {sugg_uid})",
fix_uid=sugg_uid, source_file=rd.path,
))
return mismatches
def suggest_target_from_node(exit_node_name: str, rooms: dict[str, RoomData]) -> Optional[str]:
"""Try to guess target room from exit node naming convention.
Exit nodes are typically named after their destination (e.g., 'kq4_018_cemetery').
"""
# Direct match
if exit_node_name in rooms:
return exit_node_name
# kq4_XXX_room_node -> kq4_XXX_room (strip trailing node-like suffix)
for rname in rooms:
if exit_node_name.startswith(rname):
return rname
if rname in exit_node_name:
return rname
return None
def fix_companion_mismatch(room: RoomData, new_uid: str, dry_run: bool) -> str:
"""Write the header UID into the .uid companion file."""
uid_path = Path(str(room.path) + ".uid")
content = f"{new_uid}\n"
if not dry_run:
uid_path.write_text(content)
return f"Synchronizing {room.name}: .uid → {new_uid}"
def fix_missing_companion(room: RoomData, uid: str, dry_run: bool) -> str:
"""Create a new .uid companion file with the header UID."""
uid_path = Path(str(room.path) + ".uid")
content = f"{uid}\n"
if not dry_run:
uid_path.write_text(content)
return f"Creating {room.name}: .uid ← {uid} (from .tscn header)"
def fix_stale_target(source_room: RoomData, exit_node: str, old_uid: str, new_uid: str, dry_run: bool) -> str:
"""Replace a stale target UID in the source .tscn file."""
content = source_room.path.read_text()
if not dry_run:
content = content.replace(
f'target = "{old_uid}"',
f'target = "{new_uid}"',
)
source_room.path.write_text(content)
return f"Updating {source_room.name}/{exit_node}: target {old_uid}{new_uid}"
def run_detect(rooms: dict[str, RoomData], verbose: bool) -> list[Mismatch]:
"""Detect and display all mismatches. Returns list for further processing."""
mismatches = detect_mismatches(rooms)
if not mismatches:
print("No UID mismatches found. All rooms are consistent.")
return mismatches
# Group by category
by_category: dict[str, list[Mismatch]] = {}
for m in mismatches:
by_category.setdefault(m.category, []).append(m)
total = len(mismatches)
print(f"Found {total} UID issues across {len(rooms)} rooms:\n")
if "companion_mismatch" in by_category:
msgs = by_category["companion_mismatch"]
print(f" [{len(msgs)}] Companion files out of sync with .tscn headers:")
for m in msgs[:10]:
print(f" {m.room_name}: {m.detail}")
if len(msgs) > 10:
print(f" ... and {len(msgs) - 10} more")
if "missing_companion" in by_category:
msgs = by_category["missing_companion"]
print(f" [{len(msgs)}] Missing .uid companion files:")
for m in msgs:
print(f" {m.room_name}: {m.detail}")
if "stale_target" in by_category:
msgs = by_category["stale_target"]
print(f" [{len(msgs)}] Stale target UIDs in source rooms:")
for m in (msgs[:15] if verbose else msgs[:5]):
print(f" {m.room_name}: {m.detail}")
if len(msgs) > 5 and not verbose:
print(f" ... and {len(msgs) - 5} more (use -v for full list)")
return mismatches
def run_fix(rooms: dict[str, RoomData], dry_run: bool, room_filter: Optional[str]) -> int:
"""Detect and fix all mismatches. Returns count of fixes applied."""
mismatches = detect_mismatches(rooms)
if not mismatches:
print("No UID mismatches to fix.")
return 0
if room_filter:
mismatches = [m for m in mismatches if m.room_name == room_filter]
if not mismatches:
print(f"No issues found for {room_filter}.")
return 0
mode = "(DRY RUN) " if dry_run else ""
applied = 0
for m in mismatches:
if not m.fixable or not m.fix_uid:
continue
rd = rooms.get(m.room_name)
if not rd:
continue
if m.category == "companion_mismatch" and m.source_file:
msg = fix_companion_mismatch(rd, m.fix_uid, dry_run)
print(f"{mode}{msg}")
applied += 1
elif m.category == "missing_companion":
# Check if room needs a new UID generated
uid_to_use = m.fix_uid or rd.header_uid
if not uid_to_use:
uid_to_use = generate_godot_uid()
if not dry_run:
# Also update the .tscn header with the new UID
content = rd.path.read_text()
if rd.header_uid:
content = content.replace(f'uid="{rd.header_uid}"', f'uid="{uid_to_use}"')
else:
content = content.replace('[gd_scene format=3', f'[gd_scene format=3 uid="{uid_to_use}"')
rd.path.write_text(content)
msg = fix_missing_companion(rd, uid_to_use, dry_run)
print(f"{mode}{msg}")
applied += 1
elif m.category == "stale_target":
# Extract old UID from detail message
detail_parts = m.detail.split(" -> ")
if len(detail_parts) >= 2:
exit_node = detail_parts[0].split()[-1]
old_uid = detail_parts[1]
new_uid = m.fix_uid
msg = fix_stale_target(rd, exit_node, old_uid or "", new_uid, dry_run)
# Handle case where target UID doesn't exist in any companion file
if "not found" in m.detail.lower():
# Extract via regex for more robust parsing
import re
uid_match = re.search(r"-> (uid://\S+)", m.detail)
if uid_match:
old_uid = uid_match.group(1)
else:
continue
exit_node_match = re.search(r"(kq4_\S+) -> ", m.detail)
exit_node = exit_node_match.group(1) if exit_node_match else "unknown"
msg = fix_stale_target(rd, exit_node, old_uid, new_uid, dry_run)
print(f"{mode}{msg}")
applied += 1
return applied
def run_verify() -> bool:
"""Run check_transitions.py and build_room_graph.py to confirm fixes."""
root = Path(__file__).resolve().parent.parent
check_script = root / "scripts" / "check_transitions.py"
graph_script = root / "scripts" / "build_room_graph.py"
print("\n--- Post-fix verification ---\n")
all_clean = True
if check_script.exists():
import subprocess
print("$ python scripts/check_transitions.py")
result = subprocess.run(["python", str(check_script)], cwd=str(root), capture_output=True, text=True)
lines = [l for l in result.stdout.strip().split("\n") if "ERROR:" in l]
if lines:
all_clean = False
print(f" Still has {len(lines)} error(s):")
for l in lines[:10]:
print(f" {l.strip()}")
if len(lines) > 10:
print(f" ... and {len(lines) - 10} more")
else:
OK_count = sum(1 for l in result.stdout.split("\n") if "OK:" in l)
print(f" All {OK_count} transitions valid! ✓\n")
else:
print(f"[skip] check_transitions.py not found at {check_script}\n")
if graph_script.exists():
import subprocess
print("$ python scripts/build_room_graph.py")
result = subprocess.run(["python", str(graph_script)], cwd=str(root), capture_output=True, text=True)
output = result.stdout.strip().split("\n")
for line in output[:8]:
print(f" {line}")
# Check if all rooms are connected or note component issues
for l in output:
if "component" in l.lower():
if "all rooms connected" not in l.lower():
comp_count = re.search(r"(\d+)", l)
if comp_count and int(comp_count.group(1)) > 1:
print(f" Note: {l.strip()} (some rooms disconnected by missing transitions)")
else:
print(f"[skip] build_room_graph.py not found at {graph_script}\n")
return all_clean
def main() -> None:
import argparse
parser = argparse.ArgumentParser(
description="Detect and repair UID mismatches in KQ4 room scenes",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog=__doc__,
)
parser.add_argument("--detect", action="store_true", help="Only detect mismatches")
parser.add_argument("--fix", action="store_true", help="Apply fixes for found mismatches")
parser.add_argument("--dry-run", action="store_true", help="Show what would change without writing")
parser.add_argument("--room", help="Limit to a specific room name (e.g., kq4_018_cemetery)")
parser.add_argument("-v", "--verbose", action="store_true", help="Verbose output")
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)
rooms = parse_all_rooms(scenes_dir)
if args.detect and not args.fix:
mismatches = run_detect(rooms, args.verbose)
if mismatches:
sys.exit(0)
if args.fix:
applied = run_fix(rooms, args.dry_run, args.room)
print(f"\nApplied {applied} fix(es)" + (" (dry run — no changes made)" if args.dry_run else ""))
if not args.dry_run and applied > 0:
try:
clean = run_verify()
if not clean:
sys.exit(1)
except Exception as e:
print(f"[verification warning] {e}")
if __name__ == "__main__":
main()