# TASKS_World2.md — Collapse Logic: World 2 Implementation

Feed this file to Claude Code. Work through tasks **in order**. Do not skip ahead.  
Complete each task fully before starting the next. Run tests after every task that touches the engine.

---

## Task 1 — Extend `BlockColor` with new colors

**File:** `Models/Block.swift`

Add three primary and three secondary colors to the `BlockColor` enum (yellow already may be stubbed — check first):

```swift
enum BlockColor: String, Codable, CaseIterable {
    // Primary (can merge)
    case red
    case blue
    case yellow

    // Secondary (result of merges)
    case purple
    case orange
    case green

    var isPrimary: Bool {
        switch self {
        case .red, .blue, .yellow: return true
        case .purple, .orange, .green: return false
        }
    }

    var displayHex: String {
        switch self {
        case .red:    return "#E63946"
        case .blue:   return "#457B9D"
        case .yellow: return "#F4D35E"
        case .purple: return "#7B2D8B"
        case .orange: return "#E76F51"
        case .green:  return "#2A9D8F"
        }
    }
}
```

**Verify:** No existing code breaks. `BlockColor` is `Codable` so JSON loading of `"yellow"`, `"purple"`, etc. will now work automatically.

---

## Task 2 — Add `mergeColor` to `GameEngine`

**File:** `Engine/GameEngine.swift`

Add this static helper. Place it near the top of `GameEngine` as a private static func:

```swift
private static func mergeColor(_ a: BlockColor, _ b: BlockColor) -> BlockColor? {
    guard a.isPrimary && b.isPrimary && a != b else { return nil }
    let pair = Set([a, b])
    if pair == Set([BlockColor.red,    .blue])   { return .purple }
    if pair == Set([BlockColor.red,    .yellow]) { return .orange }
    if pair == Set([BlockColor.blue,   .yellow]) { return .green  }
    return nil
}
```

**No other changes in this task.**

---

## Task 3 — Update `processMove` for merge collisions

**File:** `Engine/GameEngine.swift`

Find step 5 of the movement algorithm — the collision check after the block slides to position T. Currently it reads:

```swift
// Different color → block stops (MVP: no merge)
```

Replace that branch with:

```swift
// Different color — check for merge
if let merged = GameEngine.mergeColor(movingBlock.color, stationaryBlock.color) {
    // Remove both blocks
    var newBlocks = state.blocks
    // Remove in reverse index order to avoid index shifting
    let indicesToRemove = [movingIndex, stationaryIndex].sorted(by: >)
    for idx in indicesToRemove { newBlocks.remove(at: idx) }
    // Place merged block at stationary position
    let mergedBlock = Block(position: stationaryBlock.position, color: merged)
    newBlocks.append(mergedBlock)
    // Emit merged event
    events.append(.merged(
        blockIndex1: movingIndex,
        blockIndex2: stationaryIndex,
        resultColor: merged,
        at: stationaryBlock.position
    ))
    newState = GameState(
        grid: state.grid,
        blocks: newBlocks,
        walls: state.walls,
        moveCount: state.moveCount + 1,
        history: state.historySnapshot(),
        objective: state.objective,
        par: state.par
    )
} else {
    // No merge possible — block stops at T
    // (existing stop logic unchanged)
}
```

> Note: `historySnapshot()` should already exist from World 1's undo implementation. If it's named differently in your codebase, use the correct name.

**After this task: run ALL existing unit tests.** Zero regressions allowed before continuing.

---

## Task 4 — Unit tests for merge logic

**File:** `CollapseLogicTests/GameEngineTests.swift` (add to existing test file)

Add a `// MARK: - World 2 Merge Tests` section with these tests:

```swift
// MARK: - World 2 Merge Tests

func testRedBlueProducesPurple() {
    // red at (0,0), blue at (2,0), push red right
    // Expected: purple block at (2,0), both originals removed
}

func testRedYellowProducesOrange() { }

func testBlueYellowProducesGreen() { }

func testMergedBlockAtStationaryPosition() {
    // Verify the new block is at the stationary block's cell, not the moving block's cell
}

func testSecondaryPlusPrimaryStops() {
    // purple moving into red → block stops, no merge, no destroy
}

func testDifferentSecondariesStop() {
    // purple moving into green → block stops
}

func testSameSecondaryDestroysBoth() {
    // purple moving into purple → both destroyed (same rule as primaries)
}

func testMoveCountIncrementsOnMerge() { }

func testUndoAfterMergeRestoresState() {
    // After a merge, undo should restore both original blocks and remove the merged block
}

func testWinConditionAfterMerge() {
    // Board with one red + one blue. Merge them. If objective is clear_all,
    // that's NOT a win (merged block remains). Verify win is NOT triggered.
    // Then push merged purple into a pre-placed purple → destroy both → win.
}
```

