# Section 04: Node Ops GDScript Handler

## Overview

This section adds the `node_ops` handler to `godotiq_server.gd`, enabling batch node operations with full `EditorUndoRedoManager` support. All operations in a single call are grouped into one undo action, so the user can Ctrl+Z the entire batch at once.

This section modifies one file:
- `/Users/salvatorefarruggio/Desktop/godotiq/godot-addon/addons/godotiq/godotiq_server.gd`

## Dependencies

- **section-01-protocol-plugin-wiring** must be completed first. That section adds:
  - `var undo_redo` to the server script (untyped, set by `plugin.gd`)
  - The `_server.undo_redo = get_undo_redo()` wiring in `plugin.gd`'s `_enter_tree()`
  - The `"node_ops": 10.0` timeout in `protocol.py`

This section does NOT depend on section-02 (scene_tree) or section-03. It CAN be implemented in parallel with section-02.

## Tests

GDScript handlers are tested manually via a live bridge connection. There are no automated Python tests for this GDScript code. However, the Python-side tests (section-05 and section-09) verify correct parameter forwarding and response handling, providing indirect coverage.

Manual test checklist for verification:

```
# Manual tests — run via Python bridge or WebSocket client against a live Godot editor

# Test: move a 3D node
{"method": "node_ops", "params": {"operations": [{"op": "move", "node": "Player", "position": [1, 2, 3]}]}}
# Expected: node moves to (1, 2, 3), single undo action created

# Test: move a 2D node
# Expected: only x,y from position array used on Node2D.position

# Test: move a Control node
# Expected: only x,y from position array used on Control.position

# Test: rotate a 3D node
{"method": "node_ops", "params": {"operations": [{"op": "rotate", "node": "Player", "rotation": [0, 90, 0]}]}}
# Expected: node rotation_degrees set to (0, 90, 0)

# Test: scale a 3D node
{"method": "node_ops", "params": {"operations": [{"op": "scale", "node": "Player", "scale": [2, 2, 2]}]}}
# Expected: node scale set to (2, 2, 2)

# Test: set_property with sub-path
{"method": "node_ops", "params": {"operations": [{"op": "set_property", "node": "Player", "property": "position:x", "value": 5.0}]}}
# Expected: only the x component of position changes

# Test: add_child from scene
{"method": "node_ops", "params": {"operations": [{"op": "add_child", "parent": "World", "scene": "res://enemies/goblin.tscn", "name": "Goblin1"}]}}
# Expected: new node appears, owner set to scene root, undo removes it

# Test: add_child by type
{"method": "node_ops", "params": {"operations": [{"op": "add_child", "parent": "World", "type": "Node3D", "name": "Marker"}]}}
# Expected: new typed node created under parent

# Test: delete node (non-root)
{"method": "node_ops", "params": {"operations": [{"op": "delete", "node": "Goblin1"}]}}
# Expected: node removed, undo restores it

# Test: delete scene root should fail
{"method": "node_ops", "params": {"operations": [{"op": "delete", "node": "RootNodeName"}]}}
# Expected: error, scene root cannot be deleted

# Test: duplicate node
{"method": "node_ops", "params": {"operations": [{"op": "duplicate", "node": "Player", "name": "Player2"}]}}
# Expected: duplicate created with new name, owner set recursively

# Test: reparent node
{"method": "node_ops", "params": {"operations": [{"op": "reparent", "node": "Player", "new_parent": "World/Area2"}]}}
# Expected: node moves to new parent, undo restores original parent

# Test: batch operations (multiple ops in one call)
{"method": "node_ops", "params": {"operations": [
  {"op": "move", "node": "A", "position": [1,0,0]},
  {"op": "move", "node": "B", "position": [0,1,0]}
]}}
# Expected: both apply, single Ctrl+Z undoes both

# Test: empty operations array
{"method": "node_ops", "params": {"operations": []}}
# Expected: error response, no undo action created

# Test: all operations fail — empty undo guard
# Expected: commit_action() is NOT called, undo history stays clean

# Test: node not found
{"method": "node_ops", "params": {"operations": [{"op": "move", "node": "NonExistent", "position": [1,0,0]}]}}
# Expected: per-operation error result, no crash

# Test: find by path vs find by name
# A node path like "World/Player" is tried first as a relative path; if not found, fall back to recursive name search
```

