當『答錯的』其實聽對了
一個小孩聽到另一個符號例字開頭的「ㄅㄟˋ」,選了 ㄅ——結果配對遊戲說他沒配對成功。這與其說是修 bug,不如說是把一個產品原則變得真的可以被檢查:遊戲絕對不能懲罰答對的耳朵。
背景
bopomofokids.com 有一部分是靠配對遊戲教注音——
一張符號卡配一張會念出聲的例字卡,小孩靠耳朵跟眼睛把它們配起來。ㄟ 的例字是
「貝殼」(bèiké)。它逐字的注音拆解是 ㄅㄟˋ ㄎㄜˊ——這句話值得細看,因為 ㄟ
自己的例字,第一個音節就是 ㄅㄟˋ,而這個音節裡含有 ㄅ 這個符號。
一個真的小孩發現的 bug
有使用者直接回報了這件事:某一輪遊戲裡,ㄅ 跟「貝殼」這對(屬於 ㄟ)剛好同時 出現。小孩聽到「ㄅㄟˋ」,正確聽出了開頭的 ㄅ 音,選了 ㄅ 卡——結果遊戲告訴他 沒有配對成功。
小孩沒有錯。他的聽力判斷完全沒問題。有問題的是遊戲本身,而且這不是渲染出錯或 差一個索引值那種 bug——是設計上的漏洞:排題邏輯完全不知道 ㄅ 自己的音,實際上 就聽得到地藏在 ㄟ 的例字裡,所以沒有任何機制阻止這兩者同時出現在同一輪—— 出現了,一個發音完全正確的答案就會被判成錯的。
修法:一個子字串檢查,套用成兩種不同形狀
資料裡本來就有檢查這件事所需的一切——wordZhuyin 儲存了每個例字逐字的注音
拆解(貝殼是 ['ㄅㄟˋ', 'ㄎㄜˊ'])。防線就是一個很小的函式:
export function variantContainsSymbol(variant: WordVariant, symbol: string): boolean {
return variant.wordZhuyin.some((syllable) => syllable.includes(symbol));
}
src/utils/wordVariant.ts
兩個遊戲模式的差別,在於這個檢查該往哪個方向套用——因為它們對玩家揭露的資訊 本來就不一樣。
MatchMode 每一對兩邊都對稱地又顯示又念出聲——小孩兩張卡都看得到、也都聽
得到。所以碰撞可能發生在任何一個方向:新抽到的符號自己的音,可能藏在已經放進
這輪的某一對的例字裡;或者已經放進這輪的某個符號的音,可能藏在新符號自己的
例字裡。buildRound 在把候選符號加進這一輪之前,只要它跟已選的任何一對任一
方向相剋,就直接排除。
QuizMode 只有目標例字會被念出來——當成錯誤選項的那些符號,本身從來不會
被當成題目的一部分念出聲。所以這裡只需要檢查一個方向:generateQuestion 會
把「自己的符號恰好出現在目標例字唸出的內容裡」的錯誤選項濾掉。不需要檢查反過
來的方向,因為那些錯誤選項自己的音,本來就從來不是小孩實際聽到的內容。
為什麼這個比一般的邏輯 bug 更嚴重
大部分 bug 頂多讓使用者卡一下、困惑一下。這個 bug 的內容,是在一個整個存在 目的就是教小孩正確聽辨這些音的 App 裡,告訴一個小孩:你聽對了,但這是錯的。 對一個剛開始學著相信自己耳朵去聽注音的 4 到 6 歲小孩來說,這比當機還糟—— 當機至少不會教錯東西。
這次學到什麼
排題邏輯跟測驗產生邏輯都完全照原本寫的在跑。少的是一條沒人寫下來的規則: 一個項目唸出來的內容,不能跟另一個項目的身分在音韻上重疊到讓一個正確答案看 起來像答錯。一條從沒被講出來的規則,code review 抓不到。
wordZhuyin 本來就是每個例字注音拆解的原始真相——跟渲染注音標注用的是同一份
資料。把排除檢查建成對這個欄位的子字串比對,代表不用另開新的資料來源,也不會
有「檢查邏輯」跟「畫面上實際顯示、唸出來的內容」不同步的風險。
兩個模式、一條不變量、兩種不同的過濾方式——因為兩個模式對玩家揭露的資訊不一樣。 硬要為了「一致性」做出一模一樣的實作,結果只會是某個模式濾太多、另一個濾太少。 該保持一致的是規則本身;執行規則的程式碼,在玩家實際感知不同的時候,本來就 該不一樣。