# Sprint 15 — Character System v2 Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Extend the Character node with AnimatedSprite2D (6 states), SnapReceiver (furniture attachment), HandLeft/HandRight slots (item holding), and three OutfitLayers (clothing overlay).

**Architecture:** Four independent APIs are added to the existing `Character` class. `SnapPoint` and `SnapReceiver` are standalone nodes following the project's component pattern (`DragDropComponent`). All new nodes are added to `Character.tscn` so scene instantiation gives a complete character. ColorRect placeholder stays visible until real art replaces it.

**Tech Stack:** Godot 4.6.2, GDScript (static typing), GUT v9.6.0 (TDD), headless runner.

**GDD Reference:** `docs/game-design.md` — Kapitel 3 (Figurensystem) + Kapitel 5.1–5.2.

**Headless runner:**
```
"F:/Development/_tools/Godot_v4.6.2-stable_win64/Godot_v4.6.2-stable_win64.exe" --headless -s res://addons/gut/gut_cmdln.gd -gdir=res://test/ -gexit 2>&1
```

**Existing tests must stay green:** 42 tests passing before this sprint starts.

---

## File Map

| Action | Path | Responsibility |
|---|---|---|
| Create | `scripts/objects/snap_point.gd` | Attachment node on furniture |
| Create | `scripts/characters/snap_receiver.gd` | Snap detection on Character |
| Modify | `scripts/characters/character_data.gd` | Add `outfit: Array[String]` field |
| Modify | `scripts/characters/character.gd` | Anim state API, outfit API, hand slot API |
| Modify | `scenes/characters/Character.tscn` | Add AnimatedSprite2D, OutfitLayers, HandSlots, SnapReceiver |
| Create | `test/unit/test_snap_point.gd` | SnapPoint unit tests |
| Create | `test/unit/test_character_v2.gd` | Character API tests |
| Create | `test/unit/test_snap_receiver.gd` | SnapReceiver integration tests |

---

## Task 1: SnapPoint

**Files:**
- Create: `scripts/objects/snap_point.gd`
- Create: `test/unit/test_snap_point.gd`

- [ ] **Step 1: Write the failing tests**

Create `test/unit/test_snap_point.gd`:

```gdscript
## Tests for SnapPoint — attachment node on furniture.
extends GutTest

const SnapPointScript: GDScript = preload("res://scripts/objects/snap_point.gd")

var _snap: SnapPoint
var _char: Character


func before_each() -> void:
	_snap = SnapPointScript.new()
	_snap.pose = "sitting"
	add_child_autofree(_snap)
	_char = preload("res://scenes/characters/Character.tscn").instantiate() as Character
	add_child_autofree(_char)


func test_is_free_when_no_occupant() -> void:
	assert_true(_snap.is_free())


func test_is_not_free_when_occupied() -> void:
	_snap.snap(_char)
	assert_false(_snap.is_free())


func test_snap_sets_occupant() -> void:
	_snap.snap(_char)
	assert_eq(_snap.occupant, _char)


func test_unsnap_clears_occupant() -> void:
	_snap.snap(_char)
	_snap.unsnap()
	assert_null(_snap.occupant)


func test_accepts_any_character_when_baby_only_false() -> void:
	assert_true(_snap.accepts(_char))


func test_does_not_accept_when_occupied() -> void:
	_snap.snap(_char)
	var other: Character = preload("res://scenes/characters/Character.tscn").instantiate() as Character
	add_child_autofree(other)
	assert_false(_snap.accepts(other))


func test_baby_only_rejects_healthy_character() -> void:
	_snap.baby_only = true
	var cd: CharacterData = CharacterData.new()
	cd.state = CharacterData.State.HEALTHY
	_char.data = cd
	assert_false(_snap.accepts(_char))


func test_baby_only_accepts_baby_state_character() -> void:
	_snap.baby_only = true
	var cd: CharacterData = CharacterData.new()
	cd.state = CharacterData.State.BABY
	_char.data = cd
	assert_true(_snap.accepts(_char))


func test_snap_emits_character_snapped() -> void:
	watch_signals(_snap)
	_snap.snap(_char)
	assert_signal_emitted(_snap, "character_snapped")


func test_unsnap_emits_character_unsnapped() -> void:
	_snap.snap(_char)
	watch_signals(_snap)
	_snap.unsnap()
	assert_signal_emitted(_snap, "character_unsnapped")
```