Implement each test fully. All must pass before proceeding.

---

## Task 5 — Update `BlockNode` color rendering

**File:** `Scene/BlockNode.swift`

Add color cases for `yellow`, `purple`, `orange`, `green` in the color-to-UIColor/SKColor switch. Use:

```swift
case .yellow: return SKColor(hex: "#F4D35E")
case .purple: return SKColor(hex: "#7B2D8B")
case .orange: return SKColor(hex: "#E76F51")
case .green:  return SKColor(hex: "#2A9D8F")
```

Additionally, add a **white inner ring** for secondary-color blocks. After setting the block's fill color, add:

```swift
if !block.color.isPrimary {
    let ring = SKShapeNode(rectOf: CGSize(width: size.width - 8, height: size.height - 8),
                           cornerRadius: cornerRadius - 2)
    ring.fillColor = .clear
    ring.strokeColor = SKColor.white.withAlphaComponent(0.4)
    ring.lineWidth = 2
    ring.zPosition = 1
    addChild(ring)
}
```

**Verify visually** in the simulator: place a World 1 level with a pre-placed `purple` block (edit a level JSON temporarily) and confirm it renders with the inner ring. Revert the JSON after verifying.

---

## Task 6 — Merge animation in `GameScene`

**File:** `Scene/GameScene.swift`

Find the method that processes `GameEvent` items for animation (likely a `playEvents` or `animateEvents` function). Add a case for `.merged`:

```swift
case .merged(_, _, let resultColor, let position):
    // 1. Both source blocks have already slid/are at position — their nodes need to burst out
    // 2. Scale both up 1.2× (0.1s), then scale + fade to 0 (0.1s), remove nodes
    // 3. Create a new BlockNode for resultColor at `position`, scale 0 → 1.15 → 1.0 (0.15s spring)
    // 4. Play merge.wav

    let burstScale = SKAction.scale(to: 1.2, duration: 0.1)
    let burstFade  = SKAction.group([
        SKAction.scale(to: 0.0, duration: 0.1),
        SKAction.fadeOut(withDuration: 0.1)
    ])
    let burstRemove = SKAction.removeFromParent()
    let burst = SKAction.sequence([burstScale, burstFade, burstRemove])

    // Run burst on both source nodes (look them up by their current grid positions)
    // Then after burst completes, spawn merged node with spring pop:
    let spawnDelay  = SKAction.wait(forDuration: 0.2)
    let spawnSpring = SKAction.sequence([
        SKAction.scale(to: 0.0, duration: 0),
        SKAction.scale(to: 1.15, duration: 0.12),
        SKAction.scale(to: 1.0,  duration: 0.05)
    ])
    // Create new BlockNode, add to scene at correct sprite position, run spawnSpring
```

Sequence these so the burst finishes before the new block appears. Use `SKAction.sequence` with a wait equal to the burst duration (0.2s).

Total animation time: ~0.35s. Do not block player input during this time — use the existing input-lock pattern from the slide animation.

---

## Task 7 — Add `merge.wav`

**File:** `Resources/SFX/merge.wav` and `Audio/SFXManager.swift`

1. Add a `merge.wav` placeholder to `Resources/SFX/`. If you don't have an audio file yet, create an empty file as a placeholder — the app will not crash on a missing SKAction sound, it will just play nothing. Add a `// TODO: replace with real audio` comment.

2. In `SFXManager.swift`, add:
```swift
func playMerge() {
    playSound("merge")
}
```

3. Call `SFXManager.shared.playMerge()` from the `.merged` animation case in Task 6.

---

## Task 8 — World 2 level files

**Directory:** `Levels/world2/`

Create this directory and add `level_01.json` through `level_12.json`. All 12 files are provided in `Levels/world2/` alongside this task file. Copy them into the Xcode project the same way World 1 levels were added — as a **folder reference** (blue folder), not individual file entries.

**Xcode setup reminder:**
- Right-click `Levels/` group → Add Files → select the `world2/` folder → choose "Create folder references" (blue folder)
- Confirm `world2/` appears as a blue folder in the Levels group
- Do NOT add individual JSON files — only the folder

---

