iT邦幫忙

2026 iThome 鐵人賽

DAY 21
0
Claude AI

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

Day 21:【週記】Day 15-20 回顧:前後端整合的第一場真實戰役(踩坑實錄)

  • 分享至 

  • xImage
  •  

claude_21

系列:奇幻塔防開發實錄:用 Claude 打造一款有靈魂的塔防遊戲
今日工具:Claude Code / curl / 瀏覽器 DevTools
今日進度:make dev 起來的第一個晚上,Vue 第一次真的呼叫 Go;六個整合坑的症狀、原因、修法,與 Claude 在每個坑裡的角色

前言

這一週的六篇文章各自都是「綠燈」:引擎測試過了、CI 綠了、劇情頁能走到結局、手機能點、bug 修好了。但那些綠燈有一半是前端打本機的假資料、後端被 curl 打的綠燈。週五晚上我把 make dev 起來(api :8080、web :5173),讓兩邊真的對話,一個小時內踩了六個坑。

值得先說清楚的是這一晚的成本:沒有 AWS_LAMBDA_FUNCTION_NAME 就起 net/http,沒有 SAVES_TABLE 就用記憶體倉儲——所以整晚沒有開任何一個 AWS 資源,也沒有開 Docker,make dev 之後兩個 process 就是全部。踩坑很貴,但踩坑的環境是免費的。

這篇週記不談成就,只談坑。

一、六個坑

