# Sprint 3d — Animation Intelligence Implementation Plan

## Overview

This sprint adds 2 MCP tools to GodotIQ that provide complete animation understanding for Godot 4 projects. The tools extract animation data from 4 sources: inline .tscn animations, code-referenced GLB animations, .tres animation resources, and AnimationTree state machines.

**Deliverables:**
- `godotiq_animation_info` — query animation data for a scene/node
- `godotiq_animation_audit` — find animation problems across the project
- Test fixture project (`tests/fixtures/anim_project/`)
- 22 tests (12 info + 10 audit)

**Codebase context:** GodotIQ is a Python 3.10+ MCP server using FastMCP. Tools live in `src/godotiq/tools/<category>/__init__.py` and follow a pattern: private `_build_*()` helper + public `@mcp.tool()` async wrapper. The session object (`GodotIQSession`) provides access to parsed scenes, scripts, and project index data.

---

## Section 1: Test Fixtures

Create a self-contained Godot project at `tests/fixtures/anim_project/` with these files:

### 1.1 Project Root

`project.godot` — minimal Godot 4 project file with `config/name="AnimTest"`.

### 1.2 Animated Character Scene (`scenes/animated_character.tscn`)

A 2D scene with:
- **4 inline Animation sub_resources:**
  - "idle": 2.0s, loop_mode=1 (linear), 4 value tracks targeting Sprite2D properties
  - "walk": 1.0s, loop_mode=1, 3 value tracks + 1 method track (play_footstep)
  - "attack": 0.5s, loop_mode=0 (none), 2 tracks — one targeting `NonExistent:position` (broken track for audit testing)
  - "idle_ground": 1.5s, loop_mode=0 (none), 1 value track — for no_loop_suspect audit test (short animation with "idle" in name but not looping)
- **1 AnimationLibrary sub_resource** linking all 4 animations via `_data` property
- **1 AnimationPlayer node** referencing the library
- **1 Sprite2D child** (target of animation tracks)
- **Script attachment** via ext_resource to `scripts/entities/character.gd`

Animation sub_resource format:
```
[sub_resource type="Animation" id="Animation_idle"]
resource_name = "idle"
length = 2.0
loop_mode = 1
tracks/0/type = "value"
tracks/0/path = NodePath("Sprite2D:position")
...
```

AnimationLibrary `_data` format (single-line dict):
```
[sub_resource type="AnimationLibrary" id="AnimationLibrary_main"]
_data = { "idle": SubResource("Animation_idle"), "walk": SubResource("Animation_walk"), "attack": SubResource("Animation_attack"), "idle_ground": SubResource("Animation_idle_ground") }
```

### 1.3 Character Script (`scripts/entities/character.gd`)

GDScript with animation patterns:
- `const ANIM_MAP := {` dict mapping logical names to animation names (4 entries including "special" → "special_move")
- `const LOOPING_ANIMS := [` array with 2 entries
- `var anim_player: AnimationPlayer` declaration
- `.play()` calls: "idle", "attack", "special_move"

### 1.4 GLB Entity Script (`scripts/entities/glb_animated_entity.gd`)

Script demonstrating the PFT worker pattern (code-only animations from GLB):
- `const ANIM_MAP := {` with NlaTrack entries (idle→NlaTrack_001, walk→NlaTrack_002, run→NlaTrack_003)
- `const LOOPING_ANIMS := [` array
- `.play()` calls via helper function
- AnimationPlayer variable declaration

### 1.5 AnimationTree Scene (`scenes/animated_scene_tree.tscn`)