- [ ] **Step 2: Run tests — verify they FAIL**

```
"F:/Development/_tools/Godot_v4.6.2-stable_win64/Godot_v4.6.2-stable_win64.exe" --headless -s res://addons/gut/gut_cmdln.gd -gdir=res://test/ -gexit 2>&1
```

Expected: errors about `SnapPoint` class not found. Existing 42 tests still pass.

- [ ] **Step 3: Implement `scripts/objects/snap_point.gd`**

```gdscript
## SnapPoint — attachment position on furniture where a Character can snap into a pose.
## Add to any furniture node. The node auto-registers in the "snap_points" group on _ready.
class_name SnapPoint extends Node2D

signal character_snapped(character: Character)
signal character_unsnapped(character: Character)

@export var pose: String = "sitting"
@export var baby_only: bool = false

var occupant: Character = null


func _ready() -> void:
	add_to_group("snap_points")


func is_free() -> bool:
	return occupant == null


func accepts(character: Character) -> bool:
	if not is_free():
		return false
	if baby_only:
		if character.data == null:
			return false
		return character.data.state == CharacterData.State.BABY
	return true


func snap(character: Character) -> void:
	occupant = character
	character_snapped.emit(character)


func unsnap() -> void:
	var prev: Character = occupant
	occupant = null
	if prev != null:
		character_unsnapped.emit(prev)
```

- [ ] **Step 4: Run tests — verify 10 new tests PASS**

```
"F:/Development/_tools/Godot_v4.6.2-stable_win64/Godot_v4.6.2-stable_win64.exe" --headless -s res://addons/gut/gut_cmdln.gd -gdir=res://test/ -gexit 2>&1
```

Expected: 52/52 passed (42 existing + 10 SnapPoint).

- [ ] **Step 5: Commit**

```bash
git add scripts/objects/snap_point.gd test/unit/test_snap_point.gd
git commit -m "feat(snap-point): add SnapPoint node with pose, baby_only filter, and occupant tracking"
```

---

## Task 2: SnapReceiver stub

**Files:**
- Create: `scripts/characters/snap_receiver.gd`

The SnapReceiver script must exist before `Character.tscn` can reference it. Full implementation comes in Task 7.

- [ ] **Step 1: Create the stub**

Create `scripts/characters/snap_receiver.gd`:

```gdscript
## SnapReceiver — scans for nearby SnapPoints when the parent Character is released.
## Attach as child of Character. Full implementation connects to DragDropComponent signals.
class_name SnapReceiver extends Node

const SCAN_RADIUS: float = 80.0

var _current_snap: SnapPoint = null
var _character: Character


func _ready() -> void:
	_character = get_parent() as Character


func get_current_snap() -> SnapPoint:
	return _current_snap


func force_unsnap() -> void:
	if _current_snap != null:
		_current_snap.unsnap()
		_current_snap = null
```

- [ ] **Step 2: Run tests — verify 52 still pass**

```
"F:/Development/_tools/Godot_v4.6.2-stable_win64/Godot_v4.6.2-stable_win64.exe" --headless -s res://addons/gut/gut_cmdln.gd -gdir=res://test/ -gexit 2>&1
```

Expected: 52/52 passed. No new failures.

- [ ] **Step 3: Commit**

```bash
git add scripts/characters/snap_receiver.gd
git commit -m "feat(snap-receiver): add SnapReceiver stub (full implementation in sprint-15 task 7)"
```

---

## Task 3: Character.tscn — add new child nodes

**Files:**
- Modify: `scenes/characters/Character.tscn`
- Create: `test/unit/test_character_v2.gd` (partial — node existence tests only for now)

- [ ] **Step 1: Write node-existence tests**

Create `test/unit/test_character_v2.gd` with only the node-existence tests for now:

```gdscript
## Tests for Character System v2 — animation state, outfit layers, hand slots.
extends GutTest

var _char: Character


func before_each() -> void:
	_char = preload("res://scenes/characters/Character.tscn").instantiate() as Character
	add_child_autofree(_char)
	var cd: CharacterData = CharacterData.new()
	_char.data = cd


func test_animated_sprite_node_exists() -> void:
	assert_not_null(_char.get_node_or_null("AnimatedSprite2D"))


func test_outfit_layer_1_node_exists() -> void:
	assert_not_null(_char.get_node_or_null("OutfitLayer1"))


func test_outfit_layer_2_node_exists() -> void:
	assert_not_null(_char.get_node_or_null("OutfitLayer2"))


func test_outfit_layer_3_node_exists() -> void:
	assert_not_null(_char.get_node_or_null("OutfitLayer3"))


func test_hand_left_node_exists() -> void:
	assert_not_null(_char.get_node_or_null("HandLeft"))


func test_hand_right_node_exists() -> void:
	assert_not_null(_char.get_node_or_null("HandRight"))


func test_snap_receiver_node_exists() -> void:
	assert_not_null(_char.get_node_or_null("SnapReceiver"))
```

- [ ] **Step 2: Run tests — verify they FAIL**

```
"F:/Development/_tools/Godot_v4.6.2-stable_win64/Godot_v4.6.2-stable_win64.exe" --headless -s res://addons/gut/gut_cmdln.gd -gdir=res://test/ -gexit 2>&1
```

Expected: 7 new failures (nodes not in scene yet). 52 existing pass.

- [ ] **Step 3: Update `scenes/characters/Character.tscn`**

Read the file first (`F:/Development/_gameDev/Cozypaw-Hospital/scenes/characters/Character.tscn`), then make these changes:

Change the header line:
```
[gd_scene load_steps=4 format=3 uid="uid://cozypaw_char"]
```
to:
```
[gd_scene load_steps=5 format=3 uid="uid://cozypaw_char"]
```

After the line `[ext_resource type="Script" path="res://scripts/systems/drag_drop_component.gd" id="2_drag"]`, add:
```
[ext_resource type="Script" path="res://scripts/characters/snap_receiver.gd" id="3_snap_recv"]
```

Append at the end of the file (after the last `[node ...]` block):
```
[node name="AnimatedSprite2D" type="AnimatedSprite2D" parent="."]
visible = false

[node name="OutfitLayer1" type="Sprite2D" parent="."]
position = Vector2(0, -40)
visible = false

[node name="OutfitLayer2" type="Sprite2D" parent="."]
position = Vector2(0, -40)
visible = false

[node name="OutfitLayer3" type="Sprite2D" parent="."]
position = Vector2(0, -40)
visible = false

[node name="HandLeft" type="Node2D" parent="."]
position = Vector2(-32, -30)

[node name="HandRight" type="Node2D" parent="."]
position = Vector2(32, -30)

[node name="SnapReceiver" type="Node" parent="."]
script = ExtResource("3_snap_recv")
```

- [ ] **Step 4: Run tests — verify 7 node-existence tests now PASS**

```
"F:/Development/_tools/Godot_v4.6.2-stable_win64/Godot_v4.6.2-stable_win64.exe" --headless -s res://addons/gut/gut_cmdln.gd -gdir=res://test/ -gexit 2>&1
```

Expected: 59/59 passed (52 + 7 node-existence).

- [ ] **Step 5: Commit**

```bash
git add scenes/characters/Character.tscn test/unit/test_character_v2.gd
git commit -m "feat(character): add AnimatedSprite2D, OutfitLayers, HandSlots, SnapReceiver to Character scene"
```

---

## Task 4: CharacterData outfit field

**Files:**
- Modify: `scripts/characters/character_data.gd`

- [ ] **Step 1: Add outfit test to `test/unit/test_character_v2.gd`**

Append this function to the file (inside the class, before the final empty line):

```gdscript
func test_character_data_outfit_has_three_empty_slots() -> void:
	assert_eq(_char.data.outfit.size(), 3)
	assert_eq(_char.data.outfit[0], "")
	assert_eq(_char.data.outfit[1], "")
	assert_eq(_char.data.outfit[2], "")
```

- [ ] **Step 2: Run test — verify it FAILS**

```
"F:/Development/_tools/Godot_v4.6.2-stable_win64/Godot_v4.6.2-stable_win64.exe" --headless -s res://addons/gut/gut_cmdln.gd -gdir=res://test/ -gexit 2>&1
```

Expected: 1 failure (outfit field does not exist on CharacterData).

- [ ] **Step 3: Update `scripts/characters/character_data.gd`**