## Implementation Details

### File to Modify

`/Users/salvatorefarruggio/Desktop/godotiq/godot-addon/addons/godotiq/godotiq_server.gd`

### Step 1: Add `node_ops` to the Dispatch Table

In the existing `_dispatch()` function, add a new match arm for `"node_ops"` before the wildcard `_:` case:

```gdscript
"node_ops":
    _handle_node_ops(peer_id, id, params)
```

Note: The `"scene_tree"` arm is added by section-02. If implementing in parallel, both sections add their own arm to the match block. Order within the match does not matter.

### Step 2: Implement `_handle_node_ops()`

This is the main handler function. It:

1. Validates the `operations` array is present and non-empty
2. Gets the edited scene root via `EditorInterface.get_edited_scene_root()`
3. Checks that `undo_redo` is available (was set by plugin.gd)
4. Creates a single undo action grouping ALL operations
5. Iterates each operation, dispatching to `_execute_node_op()`
6. Tracks whether any operation actually registered undo callbacks
7. Only calls `commit_action()` if at least one operation succeeded (empty undo guard)
8. Returns results array with per-operation status

```gdscript
func _handle_node_ops(peer_id: int, id: String, params: Dictionary) -> void:
```

The response format:

```json
{
    "results": [
        {"op": "move", "node": "Player", "status": "ok"},
        {"op": "delete", "node": "Missing", "status": "error", "error": "Node not found: Missing"}
    ],
    "scene_modified": true,
    "undo_available": true,
    "action_name": "GodotIQ: 2 node operation(s)"
}
```

### Step 3: Implement `_execute_node_op()`

This function dispatches to the correct operation handler based on the `op` field. It returns a status dict for each operation.

```gdscript
func _execute_node_op(op_data: Dictionary, scene_root: Node, ur) -> Dictionary:
```

The `ur` parameter is the undo_redo reference (untyped to work with EditorUndoRedoManager).

#### Operation: `move`

Set position on a node. Handles three node types differently:
- **Node3D**: `node.position = Vector3(x, y, z)` -- uses all three components
- **Node2D**: `node.position = Vector2(x, y)` -- uses first two components
- **Control**: `node.position = Vector2(x, y)` -- uses first two components

Undo: Store old position via `ur.add_do_property()` / `ur.add_undo_property()`.

Parameters: `node` (name or path), `position` (array of [x, y, z])

#### Operation: `rotate`

Set rotation_degrees on a Node3D. For simplicity this initial implementation targets 3D nodes.
- **Node3D**: `node.rotation_degrees = Vector3(x, y, z)`

Undo: Store old rotation_degrees.

Parameters: `node`, `rotation` (array of [x, y, z] in degrees)

#### Operation: `scale`

Set scale on a Node3D.
- **Node3D**: `node.scale = Vector3(x, y, z)`

Undo: Store old scale.

Parameters: `node`, `scale` (array of [x, y, z])

#### Operation: `set_property`

Set any property on a node. Supports sub-path syntax (e.g., `"position:x"`) for individual components.

Steps:
1. Resolve the node
2. Split property by `:` to detect sub-paths
3. Get the current (reference) value for type conversion
4. Convert the JSON value to the matching Godot type via `_convert_value()`
5. Use `ur.add_do_property()` / `ur.add_undo_property()`

Parameters: `node`, `property` (string, supports `:` sub-paths), `value` (any JSON value)

#### Operation: `add_child`

Instantiate a scene or create a typed node and add it to a parent.