A 3D scene with AnimationTree + StateMachine using **real Godot serialization format**:
- **2 Animation sub_resources** (idle, walk) with basic tracks
- **1 AnimationLibrary** wrapping the animations
- **2 AnimationNodeAnimation sub_resources** with `animation = &"idle"` / `&"walk"`
- **2 AnimationNodeStateMachineTransition sub_resources** (idle↔walk)
- **1 AnimationNodeStateMachine sub_resource** with:
  ```
  states/idle/node = SubResource("AnimNode_idle")
  states/idle/position = Vector2(300, 100)
  states/walk/node = SubResource("AnimNode_walk")
  states/walk/position = Vector2(500, 100)
  transitions = [&"idle", &"walk", SubResource("Trans_idle_walk"), &"walk", &"idle", SubResource("Trans_walk_idle")]
  ```
- **AnimationPlayer node** with library
- **AnimationTree node** with `tree_root = SubResource("StateMachine_main")` and `anim_player = NodePath("../AnimationPlayer")`

### 1.6 Static Scene (`scenes/no_animation_scene.tscn`)

Minimal scene with Node3D + MeshInstance3D + StaticBody3D. No AnimationPlayer or AnimationTree.

### 1.7 GLB Entity Scene (`scenes/glb_entity.tscn`)

Scene referencing `glb_animated_entity.gd` script but NO inline animations — used to test code-only animation detection.

### 1.8 Empty Player Scene (`scenes/empty_player_scene.tscn`)

Minimal scene with an AnimationPlayer node that has NO libraries property (or empty libraries). Used for `test_audit_empty_player`.

### 1.9 conftest.py Addition

Add `anim_root` and `anim_session` fixtures to existing `tests/test_tools/conftest.py`:
```python
def anim_root() -> Path:
    """Path to the animation test fixture project."""
    return Path(__file__).resolve().parent.parent / "fixtures" / "anim_project"

def anim_session(anim_root: Path) -> GodotIQSession:
    """A loaded GodotIQSession for the animation test project."""
```

---

## Section 2: Animation Extraction Helpers

All helpers live in `src/godotiq/tools/animation/__init__.py`.

### 2.0 Shared Utility: `_strip_string_name(value) -> str`

Strips Godot StringName syntax from values. Handles:
- `&"name"` → `"name"`
- `"name"` → `"name"` (already clean)
- Non-string values → return as-is

Used consistently across ALL extraction helpers.

### 2.1 `_extract_tscn_animations(scene: TscnScene) -> list[dict]`

Iterate `scene.sub_resources` where `type == "Animation"`. For each:
- Name from `properties.get("resource_name", "")`, fallback to sub_resource id. Apply `_strip_string_name()`.
- Length from `properties.get("length", 1.0)`, cast to float
- Loop mode from `properties.get("loop_mode", 0)`, map: 0→"none", 1→"linear", 2→"pingpong"
- Count tracks by scanning property keys matching regex `r'^tracks/(\d+)/type$'`
- For each track index N, extract type from `tracks/N/type` and path from `tracks/N/path`
- Group tracks by type into `tracks_by_type` dict
- Collect unique track paths (strip `NodePath(...)` wrapper if present via value parser)

Return list of dicts with: name, length, loop_mode, track_count, tracks_by_type, track_paths, sub_resource_id, source="tscn_inline".

### 2.2 `_extract_animation_libraries(scene: TscnScene) -> dict[str, dict[str, str]]`

Iterate sub_resources where `type == "AnimationLibrary"`. For each:
- Get `_data` property value
- **Handle dual format** (CRITICAL): TSCN parser stores `_data` differently depending on context:
  - If `_data` is a `dict` (single-line, parsed by value parser): iterate directly. Values will be `{"type": "sub_resource", "id": "..."}` dicts — extract the `"id"` field.
  - If `_data` is a `str` (multiline, stored raw due to `_` prefix optimization): parse with regex `r'"(\w+)":\s*SubResource\("([^"]+)"\)'`

Return: `{ library_sub_id: { "animation_name": "Animation_sub_id", ... } }`

### 2.3 `_extract_animation_players(scene: TscnScene) -> list[dict]`

