# Phase 0 — Fun & Engagement Hotfix Implementation Plan

> **⚠️ SUPERSEDED (2026-05-02):** Phase 0의 8개 항목 모두 다른 패턴/위치로 이미 구현되어 커밋됨 (`90ba98f`, `3676d8b`). 본 plan은 스펙 추적용으로 보존하되, 실제 코드와 차이가 있으니 신규 작업 가이드로 사용하지 말 것.
>
> 실제 구현 vs plan 매핑:
> - F-8 햅틱: `src/lib/sfx.ts::vibrate()` (plan은 별도 `haptic.ts`)
> - F-1 SFX: `src/lib/sfx.ts::playRaritySfx(rarity, enabled)` (plan은 module 상태)
> - F-2 셰이크: `legendaryImpact` 이벤트 (plan은 `legendaryShake`)
> - A-3 발견 카드: `DiscoveryHero` props 컨트롤드 (plan은 event listener)
> - A-4 환영 선물: `TutorialOverlay` 인라인 (plan은 별도 `WelcomeGiftModal`)
> - 그 외 B-1, B-5, E-7는 plan과 동등한 형태로 구현됨
>
> 후속 작업은 Phase 1 plan(`docs/superpowers/plans/` 하위, 작성 예정) 참고.

---

> **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:** 출시 직후(또는 직전) v1.0 핫픽스로, 코어 시스템은 그대로 둔 채 첫 5분 후크·능동 합성 보상·감각 연출·광고 슬롯을 추가해 리텐션과 ARPU 기반을 마련한다.

**Architecture:**
- 신규 유틸 2종(`src/lib/haptic.ts`, `src/lib/sfx.ts`), 신규 컴포넌트 3종(`DiscoveryHero`, `WelcomeGiftModal`, `AdBanner`)을 추가하고, `useGameStore`에 4개 상태(`sfxEnabled`, `hapticEnabled`, `comboCount`, `comboExpiresAt`, `welcomeGiftClaimed`)를 추가한다.
- 발견·튜토리얼 보상 트리거는 `useGameStore` 액션에서 `window.dispatchEvent` 패턴(이미 `newAchievement`로 사용 중)을 따라 컴포넌트가 listen.
- 광고 SDK 연동은 본 Phase에서 하지 않음 — `AdBanner`는 placeholder 슬롯만 확보해 Phase 1에서 SDK 어댑터를 끼울 수 있게 한다.

**Tech Stack:** Vite 5, React 18, TypeScript 5, Zustand 5 (persist middleware), @emotion/react. 테스트 인프라 미설치 — 본 Plan은 `npm run typecheck` + 브라우저 수동 스모크 테스트로 검증한다 (테스트 프레임워크 도입은 별도 spec).

**상위 spec:** `docs/superpowers/specs/2026-04-28-fun-engagement-overhaul-design.md` Phase 0 섹션.

---

## File Structure

| 파일 | 역할 | 새로 생성 / 수정 |
|---|---|---|
| `src/lib/haptic.ts` | `navigator.vibrate` 어댑터 + 패턴 상수 | **Create** |
| `src/lib/sfx.ts` | Web Audio API 합성 SFX | **Create** |
| `src/components/DiscoveryHero.tsx` | 첫 발견 풀스크린 카드 | **Create** |
| `src/components/WelcomeGiftModal.tsx` | 튜토리얼 완료 보상 모달 | **Create** |
| `src/components/AdBanner.tsx` | 광고 영역 placeholder 슬롯 | **Create** |
| `src/store/useGameStore.ts` | sfx/haptic 토글, 콤보, 럭키, 발견 이벤트, 환영 선물 | **Modify** |
| `src/components/screens/FusionScreen.tsx` | 콤보 바, 럭키 배지, SFX/햅틱/셰이크 호출 | **Modify** |
| `src/App.tsx` | 셰이크 listener, DiscoveryHero/WelcomeGiftModal/AdBanner 마운트 | **Modify** |

---

## Task 0: Pre-flight — 미커밋 변경 점검 및 베이스라인 커밋

**Files:**
- Inspect: `src/store/useGameStore.ts`, `src/components/screens/SettingsScreen.tsx`, `src/components/screens/FusionScreen.tsx`, `src/components/screens/ShopScreen.tsx`, `src/components/screens/ElementsScreen.tsx`, `src/components/CharacterSprite.tsx`

**컨텍스트:** 기재 시점 `git status`에 5개 파일 modified. `SettingsScreen.tsx`가 이미 `sfxEnabled`/`hapticEnabled` 토글 UI를 참조 중이지만 `useGameStore`에 해당 필드가 없어 typecheck가 깨진 상태일 수 있음.

- [ ] **Step 1: 변경 현황 검토**

```bash
git status
git diff src/store/useGameStore.ts
git diff src/components/screens/SettingsScreen.tsx
git diff src/components/screens/ShopScreen.tsx
git diff src/components/screens/ElementsScreen.tsx
git diff src/components/screens/FusionScreen.tsx
git diff src/components/CharacterSprite.tsx
```

- [ ] **Step 2: typecheck 결과 확인**

```bash
npm run typecheck
```

만약 `sfxEnabled`/`hapticEnabled` 관련 에러가 발생하면 → Task 1 ~ 2에서 store 필드를 추가하면서 자연스럽게 해결됨. Step 3에서는 일단 현재 변경을 그대로 베이스라인으로 잡지 않고, 작업 트리를 그대로 두고 Task 1로 진행한다.

만약 typecheck가 통과하면 → 미커밋 변경은 일관된 작업 단위. Step 3에서 단일 베이스라인 커밋.

- [ ] **Step 3a (typecheck 통과 시): 베이스라인 단일 커밋**

```bash
git add src/store/useGameStore.ts src/components/screens/SettingsScreen.tsx src/components/screens/FusionScreen.tsx src/components/screens/ShopScreen.tsx src/components/screens/ElementsScreen.tsx src/components/CharacterSprite.tsx
git commit -m "chore: baseline pre-Phase-0 in-progress changes"
```

- [ ] **Step 3b (typecheck 실패 시): Task 1로 진행**

미커밋 변경을 그대로 두고 다음 Task에서 store에 누락 필드를 추가하면서 함께 커밋한다.

- [ ] **Step 4: 베이스라인 검증**

```bash
git status
```

작업 트리가 깨끗하거나(3a 경로), 또는 의도된 작업 진행 중(3b 경로)임을 확인.

---

## Task 1: F-8 햅틱 진동 라이브러리

