撤销为什么只需要 pop:用事件重放实现离线台球计分

计分板最常见的 bug,不是按钮没响应,而是比分、开球方、让局和撤销各维护一份状态,某个分支忘记同步其中一项。台球工具选择只记录"发生了什么",把"现在是什么状态"全部重算出来。

一、先把事实和结果分开

以中式八球抢局制为例,页面需要展示:

  • 两名选手当前比分;
  • 本局开球方;
  • 是否已经达到抢局目标;
  • 炸清、接清等特殊胜局次数;
  • 撤销上一局后的所有状态。

直觉实现会在点击"选手 A 获胜"时同时执行 scoreA++、切换开球方、更新特殊次数并判断获胜。这样做的问题是撤销时必须精确执行所有反操作,新增一种开球规则又会影响多个分支。

这个思路最初用于「零碎百宝箱」的台球计分功能,目标是在球房断网时仍能完整记下一场比赛。

项目只保存不可再分的事实:

js 复制代码
frames: [
  { winner: 'A', type: 'normal', at: 1720000000000 },
  { winner: 'B', type: 'clear', at: 1720000060000 }
]

比分、胜者和开球方都是派生值:

js 复制代码
function scoreOf(match, side) {
  const handicap = side === 'A' ? match.handicapA : match.handicapB;
  return handicap + match.frames.filter(f => f.winner === side).length;
}

function matchWinner(match) {
  if (scoreOf(match, 'A') >= match.raceTo) return 'A';
  if (scoreOf(match, 'B') >= match.raceTo) return 'B';
  return null;
}

撤销上一局因此只需 frames.pop(),随后所有派生函数自然得到上一时刻的状态。

二、事件重放消除了"双重真相"

事件模型的核心并不是用了数组,而是规定数组是唯一事实来源。如果同时持久化 framesscoreA,二者迟早会不一致。

flowchart LR E[frames 事件序列] --> Score[比分] E --> Breaker[当前开球方] E --> Winner[比赛胜者] E --> Specials[特殊胜局统计] E --> History[结算详情]

开球方也从历史推导:

  • 轮流开球:已完成局数的奇偶决定当前开球方;
  • 胜方开球:取上一局赢家;
  • 负方开球:取上一局赢家的另一方。

这些函数可以独立测试。页面组件不需要知道各种规则怎样组合,只负责渲染计算结果和追加事件。

三、两套玩法,共用一个思想

台球工具同时支持中式八球抢局和九球追分。追分不是简单的"多人比分加减":它有普胜、银九、大金、小金、犯规等事件,不同事件决定谁向谁转移多少分,还会影响下一局出杆顺序。

追分对局保存:

js 复制代码
{
  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 }
  ]
}

payer=null 表示其余所有人都向赢家支付。重放函数从每人的初始分开始逐条转移:

js 复制代码
for (const event of match.events) {
  const value = match.values[event.type];
  if (event.payer == null) {
    for (let i = 0; i < players.length; i++) {
      if (i !== event.winner) {
        scores[i] -= value;
        scores[event.winner] += value;
      }
    }
  } else {
    scores[event.winner] += value;
    scores[event.payer] -= value;
  }
}

这段算法天然维持一个不变量:

text 复制代码
sum(scores) === initialScore * playerCount

如果任何操作后总分变化,就说明转移规则实现有误。把业务规则转化为可断言的不变量,是提高计分类软件可靠性的有效方法。

四、出杆顺序也可以重放

追分模式的当前顺序并不单纯按局数轮换。赢家应排到首位,单一输家排第二,其余人保持上一局相对顺序;大金没有单一输家,则只把赢家提到首位。犯规属于局中处罚,不改变下一局顺序。

实现用事件类型集合区分哪些事件会结束一局,再从 firstOrder 依次重放:

js 复制代码
const ORDER_EVENTS = new Set(['normal', 'silver', 'gold9', 'golden']);

for (const event of events) {
  if (!ORDER_EVENTS.has(event.type)) continue;
  const rest = order.filter(i => i !== event.winner && i !== event.payer);
  order = event.payer == null
    ? [event.winner, ...order.filter(i => i !== event.winner)]
    : [event.winner, event.payer, ...rest];
}

分数和顺序都来自同一串事件,因此撤销一个犯规只恢复分数,不会错误改变顺序;撤销一局胜负则两者一起回到正确状态。

五、兼容旧存档要看数据形状

软件升级后,同一个玩法代码可能改变计分模型。项目曾经存在九球抢局存档,后来九球改为追分。如果只根据 mode === 'nine-ball' 判断,旧存档会被错误送进需要 players/events 的追分页面。

实现改为按数据形状判断:

