# GodotIQ v0.1.4 Implementation Plan

## Overview

This sprint fixes 15 issues discovered during real-world testing of GodotIQ with a dungeon crawler demo game. The fixes span 9 tool categories plus packaging infrastructure. Implementation follows an impact-first order: substantial feature/fix changes first, then lightweight annotation changes, then packaging.

The codebase is a Python MCP server (`src/godotiq/`) that communicates with a GDScript addon (`godot-addon/addons/godotiq/`) over WebSocket. Most fixes require changes on both sides. All changes must include new tests, and all existing tests must continue to pass.

---

## Architecture Context

### Communication Flow
```
AI Agent → Python MCP tool → Bridge (WebSocket) → GDScript Addon → Godot Editor/Game
```

### Key File Map
```
src/godotiq/tools/bridge/
  build_scene.py       # Issues 1-3
  check_errors.py      # Issue 4
  exec_code.py         # Issues 5-6
  screenshot.py        # Issue 10
  input_sim.py         # Issue 11
  node_ops.py          # Issue 12
  save_scene.py        # Issue 9

src/godotiq/tools/
  assets/__init__.py   # Issue 7
  spatial/__init__.py  # Issue 8

src/godotiq/
  license.py           # Issue 14
  cli.py               # Issue 15

godot-addon/addons/godotiq/
  godotiq_server.gd    # Issues 1-3, 5, 9-12
  godotiq_runtime.gd   # Issue 11
  plugin.cfg           # Issue 13

pyproject.toml         # Issue 13
```

---

## Section 1: BUILD_SCENE — Grid Offset (Issue 1)

### Problem
Grid placement always starts at world origin `[0, 0, 0]`. No way to shift the entire pattern.

### Design
Add an `offset` parameter (`[x, y, z]`, default `[0, 0, 0]`) that shifts ALL placement modes — grid, scatter, and line.

### Python Side (`build_scene.py`)
- Add `offset: list[float] | None = None` parameter to `build_scene()`
- Validate: if provided, must be a list of exactly 3 numbers. Default to `[0, 0, 0]`
- Pass `offset` through to the bridge request params

### GDScript Side (`godotiq_server.gd`)
- In `_expand_build_grid`: Read `offset` from params (default `[0,0,0]`). Add offset to each node's position vector after the spacing calculation. This means the final position becomes `[ox + col*spacing + offset[0], oy + offset[1], oz + row*spacing + offset[2]]` for XZ axis mode
- In `_expand_build_line`: Add offset to each computed point position before placing nodes along the path
- In scatter handling: Add offset to each item's `position` array. If an item has no position, skip (scatter items require explicit positions)
- Override tiles with custom `position` keys also get offset applied — they are still relative to the pattern origin

### Tests
- Grid with offset `[10, 0, 5]`: verify all node positions are shifted
- Line with offset: verify interpolated positions include offset
- Scatter with offset: verify item positions shifted
- Default (no offset): verify backward-compatible behavior (no position change)
- Invalid offset (e.g., `[1, 2]`): verify validation error

---

## Section 2: BUILD_SCENE — Unique Names (Issue 2)

### Problem
Nodes are named with generic prefix "Node" (e.g., `Node_0_0`) instead of meaningful names derived from the scene file.

### Design
Derive default node names from the `.tscn` scene filename:
- Extract filename without extension: `res://assets/floor.tscn` → `floor`
- Grid: `{filename}_{row}_{col}` (e.g., `floor_0_0`)
- Scatter: `{filename}_{index}` (e.g., `wall_0`)
- Line: `{filename}_{index}` (e.g., `torch_2`)
- If item has explicit `"name"` in config, use that instead

### GDScript Side (`godotiq_server.gd`)
- In `_expand_build_grid`: Extract scene filename stem. Replace current prefix derivation (which defaults to `"Node"`) with the filename stem. The existing `{prefix}_{row}_{col}` pattern already works — just change what prefix is
- In `_expand_build_line`: Same — replace prefix with scene filename stem
- In scatter expansion: For each item, if no `"name"` key, derive from `scene` path + index. Items with explicit `name` keep their value
- Create a helper `_scene_stem(scene_path: String) -> String` that extracts filename without extension from a `res://` path (use `scene_path.get_file().get_basename()`)

