从铁人三项到正式上线:Toy 构建、故障复盘与阿里云迁移
当猜曲、歌曲排序和老资历都能独立联机以后,我们开始制作铁人三项:三项固定顺序、每项三轮,共九轮累计积分。
真正困难的不是把三个入口放在一起,而是让三个状态机共享同一个房间、同一组玩家和一张总分表,并在项目切换时不留下旧状态。与此同时,同一个 React 项目还要发布到普通网页和 B 站 Toy,测试也必须覆盖真实公网环境。
铁人三项需要两套轮次编号
服务端同时记录:
- 总轮次
overallRoundNumber:1—9; - 当前项目轮次
stageRound:每项重新从 1—3; - 当前玩法
activeMode。
赛程固定为:
总第 1—3 轮:猜曲总第 4—6 轮:歌曲排序总第 7—9 轮:谁是老资历前端用项目轮次显示“第 2/3 轮”,用总轮次点亮赛程进度;服务端用总轮次判断何时切换处理器和最终结算。
每轮开始时只初始化当前玩法需要的字段,并重置玩家的本轮状态。总分、玩家身份、颜色和连接保持不变。曲目优先从整场尚未使用的歌曲中选择;曲库不足时允许合理重复,避免小曲库无法开局。
线上故障一:倒计时显示不等于权威时间
早期版本中,多个倒计时看起来会提前约三秒跳转。用户看到“剩余 3 秒”,页面却已经进入下一轮。
原因不是服务端真的少计时,而是客户端使用自己的接收时刻显示剩余时间,没有正确消化状态在网络上传输的延迟。显示值、状态广播和服务端推进受三套时刻共同影响。
修复后,每个房间投影都携带 serverNow。客户端收到消息时计算本地与服务器的偏移,并用校准后的时间显示倒计时。服务端仍然只认自己的 endsAt,客户端不会因为动画到零就擅自切换状态。
线上故障二:短暂的不完整状态也会让 React 白屏
铁人三项的排序环节曾在每轮结束时突然白屏,刷新后又恢复正常。项目切换和轮间结算涉及 phase、activeMode、题目对象和下一轮时间;如果先改变玩法标识,再准备好对应题目,React 就可能按新玩法渲染旧数据。
修复思路有两层:服务端尽量以一次对象更新提交完整状态,前端只在阶段和题目对象同时满足条件时渲染玩法组件。弹窗、答案和排行榜也都加入空值保护,不能假设 WebSocket 的每个快照都处于理想状态。
线上故障三:部分答对时的超时卡死
最隐蔽的问题出现在四人猜曲:两人答对、两人未答,等待倒计时结束后没有弹出答案,刷新也可能继续卡住。双人测试往往因为两人都答对而提前结算,恰好绕开了超时路径。
最终采用两层兜底:
- 客户端在校准时间超过截止时间 500 毫秒后,只发送一次
sync; - 服务端处理
sync前先执行tick(Date.now()),补做所有已经到期的状态转换。
计时器仍然是主要触发器,在线玩家的同步或重连则是恢复触发器。客户端不能决定结果,但房间不会因为一次漏唤醒永远停在过去。
从 Cloudflare 迁移到阿里云
最初方案使用 Cloudflare Worker 与 Durable Object。它适合用单个强一致对象协调一个房间,并用 Alarm 和休眠 WebSocket 降低空闲成本。
后来为了让服务部署、题库同步和运行环境更可控,线上权威服务迁移到阿里云香港,同时保留原有协议和玩法处理器:
B站 Toy / Cloudflare Pages │ HTTPS + WSS ▼ Nginx ▼Node.js RoomManager ▼房间 JSON 持久化目录systemd 负责开机启动和异常重启,Nginx 负责 HTTPS、WebSocket Upgrade 和证书入口。服务提供健康检查、题库版本、房间 REST API 与 WebSocket。
迁移能够保持前端改动很小,是因为房间协议没有绑定到 Durable Object 的内部 API,前端只依赖 REST 路径、WebSocket 消息和协议版本。
Toy 需要独立构建,但不需要复制源码
普通构建继续使用默认 Vite 配置,Toy 构建使用单独配置,主要差异包括:
base: './',确保资源使用相对路径;- 使用 Hash 路由,让页面留在一个
index.html内; - 注入 Toy JavaScript SDK;
- 写入阿里云多人 API 地址;
- 只保留 Toy 所需的 BGM;
- 输出到独立的
toy-dist。
路由最终表现为:
index.html#/multiplayerindex.html#/seniority/preset/allToy 包的文件全部平铺在根目录,并使用 ASCII 文件名:
index.htmlapp-xxxx.jsasset-xxxx.cssasset-xxxx.pngasset-xxxx.mp3这样可以避免平台更新后找不到嵌套资源。构建校验器会拒绝子目录文件、根绝对资源路径、错误 API 地址和超过 20MB 目标的包体。完整网站的 BGM 不全部放入 Toy,只保留三首,最终包体约 13MB。
Toy 发布分成预览和审核两步:先生成并校验 toy-dist,上传预览版本,在 iframe 中检查单机路由和多人入口,确认无误后才提交审核。
四层测试覆盖从规则到公网
第一层是纯规则函数测试,验证人数、积分、截止边界、提示解锁、排序相对顺序、同分排名以及曲库不足等情况。
第二层是房间集成测试,用临时持久化目录和模拟 WebSocket 执行完整命令流程,覆盖创建、加入、颜色唯一、隐私投影、刷新同步、超时追赶和九轮累计积分。
一条关键回归测试专门构造四人猜曲:前两人猜对,后两人未答,把截止时间移到过去,再通过第三名玩家的 sync 推动状态机。预期结果必须是完整答案公开、得分 5、3、0、0,且服务端没有异常。
第三层是 React 交互与响应式测试,检查创建和加入房间、颜色选择、对手模糊反馈、答案弹窗、排序拖动、老资历选择和最终排名。移动端还要实际检查卡片高度、长标题和页面滚动。
第四层是公网冒烟验收,直接访问生产地址检查:
/health返回正确协议和题库版本;- Toy Origin 请求返回 200;
- CORS 预检返回 204;
Access-Control-Allow-Origin精确匹配;- WSS 能建立并完成回声;
- 四种模式可以创建房间并推进到预期阶段。
上线的定义应该是验证完成
服务重启成功不等于上线完成。每次更新后,都要检查健康接口、题库版本、CORS、WebSocket 回声,并跑完猜曲、老资历、排序和铁人三项的公网流程。
多人游戏的错误往往只在特定人数、特定阶段和特定时间边界出现。规则测试解释结果,房间测试验证状态机,组件测试保护交互,公网测试确认部署环境;四层合起来,才接近玩家真正使用的系统。
对于实时服务,部署动作只占最后阶段。真正让版本可用的是:能发现旧状态、能从延迟中恢复、能解释跨域来源,并且能在真实公网入口上跑完一场游戏。
项目仓库:LonakoBc/luo-yi-ba
在线试玩:luo-yi-ba.pages.dev
支持与分享
如果这篇文章对你有帮助,欢迎分享给更多人或打赏支持!