Find all nodes with `type == "AnimationPlayer"` via `scene.get_nodes_by_type("AnimationPlayer")`.
For each, parse `libraries` property:
- **Property is a parsed dict** (from value parser): keys are library names (empty string `""` for default), values are `{"type": "sub_resource", "id": "..."}` dicts — extract the `"id"` field.
- If `libraries` property is missing or not a dict, the player has no libraries (empty_player case).

Reconstruct full node path from parent chain.

Return list with: name, path, library_refs (dict mapping library_name → sub_resource_id).

### 2.4 `_extract_animation_trees(scene: TscnScene) -> list[dict]`

Find AnimationTree nodes. For each:
- `tree_root` property: parsed as `{"type": "sub_resource", "id": "..."}` dict — extract `"id"` to get state machine SubResource id
- `anim_player` property: stored as plain string (NodePath already unwrapped by value parser), e.g., `"../AnimationPlayer"`

Find AnimationNodeStateMachine sub_resources. For each:
- Extract state names from property keys matching `r'^states/(\w+)/node$'`
- Parse `transitions` property: a **Python list** of mixed types:
  - Elements at positions 0, 1 (mod 3): strings needing `_strip_string_name()` (e.g., `&"idle"`)
  - Element at position 2 (mod 3): `{"type": "sub_resource", "id": "..."}` dict
  - Process in triplets of 3 to extract (from_state, to_state, transition_ref)
  - Guard against malformed data: if list length not divisible by 3, skip extras

Find AnimationNodeAnimation sub_resources:
- Extract `animation` property, apply `_strip_string_name()`

Return list with: node_name, node_path, anim_player_path, states, transitions (list of {from, to} dicts), animation_refs (list of animation names).

### 2.5 `_extract_code_animations(gd_content: str) -> dict`

Regex scan of raw .gd file text (NOT parsed GdScript object). Patterns:

- **`.play("name")` and `.play(&"name")`** — `r'\.play\(\s*(?:&)?"([^"]+)"'`
- **`.queue("name")`** — `r'\.queue\(\s*(?:&)?"([^"]+)"'`
- **`.travel("name")`** — `r'\.travel\(\s*(?:&)?"([^"]+)"'`
- **AnimationPlayer var** — `r'(?:var|@onready\s+var)\s+(\w+)\s*[:=].*AnimationPlayer'`
- **AnimationTree var** — `r'(?:var|@onready\s+var)\s+(\w+)\s*[:=].*AnimationTree'`
- **Animation dicts** — detect `const/var NAME :?=? {` where NAME contains "anim", "animation", "motion", "clip" (case-insensitive). Match both `:=` and `=` assignment. Use brace-counting for multi-line dicts. Extract string keys and values.
- **Animation arrays** — detect `const/var NAME :?=? [` with same name heuristic. Match both `:=` and `=`. Extract string elements.

Return dict with: play_calls (including queue calls), travel_calls, anim_maps, looping_lists, has_anim_player, has_anim_tree, anim_player_vars, anim_tree_vars, source="code".

### 2.6 `_build_animation_summary(scene_path, scene, session) -> dict`

Aggregate function:
1. Call `_extract_tscn_animations(scene)` → inline animations
2. Call `_extract_animation_libraries(scene)` → library structure
3. Call `_extract_animation_players(scene)` → player nodes
4. Call `_extract_animation_trees(scene)` → tree state machines
5. For each script attached to nodes in the scene:
   - Get res:// paths via `scene.get_all_scripts()`
   - Resolve to filesystem: `session._resolve_res_path(script_res_path)` → `Path`
   - Read raw content: `path.read_text(encoding="utf-8")`
   - Call `_extract_code_animations(content)`
6. Cross-reference: link AnimationPlayers → libraries → animations; match .play() calls against known animation names
7. Identify: code_only animations (in .play() but not in scene), unused animations (in scene but never .play()-ed and not referenced by AnimationTree states)

---

## Section 3: Tool D01 — `godotiq_animation_info`

### 3.1 Signature