**Files:**
- Create: `src/lib/haptic.ts`
- Modify: `src/store/useGameStore.ts` (필드 추가, 토글 액션 추가)
- Modify: `src/components/screens/FusionScreen.tsx` (호출)

- [ ] **Step 1: 햅틱 유틸 생성**

새 파일 `src/lib/haptic.ts`:

```ts
let _enabled = true;

export function setHapticEnabled(enabled: boolean): void {
  _enabled = enabled;
}

function safeVibrate(pattern: number | number[]): void {
  if (!_enabled) return;
  if (typeof navigator === 'undefined') return;
  const vibrate = navigator.vibrate?.bind(navigator);
  if (!vibrate) return;
  try {
    vibrate(pattern);
  } catch {
    // Some browsers throw if vibrate is called from non-user-gesture context — ignore
  }
}

export const HAPTIC = {
  fuse: (): void => safeVibrate(50),
  fuseLucky: (): void => safeVibrate([30, 20, 30]),
  legendary: (): void => safeVibrate([80, 40, 80]),
  prestige: (): void => safeVibrate(200),
  achievement: (): void => safeVibrate([60, 30, 60]),
};
```

- [ ] **Step 2: store에 hapticEnabled 필드 + 액션 + persist 추가**

`src/store/useGameStore.ts` 변경 — 다음 위치에 각각 한 줄/블록 추가:

(a) 파일 상단에 import 추가 (기존 import 블록 끝):

```ts
import { setHapticEnabled as setHapticEnabledLib } from '../lib/haptic';
```

(b) `interface GameState` 안 — `bgmEnabled: boolean;` 다음 줄에 추가:

```ts
  sfxEnabled: boolean;
  hapticEnabled: boolean;
  setSfxEnabled: (enabled: boolean) => void;
  setHapticEnabled: (enabled: boolean) => void;
```

(c) `type PersistedState = Pick<...>` 의 union에 추가 (기존 `'bgmEnabled'` 다음 줄):

```ts
  | 'sfxEnabled'
  | 'hapticEnabled'
```

(d) store 본문에서 `bgmEnabled: false,` 다음 줄들에 추가:

```ts
      sfxEnabled: true,
      hapticEnabled: true,
      setSfxEnabled: (enabled) => set({ sfxEnabled: enabled }),
      setHapticEnabled: (enabled) => {
        setHapticEnabledLib(enabled);
        set({ hapticEnabled: enabled });
      },
```

(e) `partialize` 안에 두 줄 추가 (기존 `bgmEnabled: state.bgmEnabled,` 다음):

```ts
        sfxEnabled: state.sfxEnabled,
        hapticEnabled: state.hapticEnabled,
```

(f) `onRehydrateStorage` 안에 — `state.activeBoosts = validBoosts;` 다음에 추가:

```ts
        if (typeof state.sfxEnabled !== 'boolean') state.sfxEnabled = true;
        if (typeof state.hapticEnabled !== 'boolean') state.hapticEnabled = true;
        setHapticEnabledLib(state.hapticEnabled);
```

- [ ] **Step 3: FusionScreen에서 합성 시 햅틱 호출**

`src/components/screens/FusionScreen.tsx`의 import 블록에 추가:

```ts
import { HAPTIC } from '../../lib/haptic';
```

`handleFuse` 함수의 `if (result.success && result.resultId) {` 블록 안, `setIsFusing(true);` 직후에 추가:

```ts
      if (rarity === 'legendary' || rarity === 'epic') {
        HAPTIC.legendary();
      } else {
        HAPTIC.fuse();
      }
```

- [ ] **Step 4: typecheck**

```bash
npm run typecheck
```

기대: PASS. (Task 0 Step 3b 경로였다면 SettingsScreen이 이제 정상 컴파일됨.)

- [ ] **Step 5: 브라우저 수동 검증**

```bash
npm run dev
```

확인:
1. 합성 화면에서 합성 1회 실행 → 모바일 디바이스에서 50ms 진동 (데스크톱 크롬은 지원 안 함, 무에러)
2. 설정 → "햅틱 진동" 토글 OFF → 다시 합성 → 진동 없음
3. 새로고침 후 토글 상태 유지

- [ ] **Step 6: 커밋**

```bash
git add src/lib/haptic.ts src/store/useGameStore.ts src/components/screens/FusionScreen.tsx
git commit -m "feat: add haptic vibration on fusion (F-8)"
```

(Task 0이 3b 경로였다면 미커밋 SettingsScreen·ShopScreen·ElementsScreen·CharacterSprite도 함께 staging — `git status`로 확인 후 의도된 변경만 add.)

---

## Task 2: F-1 효과음 (Web Audio 합성)

**Files:**
- Create: `src/lib/sfx.ts`
- Modify: `src/store/useGameStore.ts` (sfxEnabled lib 동기화)
- Modify: `src/components/screens/FusionScreen.tsx` (호출)

- [ ] **Step 1: SFX 유틸 생성**

새 파일 `src/lib/sfx.ts`:

```ts
let _enabled = true;
let _ctx: AudioContext | null = null;

export function setSfxEnabled(enabled: boolean): void {
  _enabled = enabled;
}

function getCtx(): AudioContext | null {
  if (!_enabled) return null;
  if (typeof window === 'undefined') return null;
  if (_ctx === null) {
    const AnyWindow = window as typeof window & { webkitAudioContext?: typeof AudioContext };
    const Ctor = window.AudioContext ?? AnyWindow.webkitAudioContext;
    if (!Ctor) return null;
    _ctx = new Ctor();
  }
  return _ctx;
}

interface ToneSpec {
  freq: number;
  durationMs: number;
  type?: OscillatorType;
  attackMs?: number;
  releaseMs?: number;
  gain?: number;
}

function playTone(spec: ToneSpec, delayMs = 0): void {
  const ctx = getCtx();
  if (!ctx) return;
  const start = ctx.currentTime + delayMs / 1000;
  const dur = spec.durationMs / 1000;
  const attack = (spec.attackMs ?? 5) / 1000;
  const release = (spec.releaseMs ?? 80) / 1000;
  const peak = spec.gain ?? 0.18;

  const osc = ctx.createOscillator();
  const gain = ctx.createGain();
  osc.type = spec.type ?? 'sine';
  osc.frequency.value = spec.freq;
  gain.gain.setValueAtTime(0, start);
  gain.gain.linearRampToValueAtTime(peak, start + attack);
  gain.gain.linearRampToValueAtTime(0, start + dur + release);
  osc.connect(gain).connect(ctx.destination);
  osc.start(start);
  osc.stop(start + dur + release + 0.05);
}

function playChord(specs: ToneSpec[], spreadMs = 0): void {
  specs.forEach((s, i) => playTone(s, i * spreadMs));
}

const SFX = {
  common: (): void => playTone({ freq: 540, durationMs: 80, type: 'triangle', gain: 0.12 }),
  uncommon: (): void =>
    playChord(
      [
        { freq: 660, durationMs: 100, type: 'triangle' },
        { freq: 880, durationMs: 100, type: 'triangle' },
      ],
      35
    ),
  rare: (): void =>
    playChord(
      [
        { freq: 660, durationMs: 220, type: 'sine' },
        { freq: 990, durationMs: 220, type: 'sine' },
      ],
      40
    ),
  epic: (): void =>
    playChord(
      [
        { freq: 523, durationMs: 320, type: 'sine' },
        { freq: 659, durationMs: 320, type: 'sine' },
        { freq: 784, durationMs: 320, type: 'sine' },
      ],
      60
    ),
  legendary: (): void =>
    playChord(
      [
        { freq: 440, durationMs: 480, type: 'sine', gain: 0.22 },
        { freq: 554, durationMs: 480, type: 'sine', gain: 0.2 },
        { freq: 659, durationMs: 480, type: 'sine', gain: 0.18 },
        { freq: 880, durationMs: 480, type: 'sine', gain: 0.16 },
      ],
      70
    ),
  lucky: (): void =>
    playChord(
      [
        { freq: 988, durationMs: 120, type: 'triangle' },
        { freq: 1318, durationMs: 120, type: 'triangle' },
      ],
      50
    ),
};

export type RarityKey = 'common' | 'uncommon' | 'rare' | 'epic' | 'legendary';

export function playRaritySfx(rarity: string): void {
  const fn = SFX[rarity as RarityKey];
  if (fn) fn();
}

export function playLuckySfx(): void {
  SFX.lucky();
}
```

- [ ] **Step 2: store에서 sfxEnabled lib 동기화**

`src/store/useGameStore.ts` 상단 import에 추가 (Task 1의 haptic import 옆):

```ts
import { setSfxEnabled as setSfxEnabledLib } from '../lib/sfx';
```

기존 `setSfxEnabled` 액션을 다음으로 교체 (Task 1 Step 2(d)에서 추가한 라인):

```ts
      setSfxEnabled: (enabled) => {
        setSfxEnabledLib(enabled);
        set({ sfxEnabled: enabled });
      },
```

`onRehydrateStorage` 안 — `setHapticEnabledLib(...)` 다음 줄에 추가:

```ts
        setSfxEnabledLib(state.sfxEnabled);
```

- [ ] **Step 3: FusionScreen에서 rarity별 SFX 호출**

`src/components/screens/FusionScreen.tsx` import 블록에 추가:

```ts
import { playRaritySfx } from '../../lib/sfx';
```

`handleFuse` 함수, `setIsFusing(true);` 직후 (Task 1에서 추가한 HAPTIC 호출 옆에) 추가:

```ts
      playRaritySfx(rarity);
```

- [ ] **Step 4: typecheck**

```bash
npm run typecheck
```

기대: PASS.

- [ ] **Step 5: 브라우저 수동 검증**

```bash
npm run dev
```

확인:
1. 합성 (common 결과) → 짧은 "톡" 사운드
2. 합성 (rare 결과 — 예: 증기 같은 Tier 2 결과 위쪽 합성) → 종 비슷한 화음
3. 설정 → 효과음 OFF → 다시 합성 → 무음
4. 새로고침 후 토글 유지

브라우저 첫 사용자 제스처 후에만 AudioContext가 동작 — 합성 버튼 클릭이 그 트리거 역할. 첫 클릭에서 무음일 수 있음 → 두 번째부터 정상.

- [ ] **Step 6: 커밋**

```bash
git add src/lib/sfx.ts src/store/useGameStore.ts src/components/screens/FusionScreen.tsx
git commit -m "feat: add rarity-based Web Audio SFX on fusion (F-1)"
```

---

## Task 3: F-2 레전더리 화면 셰이크

**Files:**
- Modify: `src/App.tsx` (셰이크 keyframes + state + listener)
- Modify: `src/components/screens/FusionScreen.tsx` (legendary 시 dispatch)

- [ ] **Step 1: App.tsx에 셰이크 keyframes·상태·리스너 추가**

`src/App.tsx` import 블록을 다음으로 교체:

```tsx
import { css, keyframes } from '@emotion/react';
import { useEffect, useState } from 'react';
```

기존 `rootStyle` 정의 다음에 추가:

```tsx
const screenShakeKeyframes = keyframes`
  0%, 100% { transform: translate(0, 0); }
  20% { transform: translate(-3px, 2px); }
  40% { transform: translate(3px, -2px); }
  60% { transform: translate(-2px, -3px); }
  80% { transform: translate(2px, 3px); }
`;

const shakingStyle = css`
  animation: ${screenShakeKeyframes} 0.4s ease-out;
`;

const legendaryGlowKeyframes = keyframes`
  0%, 100% { box-shadow: inset 0 0 0 rgba(247, 193, 42, 0); }
  50% { box-shadow: inset 0 0 60px rgba(247, 193, 42, 0.45); }
`;

const legendaryGlowStyle = css`
  position: fixed;
  inset: 0;
  pointer-events: none;
  z-index: 999;
  animation: ${legendaryGlowKeyframes} 0.4s ease-out;
`;
```

`App` 함수 상단 (`const { activeTab, ... } = useGameStore();` 다음) 추가:

```tsx
  const [isShaking, setIsShaking] = useState(false);
  const [isGlowing, setIsGlowing] = useState(false);

  useEffect(() => {
    const handler = () => {
      setIsShaking(true);
      setIsGlowing(true);
      window.setTimeout(() => {
        setIsShaking(false);
        setIsGlowing(false);
      }, 400);
    };
    window.addEventListener('legendaryShake', handler);
    return () => window.removeEventListener('legendaryShake', handler);
  }, []);
```

JSX의 루트 `<div css={rootStyle}>`을 다음으로 교체:

```tsx
    <div css={[rootStyle, isShaking && shakingStyle]}>
      {isGlowing && <div css={legendaryGlowStyle} />}
```

(닫는 `</div>`는 그대로)

- [ ] **Step 2: FusionScreen에서 legendary일 때 dispatch**