Replace the entire file with:

```gdscript
## CharacterData — Resource holding all persistent state for one character.
class_name CharacterData extends Resource

enum State { HEALTHY, SICK, SLEEPING, TIRED, PREGNANT, BABY }
enum Species { BUNNY, KITTEN }

@export var id: String = ""
@export var display_name: String = ""
@export var species: Species = Species.BUNNY
@export var state: State = State.HEALTHY
@export var current_floor: int = 0
@export var position: Vector2 = Vector2.ZERO
@export var outfit: Array[String] = ["", "", ""]
```

- [ ] **Step 4: Run tests — verify new test PASSES, all 60 pass**

```
"F:/Development/_tools/Godot_v4.6.2-stable_win64/Godot_v4.6.2-stable_win64.exe" --headless -s res://addons/gut/gut_cmdln.gd -gdir=res://test/ -gexit 2>&1
```

Expected: 60/60 passed.

- [ ] **Step 5: Commit**

```bash
git add scripts/characters/character_data.gd test/unit/test_character_v2.gd
git commit -m "feat(character-data): add outfit array field for three outfit layer slots"
```

---

## Task 5: Character animation state API

**Files:**
- Modify: `scripts/characters/character.gd`
- Modify: `test/unit/test_character_v2.gd`

- [ ] **Step 1: Add animation state tests to `test/unit/test_character_v2.gd`**

Append these functions:

```gdscript
func test_default_animation_state_is_idle() -> void:
	assert_eq(_char.get_animation_state(), "idle")


func test_set_animation_state_sitting() -> void:
	_char.set_animation_state("sitting")
	assert_eq(_char.get_animation_state(), "sitting")


func test_set_animation_state_lying() -> void:
	_char.set_animation_state("lying")
	assert_eq(_char.get_animation_state(), "lying")


func test_set_animation_state_held() -> void:
	_char.set_animation_state("held")
	assert_eq(_char.get_animation_state(), "held")


func test_set_animation_state_happy() -> void:
	_char.set_animation_state("happy")
	assert_eq(_char.get_animation_state(), "happy")


func test_set_animation_state_sleeping() -> void:
	_char.set_animation_state("sleeping")
	assert_eq(_char.get_animation_state(), "sleeping")
```

- [ ] **Step 2: Run tests — verify 6 new tests FAIL**

```
"F:/Development/_tools/Godot_v4.6.2-stable_win64/Godot_v4.6.2-stable_win64.exe" --headless -s res://addons/gut/gut_cmdln.gd -gdir=res://test/ -gexit 2>&1
```