### Python Side
No changes needed — naming is GDScript-side.

### Tests
- Grid build with `scene: "res://assets/floor.tscn"`: verify nodes are `floor_0_0`, `floor_0_1`, etc.
- Scatter with mixed named/unnamed items: verify explicit names preserved, unnamed get `{filename}_{index}`
- Line build: verify `{filename}_{index}` pattern
- Edge case: scene path with no extension or unusual characters

---

## Section 3: BUILD_SCENE — Parent Path Stripping (Issue 3)

### Problem
If user passes `"DungeonCrawler/Room1"` as parent path but the root node is `"DungeonCrawler"`, the lookup fails because `_find_node_by_name_or_path` searches relative to root.

### Design
Before resolving the parent path, check if the first segment matches the root node name. If so, strip it. Also improve the PARENT_NOT_FOUND error message with a hint.

### GDScript Side (`godotiq_server.gd`)
- In `_handle_build_scene`, before calling `_find_node_by_name_or_path`:
  1. Get root node name: `var root_name = scene_root.name`
  2. If `parent_str` starts with `root_name + "/"`, strip the first segment: `parent_str = parent_str.substr(root_name.length() + 1)`
  3. Only strip the first segment — deeper paths stay intact
- Update the PARENT_NOT_FOUND error message to include: `"root node is '{root_name}', use relative paths from root children"`

### Python Side
No changes needed.

### Tests
- Parent `"DungeonCrawler/Room1"` with root `"DungeonCrawler"`: verify path becomes `"Room1"` and resolves
- Parent `"Room1"` (already correct): verify no stripping
- Parent `"OtherNode/Room1"` with root `"DungeonCrawler"`: verify NOT stripped, proper error with hint
- Parent `"DungeonCrawler"` (just root name): verify stripping produces empty path (use root itself)

---

## Section 4: CHECK_ERRORS — False Positive Filtering (Issue 4)

### Problem
Errors at line 0 are almost always false positives from Godot's cache/reload mechanism, but they appear identical to real errors.

### Design
Post-process check_errors response to add confidence annotations.

### Python Side (`check_errors.py`)
After receiving bridge response:
1. Iterate over `errors` array in the result
2. For each error: if `line == 0` or `line` key is missing, add `"confidence": "low"`. Otherwise add `"confidence": "high"`
3. If ANY error has `confidence == "low"`, add top-level `"note": "line 0 errors are often false positives — try reloading the scene"`

### GDScript Side
No changes — the `line: 0` value is already set. Annotation happens Python-side.

### Tests
- Response with errors at line 0 and line 15: verify line-0 gets `confidence: "low"`, line-15 gets `confidence: "high"`, top-level note present
- Response with only line > 0 errors: verify all `confidence: "high"`, no note
- Response with no errors: verify no crash, no note
- Error dict missing `line` key entirely: verify `confidence: "low"`

---

## Section 5: EXEC — Error Detail Extraction (Issue 5)

### Problem
When GDScript compilation fails, exec returns only `"Compilation failed (error 43)"` with no line number or message.

### Design
Improve both GDScript (capture richer error data) and Python (format it) sides.

### GDScript Side (`godotiq_server.gd`)
In the exec handler(s) that compile GDScript:
- When a `GDScript.new()` set/load fails, the error code alone is insufficient
- Use the logger system (Godot 4.5+) to capture compilation errors: after compilation failure, call `_get_script_errors()` to retrieve any logged errors for the script
- Return structured error data: `{"error": "Compilation failed", "error_code": 43, "errors": [{"line": 12, "message": "Invalid set index..."}]}`
- If logger isn't available (Godot < 4.5), fall back to current behavior but still return `error_code` separately

### Python Side (`exec_code.py`)
- After receiving bridge response, check for structured error data
- If `errors` array exists in response, format `error_detail` from the first error: `"line {line}: {message}"`
- Add `error_detail` to the response dict alongside existing `error` field