js 复制代码
export function isChase(match) {
  return !!(match && Array.isArray(match.players));
}

这是一种实用的兼容策略:当持久数据没有明确 schema version 时,使用不会歧义的结构特征识别旧格式。更长期的方案是在新数据中加入 schemaVersion 并编写显式迁移,但在已有用户存档无法补字段时,形状检测仍是必要兜底。

六、本地是主存储,云端只是备份

球房网络不稳定时,点击记分必须立即生效。当前对局、设置和最近 100 条历史都先写入带 billiards: 命名空间的本地 storage。比赛结束后再静默尝试上传。

每场比赛创建一个客户端幂等键:

js 复制代码
`m${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`

服务端按 openid 和 clientKey 去重,所以网络重试不会制造重复比赛。同步流程为:

  1. 处理此前离线删除留下的待删队列;
  2. 拉取云端记录,与本地按 clientKey 合并;
  3. 推送所有未同步且已经完赛的本地记录。

删除也不能只操作本地。已同步记录删除后会把 clientKey 放进 pendingDeletes,下次联网时补删云端;在删除完成前,拉取逻辑会跳过同 key 的远端记录,防止它"复活"。

这种设计明确牺牲了多人同时编辑一场比赛的能力,换取球桌上最重要的体验:任何时候都能记分,杀进程后也能恢复当前对局。

七、结算数据为何可以适度冗余

进行中状态坚持事件唯一来源,但历史列表需要快速展示最终比分。完赛入库时,抢局模式会附加 scoreAscoreBwinner,追分模式附加 finalScores

这不是重新引入双重真相,因为它们是事件序列在"完赛时刻"的不可变快照,不再参与后续计分。历史卡片可以直接读取,详情页仍能用事件复核。

判断是否可以冗余的标准是:

  • 源数据是否保留;
  • 快照是否只在明确的生命周期节点生成;
  • 快照生成后是否还会被独立修改。

满足这三点,适度反规范化能简化查询;否则就会变成两个可写字段互相打架。

八、事件溯源不等于必须上复杂基础设施

这里没有消息队列、事件总线或专用事件数据库,只有普通 JavaScript 数组和 JSON 持久化,但已经获得了事件建模的核心收益:

  • 状态计算是确定性的;
  • 撤销成本低;
  • 新统计可以从旧事件补算;
  • 业务不变量容易测试;
  • 云端可以保存完整过程而非只有最终分数。

它也有边界。事件无限增长会让每次重放变慢,复杂业务还要处理事件版本迁移。台球一场比赛的事件数量有限,直接从头重放最简单;若扩展到数万事件的长期系统,再引入周期快照。

九、可复用的设计判断

计分工具适合事件重放,通常因为它同时满足三点:操作可以表达为离散事件,当前状态能由事件确定计算,用户有频繁撤销需求。

实现时应守住以下约束:

  1. 只持久化事件和必要初始条件,不持久化可变派生状态;
  2. 把所有规则写成无副作用的推导函数;
  3. 为零和、局数和顺序等业务不变量编写测试;
  4. 明确事件 schema,升级时识别或迁移旧存档;
  5. 把离线删除也建模为需要补偿的同步动作。

当撤销需要写一大串反向逻辑时,通常不是撤销太复杂,而是状态保存得太多了。

相关推荐
虚惊一场1 小时前
从一个小程序到一座工具箱:uni-app 分包与 Flask 微服务实践
微信小程序·uni-app
beichenxingzhui2 小时前
茶饮品牌“新品口感盲测问答”:适合微信小程序与互动页的工具 Top 3
微信小程序·小程序
Cry丶3 小时前
【已解决】adb devices 能识别手机,但 HBuilderX 搜索不到设备的避坑指南
android·adb·uni-app·hbuilderx·真机调试
清风programmer1 天前
H5与小程序通信
前端·vue.js·uni-app
海纳百川·纳海川2 天前
数字化升级,让废品回收更简单高效
大数据·微信小程序·小程序
VX_bysjlw9852 天前
基于微信小程序的宠物用品商城系统-后端74346-计算机毕设原创(免费领源码+带部署教程)
java·redis·微信小程序·eclipse·mybatis·idea·微信开发者工具
一只昀2 天前
基于微信小程序的咖啡店点餐小程序
微信小程序·小程序
2501_915918413 天前
深入对比iOS开发中常用性能监控工具的底层原理与优缺点分析
android·ios·小程序·https·uni-app·iphone·webview
AI多Agent协作实战派3 天前
AI多Agent协作系统实战(二十三):Agent读HEARTBEAT.md不读AGENTS.md——openclaw的文件加载之谜
前端·数据库·人工智能·uni-app