到昨天為止,我的勇者每次攻擊都造成固定 17 點傷害。打四下、小怪死掉,每一場都一模一樣。今天要加入亂數,而這也讓我第一次遇到「隨機」和「可測試」的衝突。
Math.random() 只會做一件事console.log(Math.random()); // 0.696119213070244
console.log(Math.random()); // 0.354790515352424
它回傳一個 0(含)到 1(不含)之間的小數,沒有參數、沒有選項。所有的亂數需求都得從這一個工具加工出來。
加工分兩步:
Math.random() // 0 ~ 0.9999...
Math.random() * 5 // 0 ~ 4.9999... ← 乘上範圍大小
Math.floor(...) // 0, 1, 2, 3, 4 ← 無條件捨去小數
Math.floor() 是無條件捨去,不是四捨五入:Math.floor(4.99) 是 4。
max - min + 1?萬用公式長這樣:
function randomInt(min, max) {
return Math.floor(Math.random() * (max - min + 1)) + min;
}
我第一個反應是:為什麼不直接寫 Math.random() * max + min 就好?
拿骰子驗算:randomInt(1, 6) 用我的寫法是 random * 6 + 1,Math.floor 之後是 1~6——看起來完全正確。
但換成攻擊力浮動 randomInt(-5, 5) 就露餡了:
// 我的寫法
Math.random() * 5 + (-5) // 0~4.99 再減 5 → -5 ~ -0.01
Math.floor(...) // -5,-4,-3,-2,-1 ← 只有 5 個值,正數全部消失
// 正確寫法
Math.random() * (5 - (-5) + 1) + (-5) // = random * 11 - 5
Math.floor(...) // -5 ~ 5 ← 11 個值
關鍵是:要乘的是「有幾個可能的值」,不是最大值。
| 範圍 | 有幾個值 | 算式 |
|---|---|---|
| 1~6 | 6 | 6 - 1 + 1 |
| -5~5 | 11 | 5 - (-5) + 1 |
| 0~4 | 5 | 4 - 0 + 1 |
max - min + 1 就是「數量」的公式,那個 +1 是因為兩端都要算進去。這跟 Day 5 的「陣列有 5 個元素、最後索引卻是 4」是同一家的計數問題,只是方向相反:那邊是數量換索引要減 1,這邊是起迄換數量要加 1。
而我原本的寫法只在 min = 1 時碰巧正確——這是最危險的 bug 類型:在你最常測的情況下是對的。
寫完公式我很自然地想問:這種需求這麼常見,語言難道沒有現成的?
查了之後答案是沒有。JS 內建就只有 Math.random() 這一個。
| 語言 | 直接取 1~6 的整數 |
|---|---|
| Python | random.randint(1, 6) |
| Java | rand.nextInt(1, 7) |
| C# | rand.Next(1, 7) |
| JavaScript | 沒有,要自己組 |
這是 JS 的設計取向:只提供最小的原料,其他讓開發者自己做。語言核心因此很小,代價是這種天天用到的東西每個人都得重寫一次。
嚴格說還有另一個內建的 crypto.getRandomValues(),它直接給整數,而且隨機品質更高(是給密碼、驗證碼那類場合用的)。但它只能給「0~255」「0~65535」這種一整個位元組能裝的範圍,想縮成 1~6 還是得自己算,而且用取餘數去縮會產生偏差(256 除以 6 除不盡,前面幾個數字會多出現一點)。做對反而比 Math.random() 版更麻煩。
所以結論是:遊戲用 Math.random() 就好,crypto 是給安全需求的,不是給方便的。
而這也解釋了為什麼幾乎每個 JS 專案裡都有一個 utils.js:裡面裝的就是這種「語言沒有但天天要用」的小工具。我今天寫的 randomInt 之後整個遊戲都會用到——骰傷害、骰暴擊、骰道具掉落、隨機選敵人。
光看程式碼很難確定公式對不對,所以我跑了 1000 次骰子,統計每個數字出現幾次:
const counts = { 1: 0, 2: 0, 3: 0, 4: 0, 5: 0, 6: 0 };
for (let i = 0; i < 1000; i++) {
const n = randomInt(1, 6);
counts[n] = counts[n] + 1;
}
console.log(counts);
結果:
{1: 182, 2: 180, 3: 157, 4: 160, 5: 146, 6: 175}

我檢查三件事:
+1 有效第三點特別值得說:亂數不會「剛好平均」。146 和 182 差了 36 次,看起來不太平均,但這才是正常的。如果六個數字真的都是 166 或 167,反而該懷疑它根本不隨機。
+1為了證明 +1 真的有差,我寫了一個壞掉版:
function randomIntBroken(min, max) {
return Math.floor(Math.random() * (max - min)) + min; // 少了 +1
}
跑之前我先推導了一次:Math.random() 最大接近 1,所以 0.9999 * (6-1) = 4.9995,Math.floor 之後是 4,再 +1 得到 5——最大只能到 5,6 永遠不會出現。
實際跑 1000 次:
{1: 197, 2: 223, 3: 197, 4: 196, 5: 187, 6: 0}
推導完全命中。但實際數字還讓我看到更嚴重的第二層影響:
| 1 | 2 | 3 | 4 | 5 | 6 | |
|---|---|---|---|---|---|---|
| 正確版 | 182 | 180 | 157 | 160 | 146 | 175 |
| 少了 +1 | 197 | 223 | 197 | 196 | 187 | 0 |