## Task 9 — Update `metadata.json`

**File:** `Levels/metadata.json`

Add the World 2 entry to the `worlds` array:

```json
{
  "id": 2,
  "name": "Fusion",
  "description": "Discover what happens when two colors collide.",
  "new_mechanic": "merging",
  "unlock_stars": 15,
  "levels": [
    { "id": "w2_01", "title": "First Contact",   "file": "world2/level_01.json", "is_challenge": false },
    { "id": "w2_02", "title": "Bound Together",  "file": "world2/level_02.json", "is_challenge": false },
    { "id": "w2_03", "title": "The Third Color", "file": "world2/level_03.json", "is_challenge": false },
    { "id": "w2_04", "title": "Sun Stone",       "file": "world2/level_04.json", "is_challenge": false },
    { "id": "w2_05", "title": "Color Theory",    "file": "world2/level_05.json", "is_challenge": false },
    { "id": "w2_06", "title": "Triad",           "file": "world2/level_06.json", "is_challenge": false },
    { "id": "w2_07", "title": "Inheritance",     "file": "world2/level_07.json", "is_challenge": false },
    { "id": "w2_08", "title": "Reflection",      "file": "world2/level_08.json", "is_challenge": false },
    { "id": "w2_09", "title": "Cascade",         "file": "world2/level_09.json", "is_challenge": false },
    { "id": "w2_10", "title": "Chain Reaction",  "file": "world2/level_10.json", "is_challenge": false },
    { "id": "w2_11", "title": "The Long Way",    "file": "world2/level_11.json", "is_challenge": false },
    { "id": "w2_12", "title": "Prism",           "file": "world2/level_12.json", "is_challenge": true, "stars_required": 15 }
  ]
}
```

Also update the `LevelLoader` / wherever `metadata.json` is parsed to handle the new `unlock_stars` and `new_mechanic` fields. These are optional — existing World 1 entry has neither and must still load correctly.

---

## Task 10 — World selector UI

**File:** `Views/LevelSelectView.swift`

Add a world selector at the top of the level select screen.

```swift
@State private var selectedWorld: Int = 1

// World 2 unlock condition
private var world2Unlocked: Bool {
    progressStore.totalStars() >= 15
}
```

Render two pill-shaped buttons: "World 1" and "World 2". When World 2 is locked:
- Pill is grayed out (opacity 0.4)
- Show a lock symbol (SF Symbol: `lock.fill`) and the text "15 ★"
- Tapping the locked pill does nothing (or shows a brief shake animation)

When World 2 is unlocked, tapping it switches `selectedWorld` to 2 and the level grid below shows World 2 levels.

Keep the implementation simple — the world metadata is already loaded, just filter `levels` by `selectedWorld`.

---

## Task 11 — Level loading tests (World 2)

**File:** `CollapseLogicTests/LevelLoaderTests.swift` (add to existing)

```swift
func testAllWorld2LevelsLoad() {
    for i in 1...12 {
        let id = String(format: "w2_%02d", i)
        let level = LevelLoader.load(id: id)
        XCTAssertNotNil(level, "Failed to load \(id)")
    }
}

func testPreplacedSecondaryColorsLoad() {
    // Load w2_07 (first level with pre-placed secondary blocks)
    // Verify the purple block in that level loads with color == .purple
}

func testWorld2MetadataLoads() {
    let metadata = LevelLoader.loadMetadata()
    XCTAssertEqual(metadata.worlds.count, 2)
    let world2 = metadata.worlds.first(where: { $0.id == 2 })
    XCTAssertNotNil(world2)
    XCTAssertEqual(world2?.levels.count, 12)
}
```

All must pass before marking World 2 complete.

---

## Task 12 — Full playthrough and regression check

Manual steps before closing World 2:

1. Run all unit tests. Zero failures.
2. Build and launch on iPhone 15 Pro simulator.
3. Play World 1 levels 1, 5, and 10 — verify no regressions.
4. Earn 15 stars. Verify World 2 unlocks in the level select.
5. Play all 12 World 2 levels from start to finish.
6. For each World 2 level: verify the level is solvable at par.
7. Trigger at least one of each merge type (red+blue, red+yellow, blue+yellow) and confirm the animation plays correctly.
8. Undo a merge — verify the board restores correctly.
9. Build and run on iPhone SE (3rd gen) simulator — verify layout is not broken.
10. Confirm no crashes on any level.

**Definition of Done — World 2 is complete when all 12 tasks are done and all manual steps above pass.**
