
計分板最常見的 bug不是按鈕沒響應而是比分、開球方、讓局和撤銷各維護一份狀態某個分支忘記同步其中一項。臺球工具選擇只記錄“發生了什么”把“現在是什么狀態”全部重算出來。一、先把事實和結果分開以中式八球搶局制為例頁面需要展示兩名選手當前比分本局開球方是否已經達到搶局目標炸清、接清等特殊勝局次數撤銷上一局后的所有狀態。直覺實現會在點擊“選手 A 獲勝”時同時執行scoreA、切換開球方、更新特殊次數并判斷獲勝。這樣做的問題是撤銷時必須精確執行所有反操作新增一種開球規則又會影響多個分支。這個思路最初用于「零碎百寶箱」的臺球計分功能目標是在球房斷網時仍能完整記下一場比賽。項目只保存不可再分的事實frames:[{winner:A,type:normal,at:1720000000000},{winner:B,type:clear,at:1720000060000}]比分、勝者和開球方都是派生值functionscoreOf(match,side){consthandicapsideA?match.handicapA:match.handicapB;returnhandicapmatch.frames.filter(ff.winnerside).length;}functionmatchWinner(match){if(scoreOf(match,A)match.raceTo)returnA;if(scoreOf(match,B)match.raceTo)returnB;returnnull;}撤銷上一局因此只需frames.pop()隨后所有派生函數自然得到上一時刻的狀態。二、事件重放消除了“雙重真相”事件模型的核心并不是用了數組而是規定數組是唯一事實來源。如果同時持久化frames和scoreA二者遲早會不一致。frames 事件序列比分當前開球方比賽勝者特殊勝局統計結算詳情開球方也從歷史推導輪流開球已完成局數的奇偶決定當前開球方勝方開球取上一局贏家負方開球取上一局贏家的另一方。這些函數可以獨立測試。頁面組件不需要知道各種規則怎樣組合只負責渲染計算結果和追加事件。三、兩套玩法共用一個思想臺球工具同時支持中式八球搶局和九球追分。追分不是簡單的“多人比分加減”它有普勝、銀九、大金、小金、犯規等事件不同事件決定誰向誰轉移多少分還會影響下一局出桿順序。追分對局保存{players:[A,B,C],initialScore:100,values:{normal:5,silver:10,golden:20},firstOrder:[0,1,2],events:[{type:normal,winner:0,payer:1,at:1720000000000},{type:golden,winner:2,payer:null,at:1720000060000}]}payernull表示其余所有人都向贏家支付。重放函數從每人的初始分開始逐條轉移for(consteventofmatch.events){constvaluematch.values[event.type];if(event.payernull){for(leti0;iplayers.length;i){if(i!event.winner){scores[i]-value;scores[event.winner]value;}}}else{scores[event.winner]value;scores[event.payer]-value;}}這段算法天然維持一個不變量sum(scores) initialScore * playerCount如果任何操作后總分變化就說明轉移規則實現有誤。把業務規則轉化為可斷言的不變量是提高計分類軟件可靠性的有效方法。四、出桿順序也可以重放追分模式的當前順序并不單純按局數輪換。贏家應排到首位單一輸家排第二其余人保持上一局相對順序大金沒有單一輸家則只把贏家提到首位。犯規屬于局中處罰不改變下一局順序。實現用事件類型集合區分哪些事件會結束一局再從firstOrder依次重放constORDER_EVENTSnewSet([normal,silver,gold9,golden]);for(consteventofevents){if(!ORDER_EVENTS.has(event.type))continue;constrestorder.filter(ii!event.winneri!event.payer);orderevent.payernull?[event.winner,...order.filter(ii!event.winner)]:[event.winner,event.payer,...rest];}分數和順序都來自同一串事件因此撤銷一個犯規只恢復分數不會錯誤改變順序撤銷一局勝負則兩者一起回到正確狀態。五、兼容舊存檔要看數據形狀軟件升級后同一個玩法代碼可能改變計分模型。項目曾經存在九球搶局存檔后來九球改為追分。如果只根據mode nine-ball判斷舊存檔會被錯誤送進需要players/events的追分頁面。實現改為按數據形狀判斷exportfunctionisChase(match){return!!(matchArray.isArray(match.players));}這是一種實用的兼容策略當持久數據沒有明確 schema version 時使用不會歧義的結構特征識別舊格式。更長期的方案是在新數據中加入schemaVersion并編寫顯式遷移但在已有用戶存檔無法補字段時形狀檢測仍是必要兜底。六、本地是主存儲云端只是備份球房網絡不穩定時點擊記分必須立即生效。當前對局、設置和最近 100 條歷史都先寫入帶billiards:命名空間的本地 storage。比賽結束后再靜默嘗試上傳。每場比賽創建一個客戶端冪等鍵m${Date.now().toString(36)}${Math.random().toString(36).slice(2,8)}服務端按 openid 和clientKey去重所以網絡重試不會制造重復比賽。同步流程為處理此前離線刪除留下的待刪隊列拉取云端記錄與本地按clientKey合并推送所有未同步且已經完賽的本地記錄。刪除也不能只操作本地。已同步記錄刪除后會把clientKey放進pendingDeletes下次聯網時補刪云端在刪除完成前拉取邏輯會跳過同 key 的遠端記錄防止它“復活”。這種設計明確犧牲了多人同時編輯一場比賽的能力換取球桌上最重要的體驗任何時候都能記分殺進程后也能恢復當前對局。七、結算數據為何可以適度冗余進行中狀態堅持事件唯一來源但歷史列表需要快速展示最終比分。完賽入庫時搶局模式會附加scoreA、scoreB和winner追分模式附加finalScores。這不是重新引入雙重真相因為它們是事件序列在“完賽時刻”的不可變快照不再參與后續計分。歷史卡片可以直接讀取詳情頁仍能用事件復核。判斷是否可以冗余的標準是源數據是否保留快照是否只在明確的生命周期節點生成快照生成后是否還會被獨立修改。滿足這三點適度反規范化能簡化查詢否則就會變成兩個可寫字段互相打架。八、事件溯源不等于必須上復雜基礎設施這里沒有消息隊列、事件總線或專用事件數據庫只有普通 JavaScript 數組和 JSON 持久化但已經獲得了事件建模的核心收益狀態計算是確定性的撤銷成本低新統計可以從舊事件補算業務不變量容易測試云端可以保存完整過程而非只有最終分數。它也有邊界。事件無限增長會讓每次重放變慢復雜業務還要處理事件版本遷移。臺球一場比賽的事件數量有限直接從頭重放最簡單若擴展到數萬事件的長期系統再引入周期快照。九、可復用的設計判斷計分工具適合事件重放通常因為它同時滿足三點操作可以表達為離散事件當前狀態能由事件確定計算用戶有頻繁撤銷需求。實現時應守住以下約束只持久化事件和必要初始條件不持久化可變派生狀態把所有規則寫成無副作用的推導函數為零和、局數和順序等業務不變量編寫測試明確事件 schema升級時識別或遷移舊存檔把離線刪除也建模為需要補償的同步動作。當撤銷需要寫一大串反向邏輯時通常不是撤銷太復雜而是狀態保存得太多了。