iT邦幫忙

2026 iThome 鐵人賽

DAY 25
0

模組五|關卡、UI 與資料(Day 21–25)

昨天的素材是我準備好放進 repo 的。今天這份資料不是——它是玩家自己產生的,存在他的瀏覽器裡,我改不到、也看不到。

先攤開規模。整個進度儲存是一個檔案:src/core/ProgressStore.js198 行,全檔只有一個 importimport { MAXIMUM_STARS } from '../systems/scoring.js')。存的東西只有四個欄位,一把 key。單元測試 tests/unit/ProgressStore.test.js25 條案例,其中九條在測壞資料。

結論先講:我第一天就寫了版本號,而且它確實每次載入都被讀。然後版本從 1 升到 2 的那一刻,所有版本 1 的存檔在那個 if 裡被判定不符、整份重置。版本號告訴你「不一樣了」,它不會告訴你「該怎麼辦」——那一行只買到偵測,沒買到遷移。


只有四個欄位,但少不等於簡單

createFreshProgress():13-20)就是這份資料的全部形狀:versionunlockedLevelIdstarssettings.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 的存檔被清掉,我沒有任何玩家受影響的紀錄,因為那時候遊戲根本沒有玩家。 這篇不是「我搞砸了、使用者哀鴻遍野」的故事——那個故事我沒有證據,不編。可以查證的只有那條路徑的行為,以及它被寫下來時作者知道自己在做什麼。


同一個 repo 裡,有兩個版本號

對照組不用去別的專案找。Day 21 講的關卡資料化,五個關卡檔的欄位裡都有 schemaVersion: 1src/levels/level01.js:23level02.js:24level03.js:25level04.js:40level05.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」。重玩一次玩爛了,不會洗掉之前的三星。


寫入包了三層 try/catch,然後玩家還是不會知道它失敗了

我壓得那麼用力,抽出來的那一角上面一個字都沒有

無痕模式、被擴充功能封鎖、配額滿——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-118getItem)、save():125-133setItem)。而且全程用 optional chaining,storagenull 也照跑。這裡的防護比我原本規劃的還周全。

然後是這篇第二個要誠實承認的洞。

save() 有回傳 truefalse,看起來就是要給呼叫端判斷用的。實際上:三個呼叫點全部把它丟掉。 setMuted:153recordResult:179reset:186 都是裸呼叫,不接回傳值;再往外,GameScene.js:189LevelSelectScene.js:56 也沒有接。這個布林值走完一圈,沒有任何一個人讀過它。

症狀比「玩家不知道」更麻煩一點:記憶體裡的 this.progress 已經改了。 所以當次遊玩看起來完全正常——星星有加、下一關有解鎖、音效開關有反應——重新整理才會發現什麼都沒存到。

這個行為甚至被測試鎖住了(:251-256):

it('does not throw when the write fails', ...) — 用一個 getItemsetItem 都會拋錯的假 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

跟昨天一樣:這個檔案哪幾行是我寫的、哪幾行是 AI 寫的,我沒有留下紀錄,不補寫。

但可以說一件與紀錄無關的事:這個檔案的形狀決定了它適不適合交出去。 全檔一個 import、storagekey 都能從 constructor 注入、每條失敗路徑回傳同一個值——判準可以窮舉,25 條測試就是那份窮舉表。這種東西交給誰做都能驗收。

不能交出去的是這一篇的三個決定本身:一筆壞掉丟全部、越界歸零不夾值、版本不符直接重置。這三個不是「哪個寫法比較好」的問題,是「這份資料值多少」的問題。 前者有標準答案,後者沒有——它取決於你的遊戲有沒有上線。這種問題交出去,拿回來的會是一個看起來很合理、但沒有人為它負責的預設值。


帶走什麼

一句話:

任何持久化格式第一天就要有版本號——但要清楚它只買到偵測。真正的成本在後面那個 if 裡要寫什麼,而預設值是「全部丟掉」。

三件今天就能做的事:

  1. 把「版本不符怎麼辦」那一行找出來,讀它。 大部分專案那行是空的、是 return null、或是像我這樣直接重置。你需要知道它現在寫的是什麼,以及那是不是你選的。
  2. 檢查你用的是 !== 還是 < 兩者在升版時行為相同,在降版時完全不同,而降版通常發生在你最不想再出事的時候。
  3. 把「我刻意不做 X」寫成一條會失敗的測試,並在測試名字裡寫進理由。 註解只有讀到的人會看見,測試是每一次 CI 都會經過的路。

明天 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

參考資料

如果你卡在語法

深入原理


上一篇
Day 24|素材載入:SVG 變成 Texture 之後,就不是向量了
系列文
一條線救一隻狗:我用 PixiJS、Matter.js 和一條有閘門的 AI 產線做完一款網頁小遊戲25
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言