那消失的 1/6 不會憑空不見,它們全部擠進剩下的五個值裡——原本每個約 167 次,現在變成約 200 次。
換句話說,這個 bug 的影響是雙重的:一個值消失,而且其他值的出現機率全部被拉高 20%。
如果這是遊戲的傷害骰,玩家不只永遠打不出最高傷害,連中等傷害出現的頻率都偏高了。整個遊戲的手感被改變,但沒有任何一次攻擊看起來是壞的——又一個安靜失敗,而且這次只有統計才看得出來。
我為了看清楚,在 randomInt 裡加了一行 console.log。結果那個函式被呼叫了 1000 次,主控台瞬間被灌了 1000 行。
這是 Day 7 那個無窮迴圈的小型版:在高頻呼叫的函式裡 log,會瞬間淹沒主控台。驗證完就該拿掉,不然之後打一場戰鬥主控台會吵到看不到重點。
function calcDamage(atk, def) {
const base = Math.max(1, atk - def);
const variance = randomInt(-3, 3); // 浮動 ±3
return Math.max(1, base + variance); // 保底 1 點還是要留著
}
實際打一場:
造成 14 點傷害!
造成 16 點傷害!
造成 19 點傷害!
小怪 被擊敗了!

基礎 17、浮動 ±3,實際範圍是 14~20,三次結果都落在裡面。這場一樣打了四下,但過程不再一樣了。
注意外層那個 Math.max(1, ...) 還留著——Day 8 定的保底規則,在加了負數浮動之後更重要了。
寫完之後我下意識想的第一件事是:這樣還要怎麼測?
以前我可以說「按四下,小怪死掉」。現在同一組操作,每次結果都不同。
但我想了一下,發現自己一開始問錯了問題。我原本想的是「怎麼確保勇者一定能贏」——可是如果做出的遊戲 100% 保證勝利,那就沒意義了。不確定性是這個產品的特性,不是缺陷。為了好測而讓遊戲必勝,那是犧牲產品去遷就測試。
所以真正的問題不是「怎麼讓結果固定」,而是:結果不固定的東西,要怎麼驗證它是對的?
如果玩家回報「我打了 10 下都沒贏,是不是壞掉了」,我沒辦法靠重現來判斷——重現一次也是隨機的。我的做法會是裝監控,記錄這 10 下、100 下骰出的每一個數字,然後看有沒有哪個值從來沒出現過,也就是檢查會不會又是那個少了 +1 的錯誤。
這正是今天練習 3 做的事。當時只覺得是在「檢查公式」,回頭看才發現:那 1000 次統計本身就是一種測試方法——不驗證單次結果,而是驗證整體性質。
這種「必須永遠成立的性質」在工程上叫不變量(invariant)。這個遊戲目前的不變量有:
傷害永遠 >= 1
傷害落在 14 ~ 20 之間
血量不會變成負數
骰出的值都在指定範圍內
每一條都跟「這一次會骰到幾」無關,但每一條都必須永遠成立。
想到這裡我才意識到:我的工作有一項就是測 AI 聊天,而 AI 的回答完全不固定。
我們的做法是:給一些模板讓它有個參照,再定義一些禁止的話題,確認它有沒有講出關鍵字。
這跟今天做的事一模一樣——不去驗證「它到底回了什麼」,而是驗證「它有沒有違反規則」。模板是參照的錨,禁語清單是絕對不能違反的不變量。
一邊是六面骰,一邊是語言模型,兩者的隨機程度天差地遠,但測試的方法是同一套。我做了這麼久卻沒想過它們是同一件事,今天用一個骰子函式把它想通了。
不過「裝監控看分布」還有一個前提:樣本數要夠。
所以玩家說「我打 10 下都沒贏」,單憑這句話真的無法判斷。樣本太小的時候,正常的運氣和真的壞掉長得一模一樣。這也是為什麼處理不確定系統的 bug,不能靠「重現一次看看」,而要靠累積資料。
今天搞懂的事:
Math.random() 只回傳 0~1 的小數,所有亂數都從它加工Math.floor() 是無條件捨去,不是四捨五入Math.floor(Math.random() * (max - min + 1)) + min,要乘的是值的數量不是最大值+1 的後果是雙重的:最大值永遠不出現,其他值機率被拉高console.log 會瞬間灌爆主控台戰鬥終於有變化了。不過小怪到現在還是站著挨打——明天讓牠反擊,遊戲才會有真正的輸贏。