# Sprint 5f Implementation Plan — Spatial Validation in node_ops

## Overview

GodotIQ is an MCP server for AI-assisted Godot 4.x game development. Its `godotiq_node_ops` tool lets AI agents batch-modify scene nodes (move, rotate, scale, etc.) via WebSocket bridge to the Godot editor. Currently, node_ops blindly executes operations without checking spatial validity — an agent can move a shelf inside a wall and nobody notices.

This sprint adds **spatial validation**: when an operation includes `validate: true`, the tool reads `.tscn` files from disk (via the existing `GodotIQSession.resolve_scene_spatial()` pipeline) to check the target position against the scene's spatial context before executing. If the position is invalid (inside a wall, overlapping another node), execution is blocked with a diagnostic message.

The implementation is entirely Python-side. No GDScript/addon changes.

### Known Limitations

- **Stale data**: Validation reads `.tscn` files from disk. Unsaved editor changes (via prior `node_ops` calls without `save_scene`) are not reflected. Agents should call `save_scene` before validated operations if they have made prior unsaved changes.
- **Point-based distance**: Checks use node origin positions, not collision shape extents. A node whose origin is 0.3m from a wall might still physically overlap if the object is large. Shape-aware validation is deferred to v2.
- **No rotation validation**: Only move and scale are validated. Rotation-based intersections are deferred to v2.

## Architecture

### Data Flow

```
godotiq_node_ops (MCP wrapper, __init__.py)
  │
  ├── Guards: if ctx is None or session is None → skip validation
  │
  ├── If any op has validate:true:
  │     Resolve scene path (session.to_res_path or main_scene fallback)
  │     session.resolve_scene_spatial(res_path) → (ResolvedScene, list[WorldNode])
  │     Convert WorldNode list → scene_data dict for validator
  │
  └── Calls node_ops(operations, scene_data=scene_data)
        │
        ├── For each op with validate:true:
        │     spatial_validator.validate_move/validate_scale(target, name, scene_data)
        │
        ├── If any BLOCKED → return immediately (atomic: no ops execute)
        │
        └── If all OK/WARNING → bridge.request() → attach validation to result
```

**Note:** This is the first bridge tool to access the session via MCP context. All other bridge tools currently ignore `ctx`. This is a deliberate architectural boundary crossing — bridge tools (WebSocket) now also consult filesystem-parsed state when validation is requested.

### Module Boundaries

- **`spatial_validator.py`** (new): Pure Python module. Takes dicts with full field names (`name`, `type`, `position`, `scale`), returns dicts. No imports from session, bridge, or MCP. Fully testable in isolation.
- **`node_ops.py`** (modified): Accepts optional `scene_data` dict parameter. Loops through operations, calls validator for flagged ops, decides whether to proceed.
- **`__init__.py`** (modified): MCP wrapper gains `scene` parameter. Handles session access, scene resolution, WorldNode→dict conversion. Passes scene_data down.

## Section 1: Spatial Validator Module

### Purpose

A standalone module (`src/godotiq/tools/bridge/spatial_validator.py`) containing pure validation functions. It receives scene data as a dict and operation parameters, returning structured validation results.

### Public API

```python
def validate_move(target_position: list[float], node_name: str, scene_data: dict) -> dict:
    """Validate a move operation against spatial context.

    Returns: {valid: bool, severity: "OK"|"WARNING"|"BLOCKED", message: str, checks: list[dict]}
    """

def validate_scale(target_scale: list[float], node_name: str, scene_data: dict) -> dict:
    """Validate a scale operation against similar nodes in the scene.

    Returns: same shape as validate_move
    """
```

### Validation Checks

**Check 1: Wall Proximity**
- Identifies wall nodes: type contains `StaticBody` OR name contains `Wall`/`Floor`/`Ceiling`/`Ground`/`Barrier` (case-insensitive)
- Computes XZ horizontal distance (ignoring Y axis) from target position to each wall node's position
- **Y-axis sanity check**: Skip the wall pair if Y-axis difference exceeds 3m (prevents false positives in multi-story scenes)
- Excludes the node being moved (self)
- Thresholds: < 0.2m → BLOCKED, < 0.5m → WARNING
- All thresholds are module-level constants for easy tuning

**Check 2: Node Overlap**
- Computes 3D Euclidean distance from target position to other nodes **at the same tree depth** only (depth filtering prevents false positives from parent/child nodes sharing positions — e.g., a MeshInstance3D at [0,0,0] inside a Node3D at [0,0,0])
- Excludes the node being moved (self)
- Threshold: < 0.05m → BLOCKED (conservative, matches spatial audit approach)