```python
@mcp.tool(name="godotiq_animation_info", annotations={
    "readOnlyHint": True, "destructiveHint": False,
    "idempotentHint": True, "openWorldHint": False,
})
async def godotiq_animation_info(
    scene: str, node: str | None = None,
    detail: str = "normal", ctx: Context = None,
) -> dict:
    """Complete animation understanding for a scene or specific node."""
```

### 3.2 Detail Levels

- **brief** — summary dict + animation names per player (no track counts/types)
- **normal** — full structure minus track_paths
- **full** — everything including track_paths per animation

### 3.3 Node Filtering

When `node` is specified, filter animation_players to match. Use fuzzy matching: exact name > case-insensitive > substring. If no match, return error with available player names as alternatives.

### 3.4 Output Structure

Top-level keys: scene, animation_players (list), animation_trees (list), code_animations (dict), summary (dict with total_animations, total_players, total_trees, inline_count, code_only_count, sources).

Cap: max 50 animations per player; add `truncated: true` if exceeded.

### 3.5 Edge Cases

- No AnimationPlayer → empty lists, summary all zeros
- No script attached → skip code_animations section
- Non-existent scene → error dict

---

## Section 4: Tool D02 — `godotiq_animation_audit`

### 4.1 Signature

```python
@mcp.tool(name="godotiq_animation_audit", annotations={
    "readOnlyHint": True, "destructiveHint": False,
    "idempotentHint": True, "openWorldHint": False,
})
async def godotiq_animation_audit(
    scope: str = "all", scene: str | None = None,
    ctx: Context = None,
) -> dict:
    """Find animation problems across the project or in a specific scene."""
```

### 4.2 Checks (7 types)

1. **broken_track** (CRITICAL) — animation track NodePath references non-existent node in the immediate scene tree. Extract node name portion (everything before first `:` in the track path). For multi-segment paths like `Parent/Child:property`, check the first segment exists as a child node. Self-references (`"."` or `".:method"`) always valid. Only for inline animations.

2. **orphan_animation** (WARNING) — animation defined inline but never referenced via:
   - `.play()` or `.queue()` calls in attached scripts
   - ANIM_MAP values in attached scripts
   - AnimationTree state machine animation references
   Skip if no scripts attached AND no AnimationTree references the player.

3. **missing_animation** (WARNING) — script .play()s a name not found in scene's AnimationPlayers. For GLB-sourced names (detected via ANIM_MAP with NlaTrack-like values), downgrade to INFO.

4. **no_loop_suspect** (INFO) — inline animation < 3s with name containing idle/walk/run/swim/fly/breathe but loop_mode="none".

5. **empty_player** (WARNING) — AnimationPlayer node with no animations resolved through its libraries.

6. **code_only_animations** (INFO) — animations referenced only in code, can't validate from filesystem.

7. **orphan_tree_state** (WARNING) — AnimationTree state machine references animation name not found in linked AnimationPlayer's library.

### 4.3 Scope Logic

- `scope="all"` — iterate all scenes in session._index.scenes, check each with AnimationPlayer/Tree
- `scope="scene"` — single scene + its scripts

### 4.4 Output

Issues sorted by severity (critical > warning > info), then by scene path. Capped at 50 issues (preserve all critical, then warning, then info).

Top-level keys: scope, scenes_scanned, animated_scenes, total_animations, total_issues, by_severity, issues (list of dicts).

---

## Section 5: MCP Server Registration

Add `from godotiq.tools import animation` to `src/godotiq/server.py` alongside existing imports. The import triggers tool registration via `@mcp.tool()` decorators.

Also create `src/godotiq/tools/animation/__init__.py` with proper imports from `godotiq.server`.

---

## Section 6: Test Suite — animation_info (12 tests)

File: `tests/test_tools/test_animation_info.py`

Tests use the `anim_session` fixture from conftest.py.

