iT邦幫忙

2026 iThome 鐵人賽

DAY 25
0

claude_25

系列:奇幻塔防開發實錄:用 Claude 打造一款有靈魂的塔防遊戲
今日工具:Claude Code / Claude Cowork / Playwright
今日進度:Go 用「選項序列」驅動狀態機走完三條路線、storylint 進 CI、Playwright 三結局 E2E、Cowork 產出 QA 矩陣——並且抓到一條走了三週沒人發現的死路

前言

Day 24 讓伺服器出事會叫,今天處理另一種無聲的死亡:玩家走到某個抉擇,發現三個選項全部灰掉。分支劇情的 bug 不會拋例外,它只是讓某條路默默到不了結局。Day 10 的單元測試守的是規則(在 battle 節點不能 Choose、平手優先序),守不住資料——graph.json 從 Day 4 到 Day 23 被改了十幾次,沒有任何測試讀過真實的那份。

今天的策略是一座針對「資料驅動劇情」的測試金字塔:底層是規則(已有)、中層是資料測試(今天的主角)、上層是 E2E(三個瀏覽器走三個結局)、頂端是人工 QA 矩陣。四層各抓各的 bug,今天每一層都真的抓到東西。

claude_25_diagram_01

一、資料測試:用選項序列走完三條路

想法很簡單:一個「政策」決定在每個抉擇選第幾項,純守望永遠選 0、純燃盡選 1、純餘燼選 2;對白與 route 節點就 Advance,戰鬥一律當作打贏——測劇情,不測戰鬥。internal/domain/story/routes_test.go:

func walk(t *testing.T, m *story.Machine, g *story.Graph, pick int) (string, story.State) {
	s := story.NewState(g)
	for step := 0; step < 200; step++ { // 上限:劇情圖若有環,測試會停而不是掛住
		n := m.Current(s)
		var err error
		switch n.Type {
		case story.NodeDialogue, story.NodeRoute:
			s, err = m.Advance(s)
		case story.NodeChoice:
			s, err = m.Choose(s, pick)
		case story.NodeBattle:
			s, err = m.ReportBattle(s, n.Level, true)
		case story.NodeEnding:
			return n.Ending, s
		}
		if err != nil {
			t.Fatalf("stuck at %s (%s) flags=%v ember=%d: %v", n.ID, n.Type, s.Flags, s.Ember, err)
		}
	}
	// ...
}

測試本體是一張三列的表:{"純守望 → dawn", 0, "dawn"}、{"純燃盡 → ash", 1, "ash"}、{"純餘燼 → kindling", 2, "kindling"}。圖從 GAMEDATA_DIR 讀(CI 與本機都設,沒設就用 monorepo 相對路徑 ../../../data),所以它讀的是真的那份 graph.json。也可以列舉全部 3⁵ = 243 條路徑,我先做三條純路線,因為它們是遊戲對玩家的承諾;243 條的完整列舉是下一版 storylint 的事。

第一次跑:

--- FAIL: TestRealGraph_PureRoutesReachTheirEndings/純餘燼_→_kindling
    stuck at n_c5 (choice) flags=map[truce:4] ember=1: story: requirement not met

第 5 個抉擇「米菈的請求」要 2 點薪火(Day 4 定的 requires),但餘燼路線的選項一路都不給薪火——薪火只有燃盡路線的「燒掉」「炸掉」在給。一個從頭到尾都選餘燼的玩家,會在最後一步被自己的選擇鎖在門外。這條死路從 Day 4 就存在,Day 18 首頁到結局走的是縮短版劇情圖,所以沒人看見。

修法是資料,不是程式:第 2、4 抉擇的餘燼選項各加 "ember": 1——劇情上也說得通,米菈為你保留了初火的碎片。我先問了 Claude Code「有沒有不改資料的解法」,它提了兩個:把 requires 降到 1,或讓 route 節點對薪火不足的餘燼玩家導向「半餘燼」結局。前者削弱了最後抉擇的重量,後者是在為 bug 設計新結局。都否決。資料的錯要在資料層修。

二、storylint:找到不了的節點與死路

規則測試守規則、路線測試守三條主線,但劇情圖恰好 60 個節點(dialogue 39、choice 5、battle 8、route 5、ending 3),主線以外的分支呢?cmd/storylint/main.go 做三件 Graph.Validate() 看不出來的檢查——從 start 做 BFS 找到不了的節點、非結局節點沒有出口的死路、route 節點缺路線:

seen := map[string]bool{}
queue := []string{g.Start}
for len(queue) > 0 {
	id := queue[0]
	queue = queue[1:]
	if seen[id] {
		continue
	}
	seen[id] = true
	queue = append(queue, outgoing(g.Nodes[id])...) // next / onWin / onLose / choices[].next / routes
}
for id, n := range g.Nodes {
	if !seen[id] {
		out = append(out, "unreachable: "+id)
	}
	if n.Type != story.NodeEnding && len(outgoing(n)) == 0 {
		out = append(out, "dead end: "+id)
	}
}

第一次跑就印出 unreachable: n_after4_alt——Day 7 週記提到 script.md 第 4–6 關對白只是草稿,這個節點是當時寫的另一版對白,Day 23 調整劇情時把指向它的 next 改掉了,它就變成孤兒。刪掉。storylint 現在是 ci.yml api job 的一步,非零就紅;Day 8 說過的原則又用了一次——資料是程式的一部分,載入時就要驗,CI 也要驗。

三、Playwright:三個瀏覽器,三個結局