**Check 3: Sibling Outlier**
- Extracts name prefix by finding the last underscore followed by a suffix of ≤3 characters (numeric or alpha). Examples: `Shelf_01` → `Shelf_`, `PrinterSlot_P3` → `PrinterSlot_`, `Wall_East` → `""` (4 chars, no match), `MainCamera` → `""` (no underscore pattern)
- Finds sibling nodes matching the same prefix
- If 2+ siblings exist, computes average inter-sibling distance
- If target is > 3x the average AND > 5m from nearest sibling → WARNING

**Check 4: Scale Outlier** (validate_scale only)
- Finds sibling nodes by name prefix (same as check 3)
- Computes average X-scale across siblings
- If target scale is > 3x or < 1/3x the average → WARNING

### Scene Data Format

The validator expects a dict with a `"nodes"` key containing a list of node dicts. Each node dict uses **full field names**:

```python
{
    "nodes": [
        {
            "name": "Wall_East",
            "type": "StaticBody3D",
            "position": [5.0, 0.0, 0.0],
            "scale": [1.0, 1.0, 1.0],
            "depth": 1
        },
        ...
    ]
}
```

Required fields per node: `name` (str), `type` (str), `position` (list of 3 floats).
Optional fields: `scale` (list of 3 floats), `depth` (int, for overlap filtering).

### Result Format

```python
{
    "valid": True,           # False if severity is BLOCKED
    "severity": "OK",        # "OK", "WARNING", or "BLOCKED"
    "message": "Position [1.8, 0.0, -0.5] valid. 2.3m from nearest wall (Wall_East); No overlapping nodes",
    "suggestion": None,      # Reserved for v2 pushback suggestions
    "checks": [
        {"check": "wall_distance", "severity": "OK", "detail": "2.3m from nearest wall (Wall_East)"},
        {"check": "overlap", "severity": "OK", "detail": "No overlapping nodes"},
        {"check": "sibling_distance", "severity": "OK", "detail": "1.2m from nearest sibling Shelf_03"}
    ]
}
```

## Section 2: node_ops Integration

### Modified Function Signature

```python
async def node_ops(operations: list[dict], scene_data: dict | None = None) -> dict:
    """Execute batch node operations with optional spatial validation.

    When scene_data is provided and an operation has validate:true,
    the operation is checked against scene spatial context before execution.
    """
```

### Validation Flow

Before sending operations to the WebSocket bridge:

1. Scan operations for any with `validate: true`
2. If none found OR `scene_data` is None → skip validation, proceed normally
3. For each operation with `validate: true`:
   - If `op == "move"` and `position` is present → call `validate_move()`
   - If `op == "scale"` and `scale` is present → call `validate_scale()`
   - If `op == "add_child"` and `position` is present → call `validate_move()`
   - Collect all validation results
4. If ANY result has `severity == "BLOCKED"` → return immediately with `status: "BLOCKED"`, no bridge call. **This is atomic**: even ops without `validate: true` in the same batch do not execute.
5. If all OK/WARNING → proceed with bridge call, attach validation results to response

### Response Formats

The tool can return three shapes:

**Normal (no validation):**
```python
{"results": [{"op": "move", "node": "Player", "status": "ok"}, ...]}
```

**Blocked by validation:**
```python
{
    "status": "BLOCKED",
    "validation": [...],
    "message": "One or more operations blocked by spatial validation. No changes made.",
    "operations_blocked": 1
}
```

**Success with validation warnings:**
```python
{
    "results": [...],
    "validation": [...]
}
```

## Section 3: MCP Wrapper Updates

### New Parameter

The `godotiq_node_ops` wrapper in `__init__.py` gains a `scene` parameter:

```python
async def godotiq_node_ops(
    operations: list[dict] | None = None,
    scene: str | None = None,
    ctx: Context = None,
) -> dict:
```

### Scene Data Acquisition

When any operation has `validate: true`:

1. **Guard**: if `ctx is None` → skip validation, pass `scene_data=None`
2. Get session from `ctx.request_context.lifespan_context["session"]`
3. **Guard**: if `session is None` → skip validation, pass `scene_data=None`
4. If `scene` parameter provided → normalize to res:// using `session.to_res_path(scene)`
5. If no `scene` provided → find main scene from project.godot (`run/main_scene` key). Check if `_read_main_scene` helper exists in `godotiq.tools.memory` for reuse; if not, implement inline.
6. Call `session.resolve_scene_spatial(res_path)` → returns `(ResolvedScene, list[WorldNode])` or `None`
7. If result is `None` → pass `scene_data=None` (validation skipped)
8. Convert the `WorldNode` list to the validator's dict format:
   ```python
   scene_data = {"nodes": [
       {"name": wn.node.name, "type": wn.node.type,
        "position": list(wn.world_position), "scale": list(wn.world_scale),
        "depth": wn.depth}
       for wn in world_nodes
   ]}
   ```
9. Pass `scene_data` to `_node_ops()`