| # | Test | What it validates |
|---|------|-------------------|
| 1 | test_animation_info_inline_scene | animated_character.tscn → 4 animations with correct metadata |
| 2 | test_animation_info_detail_brief | brief detail → summary only, no track counts |
| 3 | test_animation_info_detail_full | full detail → includes track_paths |
| 4 | test_animation_info_code_animations | character.gd → play_calls, ANIM_MAP, LOOPING_ANIMS detected |
| 5 | test_animation_info_code_only_glb | glb_animated_entity.gd → code_animations with NlaTrack entries |
| 6 | test_animation_info_animation_tree | animated_scene_tree.tscn → 2 states, 2 transitions |
| 7 | test_animation_info_no_animations | no_animation_scene.tscn → empty results, zeros |
| 8 | test_animation_info_node_filter | node="AnimationPlayer" → filters correctly |
| 9 | test_animation_info_node_filter_not_found | node="NonExistent" → error with alternatives |
| 10 | test_animation_info_summary_counts | verify total_animations counts unique names across sources |
| 11 | test_animation_info_missing_scene | non-existent path → error |
| 12 | test_animation_info_code_play_detection | direct _extract_code_animations unit test with synthetic input |

---

## Section 7: Test Suite — animation_audit (10 tests)

File: `tests/test_tools/test_animation_audit.py`

| # | Test | What it validates |
|---|------|-------------------|
| 1 | test_audit_broken_track | "attack" → NonExistent:position → critical broken_track |
| 2 | test_audit_orphan_animation | "idle_ground" never .play()-ed → warning orphan_animation |
| 3 | test_audit_missing_animation | .play("special_move") not in scene → warning |
| 4 | test_audit_no_loop_suspect | "idle_ground" (1.5s, loop_mode=0, "idle" in name) → info |
| 5 | test_audit_empty_player | empty_player_scene.tscn → warning empty_player |
| 6 | test_audit_code_only_info | GLB-only NlaTrack animations → info severity |
| 7 | test_audit_scope_all | scope="all" scans all project scenes |
| 8 | test_audit_scope_scene | scope="scene" scans only specified scene |
| 9 | test_audit_severity_ordering | issues sorted: critical, warning, info |
| 10 | test_audit_issue_cap | >50 issues → truncated, critical preserved |

---

## Implementation Order

1. **Fixtures first** — create anim_project/ with all .tscn and .gd files
2. **conftest.py** — add anim_root and anim_session fixtures
3. **Extraction helpers** — _strip_string_name, _extract_tscn_animations, _extract_animation_libraries, _extract_animation_players, _extract_animation_trees, _extract_code_animations
4. **animation_info tool** — _build_animation_info + @mcp.tool wrapper
5. **animation_audit tool** — _build_animation_audit + @mcp.tool wrapper
6. **Server registration** — import in server.py
7. **Info tests** — 12 tests
8. **Audit tests** — 10 tests

## Files Created/Modified

**New files:**
- `tests/fixtures/anim_project/project.godot`
- `tests/fixtures/anim_project/scenes/animated_character.tscn`
- `tests/fixtures/anim_project/scenes/animated_scene_tree.tscn`
- `tests/fixtures/anim_project/scenes/no_animation_scene.tscn`
- `tests/fixtures/anim_project/scenes/glb_entity.tscn`
- `tests/fixtures/anim_project/scenes/empty_player_scene.tscn`
- `tests/fixtures/anim_project/scripts/entities/character.gd`
- `tests/fixtures/anim_project/scripts/entities/glb_animated_entity.gd`
- `src/godotiq/tools/animation/__init__.py`
- `tests/test_tools/test_animation_info.py`
- `tests/test_tools/test_animation_audit.py`

**Modified files:**
- `src/godotiq/server.py` — add `from godotiq.tools import animation` import
- `tests/test_tools/conftest.py` — add anim_root and anim_session fixtures

**NOT modified:**
- Any existing parser, tool, or test file (except conftest.py for fixtures)
