# Pipeline nhân bản Practice Map — Nói, Đọc, Viết

Kiến trúc mục tiêu là **một component dùng chung**, không tạo `ListeningMap`, `SpeakingMap`, `ReadingMap`, `WritingMap` riêng biệt.

## Bước 1 — Chuẩn bị assets

Mỗi skill có cấu trúc cố định:

```text
public/assets/practice-island/{speaking|reading|writing}/
├── backgrounds/
│   ├── {skill}-bg-desktop.webp
│   └── {skill}-bg-mobile.webp
├── characters/
│   └── foxi-{skill}.webp
├── nodes/
│   ├── node-01-*.webp
│   ├── node-02-*.webp
│   └── ...
├── references/
│   ├── {skill}-pc.png
│   └── {skill}-mobile.png
└── README.md
```

Background là WebP phẳng. Mascot và node bắt buộc WebP alpha, không chữ, không cờ, không CTA.

## Bước 2 — Khai báo config

Antigravity tạo file thật:

```text
src/components/practice/practiceMapConfigs.ts
```

Copy type và config Nghe từ `practiceMapConfigs.example.ts`. Quy tắc:

- `PRACTICE_MAP_CONFIGS` chứa dữ liệu trình bày, không chứa React/JSX.
- Mỗi node tham chiếu `branchId` đã tồn tại trong `practiceArenaData.ts`.
- Tên trạm và URL vẫn lấy từ `practiceArenaData.ts`; config chỉ giữ ảnh, tọa độ và animation.
- `PRACTICE_MAP_FLAGS` là nơi bật/tắt rollout.
- `getEnabledPracticeMap()` chỉ trả config khi cả flag và config đều tồn tại; tránh bật nhầm trang thiếu asset.

Khi thêm skill mới:

```ts
PRACTICE_MAP_CONFIGS.noi = {
  backgrounds: { desktop: '...', mobile: '...' },
  mascot: { image: '...', desktopClassName: '...', mobileClassName: '...' },
  hero: { eyebrow: 'LUYỆN NÓI', title: 'Luyện nói', subtitle: '...' },
  canvas: { desktopHeight: '760px', mobileHeight: '1780px' },
  nodes: [/* một node cho mỗi branchId */],
};
```

Sau khi assets/config hoàn chỉnh, đổi đúng một flag:

```ts
export const PRACTICE_MAP_FLAGS = {
  nghe: true,
  noi: true,
  doc: false,
  viet: false,
};
```

Không dùng environment flag cho UI tĩnh này, tránh khác biệt giữa local và production.

## Bước 3 — Kích hoạt trong PracticeArena

Tạo component generic:

```text
src/components/practice/PracticeSkillMap.tsx
```

`PracticeArena.tsx` chỉ cần resolve config:

```tsx
const mapConfig = initialSkill ? getEnabledPracticeMap(initialSkill) : null;

// Trong nhánh skillConfig:
{mapConfig ? (
  <PracticeSkillMap
    skill={initialSkill}
    config={mapConfig}
    branches={skillConfig.branches}
    level={level}
    standard={standardQueryValue}
  />
) : (
  <LegacyPracticeBranchGrid
    skillConfig={skillConfig}
    level={level}
    standard={standardQueryValue}
  />
)}
```

Không viết chuỗi điều kiện kiểu:

```tsx
initialSkill === 'nghe' || initialSkill === 'noi' || ...
```

Component generic chịu trách nhiệm:

- Chọn background mobile/desktop.
- Ghép `config.nodes` với `branches` bằng `branchId`.
- Resolve URL dạng string hoặc function.
- Render node, badge và một pill tên HTML đóng vai trò link duy nhất.
- Áp dụng cùng responsive/focus/animation cho mọi skill.

Fallback grid phải được giữ lại: nếu flag `false`, config thiếu hoặc branch không khớp thì trang vẫn hoạt động bằng UI card hiện tại.

## Guard bắt buộc

Trước khi render map, validate:

```ts
const branchById = new Map(branches.map((branch) => [branch.id, branch]));
const hasCompleteNodes = config.nodes.every((node) => branchById.has(node.branchId));

if (!hasCompleteNodes) return null; // PracticeArena dùng fallback grid
```

Trong triển khai thực tế nên để helper `isPracticeMapComplete(config, branches)` trong `practiceMapConfigs.ts` để tránh logic rải rác.

## Điều kiện “nhân bản nhanh”

Sau khi component Nghe đạt acceptance criteria:

1. Copy assets vào folder skill.
2. Thêm một object config + vị trí node.
3. Đổi flag thành `true`.

Không sửa CSS/component khi thêm skill. Tuy vậy vẫn phải smoke test route mới ở 390×844 và 1240×800 để phát hiện thiếu asset, sai `branchId` hoặc node tràn canvas.