### Tests
- Mock bridge response with structured error data: verify `error_detail` formatted correctly
- Mock response with old-style error (just error string): verify backward-compatible, no crash
- Multiple compilation errors: verify first error used for `error_detail`
- Error with line 0: verify still formatted

---

## Section 6: EXEC — Await Warning (Issue 6)

### Problem
Users use `await create_timer().timeout` in exec code and get timeout errors with no explanation.

### Design
Add warning to the exec tool docstring.

### Python Side (`exec_code.py`)
- Append to `TOOL_DESCRIPTION`: `" WARNING: func run() must return synchronously or within timeout_ms. Do not use long await chains (e.g. await create_timer().timeout) as they will cause timeout."`
- Also add to the `exec_code()` function docstring for documentation

### Tests
- Verify TOOL_DESCRIPTION contains the warning text
- Verify docstring contains the warning text

---

## Section 7: ASSET_REGISTRY — Fuzzy Match Suggestions (Issue 7)

### Problem
When `path_filter` returns 0 results, the user has no idea what directories exist in the project.

### Design
When filter yields empty results, scan the project index for directories containing recognized asset files and return them as suggestions.

### Python Side (`assets/__init__.py`)
- After building `assets_list`, if `path_filter` is provided and `len(assets_list) == 0`:
  1. Iterate over ALL assets in `index.assets` (not filtered)
  2. Collect unique parent directory paths (relative, e.g., `"res://dungeon_glb/"`)
  3. Only include directories that contain recognized asset extensions (using `_CATEGORY_MAP`)
  4. Sort alphabetically, cap at 20 suggestions
  5. Add `"suggestions": [...]` to the result dict
- This runs only on the zero-result path — no performance impact for normal queries

### Tests
- path_filter with 0 matches: verify suggestions list returned with valid directories
- path_filter with matches: verify no suggestions key
- Empty project (no assets): verify empty suggestions list
- Suggestions contain only directories with recognized asset files

---

## Section 8: SCENE_MAP — Disk Warning (Issue 8)

### Problem
Users don't realize scene_map reads from saved `.tscn` file, not live editor state.

### Design
Add a note to the scene_map response.

### Python Side (`spatial/__init__.py`)
- In the scene_map tool function (the `@mcp.tool` handler), add to the result dict:
  `"note": "reads from saved .tscn file — call godotiq_save_scene first if you have unsaved editor changes"`

### Tests
- Verify scene_map response contains `note` key with expected text
- Verify note doesn't interfere with existing response fields

---

## Section 9: SAVE_SCENE — Enhanced Feedback (Issue 9)

### Problem
`save_scene` only returns `"saved": true` with no useful metadata.

### Design
Return `file_size_kb` and `node_count` alongside existing response.

### GDScript Side (`godotiq_server.gd`)
In the save_scene handler, after successful save:
1. Get file size: `FileAccess.get_file_length(scene_path)` or `DirAccess.get_size()`, convert to KB (divide by 1024, round)
2. Count scene tree nodes: walk `edited_scene_root` recursively, count all children
3. Add to response: `"file_size_kb": size_kb, "node_count": count`

### Python Side (`save_scene.py`)
No changes — pass through new fields from bridge response.

### Tests
- Mock bridge response with new fields: verify they pass through
- Verify `file_size_kb` is a number, `node_count` is a positive integer

---

## Section 10: SCREENSHOT — Crop Region (Issue 10)

### Problem
No way to zoom into specific areas without increasing total response size.

### Design
Add optional `region: [x, y, width, height]` parameter. Crop happens GDScript-side after capture and scale.

### Python Side (`screenshot.py`)
- Add `region: list[int] | None = None` parameter
- Validate: if provided, must be 4 integers, x >= 0, y >= 0, w > 0, h > 0
- Pass `region` in bridge request params

