模組五|關卡、UI 與資料(Day 21–25)
昨天的素材是我準備好放進 repo 的。今天這份資料不是——它是玩家自己產生的,存在他的瀏覽器裡,我改不到、也看不到。
先攤開規模。整個進度儲存是一個檔案:src/core/ProgressStore.js,198 行,全檔只有一個 import(import { MAXIMUM_STARS } from '../systems/scoring.js')。存的東西只有四個欄位,一把 key。單元測試 tests/unit/ProgressStore.test.js 有 25 條案例,其中九條在測壞資料。
結論先講:我第一天就寫了版本號,而且它確實每次載入都被讀。然後版本從 1 升到 2 的那一刻,所有版本 1 的存檔在那個 if 裡被判定不符、整份重置。版本號告訴你「不一樣了」,它不會告訴你「該怎麼辦」——那一行只買到偵測,沒買到遷移。
createFreshProgress()(:13-20)就是這份資料的全部形狀:version、unlockedLevelId、stars、settings.muted。
有兩個決定值得單獨說:
已解鎖關卡是一個整數,不是陣列。 unlockedLevelId: 1,判斷式是 levelId <= this.progress.unlockedLevelId(:136)。這叫水位線——第五關解鎖就代表一到四關都解鎖,中間不可能有洞。存 [1, 2, 5] 這種陣列的話,我就得處理「第三關沒過但第五關解鎖了」,而遊戲根本沒有路徑製造得出那個狀態。把不可能發生的狀態設計成無法表示,比事後檢查它便宜。
音效開關跟關卡進度共用一把 key('save-the-dog-web:progress',:3),有測試釘著(tests/unit/ProgressStore.test.js:228-241)。一把 key、一次讀、一次寫,代價是下一節整篇要講的東西。

