Học có lộ trình Trung bình 6 phút đọc

Chess Lab #11 — Lessons có lộ trình & goal-driven kiểm tra

Hệ thống lessons có chương: 3 chương x ~3 bài, mỗi bài có FEN khởi tạo và 1 goal (mate / pieceAt / captureAt) được kiểm tra sau mỗi nước user. Progress lưu localStorage, mở khoá tiếp tục giữa các tab.

Mục lục
  1. Goal: discriminated union
  2. Curriculum
  3. + persistence
  4. Wire vào scene
  5. SidePanel: chapter list
  6. Auto-load lesson đầu chưa xong
  7. Bẫy hay gặp
  8. Bài tập
  9. Bài tiếp theo

Puzzle ở bài 10 cho người chơi từng câu hỏi rời rạc. Bài 11 ráp thành lộ trình: chương → bài, mỗi bài có một mục tiêu cụ thể, hoàn thành được lưu lại để buổi sau quay lại đúng chỗ. Đây là chế độ "khoá học".

Goal: discriminated union

Mỗi lesson trỏ về 1 trong 3 loại goal:

export type LessonGoal =
  | { kind: "mate" }
  | { kind: "pieceAt"; square: Square; pieceType: PieceSymbol; color: Color }
  | { kind: "captureAt"; square: Square };

Checker khớp 1-1:

function matches(goal: LessonGoal, move: Move): boolean {
  switch (goal.kind) {
    case "mate":
      return chess.status().kind === "checkmate";
    case "pieceAt": {
      const p = chess.pieceAt(goal.square);
      return !!p && p.type === goal.pieceType && p.color === goal.color;
    }
    case "captureAt":
      return move.to === goal.square && !!move.captured;
  }
}

TypeScript bắt buộc bạn cover mọi case — quên 1 nhánh, compiler báo "not all paths return". Thêm reachSquare, escapeCheck, noCheckN = thêm 1 nhánh union, TS dẫn bạn ngay tới checker và mọi UI hiển thị liên quan.

Curriculum

3 chương x 2-3 bài:

Chương Bài Goal FEN
Cách quân đi Tốt e2→e4 pieceAt(e4, P, w) starting
Mã g1→f3 pieceAt(f3, N, w) starting
Tượng f1→c4 pieceAt(c4, B, w) sau 1.e4 e5
Chiếu hết cơ bản Mate-in-1 hàng cuối mate back-rank position
K+Q vs K mate endgame
Cuộn hai Xe mate 2-rook position
Bắt quân Bắt Hậu trực tiếp captureAt(d5) knight fork
Pin chéo captureAt(c6) bishop pin

8 bài đầu cho người mới hoàn toàn. Mở rộng bằng cách push vào array CHAPTERS — không cần code mới.

LessonManager + persistence

const PROGRESS_KEY = "chess-lab.lesson-progress";

export class LessonManager {
  private current: Lesson | null = null;
  private status: LessonStatus = "playing";
  private completed: Set<string>;

  constructor() {
    this.completed = loadProgress();
  }

  load(id: string): Lesson | null {
    const l = findLesson(id);
    if (!l) return null;
    this.current = l;
    this.status = "playing";
    chess.reset(l.fen);
    return l;
  }

  checkAfterMove(move: Move): boolean {
    if (!this.current || this.status !== "playing") return false;
    if (matches(this.current.goal, move)) {
      this.status = "completed";
      this.completed.add(this.current.id);
      saveProgress(this.completed);
      return true;
    }
    return false;
  }

  isCompleted(id: string): boolean { return this.completed.has(id); }
}

function loadProgress(): Set<string> {
  try {
    const raw = localStorage.getItem(PROGRESS_KEY);
    if (!raw) return new Set();
    return new Set(JSON.parse(raw) as string[]);
  } catch { return new Set(); }
}
function saveProgress(set: Set<string>): void {
  try {
    localStorage.setItem(PROGRESS_KEY, JSON.stringify([...set]));
  } catch { /* private mode → bỏ qua */ }
}

Try-catch quanh localStorage là phòng vệ cho Safari private mode (ném QuotaExceeded khi setItem). In-memory state vẫn hoạt động — chỉ là mất tiến trình khi đóng tab.

Wire vào scene

Trong applyMove sau tryMove thành công:

if (mode.current === "lesson") {
  lessons.checkAfterMove(move);
}

Một dòng. Nếu goal đạt → status = "completed", panel render banner "✓ Đã hoàn thành" + nút "Bài tiếp →".

Mode lesson cũng bypass cả AI client lẫn tutor — học không có máy phản công, không có badge ?! ?? làm rối.

SidePanel: chapter list

Render 3 cột thông tin:

  1. Lesson hiện tại — tiêu đề, intro, hint (nếu có), nút "Bài tiếp →" khi hoàn thành.
  2. Chapter list — accordion phẳng các chương, mỗi lesson là 1 button có checkmark nếu đã xong.
for (const ch of CHAPTERS) {
  listHtml += `<div class="chapter"><div class="chapter-title">${ch.title}</div>`;
  for (const l of ch.lessons) {
    const done = lessons.isCompleted(l.id);
    const isCur = cur?.id === l.id;
    listHtml += `
      <button class="lesson-card ${isCur ? "current" : ""} ${done ? "done" : ""}"
              data-lesson-id="${l.id}">
        <span class="lesson-mark">${done ? "✓" : "○"}</span>
        <span class="lesson-name">${l.title}</span>
      </button>
    `;
  }
  listHtml += `</div>`;
}