Steps:
1. Resolve parent node
2. If `scene` param: load and instantiate the PackedScene
3. If `type` param: create node via `ClassDB.instantiate(type)`
4. Set name from `name` param
5. If `position` param provided, set position after adding
6. `ur.add_do_method(parent, "add_child", new_node)` -- add the child
7. **Critical**: `new_node.owner = scene_root` -- required for the node to persist when saving
8. `ur.add_do_reference(new_node)` -- prevent GC during undo
9. For undo: `ur.add_undo_method(parent, "remove_child", new_node)`

Parameters: `parent` (name or path), `scene` (res:// path) OR `type` (class name), `name` (string), `position` (optional [x, y, z])

#### Operation: `delete`

Remove a node from the tree.

Steps:
1. Resolve the node
2. **Prevent deleting scene root**: if `node == scene_root`, return error
3. Store parent reference and child index for undo
4. `ur.add_do_method(parent, "remove_child", node)`
5. `ur.add_undo_method(parent, "add_child", node)` -- restore on undo
6. `ur.add_undo_reference(node)` -- **critical**: prevent garbage collection while the node is removed

Parameters: `node` (name or path)

#### Operation: `duplicate`

Duplicate a node with an optional new name.

Steps:
1. Resolve the source node
2. `var dup = node.duplicate()`
3. If `name` param provided, set `dup.name = name`
4. Add to same parent: `ur.add_do_method(parent, "add_child", dup)`
5. **Critical**: Recursively set `owner = scene_root` on the duplicate AND all its children. Without this, children of the duplicate lose their owner reference and won't persist on save.
6. `ur.add_do_reference(dup)`
7. For undo: `ur.add_undo_method(parent, "remove_child", dup)`

Parameters: `node` (name or path), `name` (optional new name)

#### Operation: `reparent`

Move a node to a different parent.

Steps:
1. Resolve the node and the new parent
2. Store old parent and child index
3. `ur.add_do_method(old_parent, "remove_child", node)`
4. `ur.add_do_method(new_parent, "add_child", node)`
5. `node.owner = scene_root` after reparenting
6. For undo: reverse the process (remove from new parent, add back to old parent at original index)

Parameters: `node` (name or path), `new_parent` (name or path)

### Step 4: Implement Helper Functions

#### `_find_node_by_name_or_path(name_or_path, scene_root)`

Node lookup that tries two strategies:
1. **Path first**: Try `scene_root.get_node_or_null(name_or_path)` -- this handles both absolute and relative paths within the scene tree
2. **Name fallback**: If path lookup fails, use `_find_by_name_recursive()` for a DFS name search

Returns the node or `null`.

```gdscript
func _find_node_by_name_or_path(name_or_path: String, scene_root: Node) -> Node:
```

#### `_find_by_name_recursive(node, target_name)`

Depth-first search through the tree looking for a node whose `.name` matches `target_name`.

```gdscript
func _find_by_name_recursive(node: Node, target_name: String) -> Node:
```

#### `_convert_value(value, reference_value)`

Convert a JSON value to the appropriate Godot type based on a reference value (the current property value). This is needed because JSON only has numbers, strings, booleans, arrays, and objects, but Godot has Vector3, Vector2, Color, etc.

Conversion rules:
- If `reference_value` is `Vector3` and `value` is an array of 3 numbers: return `Vector3(value[0], value[1], value[2])`
- If `reference_value` is `Vector2` and `value` is an array of 2 numbers: return `Vector2(value[0], value[1])`
- If `reference_value` is `Color` and `value` is an array of 3-4 numbers: return `Color(r, g, b, a)`
- If `reference_value` is `Color` and `value` is a string: return `Color(value)` (hex string)
- If `reference_value` is `int` and `value` is a number: return `int(value)`
- If `reference_value` is `float` and `value` is a number: return `float(value)`
- If `reference_value` is `bool`: return `bool(value)`
- Otherwise: return `value` as-is

```gdscript
func _convert_value(value, reference_value):
```

#### `_set_owner_recursive(node, owner)`

Recursively set the owner on a node and all its descendants. Used after `duplicate()` and `add_child()` to ensure persistence.

```gdscript
func _set_owner_recursive(node: Node, owner: Node) -> void:
    node.owner = owner
    for child in node.get_children():
        _set_owner_recursive(child, owner)
```

### Critical Implementation Notes

1. **Empty undo guard**: Track a counter or boolean (`any_succeeded`) during the operation loop. If ALL operations fail (e.g., all nodes not found), do NOT call `commit_action()`. This prevents polluting the undo history with empty no-op actions.

2. **Owner setting after add_child**: When using `add_do_method(parent, "add_child", node)`, the owner is not automatically set. You must explicitly set `node.owner = scene_root` for the node to be saved with the scene. This applies to `add_child`, `duplicate`, and `reparent` operations.

3. **Recursive owner on duplicate**: `node.duplicate()` copies the subtree, but the duplicated children's `owner` references point to the original owner (which is the original scene root, not the duplicate). After adding the duplicate to the tree, call `_set_owner_recursive(dup, scene_root)` to fix all owner references.

4. **UndoRedo and add_child execution timing**: `commit_action()` executes all "do" methods immediately. So after `commit_action()`, the nodes are already in the tree. If you need to set properties on nodes added via `add_do_method`, you should also use `add_do_method` for those property assignments, or set them directly before the commit.

5. **Convenience ops vs set_property**: `move`, `rotate`, and `scale` are convenience wrappers that handle type-specific differences (3D vs 2D vs Control). `set_property` can also modify transforms but requires the caller to know exact property names and types. Both are kept intentionally -- they serve different use cases and are not redundant.

6. **add_undo_reference for delete**: When deleting a node, you remove it from the tree. Without `add_undo_reference(node)`, Godot may garbage-collect the node before an undo can restore it. Always call `ur.add_undo_reference(node)` when a node is being removed from the tree.

### Supported Operations Summary Table

| Op | Required Params | Optional Params | Notes |
|----|----------------|-----------------|-------|
| `move` | `node`, `position: [x,y,z]` | | Handles Node3D, Node2D, Control |
| `rotate` | `node`, `rotation: [x,y,z]` | | Degrees, Node3D |
| `scale` | `node`, `scale: [x,y,z]` | | Node3D |
| `set_property` | `node`, `property`, `value` | | Sub-path via `:` (e.g., `position:x`) |
| `add_child` | `parent` | `scene`, `type`, `name`, `position` | Must provide `scene` OR `type` |
| `delete` | `node` | | Cannot delete scene root |
| `duplicate` | `node` | `name` | Recursive owner setting on children |
| `reparent` | `node`, `new_parent` | | Preserves in scene tree |

### Error Handling Strategy

Each individual operation in the batch can succeed or fail independently. Failures do not abort the batch -- subsequent operations still execute. The response includes per-operation status so the caller can see exactly what worked and what did not.

Error conditions per operation:
- **Node not found**: Return `{"status": "error", "error": "Node not found: <name>"}`
- **Scene root deletion**: Return `{"status": "error", "error": "Cannot delete scene root"}`
- **Invalid scene path (add_child)**: Return `{"status": "error", "error": "Failed to load scene: <path>"}`
- **Unknown type (add_child)**: Return `{"status": "error", "error": "Cannot instantiate type: <type>"}`
- **Unknown op type**: Return `{"status": "error", "error": "Unknown operation: <op>"}`
- **Missing undo_redo**: Return error response for the entire request (not per-op)
- **No scene root**: Return error response for the entire request
- **Malformed arrays**: position/rotation/scale with insufficient elements return per-op errors

---

## Implementation Notes

All 8 operation types implemented as specified. Post-review fixes applied:
1. Wired `_set_owner_recursive` into add_child, duplicate, and reparent via `ur.add_do_method(self, "_set_owner_recursive", ...)` to prevent data loss on save
2. Fixed empty undo guard: simply skip `commit_action()` when all ops fail (don't clear history)
3. Added array bounds validation for position (>=2/3), rotation (>=3), and scale (>=3)

Child index restoration on undo for delete/reparent deferred — low impact, complex with EditorUndoRedoManager.
