
系列:奇幻塔防開發實錄:用 Claude 打造一款有靈魂的塔防遊戲
今日工具:git worktree + Claude Code ×2
今日進度:兩個功能分支平行完成並合併,wall-clock 從估計 100 分鐘壓到 62 分鐘
Day 10 的狀態機與 Day 11 的模擬器都是「domain 層」的東西,今天要把它們接上 HTTP。手上正好有兩個彼此獨立的任務:
usecase/story 的 ReportBattle 加上「粗略反作弊」驗證(用 Day 11 學到的合理範圍),再開 POST /saves/{id}/battles。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 --worktreegit 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,新增 sentinelErrInvalidReport;在httpapi新增handlers_story.go的handleBattleReport。只在router.go加一行r.Post("/saves/{id}/battles", ...),不要動其他路由。 另一個 session 正在同時做劇情 handler。」
session B 的 prompt 同樣結構,最後一句一樣是「另一個 session 正在做 X,不要碰 Y」。這句話是今天最重要的 prompt 工程——它讓兩個 Claude 都知道自己不是唯一在動這個 repo 的人。
量測方法先講清楚,不然數據沒有意義。序列估計用「前兩天做同規模任務的實際時間」推估: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.md 與 domain/story,各自做了一次「理解現況」的推理。// 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 寫回倉儲。
// 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 會等很久。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 再拿來平行一次,也會故意做一個反例。
feat/battle-report:usecase/story 驗證邏輯、handlers_story.go 的 handleBattleReport
feat/story-api:handleStoryCurrent/handleStoryChoose、respond.go 的 mapError
main,go test ./... 全綠Makefile 的 PORT ?= 8080
Day 13:API 契約與單表設計:用 Claude Code 生成 RESTful 介面與 DynamoDB 存取模式。