Click bằng event delegation — listener gắn 1 lần trên container, đọc data-lesson-id. Re-render danh sách khi event bus emit không phá vỡ listener.

Auto-load lesson đầu chưa xong

Khi user chuyển sang mode lesson lần đầu, mặc định nhảy vào lesson đầu tiên chưa hoàn thành:

if (mode.current === "lesson") {
  const all = CHAPTERS.flatMap((c) => c.lessons);
  const next = all.find((l) => !lessons.isCompleted(l.id)) ?? all[0];
  scene.loadLesson(next.id);
}

UX nhỏ nhưng quan trọng: user quay lại sau 1 tuần, không phải scroll chapter list để tìm chỗ dừng — app dẫn vào ngay.

Mục tiêu bài đã đạt

App học cờ vua giờ có 3 chế độ rõ ràng: tự do (chơi với máy), puzzle (giải bài rời), lessons (khoá học có lộ trình + tiến trình). Progress lưu local. Phần IV "Học có lộ trình" đã đầy đủ về mặt feature; bài 12 sẽ nâng cấp persistence + làm responsive + a11y cho bản phát hành.

Bẫy hay gặp

Bẫy 1 — Goal 'pieceAt' fail nếu user kéo sai quân

Lesson "Tốt e2→e4": user kéo TỐT khác (vd d2→d4) lên ô không phải e4 — pieceAt(e4) check fail, lesson không hoàn thành. Đúng. Nhưng nếu user kéo Mã đến e4? chess.tryMove chấp nhận (Mã đi được tới e4 từ một số ô), pieceAt(e4) thấy có Mã, không phải pawn → fail. Đúng nốt. UX trong intro đã nói rõ "đẩy tốt e2 lên e4".

Bẫy 2 — `mate` goal phụ thuộc thứ tự check

Goal mate phải gọi sau khi tryMove đã apply (vì state mới mới thật sự là chiếu hết). Đặt sai vị trí hook trong applyMove (ví dụ trước tryMove) sẽ check state cũ — không bao giờ pass goal. Quy tắc: mọi check goal đặt sau khi animation kết thúc.

Bẫy 3 — localStorage QuotaExceeded ở Safari private

Safari private mode ném exception khi setItem. Try- catch trong saveProgress chống crash app, nhưng user mất progress đến khi out chế độ private. Bài 12 sẽ đổi sang IndexedDB qua idb-keyval — Safari private hỗ trợ tốt hơn nhiều.

Bài tập

  1. Goal mới: reachWithKnight — user phải đưa Mã đi qua 1 chuỗi ô theo thứ tự (ví dụ a1 → b3 → d4). Thêm nhánh union, checker theo move.from + move.to so với chuỗi expected.
  2. Theo dõi best score: cho mỗi lesson, lưu thêm số nước user dùng để hoàn thành. Sau mỗi lần solve, so sánh với optimal — hiển thị ★/★★/★★★ theo độ tối ưu.
  3. Chapter unlock: chapter sau chỉ mở khi chương trước hoàn thành 80%+. Disable button lesson khi chương chưa unlock, kèm icon 🔒.

Bài tiếp theo

Bài 12 — Persistence + responsive + a11y: chuyển progress sang IndexedDB qua idb-keyval. Touch-friendly drag cho mobile. Scale manager đúng nghĩa cho viewport hẹp. Contrast/aria cho a11y. Lighthouse a11y ≥ 90.

Demo bài 11 — chọn chế độ Lessons, đi từ chương 1 (cách quân đi) ↓ Tải code bài này (.zip)

Câu hỏi thường gặp

Goal là discriminated union — vì sao không là string flag?
Vì mỗi loại goal có payload khác nhau: mate không cần data, pieceAt cần (square, type, color), captureAt cần square. String flag + đoán payload qua field optional là code Java EE 2007 — discriminated union TS bắt buộc bạn handle mọi case ở compile time. Thêm goal mới (vd reachSquareWith N moves) = thêm 1 nhánh union, TS compiler chỉ ngay matches() nào chưa cover.
Vì sao localStorage thay vì IndexedDB ngay bài này?
Vì progress chỉ là Set ~30-50 phần tử. localStorage đồng bộ, đọc/ghi 1 ms, đủ rồi. IndexedDB cho dữ liệu lớn / blob / query phức tạp — bài 12 (persistence + responsive + a11y) sẽ refactor sang nó vì chuẩn bị cho user data lớn (move history theo từng lesson, statistics, …). Bài 11 ưu tiên đơn giản.
Lesson 'Tốt e2 → e4' không cần đối thủ chơi — vì sao đứng trong chế độ trò chơi 2 người?
Mode lesson disable cả AI client lẫn tutor. Goal kiểm tra ngay sau nước user. Nếu lesson cần đối thủ phản công (ví dụ mate-in-2), goal vẫn là 'mate' — manager không quan tâm bao nhiêu nước, chỉ check trạng thái cờ. Bài 11 chưa có lesson dài; thêm lesson mate-in-2 = trao đổi sau giữa user và AI ở depth thấp.
Tracking progress qua tab — collision khi user mở 2 tab?
localStorage bắn event 'storage' khi tab khác ghi. Bài 11 chưa lắng. Bài 12 sẽ thêm window.addEventListener('storage', refresh) để chapter list update khi tab khác hoàn thành lesson — nhỏ nhưng tinh tế.