ADR (Architecture Decision Record — bản ghi quyết định kiến trúc) là một ghi chú ngắn trả lời: lúc đó bối cảnh thế nào, ta chọn gì, hệ quả ra sao, và đã cân nhắc những phương án nào khác. Đọc ADR giúp bạn hiểu vì sao code trông như vậy trước khi định "sửa cho đẹp".
Mỗi bản ghi có bốn phần: Bối cảnh · Quyết định · Hệ quả (✅ được / ⚠️ phải trả giá) · Phương án đã loại.
| # | Quyết định | Ảnh hưởng tới |
|---|---|---|
| ADR-01 | Không build step, không ES modules; namespace OG + IIFE |
mọi file |
| ADR-02 | Scene stack (go/push/pop) |
core/scenes.js, pause, popup |
| ADR-03 | Event bus để tách module | Audio, Quests, HUD, Sprites FX |
| ADR-04 | Hành trình hướng dữ liệu, có compile + validate | core/journeys.js, src/journeys/ |
| ADR-05 | Mở rộng màn chơi bằng level hook | game/level.js, story, difficulty |
| ADR-06 | Vẽ procedural bằng Canvas + cache khung hình LRU | gfx/ |
| ADR-07 | Âm thanh tổng hợp bằng Web Audio | audio/ |
| ADR-08 | Một khoá localStorage, có v + gộp mặc định |
core/save.js |
| ADR-09 | Ảnh chụp lưu trong repo + ghi công, không hotlink | story/photos.js, assets/, tools/ |
| ADR-10 | Smoke test bằng DOM giả, không framework test | tests/, CI |
ADR-01. Không build step, không ES modules, namespace OG + IIFE #
Bối cảnh. Đây là giáo trình: sinh viên (và trẻ em chơi thử) phải mở được game bằng nhấp đúp index.html, không cài Node, không chạy
npm install. Trình duyệt chặn ES modules qua file:// (script type="module" bị coi là khác nguồn — CORS), và cũng chặn fetch() file cục bộ.
flowchart LR
f["Mở index.html<br/>qua file://"] --> a{"Cách nạp code?"}
a -->|"script type=module<br/>import / export"| x["❌ Trình duyệt chặn (CORS)<br/>phải có server hoặc bundler"]
a -->|"script thường<br/>IIFE gắn vào window.OG"| ok["✅ Chạy ngay"]
a -->|"fetch('data.json')"| x2["❌ Bị chặn qua file://"]
a -->|"script .js gọi register(...)"| ok2["✅ Dữ liệu nạp như code"]Quyết định. Mỗi file là một IIFE 'use strict', đọc var OG = window.OG; và gắn đúng một namespace (OG.Scenes, OG.Learn...).
Không bundler, không transpile, cú pháp ES5/ES2015 vừa phải. Dữ liệu (hành trình, ảnh) cũng là file .js gọi hàm đăng ký.
Kiểm tra kiểu làm bằng JSDoc + src/types.d.ts + // @ts-check (chỉ VS Code/tsc đọc, trình duyệt bỏ qua).
Hệ quả.
- ✅ Không công cụ nào đứng giữa code và trình duyệt: sinh viên sửa một dòng, F5 là thấy. Code trên GitHub Pages chính là code trong repo.
- ✅ Module tính năng có thể vắng mặt (gọi qua
OG.util.calltrong src/core/namespace.js). - ⚠️ Thứ tự
<script>trong index.html là đồ thị phụ thuộc, phải giữ tay (05 mục 3). - ⚠️ Mọi thứ là toàn cục: trùng tên namespace là ghi đè im lặng → quy ước sở hữu namespace (
docs/ARCHITECTURE.mdmục 1), tiền tố<jid>-cho id riêng. - ⚠️ ~70 thẻ
<script>= ~70 request; chấp nhận được với site tĩnh nhỏ, trình duyệt tải song song.
Phương án đã loại. ES modules + Vite/webpack (cần server và bước build, mất khả năng file://); một file JS khổng lồ (không chia việc được cho
nhiều người/agent cùng lúc); AMD/RequireJS (thư viện ngoài, khái niệm lỗi thời để dạy). Bài học: 01-module-pattern-iife-namespace.md.
ADR-02. Scene stack (go/push/pop) thay vì một biến "scene hiện tại" #
Bối cảnh. Game có ~20 màn hình. Nhiều màn hình phải đè lên màn chơi mà không huỷ nó: tạm dừng, cài đặt từ tạm dừng, thẻ kiến thức, câu đố, truyện chen giữa màn. Khi đóng, màn chơi phải tiếp tục đúng chỗ cũ, và thời gian đè lên không được tính vào thời gian chơi.
flowchart LR
subgraph S1["Đang chơi"]
a1["level"]
end
subgraph S2["push('pause')"]
b1["level (đứng yên, vẫn vẽ)"] --- b2["pause ← update"]
end
subgraph S3["push('settings', {overlay})"]
c1["level"] --- c2["pause"] --- c3["settings ← update"]
end
S1 -->|"Esc / P / mất focus"| S2 -->|"⚙"| S3
S3 -->|"pop()"| S2 -->|"pop() → level.resume()"| S1Quyết định. OG.Scenes (src/core/scenes.js) giữ một ngăn xếp. go thay cả stack; push đè lên; pop gỡ và gọi
resume() của scene lộ ra. Chỉ scene trên cùng được update; draw vẽ từ scene thấp nhất còn nhìn thấy (drawBelow). Mỗi lần đổi scene gọi
OG.Input.reset() và phát scene:change.
Hệ quả.
- ✅ "Tạm dừng" miễn phí: màn chơi không được
updatenên không cần cờisPausedở Player, Enemies, Bosses...;Game.tickTimecũng dừng. - ✅ Popup học tập (thẻ, câu đố) là scene bình thường, tái dùng được từ màn chơi lẫn Sổ tay.
- ⚠️ Callback sau
popphải gọi sau khi pop (bẫy được ghi trongcomic.finish); scene đang trongenterkhông nên đổi scene ngay (bẫyskipcủa scene boss). - ⚠️ Hai dạng chuyển (
govspush) phải chọn đúng: dùnggocho câu đố sẽ huỷ màn chơi.
Phương án đã loại. Một biến current + cờ paused rải khắp nơi; FSM phẳng không có stack (không quay lại được đúng chỗ);
DOM overlay riêng cho mỗi popup (logic phân tán giữa canvas và DOM). Sơ đồ chuyển scene: ARCHITECTURE-DIAGRAMS mục 2.
ADR-03. Event bus để tách module #
Bối cảnh. Một cú nhảy cần: âm thanh, bụi dưới chân, (có thể) đếm nhiệm vụ. Nếu player.js gọi thẳng OG.Audio.sfx('jump'), OG.Sprites.fx...,
OG.Quests... thì gameplay phụ thuộc vào mọi module tính năng; thiếu một file là lỗi; nhiều người cùng sửa một hàm.
flowchart LR
subgraph TRUOC["Gọi thẳng (đã loại)"]
p1["Player"] --> a1["Audio"]
p1 --> s1["Sprites"]
p1 --> q1["Quests"]
end
subgraph SAU["Event bus (đang dùng)"]
p2["Player"] -->|"emit('player:jump')"| bus(("OG.Events"))
bus --> a2["Audio"]
bus --> s2["Sprites"]
bus --> t2["smoke test ('*')"]
endQuyết định. OG.Events (src/core/events.js) với on/off/once/emit và wildcard '*'; tên chuẩn trong OG.EV
dạng '<chủ thể>:<việc vừa xảy ra>'; payload luôn là một object. Listener lỗi bị bắt (OG.util.safe), không làm sập frame.
Quy ước: event để thông báo việc đã xảy ra; khi cần kết quả (vd softFail trả true/false) thì gọi hàm qua OG.util.call.
Hệ quả.
- ✅ Thêm người nghe mà không sửa nơi phát (Audio cho mọi event v2 được thêm sau, chỉ sửa
audio.js). - ✅ Smoke test nghe
'*'để đếm mọi event, kiểm tra luồng mà không cần "móc" vào code. - ⚠️ Khó lần theo ("ai nghe event này?") → cần bảng ARCHITECTURE-DIAGRAMS mục 3.5.
- ⚠️ Chuỗi tên event là API: đổi
'item:collect'thì mọi listener im lặng ngừng chạy. Quênoff()khi scene thoát = rò listener (smoke test đếm số listener). - ⚠️ Thứ tự emit là hợp đồng (vd
player:hurttrướcplayer:dietrướcgame:over).
Phương án đã loại. Gọi thẳng; CustomEvent trên window (payload kém tiện, khó test trong Node); thư viện EventEmitter (thư viện ngoài).
Bài học: 06-event-bus-observer.md.
ADR-04. Hành trình hướng dữ liệu (data-driven) #
Bối cảnh. Bản v2 thêm 4 hành trình, 38 màn, hàng trăm thẻ/câu đố/khung truyện do nhiều người viết song song (mỗi hành trình một agent), nhiều người không phải lập trình viên game. Nội dung phải sửa được mà không đụng engine, và lỗi gõ (id thẻ sai) không được làm sập game của trẻ.
Quyết định. Mỗi hành trình là một thư mục src/journeys/<id>/ gồm theme.js → content.js → extras.js → journey.js, chỉ chứa object thuần
gọi OG.Journeys.register (gộp nhiều lần theo id). Lúc boot, OG.Journeys.init() chạy validateAll (hành trình lỗi bị gắn invalid và ẩn) rồi
compileAll (điền mặc định, mượn hình học màn v1 qua from, gắn field điều hướng). Engine chỉ đọc bản đã biên dịch.
Chi tiết dữ liệu: 01 mục 1–3.
Hệ quả.
- ✅ Thêm hành trình thứ 5 = thêm 4 file + 4 thẻ
<script>, không sửa engine. - ✅ Lỗi dữ liệu có đường dẫn rõ ràng (
home.levels.home-kitchen-1.pages[0].card: thẻ "x" không tồn tại) và không làm sập game. - ✅ Tách "dữ liệu gốc / bản biên dịch / trạng thái chạy" → chơi lại màn luôn sạch.
- ⚠️ Không có kiểm tra kiểu lúc soạn (ngoài JSDoc); ràng buộc nằm trong code
validate*và phải được bảo trì cùng định dạng. - ⚠️ Tham chiếu bằng chuỗi id → đổi tên id phải đổi mọi nơi tham chiếu.
Phương án đã loại. Viết màn bằng code (mỗi màn một hàm — khó cho người soạn nội dung, khó validate); file JSON (bị chặn qua file://, không chứa
được hàm drawIcon); trình soạn màn trực quan (quá lớn so với phạm vi).
ADR-05. Mở rộng màn chơi bằng level hook #
Bối cảnh. Hành trình cần thêm vào màn chơi: trang sách, cổng câu đố, NPC, checkpoint, truyện chen giữa màn. Bốn tính năng này do bốn nhóm khác nhau làm, trong khi src/game/level.js là vùng nhạy cảm (vật lý, giẫm địch, cửa ra) mà chiến dịch cổ điển cũng dùng.
flowchart LR
u["level.update(dt)"] --> s1["Player.update"] --> s2["handleFall"] --> h{{"runUpdateHooks<br/>theo order"}} --> s3["updateItems"] --> s4["updateEnemies"] --> s5["checkExit<br/>(hỏi hook.canExit)"]
h -.-> c["checkpoints (10)"]
h -.-> l["learn (20)"]
h -.-> g["guide (30)"]
h -.-> sc["story-comics (40)"]
h -->|"hook trả true"| stop(["dừng frame<br/>(vừa push scene)"])Quyết định. OG.Level.addHook({id, order, enter, update, canExit, drawWorld, drawFront, drawScreen}) (interface LevelHook trong
src/types.d.ts). level.js gọi các hook ở các "khe" cố định; hook trả true từ update để dừng frame, false từ canExit để chặn cửa.
Trạng thái riêng của hook gắn vào st (st.learn, st.checkpoints, st.guideSpots).
Hệ quả.
- ✅
level.jskhông có dòng nào về checkpoint, trang sách hay NPC; màn cổ điển chạy y như cũ (st.journeyId = null→ hook tự tắt). - ✅ Mỗi nhóm làm trong file của mình (difficulty.js, learn.js, guide.js, story.js).
- ⚠️
orderlà hợp đồng ngầm; đọc riênglevel.jskhông thấy hết hành vi của màn. - ⚠️ Hook dùng chung
st→ phải tránh trùng tên field.
Phương án đã loại. Viết thẳng vào level.js (xung đột khi nhiều người sửa, phình to); scene journeyLevel riêng (nhân đôi toàn bộ gameplay).
ADR-06. Vẽ procedural bằng Canvas + cache khung hình LRU #
Bối cảnh. Ràng buộc dự án: không ảnh/âm thanh từ bên ngoài, không công cụ vẽ (sinh viên chỉ có code). Nhân vật cần nhiều tư thế (14 trạng thái), nhiều skin (đổi màu), phụ kiện (nón lá, mũ nồi...), và phải nét ở mọi độ phân giải. Nhưng vẽ một nhân vật bằng lệnh Canvas tốn hàng trăm lệnh mỗi frame.
flowchart LR
call["drawCharacter(ctx, charId, state, t, opts)"] --> q["quantize(state, t)<br/>làm tròn t về khung<br/>(vd run: 12 khung / 0,36 giây)"]
q --> k["frameKey: nhân vật, skin, phụ kiện,<br/>trạng thái, khung, nháy mắt, độ phân giải"]
k --> hit{"cacheGet(key)?"}
hit -->|"trúng: delete + set lại<br/>(dời xuống cuối Map)"| blit["drawImage canvas phụ<br/>+ lật, scale, squash lúc blit"]
hit -->|"trượt"| draw["vẽ procedural vào canvas phụ<br/>bằng t đã lượng tử hoá"]
draw --> put["cachePut: thêm vào cuối Map"]
put --> ev{"size > CACHE_MAX (320)<br/>hoặc px > CACHE_MAX_PX?"}
ev -->|"có"| evict["xoá khoá ĐẦU Map<br/>(lâu chưa dùng nhất)"] --> ev
ev -->|"không"| blitQuyết định. Mọi hình vẽ bằng Canvas 2D theo thời gian (Pose(t)), không sprite sheet. src/gfx/sprites.js lượng tử hoá
thời gian (quantize) để nhiều frame game dùng chung một khung, vẽ khung vào canvas phụ, cache trong Map theo LRU với hai ngân sách (số khung
và tổng điểm ảnh). Những gì rẻ (lật hướng, alpha, scale, squash khi tiếp đất, chữ "BỐP!") áp lúc blit, không làm phình cache. Decor dùng cache
FIFO đơn giản hơn (src/gfx/decor.js, hàm remember).
Hệ quả.
- ✅ Không file ảnh nhân vật nào; skin mới = bộ màu mới; phụ kiện = vài hàm vẽ. Sắc nét ở mọi kích thước (cache theo độ phân giải).
- ✅ Hiệu năng gần như sprite sheet sau khi cache "ấm" (
OG.Sprites.cacheStats()xem tỉ lệ trúng). - ✅ Dạy được nhiều khái niệm: path, transform,
save/restore, squash & stretch, memoization, LRU bằngMap. - ⚠️ Code vẽ rất dài (
sprites.js> 3000 dòng); hoạt ảnh bị "giật nhẹ" theo số khung đã chọn (đánh đổi bộ nhớ ↔ độ mượt trongANIM_INFO). - ⚠️ Môi trường không có
document(Node) thì cache tự tắt — smoke test vẽ thẳng lên canvas giả.
Phương án đã loại. Sprite sheet PNG (cần hoạ sĩ + công cụ, trái ràng buộc "không asset ngoài"; mỗi skin một sheet); SVG/DOM cho nhân vật (chậm khi nhiều đối tượng, khó trộn với canvas); vẽ procedural không cache (FPS tụt trên điện thoại khi có trùm phóng to + nhiều kẻ địch). Bài học: 07-ve-canvas-procedural-cache.md.
ADR-07. Âm thanh tổng hợp bằng Web Audio (không dùng file) #
Bối cảnh. Cùng ràng buộc "không asset ngoài" và chạy qua file://. Game cần ~25 hiệu ứng và ~10 bản nhạc nền đổi theo scene, có âm lượng riêng
nhạc/hiệu ứng, tắt tiếng, và không bao giờ làm game lỗi khi trình duyệt không cho phát tiếng.
flowchart LR osc["OscillatorNode<br/>(tone: sóng vuông, tam giác...)"] --> env["GainNode envelope<br/>(attack, decay)"] nz["AudioBufferSource<br/>(noiseBuf 1 giây nhiễu)"] --> flt["BiquadFilter<br/>(lowpass, bandpass...)"] env --> sfx["sfxBus"] flt --> sfx seq["music.js sequencer<br/>tick mỗi TICK_MS, lên lịch trước LOOKAHEAD"] --> tg["trackGain (fade)"] --> mus["musicBus (× duck)"] sfx --> master["master (muted = 0)"] mus --> master master --> comp["DynamicsCompressor<br/>chống méo khi chồng tiếng"] --> dst["loa (destination)"]
Quyết định. src/audio/audio.js tổng hợp mọi âm thanh bằng oscillator + noise + envelope, nối vào đồ thị bus;
src/audio/music.js là sequencer: bản nhạc là dữ liệu chữ (TrackDef), biên dịch một lần thành lưới bước, lên lịch nốt bằng
đồng hồ chính xác của AudioContext (lookahead scheduling). Audio tự nghe event (ADR-03) và chỉ tạo AudioContext sau input:firstGesture
(autoplay policy, 04 mục 7).
Hệ quả.
- ✅ 0 byte file âm thanh; đổi cao độ theo nhân vật (
CHAR_PITCH), lệch ngẫu nhiên nhẹ (PITCH_JITTER) để bớt nhàm. - ✅ Âm thanh "trung thành" với từng sự kiện trong game (vd câu trả lời sai chỉ có âm trầm nhẹ, không có âm "thua").
- ⚠️ Âm sắc giới hạn (kiểu chiptune); soạn nhạc bằng chữ khó với người không biết nhạc lý.
- ⚠️ Phải xử lý nhiều trường hợp trình duyệt:
suspended, tab ẩn, iOS cần buffer im lặng, giới hạn số voice (MAX_VOICES).
Phương án đã loại. File MP3/OGG (asset ngoài, nặng, cần preload); thư viện Howler/Tone.js (thư viện ngoài); <audio> element (độ trễ cao, khó trộn nhiều tiếng).
Bài học: 10-web-audio.md.
ADR-08. Một khoá localStorage có version + gộp mặc định #
Bối cảnh. Game không có máy chủ; mọi tiến độ (xu, sao, thẻ, câu đố, nhiệm vụ, cài đặt) phải sống trong trình duyệt. Phiên bản game thay đổi
thường xuyên (MVP → v1 → v2) và mỗi lần thêm field mới; người chơi cũ không được mất dữ liệu. localStorage có thể bị chặn hoặc chứa JSON hỏng.
Quyết định. src/core/save.js: một object OG.Save.data dưới một khoá oggyAdventure.save.v1, có field v.
load() = merge(defaults(), JSON.parse(raw)) (deep merge, mảng thay nguyên) + migrate kỷ lục từ khoá oggyBest của bản MVP. Mọi truy cập qua OG.Save
(cấm gọi localStorage trực tiếp), bọc try/catch. Module tính năng ghi vào ngăn riêng ext.<tên> qua OG.Save.ns(name).
Sơ đồ: 01 mục 9–10, 03 luồng f.
Hệ quả.
- ✅ Thêm field = thêm vào
defaults(); save cũ tự nhận giá trị mặc định, không cần code migrate. - ✅ JSON hỏng / storage bị chặn → game vẫn chạy (dữ liệu trong RAM),
OG.Save.availablecho biết. - ✅ Namespace
ext→ không module nào ghi đè dữ liệu của module khác. - ⚠️ Deep merge không xử lý được đổi tên/đổi kiểu field; khi cần, phải viết nhánh theo
v(hiệnvluôn là 1, chưa có nhánh nào — xem ghi chú 01 mục 10). - ⚠️ Ghi toàn bộ object mỗi lần
commit(); dữ liệu theo tên miền, xoá dữ liệu trình duyệt là mất.
Phương án đã loại. Nhiều khoá rời (khó reset, dễ lệch nhau); IndexedDB (API bất đồng bộ phức tạp hơn nhiều cho lượng dữ liệu vài KB); cookie (gửi lên máy chủ mỗi request, giới hạn 4 KB); lưu trên máy chủ (cần backend, tài khoản — chỉ bảng xếp hạng online là tuỳ chọn). Bài học: 11-luu-tru-migrate.md.
ADR-09. Ảnh chụp lưu trong repo + ghi công, không hotlink #
Bối cảnh. Thẻ kiến thức (Hồ Gươm, kim tự tháp, Sao Mộc...) dạy tốt hơn khi có ảnh thật cạnh hình cartoon. Ảnh tự do (CC BY, CC BY-SA, Public Domain) bắt buộc ghi công đúng; một số giấy phép (NC, ND) không dùng được vì game phân phối công khai và ảnh bị thu nhỏ (bản phái sinh).
Quyết định. Công cụ tools/photos/fetch_commons.py tải ảnh về repo (assets/photos/<jid>/<cardId>.webp, cạnh dài ≤ 800 px),
kiểm giấy phép bằng danh sách trắng, sinh credits.js gọi OG.Photos.register. Game nạp ảnh lười (src/story/photos.js), luôn vẽ dòng
ghi công dưới ảnh, có trang docs/credits.html. Thiếu/lỗi ảnh → quay về hình cartoon. Sơ đồ: 05 mục 6,
03 luồng g.
Hệ quả.
- ✅ Chạy offline và qua
file://; không phụ thuộc máy chủ ảnh của người khác (link chết, đổi URL, chặn hotlink, theo dõi người dùng). - ✅ Dung lượng kiểm soát được (WebP nhỏ) — quan trọng với điện thoại của trẻ em.
- ✅ Giấy phép được kiểm ở ba lớp: công cụ,
licenseOklúc chạy,photoCheckstrong smoke test. - ⚠️ Repo nặng thêm (hơn 70 ảnh); cập nhật ảnh phải qua công cụ (sửa tay
credits.jssẽ bị lầnwritesau ghi đè).
Phương án đã loại. Hotlink trực tiếp tới upload.wikimedia.org (phụ thuộc mạng, ảnh gốc rất nặng, vi phạm tinh thần "không asset ngoài");
nhúng ảnh base64 trong JS (phình file, không cache riêng được); bỏ ảnh thật (mất giá trị giáo dục).
ADR-10. Smoke test bằng DOM giả, không dùng framework test #
Bối cảnh. Không build step, không thư viện ngoài, CI chỉ có node. Nhưng game có hàng chục module do nhiều agent viết song song; cần một lưới an toàn
đầu-cuối (end-to-end) bắt được: lỗi runtime khi nạp, scene không chuyển đúng, hành trình dữ liệu lỗi, ctx.save/restore lệch, rò listener...
flowchart LR
subgraph NODE["node tests/smoke.js"]
fd["tests/fake-dom.js<br/>window, document, canvas 2D (Proxy),<br/>localStorage, AudioContext giả, đồng hồ ảo"]
ctx["vm.createContext(sandbox)"]
fd --> ctx
idx["đọc index.html<br/>(thân trang + danh sách script)"] --> load["vm.runInContext từng file<br/>ĐÚNG thứ tự script"]
ctx --> load
load --> drive["điều khiển như người chơi:<br/>keydown, click, pointerdown<br/>+ OG.Loop.step(1/60) hàng nghìn lần"]
drive --> checks["check(...): scene, event ('*'),<br/>save, bất biến, font, ảnh"]
end
checks -->|"có lỗi → exit 1"| ci["CI chặn deploy"]Quyết định. Tự viết DOM giả tối thiểu (tests/fake-dom.js) và một script (tests/smoke.js) nạp đúng thứ tự của
index.html trong vm sandbox, rồi "chơi" hết game bằng event giả + OG.Loop.step với bước cố định 1/60 giây (tất định). Mọi console.error, ngoại lệ
trong frame/listener/timer, save/restore lệch, alert/confirm/prompt đều là lỗi. CI chạy nó trước mỗi lần deploy (05 mục 5).
Hệ quả.
- ✅ Không phụ thuộc gì ngoài Node; chạy ~30–40 giây và chơi hết 4 hành trình + chiến dịch cổ điển.
- ✅ Kiểm tra cả hợp đồng dữ liệu (
OG.Journeys.validate), quy tắc nội dung (cấm font thiếu dấu tiếng Việt, giấy phép ảnh) chứ không chỉ code. - ✅ Game được thiết kế để test được:
OG.Loop.step,OG.BossScene.getBoss(),OG.World.journeyMap.selected()... là "cửa" cho test. - ⚠️ Không thấy lỗi hiển thị (không có pixel thật, không layout); không phải unit test nên lỗi báo ở mức "luồng nào hỏng", phải đọc
stageđể khoanh vùng. - ⚠️ Game dùng API DOM mới mà DOM giả chưa có → phải bổ sung vào
fake-dom.js(comment trong file: đừng sửa game để né test).
Phương án đã loại. Jest/Mocha + jsdom (thư viện ngoài, cài đặt nặng); Playwright/Puppeteer (cần trình duyệt headless trong CI, chậm, nhiều phụ thuộc); chỉ test tay (không bắt được hồi quy khi 7 agent sửa song song).