`src/components/screens/FusionScreen.tsx` `handleFuse` 안, `playRaritySfx(rarity);` 다음에 추가:

```ts
      if (rarity === 'legendary') {
        window.dispatchEvent(new CustomEvent('legendaryShake'));
      }
```

- [ ] **Step 3: typecheck**

```bash
npm run typecheck
```

기대: PASS.

- [ ] **Step 4: 브라우저 수동 검증**

```bash
npm run dev
```

확인:
1. legendary rarity 결과를 만드는 합성 (`elements.json`의 `rarity === 'legendary'` 항목 — 예: spirit, creation 같은 최상위) 실행
2. 화면이 0.4초간 미세하게 흔들림 + 황금색 가장자리 글로우
3. 일반 합성 (common/rare)에서는 아무 일 없음
4. 햅틱 + SFX + 셰이크가 동시에 트리거되는지 확인

테스트가 어렵다면: 임시로 FusionScreen에 디버그 버튼 추가 → `window.dispatchEvent(new CustomEvent('legendaryShake'))` 호출 → 검증 후 제거 (커밋 X).

- [ ] **Step 5: 커밋**

```bash
git add src/App.tsx src/components/screens/FusionScreen.tsx
git commit -m "feat: add legendary screen shake and edge glow (F-2)"
```

---

## Task 4: B-5 럭키 합성 (1% 확률 상위 티어 승급)

**Files:**
- Modify: `src/store/useGameStore.ts` (`fuse()` 분기 + 반환 시그니처)
- Modify: `src/components/screens/FusionScreen.tsx` (LUCKY 배지 + 사운드)

- [ ] **Step 1: store에 럭키 분기 추가**

`src/store/useGameStore.ts` 상단 상수 영역에 추가 (`const TIER_SPAWN_RATE_MULTIPLIER` 근처):

```ts
const LUCKY_PROC_RATE = 0.01;
const LUCKY_GOLD_MULTIPLIER = 3;
```

`FuseResult` 인터페이스에 `lucky?: boolean` 추가:

```ts
export interface FuseResult {
  success: boolean;
  resultId?: string;
  goldGained?: number;
  lucky?: boolean;
  error?: 'no_recipe' | 'insufficient_elements';
}
```

`useGameStore` 본문 — `fuse: (slot1Id, slot2Id) => {` 함수 안의 기존 코드를 다음과 같이 수정:

```ts
        const baseResultId = recipe.result;
        const baseTier =
          (elementsData as Array<{ id: string; tier: number }>).find(
            (e) => e.id === baseResultId
          )?.tier ?? 0;

        const isLucky = Math.random() < LUCKY_PROC_RATE;
        let finalResultId = baseResultId;
        let goldGained = recipe.tier * 10;

        if (isLucky) {
          const upgradeCandidates = (elementsData as Array<{ id: string; tier: number }>).filter(
            (e) => e.tier === baseTier + 1
          );
          if (upgradeCandidates.length > 0) {
            const pick = upgradeCandidates[Math.floor(Math.random() * upgradeCandidates.length)];
            finalResultId = pick.id;
            goldGained *= LUCKY_GOLD_MULTIPLIER;
          }
        }

        const resultTier =
          (elementsData as Array<{ id: string; tier: number }>).find(
            (e) => e.id === finalResultId
          )?.tier ?? 0;
```

(기존 `const goldGained = recipe.tier * 10;` 와 `const resultTier = ...` 줄을 제거 — 위 코드로 대체)

`set((state) => { ... })` 블록 안에서 `next[recipe.result] = (next[recipe.result] ?? 0) + 1;` 줄을 다음으로 변경:

```ts
          next[finalResultId] = (next[finalResultId] ?? 0) + 1;
```

`getDiscoveredElementIds` 호출도 변경:

```ts
          const discoveredElements = getDiscoveredElementIds(
            [...state.discoveredElements, finalResultId],
            next
          );
```

함수 마지막 return 변경:

```ts
        return { success: true, resultId: finalResultId, goldGained, lucky: isLucky };
```

- [ ] **Step 2: FusionScreen에서 LUCKY 배지 + 사운드**

`src/components/screens/FusionScreen.tsx` import 블록에서 sfx import 확장:

```ts
import { playRaritySfx, playLuckySfx } from '../../lib/sfx';
```

`useState`에 `lastResult` 타입 확장 (기존 useState 줄):

```ts
  const [lastResult, setLastResult] = useState<{
    type: 'success' | 'error';
    resultId?: string;
    goldGained?: number;
    lucky?: boolean;
    message?: string;
  } | null>(null);
```

`handleFuse` 안, `setLastResult({ type: 'success', ... })` 부분을 다음으로 교체:

```ts
      setLastResult({
        type: 'success',
        resultId: result.resultId,
        goldGained: result.goldGained,
        lucky: result.lucky,
      });
```

`playRaritySfx(rarity);` 다음에 추가:

```ts
      if (result.lucky) {
        playLuckySfx();
      }
```

result banner JSX (`{lastResult?.type === 'success' && lastResult.resultId && (` 블록 안) 의 `<span css={shineBadgeStyle}>NEW</span>` 줄을 다음으로 교체:

```tsx
            <span css={shineBadgeStyle}>{lastResult.lucky ? '🍀 LUCKY' : 'NEW'}</span>
```

- [ ] **Step 3: typecheck**

```bash
npm run typecheck
```

기대: PASS.

- [ ] **Step 4: 브라우저 수동 검증 (확률 검증을 위해 임시 디버그)**

확률 1%는 수동 검증이 어려움. 다음 중 하나로 검증:

(A) `LUCKY_PROC_RATE`를 임시로 `1.0`으로 변경 → 합성 1회 → "🍀 LUCKY" 배지 + 골드 3배 + 결과가 한 단계 위 원소(예: 불+물 → 보통 증기지만 럭키면 Tier 3 결과) 확인 → `0.01`로 복원, 임시 변경 커밋 X.

(B) 브라우저 콘솔에서 `for (let i=0; i<200; i++) useGameStore.getState().fuse('fire', 'water')` 실행 → 인벤토리에서 Tier 3 원소가 의도치 않게 늘어났는지 확인.

```bash
npm run dev
```

확인:
1. (A) 방식 럭키 강제 → 배지 표시·골드 3배·상위 티어 결과
2. (A) 후 복원 → 다시 일반 합성에서 "NEW" 배지

- [ ] **Step 5: 커밋**

