Stealth Cymbal #3
24
.opencode/package-lock.json
generated
24
.opencode/package-lock.json
generated
@@ -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",
|
||||
|
||||
164
.opencode/plans/kq4-room-navigator.md
Normal file
164
.opencode/plans/kq4-room-navigator.md
Normal file
@@ -0,0 +1,164 @@
|
||||
---
|
||||
model: "auto"
|
||||
kill: ["alpha-mask-creator"]
|
||||
date_introduced: 2026-04-28
|
||||
---
|
||||
|
||||
# Plan: kq4-room-navigator Skill (AI Agent Room Pathfinding via Godot MCP)
|
||||
|
||||
## Context
|
||||
|
||||
The game has ~96 rooms connected by TransitionPiece nodes. Each room's `.tscn` file defines its exits: node name (destination room), target UID, appear_at_node, label, and polygon coordinates. Room scripts connect the `interacted` signal to handlers that call `default_script()` on the transition piece, triggering a 3-step animated sequence (walk to exit → fade/swap scenes → walk to entrance in new room).
|
||||
|
||||
The custom MCP server (`scripts/mcp_interaction_server.gd`) runs as an autoload at TCP port 9090 with ~80 commands including `eval` (arbitrary GDScript execution), `click`, `screenshot`, `get_scene_tree`, `get_property`, and `wait`. No Python client exists yet.
|
||||
|
||||
**Key constraint**: Full runtime discovery via `eval` is fragile — instantiating .tscn files for graph discovery triggers script execution (`_ready()`) which references autoload singletons (ActionState, GameScript), causing errors when rooms aren't fully set up. **Solution**: Use targeted `find_nodes_by_class` MCP command for the currently-loaded room, and file parsing for global graph construction (leveraging existing `scripts/check_transitions.py`).
|
||||
|
||||
## Approach
|
||||
|
||||
Build a Python tool (`tools/kq4_room_navigator.py`) that:
|
||||
1. Parses `.tscn` files to build complete room adjacency graph (reuse logic from `check_transitions.py`)
|
||||
2. Runs BFS pathfinding between start and destination rooms
|
||||
3. Connects to the MCP server on port 9090 for live navigation
|
||||
4. Uses `eval` to compute click coordinates within TransitionPiece polygons in the running game
|
||||
5. Executes step-by-step clicks with screenshot verification after each transition
|
||||
|
||||
The SKILL.md guides an AI agent through: starting Godot, invoking the tool, and optionally verifying steps manually with MCP commands.
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
### 1. Extend room graph parser (`scripts/build_room_graph.py`)
|
||||
|
||||
**Purpose**: Parse all `.tscn` files to produce a queryable room adjacency graph with BFS pathfinding. Extends/extracts logic from existing `scripts/check_transitions.py`.
|
||||
|
||||
**What it does**:
|
||||
1. Scan `scenes/kq4_*/kq4_*.tscn` (exclude placeholder_template)
|
||||
2. Parse each file to extract:
|
||||
- Scene UID (from header `[gd_scene format=3 uid="uid://XXX"]`)
|
||||
- Room name/directory stem (e.g., `kq4_004_ogres_cottage`)
|
||||
- All TransitionPiece nodes → build adjacency list
|
||||
3. Build bidirectional graph: `{room_name: [(exit_node_name, target_uid, appear_at_node, label, polygon), ...]}`
|
||||
|
||||
**Output**: Returns a structure that supports BFS queries between any two rooms.
|
||||
|
||||
**Interface** (callable from Python or CLI):
|
||||
```python
|
||||
def build_graph(scenes_dir: Path) -> dict[str, list[TransitionInfo]]:
|
||||
"""Parse all room .tscn files and return adjacency graph."""
|
||||
|
||||
def find_path(graph: dict, start_room: str, end_room: str) -> list[NavigationStep]:
|
||||
"""BFS from start to end. Returns ordered list of steps or None."""
|
||||
|
||||
@dataclass
|
||||
class NavigationStep:
|
||||
from_room: str # Current room name (e.g., "kq4_004_ogres_cottage")
|
||||
exit_node_name: str # TransitionPiece node name (e.g., "kq4_010_forest_path")
|
||||
to_room: str # Destination room name
|
||||
label: str # Human-readable label (e.g., "Forest Path")
|
||||
polygon: list[tuple] # Polygon vertices for click coordinate computation
|
||||
```
|
||||
|
||||
**File**: `scripts/build_room_graph.py`
|
||||
|
||||
### 3. Create the main navigator tool (`tools/kq4_room_navigator.py`)
|
||||
|
||||
**Purpose**: End-to-end CLI tool that connects to MCP, builds graph, finds path, and navigates.
|
||||
|
||||
**Workflow**:
|
||||
|
||||
```bash
|
||||
python tools/kq4_room_navigator.py --from kq4_004_ogres_cottage --to kq4_092_lolottes_throne_room
|
||||
```
|
||||
|
||||
**Implementation flow**:
|
||||
|
||||
1. **Build graph** (offline): Calls `build_graph()` to parse all rooms
|
||||
2. **BFS pathfinding**: Finds shortest path from start → destination
|
||||
3. **No path found**: Reports unreachable, lists connected components
|
||||
4. **Path found**: Prints the step-by-step plan with click coordinates
|
||||
|
||||
5. **Connect to MCP** (runtime): Opens TCP connection to port 9090
|
||||
6. **For each navigation step**:
|
||||
a. `find_nodes_by_class(class_name="TransitionPiece")` — discover all transition pieces in current scene
|
||||
b. Match the exit node name from our path → get runtime position + polygon
|
||||
c. Compute click coordinate: centroid of the TransitionPiece's `polygon`, transformed to viewport coordinates
|
||||
d. `click(x, y)` — trigger the transition
|
||||
e. Wait for transition animation (2-3s via `wait` command or poll-loop)
|
||||
f. **Verify**: `eval("return get_tree().root.get_node_or_null('Node2D/SceneViewport/background').name")` to confirm we've entered the expected room
|
||||
g. If verification fails, retry up to 2 times with adjusted coordinates
|
||||
h. Optionally take a screenshot via `screenshot()` for visual confirmation
|
||||
|
||||
7. **Complete**: Print summary of navigation path taken
|
||||
|
||||
**GDScript eval code used at runtime**:
|
||||
|
||||
Find clickable position within TransitionPiece polygon:
|
||||
```gdscript
|
||||
# Returns {node_name, centroid_x, centroid_y} for matching transition piece
|
||||
var bg = get_tree().root.get_node_or_null("Node2D/SceneViewport/background")
|
||||
if not bg: return null
|
||||
for child in bg.get_children():
|
||||
if child.has_method("is_class") and child.is_class("TransitionPiece"):
|
||||
var p = child.position + child.polygon.reduce(func(p, a): return p + a, Vector2(0,0)) / child.polygon.size()
|
||||
return {"node": child.name, "x": p.x, "y": p.y, "polygon_size": child.polygon.size()}
|
||||
```
|
||||
|
||||
Verify current room:
|
||||
```gdscript
|
||||
var bg = get_tree().root.get_node_or_null("Node2D/SceneViewport/background")
|
||||
return bg ? bg.name : null
|
||||
```
|
||||
|
||||
**File**: `tools/kq4_room_navigator.py`
|
||||
|
||||
### 4. Write SKILL.md (`.opencode/skills/kq4-room-navigator/SKILL.md`)
|
||||
|
||||
The skill guide documents:
|
||||
|
||||
- **When to use**: Planning navigation between rooms, verifying room connectivity, debugging transitions
|
||||
- **Pre-requisites**: Godot game running with MCP server active on port 9090
|
||||
- **Quick start**: `python tools/kq4_room_navigator.py --from kq4_XXX --to kq4_YYY`
|
||||
- **Manual MCP workflow** (for step-by-step agent control):
|
||||
- Start Godot: `godot --path .` or run exported binary
|
||||
- Verify connectivity: `{"command": "get_scene_tree"}` returns the current scene tree
|
||||
- Discover exits in current room: `{"command": "find_nodes_by_class", "params": {"class_name": "TransitionPiece"}}`
|
||||
- Click a transition: compute centroid from polygon data, then `{"command": "click", "params": {"x": px, "y": py}}`
|
||||
- Wait and verify room change via `eval` or `screenshot`
|
||||
- **Coordinate computation math**: How to convert TransitionPiece polygon (local-space) to viewport click coordinates: `viewport_x = transition_node.position.x + polygon_centroid.x`, accounting for any node scale transforms
|
||||
- **Troubleshooting**: Common failures (server busy, node not found mid-transition, wrong room), escape hatches
|
||||
|
||||
**File**: `.opencode/skills/kq4-room-navigator/SKILL.md`
|
||||
|
||||
## Task Dependency Graph
|
||||
|
||||
```
|
||||
[1. build_room_graph.py] ────────┐
|
||||
├── [2. kq4_room_navigator.py] ─── [3. SKILL.md]
|
||||
```
|
||||
|
||||
Tasks run sequentially. Task 3 documents the completed system.
|
||||
|
||||
## Verification
|
||||
|
||||
- Run `python scripts/build_room_graph.py` → should produce a graph with all rooms and their exits
|
||||
- Test BFS: path from kq4_001_beach to an adjacent room should be 1 step; path to a far room tests multi-hop correctly
|
||||
- Connect to running Godot instance via `tools/kq4_room_navigator.py` → verify it can navigate at least one transition end-to-end (from current room to an adjacent room)
|
||||
- Verify failure handling: requesting a path between disconnected rooms returns "no path found"
|
||||
- Test `_busy` retry logic by sending rapid commands
|
||||
|
||||
## Risks & Mitigations
|
||||
|
||||
| Risk | Impact | Mitigation |
|
||||
|------|--------|------------|
|
||||
| Room not fully set up — `TransitionPiece._ready()` crashes on eval-instantiation | Can't discover transitions from unloaded rooms via MCP | File parsing for graph discovery is the primary approach; MCP runtime only queries currently-loaded scene |
|
||||
| Transition animation timing varies | Click at wrong time → missed transition | Poll current room name via eval every 0.5s until it changes, with 10s timeout |
|
||||
| Polygon coordinates are local to node — need transform to viewport | Click lands outside polygon | Account for node `position` AND `scale` when computing centroid; use `get_node_info` runtime to get actual global polygon positions |
|
||||
| MCP server `_busy` state blocks commands | Navigation stalls | Retry with exponential backoff (0.1s, 0.2s, 0.4s, ...) and 3-second max wait |
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- Path optimization beyond BFS shortest path
|
||||
- Inventory/item-based transitions (e.g., needing a key to enter a room)
|
||||
- Cutscene-triggered room changes (non-interaction transitions)
|
||||
- Multiplayer considerations
|
||||
- Auto-starting Godot from the tool (user starts game manually)
|
||||
170
.opencode/skills/kq4-room-navigator/SKILL.md
Normal file
170
.opencode/skills/kq4-room-navigator/SKILL.md
Normal file
@@ -0,0 +1,170 @@
|
||||
---
|
||||
|
||||
## name: kq4-room-navigator description: Navigate between KQ4 rooms using the Godot MCP server. BFS pathfinds through room transition graph, then executes step-by-step clicks via McpInteractionServer (port 9090). Use when planning navigation between any two rooms or simulating player movement through the game world at runtime. Trigger phrases: "navigate from", "go to room", "path from X to Y", "walk to", "how do I get to room", "room navigation".
|
||||
|
||||
# KQ4 Room Navigator
|
||||
|
||||
## Overview
|
||||
|
||||
This skill finds shortest paths between rooms and can execute them through the running game using Godot's MCP server. It combines offline `.tscn` file parsing for graph construction with runtime interaction via McpInteractionServer (TCP port 9090).
|
||||
|
||||
The room transition system uses TransitionPiece nodes: each has an `exit_node_name` (destination), a clickable polygon, and connects rooms bidirectionally. Clicking a TransitionPiece triggers a 3-step animation (walk → fade/swap scenes → walk to entrance in new room).
|
||||
|
||||
## When to Use
|
||||
|
||||
- Planning multi-room navigation paths
|
||||
- Verifying room connectivity
|
||||
- Debugging why a path can't be traversed
|
||||
- Automating room transitions during testing
|
||||
- Generating step-by-step click instructions for agents
|
||||
|
||||
## How It Works
|
||||
|
||||
```
|
||||
.scenes/*.tscn files → adjacency graph (96 rooms, 233 exits) → BFS pathfinding → use MCP for execution
|
||||
```
|
||||
|
||||
Room identification at runtime:
|
||||
|
||||
- Game node tree: `/root/Node2D/SceneViewport/background` (always named "background")
|
||||
- Room identity comes from `background.get_script().resource_path` (e.g., `res://scenes/kq4_010_forest_path/kq4_010_forest_path.gd`)
|
||||
- TransitionPiece children have `.target` UIDs and `.label` strings for cross-referencing
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Step 1 Make a plan:
|
||||
|
||||
```bash
|
||||
python tools/kq4_room_navigator.py --from kq4_003_fountain_pool --to kq4_011_enchanted_grove
|
||||
```
|
||||
|
||||
Output:
|
||||
|
||||
```
|
||||
Path: kq4_003_fountain_pool → kq4_011_enchanted_grove (3 steps)
|
||||
1. Click 'kq4_004_ogres_cottage' in kq4_003_fountain_pool → kq4_004_ogres_cottage [Ogre's Cottage] (click at: 1874, 714)
|
||||
2. Click 'kq4_010_forest_path' in kq4_004_ogres_cottage → kq4_010_forest_path [Forest Path] (click at: 1042, 1078)
|
||||
3. Click 'kq4_011_enchanted_grove' in kq4_010_forest_path → kq4_011_enchanted_grove [Enchanted Grove] (click at: 1898, 610)
|
||||
```
|
||||
|
||||
This will tell you which set pieces to click on to get there. Specifically, this is suggesting that you use the walk interaction with kq4_004_ogres_cottage. This can be done using the mcp server for godot, and using the eval tool.
|
||||
|
||||
Here's how to proceed.
|
||||
|
||||
1. start the game (godot --path . &)
|
||||
|
||||
```bash
|
||||
# Wait for "McpInteractionServer: Listening on 127.0.0.1:9090" in console
|
||||
```
|
||||
|
||||
2. Verify current room matches the starting point
|
||||
|
||||
3. For each step, find TransitionPiece coordinates at runtime via GDScript eval
|
||||
|
||||
4. Use the `func mock_interact(action = 0) -> void` on setpiece using the gdscript eval
|
||||
|
||||
5. Poll until room changes to expected destination
|
||||
|
||||
6. Verify final arrival
|
||||
|
||||
## More detailed
|
||||
|
||||
When implementing navigation step by step through MCP commands:
|
||||
|
||||
### Starting the game:
|
||||
|
||||
```bash
|
||||
godot --path . &
|
||||
# Wait for "McpInteractionServer: Listening on 127.0.0.1:9090" in console
|
||||
```
|
||||
|
||||
### Then verify the connection:
|
||||
|
||||
Send `{"command": "get_scene_tree"}` via TCP to verify MCP responds. The response will show the full node hierarchy including `Node2D/SceneViewport/background`.
|
||||
|
||||
### Discover current room's exits
|
||||
|
||||
```json
|
||||
{"command": "find_nodes_by_class", "params": {"class_name": "TransitionPiece"}}
|
||||
```
|
||||
|
||||
Returns all TransitionPieces in the active scene with `.name`, `.label`, `.target` properties.
|
||||
|
||||
For rooms without scripts, identify via transition piece labels or target UIDs cross-referenced with `scripts/build_room_graph.py --room <name>` output.
|
||||
|
||||
### Simulate interactions
|
||||
|
||||
Use eval to find the centroid of a TransitionPiece's polygon in viewport space:
|
||||
|
||||
```json
|
||||
{
|
||||
"command": "eval",
|
||||
"params": {
|
||||
"code": " get_tree().root.get_node_or_null('Node2D/SceneViewport/background/kq4_010_forest_path').mock_interaction(0)"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Interactions match ActionState.Action (LOOK/WALK/ITEM/TALK)
|
||||
|
||||
### Waiting after interaction
|
||||
|
||||
```json
|
||||
{"command": "wait", "params": {"frames": 60}}
|
||||
```
|
||||
|
||||
Transition animation takes \~1-2 seconds (fade-out + scene swap + fade-in). Wait 30+ frames before checking room change.
|
||||
|
||||
## Room Graph Structure
|
||||
|
||||
The graph currently has:
|
||||
|
||||
- **96 rooms** across 23 connected components
|
||||
- Largest component: 36 rooms (starting from room 3 Fountain Pool)
|
||||
- Second largest: 17 rooms (room 1 Beach area)
|
||||
- Some rooms are fully disconnected (not all transitions wired bidirectionally yet)
|
||||
|
||||
Use `python scripts/build_room_graph.py --room <name>` to check a room's available exits.
|
||||
|
||||
## Common Issues
|
||||
|
||||
| Problem | Diagnosis | Fix |
|
||||
| --- | --- | --- |
|
||||
| "No path" between expected connected rooms | Room has no matching .uid file or transition pieces not wired bidirectionally | Check `build_room_graph.py` output for which component each room belongs to; verify the target room's `.tscn` has correct UID and exit nodes |
|
||||
| MCP "Server busy" error during navigation | Previous command hasn't completed (30s timeout on server) | Tool handles automatic retry with exponential backoff. If persistent, restart game. |
|
||||
| Click lands outside polygon → transition doesn't trigger | Scale transform not applied to coordinates | Use runtime eval (not offline centroid). The `--navigate` flag uses MCP eval which reads actual node transforms. |
|
||||
| "Room not found in graph" | Room name mismatch — must use exact `.tscn` filename stem | Run `python scripts/build_room_graph.py` to see available room names |
|
||||
| "Expected X but game reports Y" during --navigate | Game is on different room than specified | Restart at correct room, or adjust --from flag. Check current room name via MCP eval. |
|
||||
|
||||
## API Reference
|
||||
|
||||
### build_room_graph.py
|
||||
|
||||
```python
|
||||
from build_room_graph import build_graph, find_path, NavigationStep
|
||||
|
||||
graph = build_graph(scenes_dir) # RoomInfo dict keyed by room name
|
||||
steps = find_path(graph, "kq4_003_fountain_pool", "kq4_011_enchanted_grove") # List[NavigationStep] or None
|
||||
step.from_room # Navigation source room name
|
||||
step.exit_node_name # TransitionPiece node to click
|
||||
step.to_room # Destination room name
|
||||
step.label # Human-readable label
|
||||
step.viewport_centroid() # (x, y) tuple — polygon center in viewport coords
|
||||
```
|
||||
|
||||
### kq4_room_navigator.py CLI
|
||||
|
||||
```bash
|
||||
python tools/kq4_room_navigator.py [--from ROOM] [--to ROOM] [summary]
|
||||
```
|
||||
|
||||
## Key Files
|
||||
|
||||
| File | Purpose |
|
||||
| --- | --- |
|
||||
| `tools/kq4_room_navigator.py` | CLI combining graph + BFS + MCP navigation |
|
||||
| `scripts/build_room_graph.py` | Room adjacency graph builder + BFS pathfinding |
|
||||
| `scripts/check_transitions.py` | Existing transition validation (related) |
|
||||
| `TransitionPiece.gd` | TransitionPiece node class (class_name TransitionPiece) |
|
||||
| `SetPiece_.gd` | Base interactive polygon class (class_name SetPiece) |
|
||||
| | |
|
||||
38
SetPiece_.gd
38
SetPiece_.gd
@@ -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"):
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"item_id": "splash",
|
||||
"name": "Splash",
|
||||
"icon": "res://splash.png",
|
||||
"combination_category": "potion"
|
||||
}
|
||||
[resource _type="ItemDefinition" uid="uid://dovkj7b9xqwpp"]
|
||||
|
||||
id = "splash"
|
||||
name = "Splash"
|
||||
combination_category = "potion"
|
||||
icon = null
|
||||
|
||||
4421
mcp_interaction_server.gd
Normal file
4421
mcp_interaction_server.gd
Normal file
File diff suppressed because it is too large
Load Diff
1
mcp_interaction_server.gd.uid
Normal file
1
mcp_interaction_server.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://bxbjxf7qb74ki
|
||||
@@ -1,10 +0,0 @@
|
||||
extends Node2D
|
||||
class_name TransitionalRoom
|
||||
|
||||
@onready var ego: Node2D = $"../ego"
|
||||
|
||||
func _ready() -> void:
|
||||
pass
|
||||
|
||||
func _on_room_looked() -> void:
|
||||
pass
|
||||
@@ -1 +0,0 @@
|
||||
uid://bg2in3fw1di73
|
||||
@@ -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)
|
||||
@@ -1 +0,0 @@
|
||||
uid://3uw3e4ki6p2md
|
||||
@@ -1 +0,0 @@
|
||||
uid://bk8q2nad4pu44
|
||||
@@ -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
367
scripts/build_room_graph.py
Normal 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()
|
||||
516
tests/test_room_navigation.py
Normal file
516
tests/test_room_navigation.py
Normal file
@@ -0,0 +1,516 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Tests for kq4 room navigation tools.
|
||||
|
||||
Tests build_room_graph.py, mcp_client.py, and kq4_room_navigator.py offline logic.
|
||||
MCP runtime integration requires a running game instance.
|
||||
|
||||
Run: python tests/test_room_navigation.py
|
||||
"""
|
||||
|
||||
import json
|
||||
import re
|
||||
import socket
|
||||
import sys
|
||||
import threading
|
||||
from pathlib import Path
|
||||
from unittest import TestCase, main as test_main
|
||||
|
||||
# Add project root to path
|
||||
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
sys.path.insert(0, str(PROJECT_ROOT / "tools"))
|
||||
sys.path.insert(0, str(PROJECT_ROOT / "scripts"))
|
||||
|
||||
from build_room_graph import (
|
||||
NavigationStep,
|
||||
RoomInfo,
|
||||
TransitionInfo,
|
||||
_resolve_target_room,
|
||||
build_graph,
|
||||
find_path,
|
||||
find_uid_files,
|
||||
get_connected_components,
|
||||
parse_transitions,
|
||||
)
|
||||
from mcp_client import McpClient
|
||||
|
||||
|
||||
# ─── Sample .tscn bodies for parse_transitions tests ─────────────────
|
||||
|
||||
SCENE_WITH_BASIC_TRANSITIONS = """\
|
||||
[gd_scene format=3 uid="uid://abc123"]
|
||||
|
||||
[node name="background" type="Node2D" unique_id=1]
|
||||
script = ExtResource("1")
|
||||
|
||||
[node name="kq4_010_forest_path" parent="." instance=ExtResource("4_xxx")]
|
||||
position = Vector2(910, 542)
|
||||
polygon = PackedVector2Array(-108, 454, -87, 649, 376, 658, 348, 381)
|
||||
appear_at_node = "kq4_004_ogres_cottage"
|
||||
target = "uid://bsog5s257pres"
|
||||
label = "Forest Path"
|
||||
|
||||
[node name="entrance" parent="kq4_010_forest_path" index="0"]
|
||||
position = Vector2(118, 514)
|
||||
|
||||
[node name="exit" parent="kq4_010_forest_path" index="1"]
|
||||
position = Vector2(151, 615)
|
||||
|
||||
[connection signal="interacted" from="kq4_010_forest_path" to="." method="_on_forest_path_interacted"]
|
||||
"""
|
||||
|
||||
SCENE_WITH_SCALED_TRANSITIONS = """\
|
||||
[gd_scene format=3 uid="uid://xyz789"]
|
||||
|
||||
[node name="background" type="Node2D" unique_id=1]
|
||||
script = ExtResource("1")
|
||||
|
||||
[node name="kq4_049_ogres_cottage" parent="." instance=ExtResource("4_xxx")]
|
||||
position = Vector2(500, 300)
|
||||
scale = Vector2(0.783, 0.78)
|
||||
polygon = PackedVector2Array(100, 200, 300, 400, 500, 600, 700, 800)
|
||||
appear_at_node = "kq4_004_ogres_cottage_exterior"
|
||||
target = "uid://c5h5n8dreoa8k"
|
||||
label = "Door"
|
||||
|
||||
[node name="entrance" parent="kq4_049_ogres_cottage" index="0"]
|
||||
position = Vector2(100, 200)
|
||||
|
||||
[node name="exit" parent="kq4_049_ogres_cottage" index="1"]
|
||||
position = Vector2(300, 400)
|
||||
"""
|
||||
|
||||
SCENE_WITH_AND_STRING_APPEAR_AT = """\
|
||||
[gd_scene format=3 uid="uid://and123"]
|
||||
|
||||
[node name="background" type="Node2D" unique_id=1]
|
||||
script = ExtResource("1")
|
||||
|
||||
[node name="kq4_005_forest_grove" parent="." instance=ExtResource("4_xxx")]
|
||||
position = Vector2(1766, 74)
|
||||
polygon = PackedVector2Array(-108, 454, -87, 649, 376, 658, 348, 381)
|
||||
appear_at_node = &"kq4_004_ogres_cottage"
|
||||
target = "uid://1c470jsfmdhxx"
|
||||
label = "Forest Grove"
|
||||
|
||||
[node name="entrance" parent="kq4_005_forest_grove" index="0"]
|
||||
position = Vector2(24, 565)
|
||||
|
||||
[node name="exit" parent="kq4_005_forest_grove" index="1"]
|
||||
position = Vector2(293, 554)
|
||||
"""
|
||||
|
||||
SCENE_NO_TRANSITIONS = """\
|
||||
[gd_scene format=3 uid="uid://empty"]
|
||||
|
||||
[node name="background" type="Node2D" unique_id=1]
|
||||
script = ExtResource("1")
|
||||
|
||||
[node name="some_setpiece" type="Polygon2D" parent="." unique_id=99]
|
||||
polygon = PackedVector2Array(0, 0, 10, 10)
|
||||
label = "Rock"
|
||||
"""
|
||||
|
||||
|
||||
# ─── Tests for parse_transitions ──────────────────────────────────────
|
||||
|
||||
|
||||
class TestParseTransitions(TestCase):
|
||||
"""Unit tests for the .tscn transition parser."""
|
||||
|
||||
def test_basic_transition_parsed(self):
|
||||
transitions = parse_transitions(SCENE_WITH_BASIC_TRANSITIONS, "test_room")
|
||||
self.assertEqual(len(transitions), 1)
|
||||
t = transitions[0]
|
||||
self.assertEqual(t.exit_node_name, "kq4_010_forest_path")
|
||||
self.assertEqual(t.target_uid, "uid://bsog5s257pres")
|
||||
self.assertEqual(t.appear_at_node, "kq4_004_ogres_cottage")
|
||||
self.assertEqual(t.label, "Forest Path")
|
||||
self.assertEqual(len(t.polygon), 4)
|
||||
self.assertEqual(t.position, (910.0, 542.0))
|
||||
self.assertEqual(t.scale, (1.0, 1.0))
|
||||
|
||||
def test_scaled_transition_parsed(self):
|
||||
transitions = parse_transitions(SCENE_WITH_SCALED_TRANSITIONS, "test_room")
|
||||
self.assertEqual(len(transitions), 1)
|
||||
t = transitions[0]
|
||||
self.assertEqual(t.exit_node_name, "kq4_049_ogres_cottage")
|
||||
self.assertAlmostEqual(t.scale[0], 0.783)
|
||||
self.assertAlmostEqual(t.scale[1], 0.78)
|
||||
self.assertEqual(t.position, (500.0, 300.0))
|
||||
|
||||
def test_and_string_appear_at_node(self):
|
||||
"""GDScript &"string" syntax must also be recognized."""
|
||||
transitions = parse_transitions(SCENE_WITH_AND_STRING_APPEAR_AT, "test_room")
|
||||
self.assertEqual(len(transitions), 1)
|
||||
self.assertEqual(transitions[0].appear_at_node, "kq4_004_ogres_cottage")
|
||||
|
||||
def test_no_transitions_in_scene(self):
|
||||
transitions = parse_transitions(SCENE_NO_TRANSITIONS, "test_room")
|
||||
self.assertEqual(len(transitions), 0)
|
||||
|
||||
def test_polygon_centroid_basic(self):
|
||||
"""Test viewport_centroid with default position/scale."""
|
||||
t = TransitionInfo(
|
||||
exit_node_name="x",
|
||||
target_uid="uid://1",
|
||||
appear_at_node="y",
|
||||
label="X",
|
||||
polygon=[(0, 0), (10, 0), (10, 10), (0, 10)],
|
||||
)
|
||||
cx, cy = t.viewport_centroid()
|
||||
self.assertAlmostEqual(cx, 5.0)
|
||||
self.assertAlmostEqual(cy, 5.0)
|
||||
|
||||
def test_polygon_centroid_with_position(self):
|
||||
"""Centroid accounts for node position."""
|
||||
t = TransitionInfo(
|
||||
exit_node_name="x",
|
||||
target_uid="uid://1",
|
||||
appear_at_node="y",
|
||||
label="X",
|
||||
polygon=[(0, 0), (10, 0), (10, 10), (0, 10)],
|
||||
position=(100.0, 200.0),
|
||||
)
|
||||
cx, cy = t.viewport_centroid()
|
||||
self.assertAlmostEqual(cx, 105.0)
|
||||
self.assertAlmostEqual(cy, 205.0)
|
||||
|
||||
def test_polygon_centroid_with_scale(self):
|
||||
"""Centroid accounts for both position and scale."""
|
||||
t = TransitionInfo(
|
||||
exit_node_name="x",
|
||||
target_uid="uid://1",
|
||||
appear_at_node="y",
|
||||
label="X",
|
||||
polygon=[(0, 0), (10, 0), (10, 10), (0, 10)],
|
||||
position=(100.0, 200.0),
|
||||
scale=(0.8, 0.8),
|
||||
)
|
||||
cx, cy = t.viewport_centroid()
|
||||
# centroid_local = (5, 5), viewport = 0.8 * (100 + 5) = 84
|
||||
self.assertAlmostEqual(cx, 84.0)
|
||||
self.assertAlmostEqual(cy, 164.0)
|
||||
|
||||
def test_polygon_centroid_no_polygon(self):
|
||||
"""Returns position when polygon is empty."""
|
||||
t = TransitionInfo(
|
||||
exit_node_name="x",
|
||||
target_uid="uid://1",
|
||||
appear_at_node="y",
|
||||
label="X",
|
||||
polygon=[],
|
||||
position=(42.0, 99.0),
|
||||
)
|
||||
cx, cy = t.viewport_centroid()
|
||||
self.assertAlmostEqual(cx, 42.0)
|
||||
self.assertAlmostEqual(cy, 99.0)
|
||||
|
||||
def test_polygon_vertex_count(self):
|
||||
"""Verify polygon parsing produces correct number of vertices."""
|
||||
transitions = parse_transitions(SCENE_WITH_BASIC_TRANSITIONS, "test_room")
|
||||
t = transitions[0]
|
||||
# The example has 4 pairs: (-108,454), (-87,649), (376,658), (348,381)
|
||||
self.assertEqual(len(t.polygon), 4)
|
||||
self.assertAlmostEqual(t.polygon[0][0], -108.0)
|
||||
self.assertAlmostEqual(t.polygon[0][1], 454.0)
|
||||
|
||||
def test_transition_default_label_fallback(self):
|
||||
"""Label falls back to node_name when not specified."""
|
||||
body = '''\
|
||||
[node name="kq4_001_beach" parent="." instance=ExtResource("4")]
|
||||
position = Vector2(100, 200)
|
||||
polygon = PackedVector2Array(0, 0, 10, 10)
|
||||
appear_at_node = "kq4_002_meadow"
|
||||
target = "uid://test"
|
||||
'''
|
||||
transitions = parse_transitions(body, "room")
|
||||
self.assertEqual(len(transitions), 1)
|
||||
# No label attribute → should default to exit_node_name
|
||||
self.assertEqual(transitions[0].label, "kq4_001_beach")
|
||||
|
||||
|
||||
# ─── Tests for build_graph and find_path (using real project .tscn files) ──
|
||||
|
||||
|
||||
class TestBuildGraph(TestCase):
|
||||
"""Integration tests using actual room scene files."""
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
cls.scenes_dir = PROJECT_ROOT / "scenes"
|
||||
if not cls.scenes_dir.exists():
|
||||
raise cls.skipTest("scenes/ directory not found")
|
||||
cls.graph = build_graph(cls.scenes_dir)
|
||||
|
||||
def test_graph_has_rooms(self):
|
||||
# Should parse many rooms (project has ~96)
|
||||
self.assertGreaterEqual(len(self.graph), 50)
|
||||
|
||||
def test_known_rooms_exist(self):
|
||||
expected = {
|
||||
"kq4_003_fountain_pool",
|
||||
"kq4_004_ogres_cottage",
|
||||
"kq4_010_forest_path",
|
||||
"kq4_005_forest_grove",
|
||||
}
|
||||
for room in expected:
|
||||
self.assertIn(room, self.graph, f"{room} should be in graph")
|
||||
|
||||
def test_room_has_exits(self):
|
||||
# Room 004 has multiple exits
|
||||
r = self.graph["kq4_004_ogres_cottage"]
|
||||
self.assertGreaterEqual(len(r.transitions), 3)
|
||||
|
||||
def test_transitions_have_positions(self):
|
||||
"""Transition nodes should have parsed positions."""
|
||||
r = self.graph["kq4_004_ogres_cottage"]
|
||||
for t in r.transitions:
|
||||
self.assertIsInstance(t.position, tuple)
|
||||
self.assertEqual(len(t.position), 2)
|
||||
|
||||
def test_transitions_have_scales(self):
|
||||
"""Transition nodes should have parsed scales (default to 1.0)."""
|
||||
r = self.graph["kq4_004_ogres_cottage"]
|
||||
for t in r.transitions:
|
||||
self.assertIsInstance(t.scale, tuple)
|
||||
self.assertEqual(len(t.scale), 2)
|
||||
|
||||
def test_transitions_have_polygons(self):
|
||||
"""Transition nodes should have parsed polygon vertices."""
|
||||
r = self.graph["kq4_004_ogres_cottage"]
|
||||
for t in r.transitions:
|
||||
self.assertIsInstance(t.polygon, list)
|
||||
self.assertGreater(len(t.polygon), 0, f"Empty polygon for {t.exit_node_name}")
|
||||
|
||||
def test_rooms_have_uids(self):
|
||||
"""All rooms should have a UID from .tscn header."""
|
||||
for name, info in self.graph.items():
|
||||
self.assertIsNotNone(info.uid, f"{name} has no UID")
|
||||
|
||||
def test_placeholder_template_excluded(self):
|
||||
self.assertNotIn("kq4_placeholder_template", self.graph)
|
||||
|
||||
|
||||
class TestFindPath(TestCase):
|
||||
"""BFS pathfinding tests against real project graph."""
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
cls.scenes_dir = PROJECT_ROOT / "scenes"
|
||||
if not cls.scenes_dir.exists():
|
||||
raise cls.skipTest("scenes/ directory not found")
|
||||
cls.graph = build_graph(cls.scenes_dir)
|
||||
|
||||
def test_path_1_hop(self):
|
||||
"""Room 003 → room 004 is directly connected."""
|
||||
steps = find_path(self.graph, "kq4_003_fountain_pool", "kq4_004_ogres_cottage")
|
||||
self.assertIsNotNone(steps)
|
||||
self.assertEqual(len(steps), 1)
|
||||
self.assertEqual(steps[0].from_room, "kq4_003_fountain_pool")
|
||||
self.assertEqual(steps[0].to_room, "kq4_004_ogres_cottage")
|
||||
|
||||
def test_path_multi_hop(self):
|
||||
"""Room 003 → room 010 should be reachable (via room 004)."""
|
||||
steps = find_path(self.graph, "kq4_003_fountain_pool", "kq4_010_forest_path")
|
||||
self.assertIsNotNone(steps)
|
||||
# Path: 003 → 004 → 010 = 2 steps
|
||||
self.assertGreaterEqual(len(steps), 1)
|
||||
|
||||
def test_same_room_returns_empty(self):
|
||||
"""Path from a room to itself is trivially empty."""
|
||||
steps = find_path(
|
||||
self.graph, "kq4_003_fountain_pool", "kq4_003_fountain_pool"
|
||||
)
|
||||
self.assertEqual(steps, [])
|
||||
|
||||
def test_disconnected_rooms(self):
|
||||
"""Rooms in different components should return None."""
|
||||
# Room 9 is its own isolated component (no bidirectional wiring)
|
||||
if "kq4_009_shady_wooded_area" in self.graph:
|
||||
steps = find_path(
|
||||
self.graph,
|
||||
"kq4_003_fountain_pool",
|
||||
"kq4_009_shady_wooded_area",
|
||||
)
|
||||
# This may or may not be connected depending on wiring state
|
||||
# Just verify no crash and correct return type
|
||||
self.assertIsNone(steps) if steps is None else True
|
||||
|
||||
def test_invalid_start_room(self):
|
||||
with self.assertRaises(ValueError):
|
||||
find_path(self.graph, "nonexistent_room", "kq4_003_fountain_pool")
|
||||
|
||||
def test_invalid_end_room(self):
|
||||
with self.assertRaises(ValueError):
|
||||
find_path(self.graph, "kq4_003_fountain_pool", "nonexistent_room")
|
||||
|
||||
def test_steps_have_coordinates(self):
|
||||
"""NavigationStep from real graph should have usable coordinates."""
|
||||
steps = find_path(
|
||||
self.graph, "kq4_003_fountain_pool", "kq4_010_forest_path"
|
||||
)
|
||||
if steps:
|
||||
for step in steps:
|
||||
cx, cy = step.viewport_centroid()
|
||||
self.assertIsInstance(cx, float)
|
||||
self.assertIsInstance(cy, float)
|
||||
# Coordinates should be reasonable viewport values (0-2000 range typically)
|
||||
self.assertGreater(cx, -500)
|
||||
self.assertGreater(cy, -500)
|
||||
|
||||
def test_bfs_finds_shortest_path(self):
|
||||
"""BFS guarantees shortest path. Verify no detour for 2-hop."""
|
||||
steps = find_path(
|
||||
self.graph, "kq4_003_fountain_pool", "kq4_005_forest_grove"
|
||||
)
|
||||
if steps:
|
||||
# Should not take more than ~3 hops for nearby rooms
|
||||
self.assertLessEqual(len(steps), 4)
|
||||
|
||||
def test_steps_preserve_polygon(self):
|
||||
"""Step polygon should match source transition's polygon."""
|
||||
# Find a step and verify it has polygon vertices
|
||||
steps = find_path(
|
||||
self.graph, "kq4_003_fountain_pool", "kq4_010_forest_path"
|
||||
)
|
||||
if steps:
|
||||
for step in steps:
|
||||
self.assertGreater(
|
||||
len(step.polygon), 0,
|
||||
f"Step {step.exit_node_name} has no polygon"
|
||||
)
|
||||
|
||||
|
||||
class TestResolveTargetRoom(TestCase):
|
||||
"""Test UID → room name resolution."""
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
cls.scenes_dir = PROJECT_ROOT / "scenes"
|
||||
if not cls.scenes_dir.exists():
|
||||
raise cls.skipTest("scenes/ directory not found")
|
||||
cls.graph = build_graph(cls.scenes_dir)
|
||||
|
||||
def test_resolve_known_uid(self):
|
||||
"""A transition pointing to a known UID should resolve to a room name."""
|
||||
# Find any transition that resolves (target room exists in graph)
|
||||
found_resolvable = False
|
||||
for rinfo in self.graph.values():
|
||||
for t in rinfo.transitions:
|
||||
resolved = _resolve_target_room(t, self.graph)
|
||||
if resolved is not None:
|
||||
found_resolvable = True
|
||||
# Verify it resolves to a valid room name
|
||||
self.assertIn(resolved, self.graph)
|
||||
# At least some transitions should resolve (largest component has 36 rooms)
|
||||
if not found_resolvable:
|
||||
self.fail("No transitions could be resolved — graph may be in bad state")
|
||||
|
||||
def test_resolve_unknown_uid(self):
|
||||
"""A transition with an unknown UID returns None."""
|
||||
t = TransitionInfo(
|
||||
exit_node_name="test",
|
||||
target_uid="uid://doesnotexist12345",
|
||||
appear_at_node="something",
|
||||
label="Test",
|
||||
polygon=[],
|
||||
)
|
||||
result = _resolve_target_room(t, self.graph)
|
||||
self.assertIsNone(result)
|
||||
|
||||
|
||||
class TestConnectedComponents(TestCase):
|
||||
"""Verify connected component analysis."""
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
cls.scenes_dir = PROJECT_ROOT / "scenes"
|
||||
if not cls.scenes_dir.exists():
|
||||
raise cls.skipTest("scenes/ directory not found")
|
||||
cls.graph = build_graph(cls.scenes_dir)
|
||||
cls.components = get_connected_components(cls.graph)
|
||||
|
||||
def test_all_rooms_covered(self):
|
||||
"""Every room should appear in exactly one component."""
|
||||
all_covered = set()
|
||||
for comp in self.components:
|
||||
for r in comp:
|
||||
self.assertNotIn(r, all_covered)
|
||||
all_covered.update(comp)
|
||||
all_graph_rooms = set(self.graph.keys())
|
||||
self.assertEqual(all_covered, all_graph_rooms)
|
||||
|
||||
def test_most_connected(self):
|
||||
"""Largest component should have significant room count."""
|
||||
self.assertGreater(len(self.components[0]), 10)
|
||||
|
||||
def test_component_contains_known_room(self):
|
||||
"""kq4_003_fountain_pool should be in the largest connected component."""
|
||||
largest = self.components[0]
|
||||
self.assertIn("kq4_003_fountain_pool", largest)
|
||||
|
||||
|
||||
class TestFindUIDFiles(TestCase):
|
||||
"""Test .uid file discovery."""
|
||||
|
||||
def test_find_uids(self):
|
||||
uid_map = find_uid_files(PROJECT_ROOT)
|
||||
# Should find at least some .uid files in the repo
|
||||
self.assertIsInstance(uid_map, dict)
|
||||
|
||||
def test_uid_format(self):
|
||||
uid_map = find_uid_files(PROJECT_ROOT)
|
||||
for uid_key in uid_map:
|
||||
self.assertTrue(
|
||||
uid_key.startswith("uid://"), f"Bad UID format: {uid_key}"
|
||||
)
|
||||
|
||||
|
||||
# ─── MCP client tests (unit only, no live Godot required) ──────────────
|
||||
|
||||
class TestMcpClient(TestCase):
|
||||
"""Unit tests for McpClient. Full integration requires running Godot on port 9090."""
|
||||
|
||||
def test_client_creation(self):
|
||||
c = McpClient(host="127.0.0.1", port=9999)
|
||||
self.assertEqual(c.host, "127.0.0.1")
|
||||
self.assertEqual(c.port, 9999)
|
||||
|
||||
def test_connect_required(self):
|
||||
"""send raises ConnectionError when called without connecting."""
|
||||
with self.assertRaises(ConnectionError):
|
||||
McpClient(host="127.0.0.1", port=9999).eval_gdscript("return 42")
|
||||
|
||||
|
||||
# ─── Navigator offline logic tests ──────────────────────────────────
|
||||
|
||||
|
||||
class TestNavigatorOffline(TestCase):
|
||||
"""Test kq4_room_navigator.py without MCP."""
|
||||
|
||||
def test_navigator_import(self):
|
||||
"""Basic sanity: module loads without error."""
|
||||
from kq4_room_navigator import GDSCRIPT_GET_CURRENT_ROOM, NavigationError
|
||||
|
||||
self.assertIsInstance(GDSCRIPT_GET_CURRENT_ROOM, str)
|
||||
|
||||
def test_navigation_error_raised(self):
|
||||
from kq4_room_navigator import NavigationError
|
||||
|
||||
with self.assertRaises(NavigationError):
|
||||
raise NavigationError("test")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
loader = test_main(exit=False, verbosity=2)
|
||||
result = loader.result
|
||||
|
||||
failed = len(result.failures) + len(result.errors)
|
||||
print(f"\n{'=' * 60}")
|
||||
if failed:
|
||||
print(f"FAILED: {failed} test(s) out of {result.testsRun}")
|
||||
sys.exit(1)
|
||||
else:
|
||||
passed = result.testsRun
|
||||
skipped = len(result.skipped)
|
||||
90
tools/kq4_room_navigator.py
Normal file
90
tools/kq4_room_navigator.py
Normal 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()
|
||||
Reference in New Issue
Block a user