iT邦幫忙

2026 iThome 鐵人賽

DAY 12
0
Claude AI

奇幻塔防開發實錄:用 Claude 打造一款有靈魂的塔防遊戲系列 第 12 篇

Day 12:worktree 初體驗:同時開發「戰鬥系統」與「劇情系統」的平行分身術(含實測數據)

  • 分享至 

  • xImage
  •  

claude_12

系列:奇幻塔防開發實錄:用 Claude 打造一款有靈魂的塔防遊戲
今日工具:git worktree + Claude Code ×2
今日進度:兩個功能分支平行完成並合併,wall-clock 從估計 100 分鐘壓到 62 分鐘

前言

Day 10 的狀態機與 Day 11 的模擬器都是「domain 層」的東西,今天要把它們接上 HTTP。手上正好有兩個彼此獨立的任務:

  • 任務 A(戰鬥):usecase/story 的 ReportBattle 加上「粗略反作弊」驗證(用 Day 11 學到的合理範圍),再開 POST /saves/{id}/battles。
  • 任務 B(劇情):usecase/story 的 Current/Choose,開 GET /saves/{id}/story 與 POST /saves/{id}/story/choose,加上 mapError 把 domain 錯誤翻成 HTTP 狀態碼。

它們碰的檔案幾乎不重疊——除了 router.go 都要加一行。這是 README 裡我承諾要做、也是社群評審最想看數據的實驗:兩個 Claude Code session,兩個工作目錄,同一個 repo,同時進行。平常一個 session 在跑測試、我在等的那幾十秒,能不能拿來推進另一件事?

一、兩種開法:手動 git worktree 與 claude --worktree

git worktree 讓同一個 repo 在多個目錄各自 checkout 不同分支,共用 .git 物件庫,不用 clone 第二份、也不用 stash 來 stash 去。

手動版(任務 A):

git worktree add ../emberhold-battle -b feat/battle-report
cd ../emberhold-battle/backend && claude

Claude Code 內建版(任務 B):

claude --worktree story-api        # 也可寫 -w story-api

它會在 .claude/worktrees/story-api/ 建立 worktree 並直接把 session 開在裡面;加 --tmux 會順便開一個 tmux 分頁,分頁名就是 worktree 名。兩種做法的差別只在目錄位置與是否自動命名分支,我刻意各用一種感受差異:手動版目錄在 repo 旁邊、路徑短,適合要開很多終端機的人;內建版一切都在 .claude/ 底下,不會污染上層目錄,session 結束後清理也比較不會忘。git worktree list 隨時可以看到三個目錄各自在哪個分支。要注意的是 worktree 之間不能 checkout 同一個分支——git 會直接拒絕——所以每個 worktree 一定對應一個獨立分支,這剛好強迫你把任務切乾淨。

兩個 session 的開場 prompt 我寫得比平常囉嗦,因為它們彼此看不到:

Prompt(session A)
「任務:在 usecase/story 實作 ReportBattle 與私有的 validate,新增 sentinel ErrInvalidReport;在 httpapi 新增 handlers_story.go 的 handleBattleReport。只在 router.go 加一行 r.Post("/saves/{id}/battles", ...),不要動其他路由。 另一個 session 正在同時做劇情 handler。」

session B 的 prompt 同樣結構,最後一句一樣是「另一個 session 正在做 X,不要碰 Y」。這句話是今天最重要的 prompt 工程——它讓兩個 Claude 都知道自己不是唯一在動這個 repo 的人。

二、實測:序列估計 vs 平行實測

量測方法先講清楚,不然數據沒有意義。序列估計用「前兩天做同規模任務的實際時間」推估:Day 10 的狀態機(含測試)花了 58 分鐘,任務 A 規模相當、任務 B 略小。平行實測用碼表,從兩個 session 同時開始,到兩個分支都合進 main 且 go test ./... 全綠為止。token 用 Claude Code 的 /cost 讀數。

指標 序列(估) 平行(實測)
任務 A:驗證 usecase + handler + 測試 55 min —
任務 B:劇情 handler + mapError + curl 測試 45 min —
總 wall-clock 100 min 62 min(−38%)
我真正盯著螢幕的時間 100 min 62 min
主動切換 session 次數 0 11
切換後需要重讀上文的次數 0 3
merge 衝突 0 1(router.go)
輸出 token(A / B) 約 36k(單一 session) 21.4k / 17.9k

把兩條時間軸攤開來看,「省下的時間」跟「切換與衝突的成本」長什麼樣一目了然:

claude_12_diagram_01

幾個誠實的註解:

  1. wall-clock 省 38%,但「盯著螢幕」的時間一樣是 62 分鐘——worktree 省的是等待,不是注意力。11 次切換裡有 3 次我切回來已經忘了上一句在幹嘛,得重讀 Claude 的輸出,每次大約損失一分鐘。
  2. 輸出 token 加起來 39.3k 比單一 session 估計的 36k 多 9%,因為兩個 session 各自讀了一次 CLAUDE.md 與 domain/story,各自做了一次「理解現況」的推理。
  3. 序列估計本來就是估的,誤差可能 ±15%。單一筆數據不足以下結論,Day 13 會再做兩次,Day 14 週記用三筆數據一起看。

三、任務 A 的成果:戰鬥結果驗證

// internal/usecase/story/service.go
func (s *Service) validate(r BattleReport) error {
	lv, ok := s.cat.Levels[r.LevelID] // 不存在的關卡也是 ErrInvalidReport
	switch {
	case !ok:
		return fmt.Errorf("%w: unknown level %q", ErrInvalidReport, r.LevelID)
	case r.LivesLeft < 0 || r.LivesLeft > lv.Lives:
		return fmt.Errorf("%w: livesLeft out of range", ErrInvalidReport)
	case r.Won && (r.LivesLeft == 0 || r.WavesCleared != len(lv.Waves)):
		return fmt.Errorf("%w: a win needs lives > 0 and all waves cleared", ErrInvalidReport)
	case r.DurationMs < 5000:
		return fmt.Errorf("%w: battle too short", ErrInvalidReport)
	}
	return nil
}