```bash
git add src/store/useGameStore.ts src/components/screens/FusionScreen.tsx
git commit -m "feat: add 1% lucky fusion proc with tier upgrade (B-5)"
```

---

## Task 5: B-1 합성 콤보 미터

**Files:**
- Modify: `src/store/useGameStore.ts` (콤보 상태 + `fuse()` 적용)
- Modify: `src/components/screens/FusionScreen.tsx` (콤보 바 UI)

**규칙 (spec):** 직전 합성 후 8초 이내 재합성 시 콤보 누적. 3콤보 x1.5 / 6콤보 x2 / 10콤보 x3 (cap). 8초 무합성 시 리셋.

- [ ] **Step 1: store에 콤보 상태 + 적용**

`src/store/useGameStore.ts` 상단 상수 영역에 추가:

```ts
const COMBO_WINDOW_MS = 8_000;

function comboMultiplier(count: number): number {
  if (count >= 10) return 3;
  if (count >= 6) return 2;
  if (count >= 3) return 1.5;
  return 1;
}
```

`interface GameState` 안 (`fuse: (...) => FuseResult;` 근처)에 추가:

```ts
  comboCount: number;
  comboExpiresAt: number;
```

`type PersistedState = Pick<...>` union에 추가:

```ts
  | 'comboCount'
  | 'comboExpiresAt'
```

store 본문 초기값 추가 (zustand `(set, get) => ({ ... })` 본문에 다른 초기값들과 함께):

```ts
      comboCount: 0,
      comboExpiresAt: 0,
```

`partialize` 안에 두 줄 추가:

```ts
        comboCount: state.comboCount,
        comboExpiresAt: state.comboExpiresAt,
```

`onRehydrateStorage` 안에 추가 (다른 보정 코드 옆):

```ts
        if (typeof state.comboCount !== 'number') state.comboCount = 0;
        if (typeof state.comboExpiresAt !== 'number') state.comboExpiresAt = 0;
        if (state.comboExpiresAt < Date.now()) {
          state.comboCount = 0;
          state.comboExpiresAt = 0;
        }
```

`fuse` 함수 안 — Task 4에서 만든 `let goldGained = recipe.tier * 10;` 가 있는 위치 직후, 럭키 적용 직전에 추가:

```ts
        const now = Date.now();
        const previousCombo = get().comboCount;
        const previousExpiresAt = get().comboExpiresAt;
        const continuingCombo = now < previousExpiresAt;
        const newComboCount = continuingCombo ? Math.min(previousCombo + 1, 99) : 1;
        const comboMult = comboMultiplier(newComboCount);
        goldGained = Math.floor(goldGained * comboMult);
```

`set((state) => { ... return { ... }; })` 의 return 객체에 다음 두 줄 추가 (다른 키들과 함께):

```ts
            comboCount: newComboCount,
            comboExpiresAt: now + COMBO_WINDOW_MS,
```

- [ ] **Step 2: FusionScreen에 콤보 바 UI**

`src/components/screens/FusionScreen.tsx` 의 `useGameStore()` destructure를 수정:

```ts
  const { elements, fuse, comboCount, comboExpiresAt } = useGameStore();
```

`useState` 영역에 추가 (다른 useState 옆):

```ts
  const [, forceTick] = useState(0);
```

`useFloatingItems` 호출 다음에 추가:

```ts
  useEffect(() => {
    if (comboExpiresAt < Date.now()) return;
    const id = window.setInterval(() => forceTick((t) => t + 1), 100);
    return () => window.clearInterval(id);
  }, [comboExpiresAt]);
```

`useEffect` import 추가 (`useState` 옆):

```ts
import { useEffect, useState } from 'react';
```

스타일 영역에 추가 (예: `errorBannerStyle` 다음):