Expected: 6 failures (methods don't exist yet). 60 existing pass.

- [ ] **Step 3: Add animation state API to `scripts/characters/character.gd`**

Add `var _current_anim: String = "idle"` after the existing `var _is_held: bool = false` line.

Add these two methods after `set_state()`:

```gdscript
func set_animation_state(anim: String) -> void:
	_current_anim = anim
	var sprite: AnimatedSprite2D = get_node_or_null("AnimatedSprite2D") as AnimatedSprite2D
	if sprite == null or sprite.sprite_frames == null:
		return
	if sprite.sprite_frames.has_animation(anim):
		sprite.play(anim)


func get_animation_state() -> String:
	return _current_anim
```

Also update `_on_drag_picked_up` to set animation:

```gdscript
func _on_drag_picked_up(_pos: Vector2) -> void:
	_is_held = true
	set_animation_state("held")
	character_picked_up.emit(self)
```

And update `_on_drag_released`:

```gdscript
func _on_drag_released(pos: Vector2) -> void:
	_is_held = false
	set_animation_state("idle")
	if data == null or data.id.is_empty():
		return
	GameState.set_character_position(character_id, global_position)
	character_placed.emit(self, global_position)
```

- [ ] **Step 4: Run tests — verify 6 new tests PASS, 66 total**

```
"F:/Development/_tools/Godot_v4.6.2-stable_win64/Godot_v4.6.2-stable_win64.exe" --headless -s res://addons/gut/gut_cmdln.gd -gdir=res://test/ -gexit 2>&1
```

Expected: 66/66 passed.

- [ ] **Step 5: Commit**

```bash
git add scripts/characters/character.gd test/unit/test_character_v2.gd
git commit -m "feat(character): add animation state API (set/get_animation_state)"
```

---

## Task 6: Character outfit layer API

**Files:**
- Modify: `scripts/characters/character.gd`
- Modify: `test/unit/test_character_v2.gd`

- [ ] **Step 1: Add outfit layer tests to `test/unit/test_character_v2.gd`**

Append these functions:

```gdscript
func test_get_outfit_returns_empty_for_all_layers_initially() -> void:
	assert_eq(_char.get_outfit(1), "")
	assert_eq(_char.get_outfit(2), "")
	assert_eq(_char.get_outfit(3), "")


func test_set_outfit_stores_item_id() -> void:
	_char.set_outfit(1, "white_coat", null)
	assert_eq(_char.get_outfit(1), "white_coat")


func test_set_outfit_does_not_affect_other_layers() -> void:
	_char.set_outfit(1, "white_coat", null)
	assert_eq(_char.get_outfit(2), "")
	assert_eq(_char.get_outfit(3), "")


func test_clear_outfit_returns_item_id() -> void:
	_char.set_outfit(2, "cast_arm", null)
	var returned: String = _char.clear_outfit(2)
	assert_eq(returned, "cast_arm")


func test_clear_outfit_empties_layer() -> void:
	_char.set_outfit(2, "cast_arm", null)
	_char.clear_outfit(2)
	assert_eq(_char.get_outfit(2), "")


func test_set_outfit_invalid_layer_zero_is_noop() -> void:
	_char.set_outfit(0, "white_coat", null)
	assert_eq(_char.get_outfit(1), "")


func test_set_outfit_invalid_layer_four_is_noop() -> void:
	_char.set_outfit(4, "white_coat", null)
	assert_eq(_char.get_outfit(3), "")
```

- [ ] **Step 2: Run tests — verify 7 new tests FAIL**

```
"F:/Development/_tools/Godot_v4.6.2-stable_win64/Godot_v4.6.2-stable_win64.exe" --headless -s res://addons/gut/gut_cmdln.gd -gdir=res://test/ -gexit 2>&1
```

Expected: 7 failures. 66 existing pass.

- [ ] **Step 3: Add outfit API to `scripts/characters/character.gd`**

Add these three methods after `get_animation_state()`:

```gdscript
func set_outfit(layer: int, item_id: String, texture: Texture2D) -> void:
	if layer < 1 or layer > 3:
		return
	if data != null:
		data.outfit[layer - 1] = item_id
	var layer_node: Sprite2D = get_node_or_null("OutfitLayer%d" % layer) as Sprite2D
	if layer_node == null:
		return
	layer_node.texture = texture
	layer_node.visible = not item_id.is_empty()


func clear_outfit(layer: int) -> String:
	if layer < 1 or layer > 3:
		return ""
	var old_id: String = ""
	if data != null:
		old_id = data.outfit[layer - 1]
		data.outfit[layer - 1] = ""
	var layer_node: Sprite2D = get_node_or_null("OutfitLayer%d" % layer) as Sprite2D
	if layer_node != null:
		layer_node.texture = null
		layer_node.visible = false
	return old_id


func get_outfit(layer: int) -> String:
	if data == null or layer < 1 or layer > 3:
		return ""
	return data.outfit[layer - 1]
```

Also add `_refresh_outfit_layers()` call in `_ready()`, just after `_update_visual_state()`:

```gdscript
func _ready() -> void:
	var drag: DragDropComponent = get_node_or_null("DragDropComponent") as DragDropComponent
	if drag != null:
		drag.drag_picked_up.connect(_on_drag_picked_up)
		drag.drag_released.connect(_on_drag_released)
	if data != null:
		_update_visual_state()
	_refresh_outfit_layers()
```

Add `_refresh_outfit_layers()` helper at the bottom of the file:

```gdscript
func _refresh_outfit_layers() -> void:
	if data == null:
		return
	for i: int in range(3):
		var layer_node: Sprite2D = get_node_or_null("OutfitLayer%d" % (i + 1)) as Sprite2D
		if layer_node != null:
			layer_node.visible = not data.outfit[i].is_empty()
```

- [ ] **Step 4: Run tests — verify 7 new tests PASS, 73 total**

```
"F:/Development/_tools/Godot_v4.6.2-stable_win64/Godot_v4.6.2-stable_win64.exe" --headless -s res://addons/gut/gut_cmdln.gd -gdir=res://test/ -gexit 2>&1
```

Expected: 73/73 passed.

- [ ] **Step 5: Commit**

```bash
git add scripts/characters/character.gd test/unit/test_character_v2.gd
git commit -m "feat(character): add outfit layer API (set/clear/get_outfit per layer 1-3)"
```

---

## Task 7: Character hand slot API

**Files:**
- Modify: `scripts/characters/character.gd`
- Modify: `test/unit/test_character_v2.gd`

- [ ] **Step 1: Add hand slot tests to `test/unit/test_character_v2.gd`**

Append these functions:

```gdscript
func test_both_hands_free_initially() -> void:
	assert_true(_char.is_hand_free("left"))
	assert_true(_char.is_hand_free("right"))


func test_attach_item_to_left_hand() -> void:
	var item: Node2D = Node2D.new()
	add_child_autofree(item)
	_char.attach_item("left", item)
	assert_false(_char.is_hand_free("left"))


func test_get_held_item_returns_attached_item() -> void:
	var item: Node2D = Node2D.new()
	add_child_autofree(item)
	_char.attach_item("right", item)
	assert_eq(_char.get_held_item("right"), item)


func test_attach_to_occupied_hand_returns_false() -> void:
	var item1: Node2D = Node2D.new()
	var item2: Node2D = Node2D.new()
	add_child_autofree(item1)
	add_child_autofree(item2)
	_char.attach_item("left", item1)
	var result: bool = _char.attach_item("left", item2)
	assert_false(result)


func test_attach_returns_true_on_success() -> void:
	var item: Node2D = Node2D.new()
	add_child_autofree(item)
	var result: bool = _char.attach_item("right", item)
	assert_true(result)


func test_detach_item_frees_hand() -> void:
	var item: Node2D = Node2D.new()
	add_child_autofree(item)
	_char.attach_item("left", item)
	_char.detach_item("left")
	assert_true(_char.is_hand_free("left"))


func test_detach_returns_item() -> void:
	var item: Node2D = Node2D.new()
	add_child_autofree(item)
	_char.attach_item("right", item)
	var returned: Node2D = _char.detach_item("right")
	assert_eq(returned, item)


func test_detach_from_empty_hand_returns_null() -> void:
	var returned: Node2D = _char.detach_item("left")
	assert_null(returned)
```

- [ ] **Step 2: Run tests — verify 8 new tests FAIL**

```
"F:/Development/_tools/Godot_v4.6.2-stable_win64/Godot_v4.6.2-stable_win64.exe" --headless -s res://addons/gut/gut_cmdln.gd -gdir=res://test/ -gexit 2>&1
```

Expected: 8 failures. 73 existing pass.

- [ ] **Step 3: Add hand slot API to `scripts/characters/character.gd`**

Add these four methods after `get_outfit()`:

```gdscript
func attach_item(hand: String, item: Node2D) -> bool:
	if hand != "left" and hand != "right":
		return false
	var slot: Node2D = get_node_or_null("Hand" + hand.capitalize()) as Node2D
	if slot == null:
		return false
	if slot.get_child_count() > 0:
		return false
	var old_parent: Node = item.get_parent()
	if old_parent != null:
		old_parent.remove_child(item)
	slot.add_child(item)
	item.position = Vector2.ZERO
	return true


func detach_item(hand: String) -> Node2D:
	if hand != "left" and hand != "right":
		return null
	var slot: Node2D = get_node_or_null("Hand" + hand.capitalize()) as Node2D
	if slot == null or slot.get_child_count() == 0:
		return null
	var item: Node2D = slot.get_child(0) as Node2D
	slot.remove_child(item)
	var scene_parent: Node = get_parent()
	if scene_parent != null:
		scene_parent.add_child(item)
	return item


func get_held_item(hand: String) -> Node2D:
	if hand != "left" and hand != "right":
		return null
	var slot: Node2D = get_node_or_null("Hand" + hand.capitalize()) as Node2D
	if slot == null or slot.get_child_count() == 0:
		return null
	return slot.get_child(0) as Node2D


func is_hand_free(hand: String) -> bool:
	return get_held_item(hand) == null
```

- [ ] **Step 4: Run tests — verify 8 new tests PASS, 81 total**

```
"F:/Development/_tools/Godot_v4.6.2-stable_win64/Godot_v4.6.2-stable_win64.exe" --headless -s res://addons/gut/gut_cmdln.gd -gdir=res://test/ -gexit 2>&1
```

Expected: 81/81 passed.

- [ ] **Step 5: Commit**

```bash
git add scripts/characters/character.gd test/unit/test_character_v2.gd
git commit -m "feat(character): add hand slot API (attach/detach/get_held_item/is_hand_free)"
```

---

## Task 8: SnapReceiver full implementation + tests

**Files:**
- Modify: `scripts/characters/snap_receiver.gd`
- Create: `test/unit/test_snap_receiver.gd`

- [ ] **Step 1: Write the SnapReceiver tests**

Create `test/unit/test_snap_receiver.gd`:

```gdscript
## Tests for SnapReceiver — snap detection when Character is released near a SnapPoint.
extends GutTest

var _char: Character
var _snap: SnapPoint


func before_each() -> void:
	_char = preload("res://scenes/characters/Character.tscn").instantiate() as Character
	add_child_autofree(_char)
	_snap = SnapPoint.new()
	_snap.pose = "sitting"
	add_child_autofree(_snap)


func _get_receiver() -> SnapReceiver:
	return _char.get_node("SnapReceiver") as SnapReceiver


func test_snap_receiver_exists_on_character() -> void:
	assert_not_null(_get_receiver())


func test_no_current_snap_initially() -> void:
	assert_null(_get_receiver().get_current_snap())


func test_snap_detected_when_character_released_within_radius() -> void:
	_snap.global_position = Vector2(0.0, 0.0)
	_char.global_position = Vector2(50.0, 0.0)
	_get_receiver()._on_drag_released(Vector2(50.0, 0.0))
	assert_eq(_get_receiver().get_current_snap(), _snap)


func test_no_snap_when_released_outside_radius() -> void:
	_snap.global_position = Vector2(0.0, 0.0)
	_char.global_position = Vector2(200.0, 0.0)
	_get_receiver()._on_drag_released(Vector2(200.0, 0.0))
	assert_null(_get_receiver().get_current_snap())


func test_character_position_set_to_snap_point_on_snap() -> void:
	_snap.global_position = Vector2(100.0, 100.0)
	_char.global_position = Vector2(120.0, 100.0)
	_get_receiver()._on_drag_released(Vector2(120.0, 100.0))
	assert_eq(_char.global_position, Vector2(100.0, 100.0))


func test_character_animation_set_to_snap_pose_on_snap() -> void:
	_snap.global_position = Vector2(0.0, 0.0)
	_snap.pose = "lying"
	_char.global_position = Vector2(50.0, 0.0)
	_get_receiver()._on_drag_released(Vector2(50.0, 0.0))
	assert_eq(_char.get_animation_state(), "lying")


func test_pickup_clears_current_snap() -> void:
	_snap.global_position = Vector2(0.0, 0.0)
	_char.global_position = Vector2(50.0, 0.0)
	_get_receiver()._on_drag_released(Vector2(50.0, 0.0))
	_get_receiver()._on_drag_picked_up(Vector2(50.0, 0.0))
	assert_null(_get_receiver().get_current_snap())


func test_pickup_frees_snap_point() -> void:
	_snap.global_position = Vector2(0.0, 0.0)
	_char.global_position = Vector2(50.0, 0.0)
	_get_receiver()._on_drag_released(Vector2(50.0, 0.0))
	_get_receiver()._on_drag_picked_up(Vector2(50.0, 0.0))
	assert_true(_snap.is_free())


func test_second_character_cannot_snap_to_occupied_point() -> void:
	_snap.global_position = Vector2(0.0, 0.0)
	_char.global_position = Vector2(50.0, 0.0)
	_get_receiver()._on_drag_released(Vector2(50.0, 0.0))
	var char2: Character = preload("res://scenes/characters/Character.tscn").instantiate() as Character
	add_child_autofree(char2)
	char2.global_position = Vector2(60.0, 0.0)
	var recv2: SnapReceiver = char2.get_node("SnapReceiver") as SnapReceiver
	recv2._on_drag_released(Vector2(60.0, 0.0))
	assert_null(recv2.get_current_snap())
```

- [ ] **Step 2: Run tests — verify 8 new tests FAIL (most of them)**

```
"F:/Development/_tools/Godot_v4.6.2-stable_win64/Godot_v4.6.2-stable_win64.exe" --headless -s res://addons/gut/gut_cmdln.gd -gdir=res://test/ -gexit 2>&1
```

Expected: several failures for snap detection tests. `test_snap_receiver_exists_on_character` may pass already. 81 existing tests pass.

- [ ] **Step 3: Implement full `scripts/characters/snap_receiver.gd`**

Replace the entire file:

```gdscript
## SnapReceiver — scans for nearby SnapPoints when the parent Character is released.
## Attach as child of Character. Connects automatically to DragDropComponent signals.
class_name SnapReceiver extends Node

const SCAN_RADIUS: float = 80.0

var _current_snap: SnapPoint = null
var _character: Character


func _ready() -> void:
	_character = get_parent() as Character
	var drag: DragDropComponent = _character.get_node_or_null("DragDropComponent") as DragDropComponent
	if drag != null:
		drag.drag_picked_up.connect(_on_drag_picked_up)
		drag.drag_released.connect(_on_drag_released)


func _on_drag_picked_up(_pos: Vector2) -> void:
	if _current_snap != null:
		_current_snap.unsnap()
		_current_snap = null
	_character.set_animation_state("held")


func _on_drag_released(_pos: Vector2) -> void:
	var nearest: SnapPoint = _find_nearest_accepting_snap()
	if nearest != null:
		_current_snap = nearest
		nearest.snap(_character)
		_character.global_position = nearest.global_position
		_character.set_animation_state(nearest.pose)
	else:
		_character.set_animation_state("idle")


func _find_nearest_accepting_snap() -> SnapPoint:
	var best: SnapPoint = null
	var best_dist: float = SCAN_RADIUS
	for node: Node in get_tree().get_nodes_in_group("snap_points"):
		var snap_point: SnapPoint = node as SnapPoint
		if snap_point == null:
			continue
		if not snap_point.accepts(_character):
			continue
		var dist: float = _character.global_position.distance_to(snap_point.global_position)
		if dist < best_dist:
			best_dist = dist
			best = snap_point
	return best


func get_current_snap() -> SnapPoint:
	return _current_snap


func force_unsnap() -> void:
	if _current_snap != null:
		_current_snap.unsnap()
		_current_snap = null
		_character.set_animation_state("idle")
```

- [ ] **Step 4: Run all tests — verify 8 new tests PASS, 89 total**

```
"F:/Development/_tools/Godot_v4.6.2-stable_win64/Godot_v4.6.2-stable_win64.exe" --headless -s res://addons/gut/gut_cmdln.gd -gdir=res://test/ -gexit 2>&1
```

Expected: 89/89 passed. 0 failures.

- [ ] **Step 5: Commit**

```bash
git add scripts/characters/snap_receiver.gd test/unit/test_snap_receiver.gd
git commit -m "feat(snap-receiver): implement snap detection, position snapping, and pose animation trigger"
```

---

## Final Check

- [ ] **Run full test suite one last time**

```
"F:/Development/_tools/Godot_v4.6.2-stable_win64/Godot_v4.6.2-stable_win64.exe" --headless -s res://addons/gut/gut_cmdln.gd -gdir=res://test/ -gexit 2>&1
```

Expected output:
```
Scripts               8
Tests                89
Passing Tests        89
Asserts             (varies)
---- All tests passed! ----
```

- [ ] **Verify git log shows 7 clean commits**

```bash
git log --oneline -8
```

Expected (most recent first):
```
feat(snap-receiver): implement snap detection, position snapping, and pose animation trigger
feat(character): add hand slot API (attach/detach/get_held_item/is_hand_free)
feat(character): add outfit layer API (set/clear/get_outfit per layer 1-3)
feat(character): add animation state API (set/get_animation_state)
feat(character-data): add outfit array field for three outfit layer slots
feat(character): add AnimatedSprite2D, OutfitLayers, HandSlots, SnapReceiver to Character scene
feat(snap-receiver): add SnapReceiver stub (full implementation in sprint-15 task 7)
feat(snap-point): add SnapPoint node with pose, baby_only filter, and occupant tracking
```