時間可以查。這個檔案只被兩個 commit 動過:
| 時間 | commit | 發生什麼 |
|---|---|---|
| 2026-08-06 14:21:14 | 65a2c18 |
ProgressStore.js 進 repo,PROGRESS_VERSION = 1,存三個欄位 |
| 2026-08-06 15:45:18 | 59b8741 |
音效設定加進存檔,PROGRESS_VERSION 升到 2 |
中間隔 一小時二十四分鐘。
而升版的那個 commit,同時加了這段註解。這是整篇文章的核心,程式碼加註解一共只有這麼多(:5-11 與 :61-63):
/**
* Bumped to 2 when sound settings joined the record. Version 1 profiles are
* rejected as a mismatch and reset, which loses their level progress. That is
* acceptable only because the game has not shipped and has no real players; a
* released game would need a migration here instead.
*/
export const PROGRESS_VERSION = 2
// ...parseProgress 內:
if (parsed.version !== PROGRESS_VERSION) {
return createFreshProgress()
}
三件事要拆開講。
第一,這是一份附帶適用條件的技術債告白。 註解沒說「這樣做是對的」,它說的是「只有在遊戲還沒上線、沒有真實玩家的前提下才可以接受;已上線的遊戲這裡需要一個遷移函式」。它不只承認債務,還標明了這筆債什麼時候會變成事故。 寫「TODO: 之後補」的註解沒有觸發條件,所以永遠不會被觸發;這一條有。
第二,遷移函式真的不存在。 我在整個 src/ 掃過 migrat 字根,唯一命中就是註解裡那句 would need a migration here instead。
第三,那個運算子是 !==,不是 <。 這一點我到寫這篇才注意到。它不只清掉比較舊的存檔——未來若要降版(版本 3 上線後發現有問題、回滾回 2),玩家的存檔一樣會被清掉。 這比「清掉舊存檔」更難察覺,因為回滾時你想的是「我把程式碼退回去了,一切照舊」。
我要誠實界定一件事:版本 1 的存檔被清掉,我沒有任何玩家受影響的紀錄,因為那時候遊戲根本沒有玩家。 這篇不是「我搞砸了、使用者哀鴻遍野」的故事——那個故事我沒有證據,不編。可以查證的只有那條路徑的行為,以及它被寫下來時作者知道自己在做什麼。
對照組不用去別的專案找。Day 21 講的關卡資料化,五個關卡檔的欄位裡都有 schemaVersion: 1(src/levels/level01.js:23、level02.js:24、level03.js:25、level04.js:40、level05.js:34)。
差別是:沒有任何程式碼讀它。 關卡是 build 進 bundle 的靜態資料,改 schema 就直接改五個檔案,沒有舊資料需要相容。
PROGRESS_VERSION |
schemaVersion |
|
|---|---|---|
| 有欄位 | ✅ | ✅ |
| 有人讀 | ✅ :61 每次載入 |
❌ 全 src/ 零讀取 |
| 有遷移 | ❌ | ❌ |
| 不符時 | 整份重置 | 不會發生(資料跟程式一起部署) |
兩個都是「有版本號 ≠ 有遷移能力」,但成熟度差一級:一個會重置,一個連重置都不會。 而那個連重置都不會的反而更安全,因為它的資料從來不會比程式舊——這也是「你需不需要版本號」的準則:資料會不會活得比讀它的程式久。
這是這個檔案我最滿意的部分。parseProgress(:44-101)總共有 九個 return createFreshProgress():
| # | 行 | 擋掉什麼 |
|---|---|---|
| 1 | :46 |
不是字串或空字串 |
| 2 | :54 |
JSON.parse 拋錯 |
| 3 | :58 |
不是物件,或是陣列 |
| 4 | :62 |
版本不符 |
| 5 | :66 |
unlockedLevelId 不是正整數 |
| 6 | :70 |
stars 不是物件 |
| 7 | :79 |
任何一筆 star 條目壞掉 |
| 8 | :88 |
settings 不是物件 |
| 9 | :92 |
muted 不是布林 |
第七條值得單獨看,因為它是一個明確的取捨,而且理由寫在旁邊(:73-83):
const stars = {}
for (const [levelId, value] of Object.entries(parsed.stars)) {
if (!/^\d+$/.test(levelId) || !isValidStars(value)) {
// One bad entry means we cannot trust the record: fall back wholesale
// rather than letting a partially bogus profile into the game.
return createFreshProgress()
}
stars[levelId] = value
}
一筆星等資料壞掉,整份存檔丟掉,不是只丟那一筆。 假設有人手改 localStorage 把第三關改成 99 顆星,結果不是「第三關變成 3 星、其他保留」,而是前面所有關卡的進度一起歸零。
這在別的系統裡會是災難。撐住它的是寫在函式上方的一句設計前提(:39-43):
Progress is a bonus, never a precondition for the game running, so every failure path returns fresh progress instead of throwing.
先決定了這份資料的重要性等級,才敢把驗證寫這麼絕。 順序不能反過來——先寫嚴格驗證、再回頭問「這樣會不會太狠」,你不會有能回答的依據。
同一個原則在寫入端也成立。recordResult 收到越界的星數時,isValidStars(stars) ? stars : 0(:167)——是歸零,不是夾到 3。測試把這個行為釘住了(:202-209):傳 { levelId: 1, stars: 99, nextLevelId: 2 } 進去,斷言 getStars(1) 是 0、且第二關沒有解鎖。(那條測試叫 clamps an out-of-range star value,但它斷言的行為不是 clamp——名字寫錯,行為是對的。)
不信任的輸入不要試著修好它,直接當成沒有。 一顆 99 星修成 3 星,等於獎勵作弊;修成 0 星,等於什麼也沒發生。
至於做得對的另一半:星等只升不降。recordResult 只在 safeStars > this.getStars(levelId) 時才寫入(:169-171),解鎖水位線也只升不降(:175-177),註解寫明理由——「a scrappy replay cannot erase an earlier three-star run」。重玩一次玩爛了,不會洗掉之前的三星。

