
Day 03 埋過一個伏筆:規劃階段的傷害公式,和後來實際寫進程式的方式有一點不同。
實作時,Claude Code 另外用了變數記錄「這次擋下了多少」。當時看起來只是寫法上的差異,我沒有特別去追問。
直到 Phase 1 開始補測失敗路徑,戰鬥日誌出現了一個不可能的結果:
魔王攻擊 16,護甲擋下 2,你失去 14 生命。
問題是,那一場戰鬥裡玩家的護甲從頭到尾都是 0。
後來我請 Claude Code 往回追查,才找到原本用來計算「護甲擋下多少」的方式,在血量被 max(0, ...) 夾限時,會把溢殺傷害算成護甲吸收量。
魔王攻擊玩家時,流程很單純:
問題是,「護甲擋下多少」這個數字當時並沒有直接保存下來。
當時程式可以直接取得的數字有兩個:
所以 Claude Code 當時用了這樣的方式:
魔王打出 value 點傷害,玩家實際少了 lost 點生命,那麼兩者的差額,就當成護甲吸收的傷害。
var lost := hp_before - Global.player_hp
var blocked := value - lost
例如魔王打 16 點,玩家掉 11 點血:
16 - 11 = 5
看起來就是護甲擋了 5 點。
這個寫法當時也通過了前面的測試。
貪婪出牌的勝利路徑、第 4 回合打贏,以及純防禦流撐到第 20 回合才輸,都沒有出現異常。
所以當時沒有理由只看這段公式,就直接判定它有問題。
Phase 1 補測失敗路徑時,我要求 Claude Code 加入一個很單純的測試:
完全不出牌,一路結束回合,讓玩家承受攻擊直到倒下。
這時候戰鬥日誌出現:
魔王攻擊 16,護甲擋下 2,你失去 14 生命。
但這場測試裡,玩家沒有使用任何防禦卡,護甲值一直都是 0。
也就是:
魔王攻擊:16
護甲:0
實際失血:14
日誌卻算出:
護甲擋下:2
這時我追問 Claude Code:
為什麼這個結果會出現?
因為程式沒有報錯,戰鬥也正常結束,問題只出現在日誌數字上。
接下來需要確認的不是「這段程式碼看起來像不像有問題」,而是這個 2 到底是從哪裡算出來的。
max(0, ...) 改變了「實際失血」代表的意思Claude Code 往回追查後,找到問題出在 Global.take_damage() 裡的這一行:
hp = max(hp, 0)
它的作用是讓玩家 HP 不會低於 0。
這本身沒有問題。
問題在於,前面的 blocked 計算把「實際失血」當成了「這次傷害真正造成的全部傷害」。
這個前提在血量還沒有歸零時成立。
但如果玩家只剩 14 HP,魔王打出 16 點:
原本 HP = 14
受到傷害 = 16
如果沒有夾限,HP 會變成:
14 - 16 = -2
但程式會執行:
hp = max(hp, 0)
最後變成:
HP = 0
我要求 Claude Code 把這個情境單獨重建出來:
var armor: int = 0
var hp: int = 14
var value: int = 16
var hp_before: int = hp
var armor_before: int = armor
if armor >= value:
armor -= value
else:
var remaining: int = value - armor
armor = 0
hp -= remaining
hp = max(hp, 0)
測試結果:
armor = 0
hp = 0
接著套用原本的公式:
lost = 14 - 0 = 14
blocked = 16 - 14 = 2
於是就得到:
護甲擋下 2
但實際上:
護甲從 0 → 0
根本沒有擋下任何傷害。
這 2 點其實是溢殺傷害。
玩家原本只有 14 HP,卻受到 16 點傷害,多出來的 2 點因為 HP 被限制在 0,沒有出現在「實際失血」裡。
因此用:
傷害 - 實際失血
去反推護甲吸收量時,這 2 點就被算進去了。
確認這個原因後,我請 Claude Code 再做一組對照測試。
這次設定:
護甲:5
HP:30
魔王攻擊:16
這一擊不會讓 HP 低於 0。
結算後:
armor = 0
hp = 19
原本的公式:
lost = 30 - 19 = 11
blocked = 16 - 11 = 5
實際護甲變化:
armor_before - armor_after
= 5 - 0
= 5
兩種方式得到的結果相同:
原本的公式:5
直接計算護甲變化:5
我把兩組測試結果放在一起比較後,才確認原本的公式並不是每次都算錯。
只要 HP 沒有觸發:
max(hp, 0)
原本的公式就能得到正確答案。
真正有問題的是這個邊界:
受到的傷害 > 剩餘 HP
一旦超過剩餘 HP,部分傷害就不會反映在「實際失血」裡。
這也解釋了為什麼前面的勝利路徑和純防禦測試沒有發現它,而補測「完全不出牌、一路受到傷害直到倒下」時,問題開始穩定出現。
確認原因後,我請 Claude Code 修改計算方式。
原本:
var hp_before: int = Global.player_hp
Global.take_damage(value)
var lost: int = hp_before - Global.player_hp
var blocked: int = value - lost
改成:
var hp_before: int = Global.player_hp
var armor_before: int = Global.player_armor
Global.take_damage(value)
var lost: int = hp_before - Global.player_hp
var blocked: int = armor_before - Global.player_armor
這樣 blocked 的來源就很直接:
護甲原本是多少
↓
受到傷害
↓
護甲現在是多少
↓
兩者差值 = 實際消耗的護甲
我再要求 Claude Code 用剛才的失敗情境重新驗證:
armor_before = 0
armor_after = 0
blocked = 0 - 0 = 0
結果就和遊戲狀態一致。
護甲 5 的情境也重新驗證:
armor_before = 5
armor_after = 0
blocked = 5 - 0 = 5
兩種情境都得到預期結果。
這次的修法並不複雜,關鍵是先確認「這個數字到底應該從哪裡取得」。
這個 bug 是在提交前的測試階段被抓到並修掉的,玩家實際上沒有看過那句錯誤的戰鬥日誌。
所以這次事件本身沒有造成玩家端的影響。
不過,我會把它留下來,是因為這次問題不是程式直接報錯,而是某個數字在特定條件下失去原本代表的意思。
這類情況如果只測一般流程,不一定會出現。
這次是護甲計算。
如果其他地方也有類似的寫法,而輸出值中間又經過 max、min 或 clamp,同樣的推理就可能在邊界條件下失效。
所以這次留下來的判斷方式比較簡單:
如果要知道某個狀態實際改變了多少,優先直接量那個狀態的前後差值。
這次能抓到問題,直接原因是 Phase 1 後來補了一條失敗路徑。
如果測試只包含正常戰鬥,這個情況不一定會出現。
所以確認 bug 之後,我也請 Claude Code 補上幾個邊界情境:
HP 還很多,受到傷害
↓
正常扣血
HP 剛好等於受到的傷害
↓
HP 變成 0
HP 小於受到的傷害
↓
觸發 max(0, hp)
↓
檢查溢殺是否被錯誤計算
其中最後一種,就是這次真正出問題的情境。
像 max()、min()、clamp() 這類會限制輸出範圍的程式碼,之後測試時就值得特別注意邊界。
因為一般情況下得到正確結果,不代表碰到邊界時,原本的推理仍然成立。
這次的修正本身不複雜,比較重要的是確認問題為什麼只在某些情況出現。
最後留下兩個測試方向。
第一,如果要知道某個狀態改變了多少,優先直接量那個狀態的前後差值。
想知道護甲擋了多少,就比較:
armor_before
和:
armor_after
不要用其他數字的差額去猜。
第二,測試不能只有正常路徑,也要刻意放進邊界情境。
這次的邊界就是:
受到的傷害 > 剩餘 HP
如果沒有這個測試,原本的公式在其他情況下都能得到正確答案,問題就可能繼續留在程式裡。
這個 bug 最後沒有上線,但它讓我多了一個之後檢查程式碼時會注意的條件:
看到「前後差值反推中間值」時,要先確認中間過程有沒有經過 max、min 或 clamp。
Day 23:先量化,再決定要不要改。怎麼用模擬取代「我覺得這張卡有點強」。