```ts
const comboBarStyle = css`
  display: flex;
  align-items: center;
  gap: 8px;
  background: linear-gradient(135deg, #ff6b35, #f7c12a);
  border-radius: 10px;
  padding: 7px 12px;
  margin-bottom: 10px;
  color: #fff;
  font-weight: 800;
  font-size: 13px;
`;

const comboCountdownStyle = css`
  margin-left: auto;
  font-size: 11px;
  opacity: 0.85;
`;

const comboTrackStyle = css`
  height: 4px;
  background: rgba(255, 255, 255, 0.25);
  border-radius: 999px;
  overflow: hidden;
  margin-bottom: 10px;
`;

const comboFillStyle = (ratio: number) => css`
  height: 100%;
  width: ${(Math.max(0, Math.min(1, ratio)) * 100).toFixed(1)}%;
  background: #ffffff;
  border-radius: 999px;
  transition: width 0.1s linear;
`;
```

`return` JSX 안 — 합성 패널의 `previewBannerStyle` 영역 위 (또는 `slotsRowStyle` 다음)에 다음 추가:

```tsx
        {comboCount >= 2 && comboExpiresAt > Date.now() && (() => {
          const remaining = Math.max(0, comboExpiresAt - Date.now());
          const ratio = remaining / 8000;
          const mult = comboCount >= 10 ? 3 : comboCount >= 6 ? 2 : comboCount >= 3 ? 1.5 : 1;
          return (
            <>
              <div css={comboBarStyle}>
                <span>🔥 {comboCount} COMBO</span>
                <span>골드 x{mult}</span>
                <span css={comboCountdownStyle}>{(remaining / 1000).toFixed(1)}s</span>
              </div>
              <div css={comboTrackStyle}>
                <div css={comboFillStyle(ratio)} />
              </div>
            </>
          );
        })()}
```

- [ ] **Step 3: typecheck**

```bash
npm run typecheck
```

기대: PASS.

- [ ] **Step 4: 브라우저 수동 검증**

```bash
npm run dev
```

확인:
1. 합성 화면에서 8초 내에 연속 합성 3번 → 콤보 바 표시 + "🔥 3 COMBO 골드 x1.5"
2. 6번째 합성 → x2, 10번째 → x3
3. 8초 대기 후 합성 → 콤보 1로 리셋
4. 합성 사이에 `+골드` 플로팅이 콤보 배율로 늘어나는지 (육안)
5. 페이지 새로고침 후 콤보 만료됐다면 0으로 초기화

- [ ] **Step 5: 커밋**

```bash
git add src/store/useGameStore.ts src/components/screens/FusionScreen.tsx
git commit -m "feat: add fusion combo meter with x1.5/x2/x3 gold multiplier (B-1)"
```

---

## Task 6: A-3 첫 발견 풀스크린 카드

**Files:**
- Create: `src/components/DiscoveryHero.tsx`
- Modify: `src/store/useGameStore.ts` (newDiscovery 이벤트 dispatch)
- Modify: `src/App.tsx` (마운트)

- [ ] **Step 1: DiscoveryHero 컴포넌트 생성**

새 파일 `src/components/DiscoveryHero.tsx`:

```tsx
import { css, keyframes } from '@emotion/react';
import { useEffect, useState } from 'react';
import elementsData from '../data/elements.json';
import { CharacterSprite } from './CharacterSprite';

type ElementData = (typeof elementsData)[number];

const HERO_DURATION_MS = 1200;

const fadeIn = keyframes`
  0% { opacity: 0; }
  20% { opacity: 1; }
  80% { opacity: 1; }
  100% { opacity: 0; }
`;

const popIn = keyframes`
  0% { transform: scale(0.4); opacity: 0; }
  40% { transform: scale(1.2); opacity: 1; }
  60% { transform: scale(0.95); }
  100% { transform: scale(1); opacity: 1; }
`;

const overlayStyle = css`
  position: fixed;
  inset: 0;
  background: rgba(8, 12, 24, 0.78);
  backdrop-filter: blur(6px);
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  z-index: 500;
  animation: ${fadeIn} ${HERO_DURATION_MS}ms ease-out forwards;
  cursor: pointer;
`;

const cardStyle = css`
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 12px;
  animation: ${popIn} 0.5s cubic-bezier(0.34, 1.56, 0.64, 1) forwards;
`;

const spriteWrapStyle = css`
  width: 120px;
  height: 120px;
`;

const eyebrowStyle = css`
  font-size: 12px;
  font-weight: 700;
  color: #f7c12a;
  letter-spacing: 4px;
  text-transform: uppercase;
`;

const nameStyle = css`
  font-size: 32px;
  font-weight: 900;
  color: #ffffff;
  text-shadow: 0 2px 12px rgba(247, 193, 42, 0.5);
`;

const subtitleStyle = css`
  font-size: 13px;
  color: rgba(255, 255, 255, 0.75);
  margin-top: 4px;
`;

interface DiscoveryDetail {
  elementId: string;
}

export function DiscoveryHero(): JSX.Element | null {
  const [active, setActive] = useState<ElementData | null>(null);

  useEffect(() => {
    const handler = (event: Event) => {
      const detail = (event as CustomEvent<DiscoveryDetail>).detail;
      const el = elementsData.find((x) => x.id === detail.elementId);
      if (!el) return;
      setActive(el);
      window.setTimeout(() => setActive(null), HERO_DURATION_MS);
    };
    window.addEventListener('newDiscovery', handler);
    return () => window.removeEventListener('newDiscovery', handler);
  }, []);

  if (!active) return null;

  return (
    <div css={overlayStyle} onClick={() => setActive(null)}>
      <div css={cardStyle}>
        <span css={eyebrowStyle}>NEW DISCOVERY</span>
        <div css={spriteWrapStyle}>
          <CharacterSprite
            elementId={active.id}
            elementColor={active.color}
            tier={active.tier}
            size={120}
            state="obtained"
          />
        </div>
        <div css={nameStyle}>{active.name}</div>
        <div css={subtitleStyle}>창조자여, 새로운 빛을 발견했군요</div>
      </div>
    </div>
  );
}
```

- [ ] **Step 2: store에서 newDiscovery 이벤트 dispatch**

`src/store/useGameStore.ts`의 헬퍼 함수 영역 (예: `getDiscoveredFromElements` 다음)에 추가:

```ts
function dispatchNewDiscoveries(prevDiscovered: string[], nextDiscovered: string[]): void {
  if (typeof window === 'undefined') return;
  const prevSet = new Set(prevDiscovered);
  for (const id of nextDiscovered) {
    if (!prevSet.has(id)) {
      window.dispatchEvent(
        new CustomEvent('newDiscovery', { detail: { elementId: id } })
      );
    }
  }
}
```

다음 액션들에서 `discoveredElements` 가 갱신되는 직후 dispatch 호출:

(a) `addElement` 안 — `const discoveredElements = getDiscoveredElementIds(...)` 다음 줄에 추가:

```ts
          dispatchNewDiscoveries(state.discoveredElements, discoveredElements);
```

(b) `fuse` 안 — `const discoveredElements = getDiscoveredElementIds(...)` 다음 줄에 추가:

```ts
          dispatchNewDiscoveries(state.discoveredElements, discoveredElements);
```

(c) `claimOfflineReward` 안 — `const newDiscoveredElements = getDiscoveredElementIds(...)` 다음 줄에 추가:

```ts
          dispatchNewDiscoveries(state.discoveredElements, newDiscoveredElements);
```

(d) `tickIdle` 안 — `const newDiscoveredElements = getDiscoveredElementIds(...)` 다음 줄에 추가:

```ts
          dispatchNewDiscoveries(state.discoveredElements, newDiscoveredElements);
```

- [ ] **Step 3: App.tsx에 DiscoveryHero 마운트**

`src/App.tsx` import 추가:

```tsx
import { DiscoveryHero } from './components/DiscoveryHero';
```

JSX에서 `<TutorialOverlay />` 다음 줄에 추가:

```tsx
      <DiscoveryHero />
```

- [ ] **Step 4: typecheck**

```bash
npm run typecheck
```

기대: PASS.

- [ ] **Step 5: 브라우저 수동 검증**

```bash
npm run dev
```

확인:
1. 새로 합성으로 처음 발견하는 원소(예: 새 게임 시작 후 fire+water=steam) → 풀스크린 카드 1.2초간 표시
2. 같은 원소 재합성 → 카드 미표시 (이미 발견)
3. 카드 클릭하면 즉시 닫힘
4. 자동생산으로 새 원소 첫 발견 시에도 동일 (idle tick에서)

- [ ] **Step 6: 커밋**

```bash
git add src/components/DiscoveryHero.tsx src/store/useGameStore.ts src/App.tsx
git commit -m "feat: add full-screen discovery hero card on first element find (A-3)"
```

---

## Task 7: A-4 튜토리얼 완료 보상 모달

**Files:**
- Create: `src/components/WelcomeGiftModal.tsx`
- Modify: `src/store/useGameStore.ts` (`welcomeGiftClaimed`, `claimWelcomeGift`)
- Modify: `src/App.tsx` (조건부 마운트)

- [ ] **Step 1: store에 환영 선물 상태 + 액션**

`src/store/useGameStore.ts`:

`interface GameState` 안 — 튜토리얼 영역 근처에 추가:

```ts
  welcomeGiftClaimed: boolean;
  welcomeGiftElementId: string | null;
  claimWelcomeGift: () => void;
```

`type PersistedState = Pick<...>` union에 추가:

```ts
  | 'welcomeGiftClaimed'
  | 'welcomeGiftElementId'
```

store 본문 초기값:

```ts
      welcomeGiftClaimed: false,
      welcomeGiftElementId: null,

      claimWelcomeGift: () => {
        set((state) => {
          if (state.welcomeGiftClaimed) return state;
          const id = state.welcomeGiftElementId;
          if (!id) return state;
          const newElements = {
            ...state.elements,
            [id]: (state.elements[id] ?? 0) + 1,
          };
          const newDiscovered = getDiscoveredElementIds(
            [...state.discoveredElements, id],
            newElements
          );
          dispatchNewDiscoveries(state.discoveredElements, newDiscovered);
          return {
            welcomeGiftClaimed: true,
            elements: newElements,
            discoveredElements: newDiscovered,
          };
        });
      },
```

`partialize` 안 추가:

```ts
        welcomeGiftClaimed: state.welcomeGiftClaimed,
        welcomeGiftElementId: state.welcomeGiftElementId,
```

`onRehydrateStorage` 안 추가:

```ts
        if (typeof state.welcomeGiftClaimed !== 'boolean') state.welcomeGiftClaimed = false;
        if (typeof state.welcomeGiftElementId !== 'string' && state.welcomeGiftElementId !== null) {
          state.welcomeGiftElementId = null;
        }
```

- [ ] **Step 2: WelcomeGiftModal 생성**

새 파일 `src/components/WelcomeGiftModal.tsx`:

```tsx
import { css, keyframes } from '@emotion/react';
import { useGameStore } from '../store/useGameStore';
import elementsData from '../data/elements.json';
import { CharacterSprite } from './CharacterSprite';

const fadeIn = keyframes`
  from { opacity: 0; }
  to { opacity: 1; }
`;

const slideUp = keyframes`
  from { transform: translateY(40px); opacity: 0; }
  to { transform: translateY(0); opacity: 1; }
`;

const overlayStyle = css`
  position: fixed;
  inset: 0;
  background: rgba(0, 0, 0, 0.55);
  backdrop-filter: blur(4px);
  z-index: 450;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 20px;
  animation: ${fadeIn} 0.2s ease-out;
`;

const cardStyle = css`
  background: linear-gradient(160deg, #ffffff, #fff8ed);
  border-radius: 24px;
  padding: 28px 24px;
  width: 100%;
  max-width: 360px;
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 14px;
  box-shadow: 0 24px 60px rgba(0, 0, 0, 0.25);
  animation: ${slideUp} 0.32s cubic-bezier(0.34, 1.56, 0.64, 1);
`;

const eyebrowStyle = css`
  font-size: 11px;
  font-weight: 800;
  color: #ff6b35;
  letter-spacing: 3px;
`;

const titleStyle = css`
  font-size: 22px;
  font-weight: 800;
  color: #191919;
  text-align: center;
`;

const descStyle = css`
  font-size: 13px;
  color: #6b7684;
  text-align: center;
  line-height: 1.5;
`;

const spriteWrapStyle = css`
  width: 96px;
  height: 96px;
`;

const giftNameStyle = css`
  font-size: 18px;
  font-weight: 800;
  color: #191919;
`;

const claimButtonStyle = css`
  width: 100%;
  margin-top: 8px;
  padding: 14px;
  border-radius: 14px;
  border: none;
  background: linear-gradient(135deg, #ff6b35, #f7c12a);
  color: #ffffff;
  font-size: 15px;
  font-weight: 800;
  cursor: pointer;
`;

export function WelcomeGiftModal(): JSX.Element | null {
  const tutorialCompleted = useGameStore((s) => s.tutorialCompleted);
  const welcomeGiftClaimed = useGameStore((s) => s.welcomeGiftClaimed);
  const welcomeGiftElementId = useGameStore((s) => s.welcomeGiftElementId);
  const claimWelcomeGift = useGameStore((s) => s.claimWelcomeGift);

  if (!tutorialCompleted) return null;
  if (welcomeGiftClaimed) return null;
  if (!welcomeGiftElementId) return null;

  const el = elementsData.find((x) => x.id === welcomeGiftElementId);
  if (!el) return null;

  return (
    <div css={overlayStyle}>
      <div css={cardStyle}>
        <span css={eyebrowStyle}>WELCOME GIFT</span>
        <div css={titleStyle}>환영 선물이 도착했어요</div>
        <div css={descStyle}>
          첫 여정을 축하합니다.{'\n'}창조의 다음 단계로 도와줄 원소 한 개를 받으세요.
        </div>
        <div css={spriteWrapStyle}>
          <CharacterSprite
            elementId={el.id}
            elementColor={el.color}
            tier={el.tier}
            size={96}
            state="obtained"
          />
        </div>
        <div css={giftNameStyle}>{el.emoji} {el.name} ×1</div>
        <button css={claimButtonStyle} onClick={claimWelcomeGift}>
          받기
        </button>
      </div>
    </div>
  );
}
```

- [ ] **Step 3: store의 advanceTutorial이 마지막 단계 도달 시 prepareWelcomeGift 호출**

`src/store/useGameStore.ts` 의 `advanceTutorial` 액션을 다음으로 교체:

```ts
      advanceTutorial: () =>
        set((state) => {
          const nextStep = state.tutorialStep + 1;
          const completed = nextStep >= 5;
          if (completed && !state.welcomeGiftClaimed && !state.welcomeGiftElementId) {
            const tier2 = (elementsData as Array<{ id: string; tier: number }>).filter(
              (e) => e.tier === 2
            );
            if (tier2.length > 0) {
              const pick = tier2[Math.floor(Math.random() * tier2.length)];
              return {
                tutorialStep: nextStep,
                tutorialCompleted: completed,
                welcomeGiftElementId: pick.id,
              };
            }
          }
          return {
            tutorialStep: nextStep,
            tutorialCompleted: completed,
          };
        }),
```

`skipTutorial`도 마찬가지로:

```ts
      skipTutorial: () =>
        set((state) => {
          if (state.welcomeGiftClaimed || state.welcomeGiftElementId) {
            return { tutorialStep: 5, tutorialCompleted: true };
          }
          const tier2 = (elementsData as Array<{ id: string; tier: number }>).filter(
            (e) => e.tier === 2
          );
          if (tier2.length === 0) {
            return { tutorialStep: 5, tutorialCompleted: true };
          }
          const pick = tier2[Math.floor(Math.random() * tier2.length)];
          return {
            tutorialStep: 5,
            tutorialCompleted: true,
            welcomeGiftElementId: pick.id,
          };
        }),
```

- [ ] **Step 4: App.tsx에 마운트**

`src/App.tsx` import:

```tsx
import { WelcomeGiftModal } from './components/WelcomeGiftModal';
```

JSX 안, `<DiscoveryHero />` 다음에:

```tsx
      <WelcomeGiftModal />
```

- [ ] **Step 5: typecheck**

```bash
npm run typecheck
```

기대: PASS.

- [ ] **Step 6: 브라우저 수동 검증**

```bash
npm run dev
```

확인 (게임 데이터 초기화 권장 — 설정 화면에서 리셋):
1. 새 게임 → 튜토리얼 5단계 완주 → "환영 선물" 모달 등장
2. 받기 클릭 → 인벤토리에 Tier 2 원소 1개 추가 + DiscoveryHero 카드 등장 (Task 6과 연동)
3. 새로고침 → 모달 다시 안 뜸
4. 튜토리얼 스킵 → 동일하게 작동

- [ ] **Step 7: 커밋**

```bash
git add src/components/WelcomeGiftModal.tsx src/store/useGameStore.ts src/App.tsx
git commit -m "feat: add welcome gift modal after tutorial completion (A-4)"
```

---

## Task 8: E-7 광고 배너 영역 슬롯

**Files:**
- Create: `src/components/AdBanner.tsx`
- Modify: `src/App.tsx` (마운트 + 레이아웃 보정)

**참고:** `BottomTabBar`는 `position: fixed; bottom: 0`. AdBanner도 동일한 방식으로 BottomTabBar 위에 고정 + content padding-bottom 증가.

- [ ] **Step 1: AdBanner placeholder 생성**

새 파일 `src/components/AdBanner.tsx`:

```tsx
import { css } from '@emotion/react';
import { adaptive } from '../styles/adaptive';

export const AD_BANNER_HEIGHT = 56;

const containerStyle = css`
  position: fixed;
  left: 0;
  right: 0;
  bottom: calc(72px + env(safe-area-inset-bottom, 0px));
  height: ${AD_BANNER_HEIGHT}px;
  background: ${adaptive.greyBackground};
  border-top: 1px solid ${adaptive.grey200};
  display: flex;
  align-items: center;
  justify-content: center;
  font-size: 11px;
  color: ${adaptive.grey400};
  letter-spacing: 1px;
  z-index: 90;
`;

export function AdBanner(): JSX.Element {
  // Phase 0: 광고 SDK 미연동, placeholder 슬롯만 확보.
  // Phase 1에서 src/platform/ads.ts 어댑터 + 실제 SDK로 교체 예정.
  return <div css={containerStyle}>광고 영역</div>;
}
```

- [ ] **Step 2: App.tsx에 마운트 + content padding 보정**

`src/App.tsx` import 추가:

```tsx
import { AdBanner, AD_BANNER_HEIGHT } from './components/AdBanner';
```

기존 `contentStyle` 상수를 다음으로 교체:

```tsx
const contentStyle = css`
  flex: 1;
  overflow-y: auto;
  padding-bottom: calc(72px + ${AD_BANNER_HEIGHT}px);
`;
```

JSX에서 `<BottomTabBar ... />` 직전에 추가:

```tsx
      <AdBanner />
```

- [ ] **Step 3: typecheck**

```bash
npm run typecheck
```

기대: PASS.

- [ ] **Step 4: 브라우저 수동 검증**

```bash
npm run dev
```

확인:
1. 화면 하단에 "광고 영역" placeholder가 BottomTabBar 위 56px 슬롯으로 표시
2. 콘텐츠 영역(원소·합성 화면) 스크롤이 placeholder에 가려지지 않음 (가장 아래 카드까지 스크롤로 도달)
3. 모바일 노치 디바이스에서 safe-area-inset-bottom 적용 (Pretendard iOS 시뮬레이터 가능 시)

- [ ] **Step 5: 커밋**

```bash
git add src/components/AdBanner.tsx src/App.tsx
git commit -m "feat: reserve ad banner slot above bottom tab bar (E-7)"
```

---

## Final Verification

- [ ] **Step 1: 통합 typecheck**

```bash
npm run typecheck
```

기대: PASS.

- [ ] **Step 2: 통합 빌드**

```bash
npm run build
```

기대: 성공. 산출물 `dist/`.

- [ ] **Step 3: 통합 스모크 테스트 (브라우저)**

```bash
npm run dev
```

체크리스트:
- [ ] 새 게임 시작 → 튜토리얼 진행 → 완료 시 환영 선물 모달 등장
- [ ] 환영 선물 받기 → DiscoveryHero 카드 표시
- [ ] 합성 → SFX 들림 + 모바일에서 진동
- [ ] 30초 내 합성 3회 → 콤보 바 표시 + x1.5 골드
- [ ] 신규 원소 합성 → DiscoveryHero 카드
- [ ] (강제 럭키 트리거하여) LUCKY 배지 + 사운드
- [ ] 레전더리 합성 → 화면 셰이크 + 황금 글로우
- [ ] 광고 영역 placeholder가 BottomTabBar 위에 보임
- [ ] 설정 → SFX/햅틱 토글 OFF/ON 정상

- [ ] **Step 4: 커밋 로그 확인**

```bash
git log --oneline -10
```

기대: 8~9개의 새 커밋 (Task 0 베이스라인 1 + Task 1~8).

---

## Self-Review Notes

본 plan에서 의도적으로 다루지 않은 항목 (Phase 1+로 미룸):
- 광고 SDK 실제 연동 (E-1, E-2 등) — `AdBanner`는 슬롯만
- 사운드 파일 외부 자산 도입 — Web Audio 합성으로 충분 (Phase 1에서 mp3 교체)
- 콤보 도달 SFX — 단순 골드 배율만 적용 (필요 시 후속)
- 럭키 합성의 시각 이펙트 강화 — 배지·SFX만
- 원소 도감(Codex), 일일 미션 등 다른 Phase 0 외 항목

본 plan에서 도입하는 새 의존성: 없음. 기존 React/Zustand/Emotion 만 사용.