無痕模式、被擴充功能封鎖、配額滿——localStorage 有很多種壞法,而且有一種壞法是連碰它都會拋錯。這個檔案三個接觸點全部包住了:
function resolveStorage(storage) {
if (storage) {
return storage
}
try {
return globalThis.localStorage ?? null
} catch {
// Accessing localStorage itself can throw when storage is blocked.
return null
}
}
save() {
try {
this.storage?.setItem(this.key, JSON.stringify(this.progress))
return true
} catch {
// A full or disabled quota must never break the run.
return false
}
}
三層是:resolveStorage:22-33(存取 globalThis.localStorage 本身)、load():114-118(getItem)、save():125-133(setItem)。而且全程用 optional chaining,storage 是 null 也照跑。這裡的防護比我原本規劃的還周全。
然後是這篇第二個要誠實承認的洞。
save() 有回傳 true/false,看起來就是要給呼叫端判斷用的。實際上:三個呼叫點全部把它丟掉。 setMuted:153、recordResult:179、reset:186 都是裸呼叫,不接回傳值;再往外,GameScene.js:189 跟 LevelSelectScene.js:56 也沒有接。這個布林值走完一圈,沒有任何一個人讀過它。
症狀比「玩家不知道」更麻煩一點:記憶體裡的 this.progress 已經改了。 所以當次遊玩看起來完全正常——星星有加、下一關有解鎖、音效開關有反應——重新整理才會發現什麼都沒存到。
這個行為甚至被測試鎖住了(:251-256):
it('does not throw when the write fails', ...)— 用一個getItem/setItem都會拋錯的假 storage 建立 store,斷言setMuted(true)不會 throw,而且isMuted()仍然回true。
那條測試在保護「壞掉的時候不要炸」,這是對的。但它同時也把「壞掉的時候不告訴任何人」寫成了正式契約。我知道該做一個提示,我沒有做,這一條到現在還是紅的。
還有兩件我沒做的:無痕模式我沒實測過——三層 try/catch 是寫的,不是驗的;以及 grep -rn "localStorage" tests/e2e/ 零命中,整個存檔層的防護只有單元測試,沒有任何「重整頁面後進度還在嗎」的端到端驗證。
tests/unit/ProgressStore.test.js:283-291 這條測試我想單獨拿出來,因為它讓整件事變得無法假裝:
it('resets a version 1 profile, which predates sound settings', ...)— 塞一份{ version: 1, unlockedLevelId: 5, stars: { 1: 3, 2: 3 } }進去,斷言snapshot()等於全新存檔。
一份解鎖到第五關、前兩關滿星的存檔,測試明確要求它被清成空的。
這不是一條保護遷移的測試,是一條保護重置的測試。 而它做對了一件事:把原本只存在於註解裡的決定,變成了會失敗的斷言。哪天有人真的寫了遷移函式,這條測試會紅,紅的地方就是「這個決定要被重新做一次」的位置。
註解會被忽略,測試不會。 前提是測試名字裡寫清楚為什麼(which predates sound settings),否則下一個人只會看到一條莫名其妙的紅燈然後刪掉它。
跟昨天一樣:這個檔案哪幾行是我寫的、哪幾行是 AI 寫的,我沒有留下紀錄,不補寫。
但可以說一件與紀錄無關的事:這個檔案的形狀決定了它適不適合交出去。 全檔一個 import、storage 與 key 都能從 constructor 注入、每條失敗路徑回傳同一個值——判準可以窮舉,25 條測試就是那份窮舉表。這種東西交給誰做都能驗收。
不能交出去的是這一篇的三個決定本身:一筆壞掉丟全部、越界歸零不夾值、版本不符直接重置。這三個不是「哪個寫法比較好」的問題,是「這份資料值多少」的問題。 前者有標準答案,後者沒有——它取決於你的遊戲有沒有上線。這種問題交出去,拿回來的會是一個看起來很合理、但沒有人為它負責的預設值。
一句話:
任何持久化格式第一天就要有版本號——但要清楚它只買到偵測。真正的成本在後面那個 if 裡要寫什麼,而預設值是「全部丟掉」。
三件今天就能做的事:
return null、或是像我這樣直接重置。你需要知道它現在寫的是什麼,以及那是不是你選的。!== 還是 <。 兩者在升版時行為相同,在降版時完全不同,而降版通常發生在你最不想再出事的時候。明天 Day 26 進模組六,第一篇談測試的分工。今天已經給了一個現成的題目:這個存檔層有 25 條單元測試,端到端測試碰過它的次數是 0——而「重整頁面之後進度還在嗎」剛好是單元測試永遠測不到、也只有端到端測得到的那一類問題。明天要把三種測試各自能證明什麼、不能證明什麼分乾淨,包括一個我原本以為存在、查證後發現不存在的東西。
本篇數字的快照時間:2026-08-07 12:35(+0800),對應 commit
5aa3705。專案仍在開發中,量體數字會變動;引用的每一項都可以用本文提到的檔案路徑與行號自行對照。
可玩網址:https://save-the-dog-web.vercel.app/|原始碼:https://github.com/HarryFan/save-the-dog-web
如果你卡在語法
深入原理