**Why `resolve_scene_spatial` instead of `_build_scene_map`:** The scene_map MCP tool filters to depth ≤ 1 and caps at 50 nodes — walls deeper in the tree (e.g., `Room/Walls/Wall_East`) would be invisible. `resolve_scene_spatial` returns ALL nodes at ALL depths with world-space transforms already computed.

### Updated Tool Description

The tool docstring should document:
- `validate: true` in any operation enables spatial validation
- `scene` parameter specifies which .tscn to validate against (optional, falls back to main scene)
- The three possible response formats (normal, blocked, success-with-warnings)
- Atomic batch behavior (one blocked op blocks all)

## Section 4: MCP Prompt Update

Add a "Spatial Validation" subsection to `src/godotiq/prompts/godot_development.md` explaining:
- How to use `validate: true` on move, scale, or add_child operations
- The `scene` parameter for specifying which scene to validate against
- The three severity levels (OK, WARNING, BLOCKED)
- Atomic batch behavior (if one op is blocked, none execute)
- Recommendation to always use `validate: true` when moving objects in 3D
- Recommendation to `save_scene` before validated operations if prior unsaved changes exist
- Example usage

## Section 5: Testing

### Test File

`tests/test_bridge/test_spatial_validator.py` — tests for the pure validation module AND node_ops integration.

### Unit Tests for validate_move

| Test | Input | Expected |
|------|-------|----------|
| Empty scene data | Any position, `{"nodes": []}` | OK (no data to validate against) |
| Valid position | Position far from walls | OK, valid=True |
| Too close to wall | Position < 0.2m from StaticBody3D | BLOCKED, message mentions wall name |
| Warning near wall | Position 0.2-0.5m from wall | WARNING |
| Overlap with same-depth node | Position < 0.05m from another node at same depth | BLOCKED, "overlap" in message |
| Overlap ignored for different depth | Position same as node at different depth | OK (depth filtering) |
| Self-node excluded | Position same as self node | OK (doesn't collide with itself) |
| Invalid position | `[1]` (less than 3 values) | BLOCKED |
| Sibling outlier | Position 50m from Shelf_01-03 siblings | WARNING, "sibling" in message |
| Wall ignored if Y-diff > 3m | Wall at Y=0, target at Y=5 | OK (multi-story) |

### Unit Tests for validate_scale

| Test | Input | Expected |
|------|-------|----------|
| Consistent scale | Scale matching siblings | OK |
| Outlier scale | Scale 40x smaller than siblings | WARNING |
| No siblings | Unique node name | OK |

### Unit Tests for _get_name_prefix

| Test | Input | Expected |
|------|-------|----------|
| Numbered suffix | `"Shelf_01"` | `"Shelf_"` |
| Short alpha suffix | `"PrinterSlot_P3"` | `"PrinterSlot_"` |
| Long alpha suffix | `"Wall_East"` | `""` (4 chars > max 3) |
| No suffix | `"MainCamera"` | `""` |

### Integration Tests for node_ops

| Test | Setup | Expected |
|------|-------|----------|
| No validate flag → no validation | Operations without validate:true, scene_data provided | Bridge called normally |
| No scene_data → no validation | Operations with validate:true, scene_data=None | Bridge called normally |
| BLOCKED prevents bridge call | validate:true op that would be blocked | Bridge NOT called, status=BLOCKED |
| WARNING allows bridge call | validate:true op with warning | Bridge called, validation attached |
| Mixed batch atomic blocking | One BLOCKED op + one valid op | Bridge NOT called for any |

### Testing Pattern

Validator tests use the pure function API directly — no mocking needed:
```python
def _scene_data(self, nodes):
    return {"nodes": nodes}
# Nodes use full field names: {"name": "Wall_East", "type": "StaticBody3D", "position": [5, 0, 0]}
```

Integration tests mock the bridge:
```python
mock_bridge = AsyncMock()
mock_bridge.request.return_value = {"results": [...]}
with patch("godotiq.tools.bridge.node_ops.get_bridge", return_value=mock_bridge):
    result = await node_ops(operations=ops, scene_data=scene_data)
```

## Constraints

- **Do not modify** `godot-addon/`, `src/godotiq/parsers/`, `src/godotiq/resolvers/`, or existing test files
- **Validation is best-effort**: if session is None, scene path is invalid, or scene data is empty, proceed without validation (return OK)
- **Backward compatible**: existing node_ops calls without `validate` or `scene` parameters work identically to before

## Deferred to v2

- Room bounds from `.godotiq.json` config
- Pushback suggestion computation (safe position)
- Group/collision layer wall detection
- Collision shape dimension analysis (BoxShape3D extents for shape-aware distance)
- Configurable wall detection rules
- Rotation validation (rotation-based intersections)
