从铁人三项到正式上线:Toy 构建、故障复盘与阿里云迁移

1927 字
10 分钟
从铁人三项到正式上线:Toy 构建、故障复盘与阿里云迁移

当猜曲、歌曲排序和老资历都能独立联机以后,我们开始制作铁人三项:三项固定顺序、每项三轮,共九轮累计积分。

真正困难的不是把三个入口放在一起,而是让三个状态机共享同一个房间、同一组玩家和一张总分表,并在项目切换时不留下旧状态。与此同时,同一个 React 项目还要发布到普通网页和 B 站 Toy,测试也必须覆盖真实公网环境。

铁人三项需要两套轮次编号#

服务端同时记录:

  • 总轮次 overallRoundNumber:1—9;
  • 当前项目轮次 stageRound:每项重新从 1—3;
  • 当前玩法 activeMode

赛程固定为:

总第 1—3 轮:猜曲
总第 4—6 轮:歌曲排序
总第 7—9 轮:谁是老资历

前端用项目轮次显示“第 2/3 轮”,用总轮次点亮赛程进度;服务端用总轮次判断何时切换处理器和最终结算。

每轮开始时只初始化当前玩法需要的字段,并重置玩家的本轮状态。总分、玩家身份、颜色和连接保持不变。曲目优先从整场尚未使用的歌曲中选择;曲库不足时允许合理重复,避免小曲库无法开局。

线上故障一:倒计时显示不等于权威时间#

早期版本中,多个倒计时看起来会提前约三秒跳转。用户看到“剩余 3 秒”,页面却已经进入下一轮。

原因不是服务端真的少计时,而是客户端使用自己的接收时刻显示剩余时间,没有正确消化状态在网络上传输的延迟。显示值、状态广播和服务端推进受三套时刻共同影响。

修复后,每个房间投影都携带 serverNow。客户端收到消息时计算本地与服务器的偏移,并用校准后的时间显示倒计时。服务端仍然只认自己的 endsAt,客户端不会因为动画到零就擅自切换状态。

线上故障二:短暂的不完整状态也会让 React 白屏#

铁人三项的排序环节曾在每轮结束时突然白屏,刷新后又恢复正常。项目切换和轮间结算涉及 phaseactiveMode、题目对象和下一轮时间;如果先改变玩法标识,再准备好对应题目,React 就可能按新玩法渲染旧数据。

修复思路有两层:服务端尽量以一次对象更新提交完整状态,前端只在阶段和题目对象同时满足条件时渲染玩法组件。弹窗、答案和排行榜也都加入空值保护,不能假设 WebSocket 的每个快照都处于理想状态。

线上故障三:部分答对时的超时卡死#

最隐蔽的问题出现在四人猜曲:两人答对、两人未答,等待倒计时结束后没有弹出答案,刷新也可能继续卡住。双人测试往往因为两人都答对而提前结算,恰好绕开了超时路径。

最终采用两层兜底:

  1. 客户端在校准时间超过截止时间 500 毫秒后,只发送一次 sync
  2. 服务端处理 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#/multiplayer
index.html#/seniority/preset/all

Toy 包的文件全部平铺在根目录,并使用 ASCII 文件名:

index.html
app-xxxx.js
asset-xxxx.css
asset-xxxx.png
asset-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

支持与分享

如果这篇文章对你有帮助,欢迎分享给更多人或打赏支持!

打赏
从铁人三项到正式上线:Toy 构建、故障复盘与阿里云迁移
https://lonako-blog.bocchi0708.workers.dev/posts/post2026-8-16-1/
作者
Lonako
发布于
2026-08-16
许可协议
CC BY-NC-SA 4.0
Profile Image of the Author
Lonako
Hello, I'm Lonako !
公告
欢迎来到我的博客!这是一则示例公告。
分类
标签
站点统计
文章
13
分类
7
标签
35
总字数
19,588
运行时长
0
最后活动
0 天前
站点信息
构建平台
Local
博客版本
Firefly v6.16.3
文章许可
CC BY-NC-SA 4.0