E2E 只驗一件事:從首頁輸入名字到看見結局標題,整條流程在真的瀏覽器裡走得通。兩個工程決定:

  1. 用縮短版劇情圖當 fixture。 完整六關的 E2E 要打六場戰鬥,太慢也不是重點。vite.config.ts 的 @data alias 改成讀 DATA_DIR 環境變數,E2E 時指向 frontend/e2e/fixtures/data/;後端同一份資料用 GAMEDATA_DIR 起,兩邊看到同一張圖。
  2. 戰鬥用測試鉤子跳過。 import.meta.env.MODE === 'e2e' 時,BattleView 在 window.__emberhold 掛一個 forceBattleEnd(won),直接對引擎發 battleEnded。E2E 測的是劇情流程,不是戰鬥手感——手感 Day 19 用五台實機測過了。這個鉤子在建置期就被決定要不要存在(MODE 是 Vite 的建置時常數),production bundle 裡連那行程式都不會有,不是執行期的 if。

Claude Code 第一版想讓 E2E 真的打戰鬥:自動蓋 Day 23 的 mixed 配置、把引擎調到 8 倍速。技術上可行,但一場要 40 秒、三個瀏覽器九個測試就是六分鐘,而且戰鬥的隨機傷害會讓測試偶爾失敗。E2E 一旦「偶爾紅」,三天後就沒人看它了。

for (const r of ROUTES) { // [{ name: '守望', pick: 0, ending: '黎明' }, ...]
  test(`${r.name}之路 → ${r.ending}結局`, async ({ page }) => {
    await page.goto('/')
    await page.getByLabel('守望者之名').fill(`e2e-${r.name}`)
    await page.getByRole('button', { name: '點燃初火' }).click()
    for (let guard = 0; guard < 30; guard++) {
      if (await page.getByTestId('ending-title').isVisible().catch(() => false)) break
      if (page.url().includes('/battle/')) {
        await page.evaluate(() => window.__emberhold!.forceBattleEnd(true))
        continue
      }
      const choices = page.getByTestId('choice')
      if (await choices.count()) { await choices.nth(r.pick).click(); continue }
      await page.getByTestId('dialogue').click() // 跳過打字機/下一句
    }
    await expect(page.getByTestId('ending-title')).toHaveText(`${r.ending}結局`)
  })
}

三條路線 × chromium、webkit、Mobile Chrome 三個 project = 9 個測試,本機 51 秒。它抓到的不是劇情 bug,是 webkit 上的 UI bug:ChoiceList 的按鈕在 Safari 引擎下 min-height: 44px 沒生效(display: grid 的子元素要自己設 min-height,父層設沒用),Day 19 用 iPhone 測時剛好都在直立提示畫面沒點到選項。

四、Cowork 的 QA 矩陣:機器管結構,人管內容

以上三層都是「路走不走得通」,走得通不代表對白是對的。我把 docs/story/script.md(Day 4 的角色設定與對白)和 data/story/graph.json 交給 Claude Cowork:

Prompt(給 Claude Cowork,指定 docs/ 資料夾)
「產出 docs/qa-matrix.md:每一列是一個 choice 或 ending 節點,欄位為節點 id、所屬路線、前置條件(requires)、選擇後的 flags 與 ember 變化、對白說話者、人工檢查結果(留空)。另外依 script.md 的角色禁忌逐句檢查對白,把違反的列出來。」

矩陣 38 列,我花 40 分鐘人工走完。Cowork 的「角色禁忌」檢查抓到兩處:卡爾登在第 4 抉擇說了「也許我們該撤」——Day 4 定的規則是卡爾登不說「也許」;米菈在 n_after3 下了一個命令句。兩句都是 Day 23 調劇情時我順手改的,改的時候完全沒想起三週前定的規則。規則寫在文件裡不會自己執行,但 Cowork 可以替你重讀一次文件。

本日四層的戰績:

層 工具 數量 耗時 抓到
規則 Go table tests(Day 10) 5 tests / 14 subtests 0.37s —
資料 routes_test.go + storylint 3 subtests + 3 種檢查 0.5s 餘燼路線死路、孤兒節點
E2E Playwright 9 tests 51s webkit 按鈕高度
人工 Cowork QA 矩陣 38 列 40 分鐘 兩句違反角色設定的對白

小結

四層各抓到不同種類的 bug,沒有一層是多餘的。最重要的一條是餘燼路線的死路:它不是程式錯,是資料在三週的修改中慢慢長出來的矛盾,而只有「讀真實資料」的測試看得見。Claude Code 在這裡兩次提出「用程式繞過資料問題」的方案,兩次都被否決——它給的都是可行解,但可行解不等於對的解,對不對取決於 Day 4 定下的原則是什麼。這個判斷 AI 做不了,因為原則是我的。

明天回到效能:Redmi 第 5 波只有 41 FPS,桌機開 150 隻敵人的壓力測試只剩 31 FPS。

今日產出

  • [x] backend/internal/domain/story/routes_test.go;data/story/graph.json 第 2、4 抉擇餘燼選項 +1 薪火
  • [x] backend/cmd/storylint/main.go,進 ci.yml;刪除孤兒節點 n_after4_alt
  • [x] frontend/e2e/{endings.spec.ts,fixtures/data/}、playwright.config.ts、vite.config.ts 的 DATA_DIR
  • [x] docs/qa-matrix.md(Cowork);修正兩句對白、ChoiceList 的 webkit 高度

明日預告

Day 26:效能優化總攻:Vue 渲染瓶頸與 Go API 延遲的雙線調校——worktree 再登場,前端 SpatialGrid 與後端 pprof 兩條線同時開工。


上一篇
Day 24:監控告警上線:CloudWatch 與雲端預警系統,讓伺服器不再無聲陣亡
下一篇
Day 26:效能優化總攻:Vue 渲染瓶頸與 Go API 延遲的雙線調校
系列文
奇幻塔防開發實錄:用 Claude 打造一款有靈魂的塔防遊戲 共 26 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言