
系列:奇幻塔防開發實錄:用 Claude 打造一款有靈魂的塔防遊戲
今日工具:Claude Code + git worktree
今日進度:REST 契約定案、DynamoDB 倉儲取代記憶體版、curl走完「建檔 → 對話 → 抉擇 → 戰鬥」
Day 12 把劇情與戰鬥的 handler 都合進 main 了,但存檔還躺在記憶體裡,重啟就消失。今天做三件事:把 REST 契約正式寫成文件(前端 Day 18、22 會照這張表打)、決定 DynamoDB 的單表長相、把 save.Repository 換成 DynamoDB 實作。
後兩件事我又拿來做了一次 worktree 平行實驗——Go 倉儲 vs Terraform 改表,兩者完全不碰同一個檔案。數據留到明天週記一起看,今天先把 API 說完。
加上 Day 8 的三支唯讀 API,總共八支。存檔與劇情的五支如下(路徑省略 /api/v1 前綴):
| Method | Path | Body | 回應 |
|---|---|---|---|
| POST | /saves |
{"playerName"} |
201 {"save"} |
| GET | /saves/{id} |
{"save"} |
|
| GET | /saves/{id}/story |
{"node","state"} |
|
| POST | /saves/{id}/story/choose |
{"choiceIndex"}(0-based) |
同上 |
| POST | /saves/{id}/battles |
戰鬥結果(levelId、won、livesLeft、wavesCleared、goldLeft、durationMs) | 同上 |
表裡的 save 只有存檔本身;Day 27 補上認證後 POST /saves 會多回一個 token,今天的契約還不含它。
兩個刻意的決定,都是為了讓前端簡單:
choose。前端的 advance() 就是 choose(0):usecase 看到目前節點是 dialogue/route 就忽略 choiceIndex 直接推進。少一支 API,前端只要記一個「往下走」的動作,不用先判斷節點型別再決定打哪支。{node, state}。前端 store 只需要一個 reducer,不管是對話、選擇還是戰鬥回報,收到回應就整包覆蓋。錯誤一律 {"error":{"code","message"}},錯誤碼在 Day 12 的 mapError 裡定好了。今天多加的是一支別名:GET /api/v1/healthz。Day 8 的健康檢查掛在 /healthz,但 Day 16 之後 CloudFront 只會把 /api/* 轉給後端,/healthz 走不到 CDN,部署後的煙霧測試會打到前端的 index.html 然後「成功」。現在就讓兩條路徑都回 {"status":"ok"}。
這張表存成 docs/api.md。它是這週最重要的產出——不是程式碼,是合約。我先寫表、再請 Claude Code 依表補齊 handler 與文件,而不是反過來讓它「看程式碼產生 API 文件」。理由是後者只會得到一份「程式碼現在長怎樣」的描述,前者才是「程式碼應該長怎樣」的承諾;Day 18 前端 session 讀的是這份承諾,不是 Go 原始碼。Claude 在這一步的角色是「對照者」:我請它逐列比對 docs/api.md 與 router.go、handlers_*.go,找出不一致——它抓到 GET /saves/{id} 回傳的 save 物件裡漏了 updatedAt 的 JSON tag,這種事我自己讀十遍也不會發現。
昨天以前我腦裡的資料層還是三張關聯式表:存檔、戰鬥紀錄、劇情事件。DynamoDB 沒有 join,這三張表的關係要用 key 表達:同一份存檔的所有東西共用一個 PK,靠 SK 前綴分型別。

三個決定值得說:
State 的四個欄位攤平在 item 頂層(node / flags / ember / cleared),不是塞成一個巢狀 map。理由很硬:GSI 的 key 不能取巢狀屬性,而我要 GSI1PK = NODE#<node> 來查「有多少存檔停在哪個節點」(Day 25 QA 會用)。cleared 用 L(List)不用 SS(String Set)。SS 不保順序、而且不允許空集合——cleared 既要保順序,開局又永遠是空的。expiresAt 只寫在稽核 item 上。存檔本體不帶這個屬性,所以「稽核 90 天過期、存檔永不過期」只需要一個 TTL 設定,不需要兩張表或兩套清理排程。戰鬥 item 與劇情 item 就是舊 battle_results、story_events 的替身,Day 23 調平衡與 Day 25 QA 都靠它們。
最大的差別在於:DynamoDB 沒有 schema migration。沒有 CREATE TABLE、沒有 Up/Down、部署時不用跑 job,代價是相容性整包移到程式碼身上——新增一個屬性時,讀舊 item 的路徑必須自己容忍它不存在。這不是「比較簡單」,是「把複雜度從資料庫搬到 fromItem」。
item.go 從 save_repo.go 切出來prompt 只講四件事:用 aws-sdk-go-v2 實作 save.Repository、flags 存成 M、cleared 存成 L、找不到就回 save.ErrNotFound。Claude 第一版寫出 dynamodb.New(session) 這種 v1 風格的建構方式——v2 是 config.LoadDefaultConfig(ctx) + dynamodb.NewFromConfig(cfg)。這是這週第三次遇到「Claude 的訓練資料偏舊版 API」:chi 的 middleware 套件路徑、aws-sdk-go 的大版本,都要我拿目前版本的文件去對。AI 生成第三方套件的程式碼時,版本是你的責任。
真正屬於我的設計決定是把檔案切成兩個。item.go 只放純轉換函式 toItem / fromItem,不接觸任何 AWS client;save_repo.go 只剩「呼叫 SDK + 翻譯錯誤」。好處立刻兌現:item_test.go 在沒有 AWS 帳號、沒有 Docker 的機器上就能跑 round-trip 測試。
save_repo.go 剩下的邏輯少到可以逐句唸:GetItem 帶 ConsistentRead(choose 是「讀→算→寫」,最終一致性讀會讓玩家連點兩次看到舊節點),out.Item == nil 就回 save.ErrNotFound 讓 mapError 翻成 404;Create 帶 attribute_not_exists(PK);Update 帶 attribute_exists(PK) AND version = :expected。
那個 version 是今天唯一「不是抄關聯式思維」的東西:Lambda 會同時起數十個執行環境,同一份存檔的兩個請求可能真的並行。條件失敗時倉儲另外 Get 一次分辨「存檔不存在」還是「版本衝突」,衝突就回 save.ErrConflict——倉儲只誠實回報,重試策略屬於 usecase:applyMutation 收到 ErrConflict 就重讀、重跑一次 mutate、再寫一次,只重試一次,不新增錯誤碼、對前端完全不可見。

$ curl -s -X POST .../saves/$ID/battles -d '{"levelId":"ashfield","won":true,"livesLeft":17,"wavesCleared":5,"goldLeft":40,"durationMs":183000}'
{"node":{"id":"n_after1","speaker":"米菈",...},"state":{"node":"n_after1","flags":{"blaze":1},"ember":1,"cleared":["ashfield"]}}
這幾個請求走完 Day 4 劇情圖的第一段:序章對話、糧倉抉擇、灰原邊境之戰、米菈的第一句話。重啟服務再 GET /saves/$ID,狀態還在——存檔終於落地了。flags.blaze 與 ember 也如預期記下了「燒了它」的代價與報酬,Day 4 設計的「劇情選擇給薪火、薪火在戰鬥裡用」這條耦合線,後端這一半今天接通了。
main.go 只改一段:SAVES_TABLE 為空就用 Day 10 的記憶體版,否則 platform.NewDynamoClient → dynamo.NewSaveRepo(ddb, table)。usecase 層一行沒動,這就是 Day 8 畫依賴線、Day 10 先定介面的回報。
這一輪抓到一個 bug:選擇之後 state.cleared 回的是 null 而不是 []。原因是 Day 10 的 clone 用 append([]string(nil), s.Cleared...),空切片經過 append 還是 nil,encoding/json 就輸出 null,前端一 .includes() 就炸。改成 make + copy 解決。換成 DynamoDB 之後這是同一個坑——fromItem 讀回空的 L 屬性時,用 var cleared []string 再 append 一樣是 nil,所以那裡也一律明確 make。flags 的 nil map 同理。這種「後端覺得沒差、前端會炸」的細節只有 curl 得出來,單元測試看不到。Day 21 週記還會再列一整排。
給想跟著做的讀者:什麼都不用裝。不設 SAVES_TABLE 就是記憶體倉儲,make api 直接跑,沒有資料庫容器、不需要 AWS 帳號。上面這串請求整理進 Makefile 的 smoke target(scripts/contract-test.sh),每次改 handler 都跑一次。
Terraform 那半是今天上午的 worktree 實驗:session A 在 backend/ 寫 DynamoDB 倉儲,session B 在 infra/terraform 改表,兩邊零交集,39 分鐘完成序列估計 70 分鐘的工作。下午我刻意做了一個反例——「router 加 middleware 鏈重構」與「auth 空殼 middleware」兩個都要動 router.go 的任務——結果比序列還慢,衝突三次,其中一次合併後 middleware.Timeout 被掛了兩次,測試沒抓到。完整數據明天週記公開。
Day 9 的表只有 PK/SK,今天補三樣東西:global_secondary_index "GSI1"(GSI1PK/GSI1SK,投影 ALL)、ttl { attribute_name = "expiresAt" }、以及 point_in_time_recovery。IAM 那條 policy 也要跟著加一行資源 "${aws_dynamodb_table.main.arn}/index/GSI1"——只寫表的 ARN,GetItem/PutItem 會過,但每一次 GSI1 的 Query 都會拿 AccessDeniedException,而記憶體倉儲與 CI 永遠測不出這件事。
然後 terraform plan 給了我這系列到目前為止最有意思的一行輸出:
Plan: 0 to add, 1 to change, 0 to destroy.
第一次看到 ~ 而不是 +。 前面九天所有的 plan 都是「從無到有」,今天是「同一個資源就地改」——資源總數還是 12,但那張表要多長出一個索引。~ 值得多看兩秒:它代表 Terraform 認為可以就地改;如果給的是 -/+,那是要把玩家存檔整張表刪掉重建,必須當場中止。一樣沒有 apply。
今天的主角其實是「契約」:一張表定下來,前端三週後照著打,後端現在照著做,中間靠 curl 對答案。真正的思維轉換不在 SDK,而在「三張表變成同一個 PK 底下的三種 item」,以及「沒有 migration 之後,相容性是我的責任」。cleared: null 那個坑提醒我:語言內的等價不等於協定上的等價,每一個跨邊界的型別都值得用真實請求打一次。明天週記結算這週三次 worktree 實驗。
docs/api.md(八支端點的契約表,含 /api/v1/healthz 別名)adapter/dynamo/{item,save_repo,events}.go、item_test.go 離線 round-trip、platform/dynamo.go
make smoke(記憶體模式,零外部依賴)GSI1 + TTL + PITR,plan 顯示 0 to add, 1 to change
Day 14:【週記】Day 8-13 回顧:worktree 平行開發真的有比較快嗎?數據公開與反思。