這不是真正的反作弊——前端送什麼,後端終究只能檢查「範圍合不合理」——但能擋掉最無腦的「直接 POST won:true」。真正的驗證要嘛把戰鬥搬到後端跑(Day 5 的 ADR 已經否決),要嘛讓前端回傳整段操作紀錄由後端用 Day 11 的模擬器重播;後者是 Day 30 延伸挑戰清單上的項目,一人 30 天做不到,但架構上已經留了門。Day 11 的模擬器數據說一場灰原邊境至少 270 秒,DurationMs < 5000 這條線之後可以用模擬結果收緊到每關不同。驗證通過後才呼叫 Day 10 的 machine.ReportBattle,再由 commit 寫回倉儲。

四、任務 B 的成果:劇情 handler 與錯誤翻譯

// internal/adapter/httpapi/respond.go(mapError 的三個分支)
case errors.Is(err, save.ErrNotFound):                                   // 404 SAVE_NOT_FOUND
case errors.Is(err, story.ErrNotChoice), errors.Is(err, story.ErrNotBattle): // 409 INVALID_NODE
case errors.Is(err, story.ErrBadChoice), errors.Is(err, story.ErrRequirement),
	errors.Is(err, story.ErrLevelMismatch), errors.Is(err, storysvc.ErrInvalidReport): // 422 INVALID_INPUT

其餘一律 500 INTERNAL。handler 拿到 usecase 的 error 就丟給 mapError,自己不判斷任何東西。Day 10 堅持用 sentinel error 的回報在這裡:errors.Is 一路穿過 fmt.Errorf("%w") 的包裝,handler 不需要知道 usecase 內部怎麼包。「在錯的節點做錯的事」回 409、「參數不合理」回 422、「找不到存檔」回 404,前端 Day 22 就能依錯誤碼決定要重整、提示還是回首頁。default 分支故意不把 err.Error() 吐出去——內部錯誤訊息可能含 table 名、ARN、AWS request id。

五、合併與踩雷

git checkout main
git merge feat/battle-report      # 乾淨
git merge feat/story-api          # CONFLICT: internal/adapter/httpapi/router.go

衝突只有一處:兩邊都在 r.Route("/api/v1", ...) 裡加路由。我把衝突區塊貼給 Claude Code,它正確地保留兩邊並依路徑排序。但我沒有直接接受——解衝突是 AI 最容易「看起來對」的地方,因為它只看得到 <<<<<<< 到 >>>>>>> 之間的幾行,不知道兩邊各自的意圖。我的做法是合併後立刻 go test ./...,再用 Day 13 會整理進 make smoke 的三個 curl 打一輪——make api 直接跑就有服務可以打,本機不需要任何額外依賴——兩關都過才 commit。今天這個衝突很單純,但 Day 13 下午的反例會證明這一步不能省。然後 go test ./... 全綠、git worktree remove ../emberhold-battle、git branch -d 兩個分支。整個合併過程 4 分鐘,已經算進上面的 62 分鐘。

踩到的雷,全部記下:

  • go.sum 各自一份:worktree 是完整 checkout,go mod tidy 在兩邊各跑一次;前端的 node_modules 同理,Day 26 做前端平行時要先各自 bun install,不然第一次 bun run test 會等很久。
  • port 衝突:兩邊都 go run ./cmd/api 會搶 8080,任務 B 改用 PORT=8081。我回頭在 Day 6 的 Makefile 加了 PORT ?= 8080,之後 make api PORT=8081 就好。
  • .env 不在 git 裡:新 worktree 沒有 .env,GAMEDATA_DIR 要重設——好在預設值 ../data 在任何 worktree 內都成立,因為 data/ 也一起 checkout 了。
  • CLAUDE.md 是共享的:這反而是好事,兩個 session 讀到同一份規則;但 session 之間不共享對話記憶,任務 B 不知道任務 A 定義了 ErrInvalidReport,所以 mapError 裡那一個 case 是合併後我手動補的。

小結

worktree 的價值今天很明確:省下的是「Claude 在跑測試、我在發呆」的時間,而不是我的腦力;任務越獨立、每個任務的等待時間越長,省得越多。代價是切換成本、9% 的 token,以及一個必然的 router.go 衝突。最意外的發現是「不共享記憶」——兩個 session 像兩位沒開會的同事,公開名稱要事先講好。明天會把 DynamoDB 倉儲與 Terraform 補 GSI 再拿來平行一次,也會故意做一個反例。

今日產出

  • [x] feat/battle-report:usecase/story 驗證邏輯、handlers_story.go 的 handleBattleReport
  • [x] feat/story-api:handleStoryCurrent/handleStoryChoose、respond.go 的 mapError
  • [x] 兩個分支合併進 main,go test ./... 全綠
  • [x] 實測數據表(本篇第二節)、Makefile 的 PORT ?= 8080

明日預告

Day 13:API 契約與單表設計:用 Claude Code 生成 RESTful 介面與 DynamoDB 存取模式。


上一篇
Day 11:塔防戰鬥核心:波次生成器與敵人 AI 邏輯,Go 的 goroutine 如何模擬戰場
下一篇
Day 13:API 契約與單表設計:用 Claude Code 生成 RESTful 介面與 DynamoDB 存取模式
系列文
奇幻塔防開發實錄:用 Claude 打造一款有靈魂的塔防遊戲 共 14 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言