# 症狀 原因 修法 Claude 的角色
1 首頁按「點燃初火」沒反應,Console:No 'Access-Control-Allow-Origin' header POST + JSON 會先送 OPTIONS preflight;Day 13 的 backend/internal/adapter/httpapi/router.go 沒有處理 OPTIONS,chi 回 404 加 middleware/cors.go:允許 WEB_ORIGIN、OPTIONS 回 204、無論命中與否都 Vary: Origin(完整程式 Day 22) 貼錯誤訊息後第一句就指出「這是 preflight 沒回 204,不是 header 沒加」,省下我去看 chi 文件
2 選第一個選項回 422 INVALID_INPUT,選第二個卻成功 前端 choiceIndex 從 0 起算;handlers_story.go 驗證範圍寫成 idx < 1 || idx > len(1 起算) 契約定 0 起算,後端改;docs/api.md 的表格加註「0-based」 我把 curl 指令與瀏覽器的 request 一起貼給它,它指出兩邊 off-by-one;但它在 Day 13 生後端、Day 18 生前端時各自都「合理」——契約沒寫的東西,AI 不會替你統一
3 戰敗後畫面閃一下又回到戰鬥,無限重開 n_b1.onLose 指回 n_b1;reportBattle 回來後 story.node 仍是同一個 battle 節點,StoryView 的 watch(immediate: true)立刻又把玩家導回 /battle/ashfield 輸了照樣回報(要寫一筆 SK = BATTLE#<時間戳> 的稽核 item),但 BattleView 不導頁,改顯示「灰潮吞沒了邊境」面板,按「再次點燃」用 :key 遞增重新掛載 它先提議「後端 onLose 改指向一個對白節點」,我否決:劇情資料不該為前端的導頁邏輯改。第二個提案才是現在的做法
4 用縮短版劇情打到終章前,畫面一片黑:沒有對白、沒有選項、也沒有導頁 後端回的是 n_final_gate,type: "route"(Day 4 定的、Day 10 實作的,依 flags 分流);前端 StoryView 只處理 dialogue / choice / battle / ending,route 被 v-if 鏈默默略過 route 節點視同對白自動 advance()(= choose(0)),後端依 flags 回下一個節點;types/story.ts 的 StoryNode union 補上 route,vue-tsc 從此會擋 我貼上 Network 面板的回應,它先問「StoryNode 的 union 有沒有 route?」——沒有,因為 Day 18 我手寫型別時漏了。型別是契約的一部分,漏一種節點就是漏一段契約
5 story.saveId 是 undefined,但 Network 面板明明有回 id backend/internal/adapter/httpapi/handlers_save.go 的回應 DTO 沒加 json tag,Go 回的是 "ID"、"PlayerName",前端讀 save.id 所有 DTO struct 補 json:"id" 等 camelCase tag,集中到 dto.go/respond.go;ci.yml 跑 Day 13 的 make smoke 並加 jq -e '.save.id' 它生 Go 時 handlers_story.go 有加 tag、handlers_save.go 沒加——一致性要靠 CI 擋,不能靠「這次有加」
6 Day 29 要用的「每日場次」統計,今天先試跑:深夜到清晨打的那幾場全被算進「前一天」 createdAt 存 UTC 的 RFC3339Nano,這沒錯;錯的是 GSI1PK 我寫成 DAY# + UTC 日期。台北 07:30 的那一場,UTC 還是前一天 23:30 dynamo/events.go 改成 createdAt.In(taipei).Format("2006-01-02"),並在 binary 裡 import _ "time/tzdata" 內嵌一份時區資料,免得執行環境找不到 Asia/Taipei 它一開始建議「查詢時再換算回台北」——那是 SQL 的思路。我要它說明 DynamoDB 怎麼做,它自己推翻了自己

坑 #6 值得單獨記一句,因為它是這次改架構後唯一一個變得更嚴重的坑:SQL 的 created_at 存錯時區還能事後 AT TIME ZONE 重算,DynamoDB 的 partition key 一旦寫進去就不能改分桶——你只能整批讀出來重寫。時區決定必須在寫入的那一刻做對,這是資料庫換掉之後我才真正學會的事。

修復順序也值得記:先修 #1(不修什麼都看不到),再修 #5(看到了但讀不到),然後才是邏輯類的 #2、#4、#3,#6 最後。先讓資料流通、再談對錯——這個順序讓 Claude 的每一次回答都有真實的 request 與 response 可以看,而不是憑我的轉述猜。

claude_21_diagram_01

那張圖的最後一行是我當晚沒想通、隔天才想通的事:CORS middleware 只服務本機的 5173 → 8080。正式環境同源,Terraform 不會設 WEB_ORIGIN,這段 middleware 連掛都不會掛上去。我花在它上面的時間,一分鐘都不會出現在線上。

六個坑裡,有四個(#2、#4、#5、#6)本質上是契約問題:兩邊各自合理,合在一起不合理。Claude Code 在單一 session 裡是非常一致的工程師,但它沒有跨 session 的記憶——Day 13 的 session 不知道 Day 18 的 session 決定了什麼。CLAUDE.md 能記錄慣例,記錄不了「這個欄位從 0 起算」這種細節,這種細節要寫進可執行的東西裡。

claude_21_diagram_02

坑 #5 還有一個新架構特有的加碼:同一個欄位現在有兩套名字對應。給前端的形狀由 dto.go 的 json:"playerName" 決定,寫進 DynamoDB 的形狀由 dynamo/item.go 裡手寫的 "playerName" 字串決定,兩邊沒有任何編譯器幫你對齊。漏掉 json tag 的症狀是前端讀到 undefined,寫錯 item 的屬性名則是寫得進去、讀不回來——fromItem 少一個欄位就靜靜地變成零值,要等到重新載入存檔才發現。

二、坑 #3 值得多說一點

「無限重開」是這週唯一一個讓我思考「該改資料還是改程式」的坑。Claude 的第一個提案在技術上完全可行:在 graph.json 加一個 n_retry1 對白節點,onLose 指過去,對白說「灰潮退了,但還會再來」,next 再回 n_b1,連對白都寫好了。

我否決的理由是:這讓劇情作者為了前端的路由行為加節點。Day 4 定下的原則是「資料驅動劇情」,資料應該只描述故事;「輸了怎麼重來」是遊戲流程,屬於前端。改成 BattleView 顯示結果面板之後,onLose 指回自己反而是最誠實的資料——它就是在說「這關要再打一次」。

這種取捨 AI 做不了,因為它需要知道「原則是誰定的、為了什麼」。

三、本週數據

以下都是 Day 15–20 這六天的數字,不含今天寫週記本身:

項目 數值 備註
專注工時 1,120 分鐘(6 天) 用 Day 1 的 day-log 模板記錄;Day 19 實機測試佔 210 分鐘
Claude Code session 14 含 Day 20 的 3 個 subagent
新增測試 vitest 12、Go 3 Day 20 的 3 個是本週最有價值的測試
整合坑 6 個,平均修復 23 分鐘 最長是 #4(55 分鐘),因為要同時改前後端
/ultracode 使用 1 次,41 分鐘 /ultracode 是我自訂的 slash command,不是官方功能;它擋下一次錯誤修改(MAX_DT 0.1 → 0.05)
部署次數 api 9、web 14 Day 16 之後全部自動
目前可玩範圍 首頁 → 序章 → 灰原邊境 → 結局(縮短版劇情) 完整六關要等 Day 23 平衡

比 Day 14 那週的 790 分鐘多了 330 分鐘,其中 210 分鐘是 Day 19 的實機測試——這是前端週的固定成本,沒有工具能省。

四、三個教訓

契約要可執行。 表格寫在 docs/api.md 裡,兩個 session 還是各解釋各的。Day 13 的 make smoke 已經會起 API 走一遍 create → story → choose → battles,但它只看 HTTP 狀態碼;Day 22 會把它補成真正的 contract test,用 jq 斷言每個欄位存在且型別正確。它就是一支 bash:先 go build -o "$API_BIN" ./cmd/api,再 ( cd backend && GAMEDATA_DIR=../data PORT=18080 exec "$API_BIN" ) &。刻意不用 go run——go run 會另外 fork 一支編譯好的二進位,kill 掉 go run 殺不到那支子行程,API 會變成孤兒繼續佔住 18080,下一輪就沉默地打到上一輪的舊 server;exec 讓拿到的 $API_PID 就是 server 本身。不設 SAVES_TABLE,倉儲自動 fallback 到記憶體版,連資料庫都不用起。然後每個 curl 接一個 jq -e——.save.id | test("^[0-9a-f-]{36}$")、.node.type == "choice"、.state.cleared | type == "array"、.state.flags.vigil | type == "number"。跑不過就紅,不需要任何框架。Claude 寫的時候提議用 Playwright 的 request fixture,我拒絕:契約測試越笨越好,越笨越不會跟著實作一起錯。

AI 在不同 session 給不同答案,是訊號不是雜訊。 坑 #2 是這樣被確認的:我把「choiceIndex 從幾起算」分別丟給前端與後端的 session,一個說 0、一個說 1,兩個都引用了自己那邊的程式碼當證據。之後遇到跨端的設計,我會刻意在兩個 session 問同一個問題,答案不同就代表契約沒寫清楚。

原則要寫進 CLAUDE.md,而且要寫「為什麼」。 「劇情資料只描述故事」這句話如果早在 Day 6 就寫進去,坑 #3 的第一個提案可能就不會出現。今天補上了。

小結

整合週的坑沒有一個是「程式寫錯」,全部是「兩邊各自正確」。這也解釋了為什麼 Day 15–20 的六個綠燈擋不住它們:單元測試驗證的是各自的正確性,契約需要另一種測試。

下週的主題就是「讓兩邊真的成為一邊」:Day 22 把 CORS、client 重試、contract test 補齊,順便讓 CloudFront 把 /api/* 轉給 API Gateway,做出正式環境的同源;Day 23 用模擬器把六關數值調到能打完;Day 24 讓線上出事時會叫——雖然已經沒有「伺服器」可以出事了。

今日產出

  • [x] 六個整合坑修復(含 route 節點自動前進、前端戰敗面板、DTO json tag、DAY# 改台北日期)
  • [x] CLAUDE.md 補「劇情資料只描述故事」與「choiceIndex 0-based」
  • [x] 本週數據表與 day-log 歸檔

明日預告

Day 22:前後端聯軍:API 串接與跨域戰爭,Vue 呼叫 Go 服務的實戰筆記——CORS middleware 完整版、client 的錯誤與重試、.env 分層,以及正式環境用同源把跨域整個消滅掉。


上一篇
Day 20:自訂 Workflow 實戰:用 `ultracode` 觸發 Claude Code 除錯一個難纏的碰撞判定 bug
系列文
奇幻塔防開發實錄:用 Claude 打造一款有靈魂的塔防遊戲 共 21 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言