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
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:
- Lesson hiện tại — tiêu đề, intro, hint (nếu có), nút "Bài tiếp →" khi hoàn thành.
- 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
- 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 theomove.from + move.toso với chuỗi expected. - 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.
- 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.
Câu hỏi thường gặp
Goal là discriminated union — vì sao không là string flag?
Vì sao localStorage thay vì IndexedDB ngay bài này?
Lesson 'Tốt e2 → e4' không cần đối thủ chơi — vì sao đứng trong chế độ trò chơi 2 người?
Tracking progress qua tab — collision khi user mở 2 tab?
window.addEventListener('storage', refresh) để chapter list update khi tab khác hoàn thành lesson — nhỏ nhưng tinh tế.