### GDScript Side (`godotiq_server.gd`)
In screenshot handlers (both editor and game):
- After capture and scale, if `region` is provided:
  1. Read region: `var rx = region[0]; var ry = region[1]; var rw = region[2]; var rh = region[3]`
  2. Clamp to image bounds: `rx = clamp(rx, 0, image.get_width()); ry = clamp(ry, 0, image.get_height());` etc.
  3. If clamped values differ from original, set `crop_clamped = true`
  4. Apply crop: `image = image.get_region(Rect2i(rx, ry, rw, rh))`
  5. If clamped, add `"crop_warning"` to response with original and clamped values
- Image dimensions in response should reflect the cropped size

### Tests
- Screenshot with region within bounds: verify response dimensions match region
- Region exceeding bounds: verify clamping + warning
- No region: verify backward-compatible behavior
- Invalid region (negative w/h): verify validation error Python-side

---

## Section 11: INPUT — Mouse Motion (Issue 11)

### Problem
No way to simulate mouse movement for FPS camera rotation testing.

### Design
Add `mouse_motion` command type with `relative_x` and `relative_y` fields.

### Python Side (`input_sim.py`)
- Add `mouse_motion` to the supported command types in documentation
- In the docstring, add: `- Mouse motion: {"mouse_motion": {"relative_x": 200, "relative_y": 0}}`
- Validation: if command has `mouse_motion` key, verify it's a dict with numeric `relative_x` and `relative_y`
- Pass through to bridge (game-side handler does the actual input injection)

### GDScript Side (`godotiq_runtime.gd`)
In the input command handler:
- Add case for `mouse_motion` key in command dict
- Create `InputEventMouseMotion`:
  ```
  var ev = InputEventMouseMotion.new()
  ev.relative = Vector2(cmd.mouse_motion.relative_x, cmd.mouse_motion.relative_y)
  Input.parse_input_event(ev)
  ```
- Return `{"type": "mouse_motion", "relative": [rx, ry]}` as the command result

### Tests
- Command with mouse_motion: verify bridge receives correct params
- Missing relative_x/y: verify validation error
- Mixed commands (action + mouse_motion + wait): verify sequential execution

---

## Section 12: NODE_OPS — Rename and Get Property (Issue 12)

### Problem
No way to rename nodes or read property values.

### Design
Add `rename` and `get_property` operations.

### Python Side (`node_ops.py`)
- Add `"rename"` and `"get_property"` to the docstring's supported operations list
- `rename` validation: requires `node` and `new_name` (non-empty string)
- `get_property` validation: requires `node` and `property` (non-empty string). This is a read-only op — no spatial validation needed

### GDScript Side (`godotiq_server.gd`)
In the node_ops handler:

**rename:**
- Find node by path
- Use `undo_redo` to rename: set node's `name` property
- Return `{"node": original_path, "op": "rename", "status": "OK", "new_name": actual_name}` (Godot may adjust the name to avoid duplicates)

**get_property:**
- Find node by path
- Get property value: `node.get(property_name)`
- Convert to JSON-friendly types with fallback:
  - `Vector2` → `[x, y]`
  - `Vector3` → `[x, y, z]`
  - `Color` → `[r, g, b, a]` (float array, 0-1 range)
  - `Transform3D` → `{"origin": [x,y,z], "basis": [[xx,xy,xz],[yx,yy,yz],[zx,zy,zz]]}`
  - `int`, `float`, `bool`, `String` → native JSON
  - `null`/`nil` → `null`
  - Everything else → `str(value)`
- Return `{"node": path, "op": "get_property", "property": name, "value": converted_value}`
- If property doesn't exist, return error

### Tests
- Rename: verify name changed, undo action created
- Rename collision (Godot adjusts name): verify actual name returned
- Get property — position (Vector3): verify `[x, y, z]` format
- Get property — string property: verify native string
- Get property — nonexistent property: verify error
- Get property — Color: verify `[r, g, b, a]` format

---

## Section 13: PACKAGING — Version Sync (Issue 13)

### Problem
`plugin.cfg` version is hardcoded at `"0.1.0"` while `pyproject.toml` is at `"0.1.3"`.

### Design
Create a sync script that reads `pyproject.toml` version and updates `plugin.cfg` and `godotiq_server.gd`.

### Implementation
- Create `scripts/sync_version.py`:
  1. Parse `pyproject.toml` to extract `version` value (use regex or `tomllib`)
  2. Update `plugin.cfg`: replace the `version="X.Y.Z"` line
  3. Update `godotiq_server.gd`: replace the `const ADDON_VERSION := "X.Y.Z"` line
  4. Print what was updated for visibility
- Add a Makefile target or document in README that `python scripts/sync_version.py` must run before `python -m build`
- In the verification step, run sync before building

### Tests
- Unit test `sync_version.py`: verify it correctly reads toml and patches files
- Test with version `"0.1.4"`: verify all files updated

---

## Section 14: LICENSE — Dev Key Hashing (Issue 14)

### Problem
`GODOTIQ_LICENSE=pro` is a plain-text bypass that anyone can use.

### Design
Replace with SHA-256 hashed dev key.

### Implementation (`license.py`)
1. Generate a secure random passphrase (32+ chars) — this is the "dev key"
2. Compute SHA-256 hash and hardcode it as `_DEV_KEY_HASH` constant
3. Replace line 119 (`GODOTIQ_LICENSE == "pro"` check):
   - Instead: read `GODOTIQ_DEV_KEY` env var
   - Hash it with SHA-256
   - Compare hash to `_DEV_KEY_HASH`
   - If match → `return True`
4. Remove all references to `GODOTIQ_LICENSE=pro` bypass
5. Keep `GODOTIQ_LICENSE_KEY` (Polar.sh) path unchanged

### Security Notes
- The passphrase itself is never stored in source code — only its hash
- The dev key is printed once during generation for the developer to save
- `_DEV_KEY_HASH` in source is not reversible

### Tests
- `GODOTIQ_DEV_KEY=correct_passphrase` → `is_pro()` returns True
- `GODOTIQ_DEV_KEY=wrong_passphrase` → `is_pro()` returns False
- `GODOTIQ_LICENSE=pro` (old bypass) → `is_pro()` returns False (removed)
- No env vars → `is_pro()` returns False (unless license key set)

---

## Section 15: CLI — Version Warning Suppression (Issue 15)

### Problem
`godotiq --version` triggers a "no project found" warning before printing the version.

### Design
Suppress warnings when `--version` is the intent.

### Implementation (`cli.py`)
- At the top of `cli_main()`, before any imports that might trigger warnings:
  1. Check if `"--version"` in `sys.argv`
  2. If so, set logging level to `CRITICAL` or suppress warnings via `warnings.filterwarnings("ignore")`
- The `from godotiq import __version__` import triggers session initialization which emits the warning. By suppressing before that import, the warning is silenced
- Alternative: defer the `__version__` import to only happen when needed, or use `importlib.metadata.version("godotiq")` directly

### Tests
- Verify that `--version` flag suppresses warnings (capture stderr)
- Verify normal operation (no `--version`) still shows warnings

---

## Implementation Order

Following the impact-first principle from stakeholder interview:

1. **Section 1-3**: BUILD_SCENE (offset, names, parent path) — most user-visible
2. **Section 12**: NODE_OPS (rename, get_property) — high impact
3. **Section 5**: EXEC error detail — high debugging value
4. **Section 10**: SCREENSHOT crop — significant feature
5. **Section 11**: INPUT mouse motion — significant feature
6. **Section 4**: CHECK_ERRORS confidence — moderate impact
7. **Section 7**: ASSET_REGISTRY suggestions — moderate impact
8. **Sections 6, 8, 9**: Light annotation changes (docstring, notes, feedback)
9. **Sections 13-15**: Packaging (version sync, dev key, CLI warning)

After all fixes:
1. Run `pytest tests/ -v` — all pass
2. Bump version to `0.1.4` in `pyproject.toml`
3. Run `python scripts/sync_version.py`
4. Rebuild: `rm -rf dist/ build/ && .venv/bin/python -m build`
5. Do NOT upload to PyPI
