
摘要前端项目把 Math.random() 换成带 seed 的 PRNG 以后结果仍然可能因为帧率、输入密度和异步顺序而分叉。本文不讨论某一款游戏怎么玩而是讨论如何把随机过程前移到编译阶段——用局部 PRNG 和业务配置构建带稳定 ID 的事件计划让运行时只按时间游标消费。这套结构比只固定 seed更容易复现、测试、平衡和排查问题。Seed 真正保证了什么先把 PRNG 的保证说准确。给定相同初始 seed相同算法实现相同调用次数相同调用顺序PRNG 会返回相同数值流例如0.17, 0.82, 0.43, 0.61, 0.09 ...它保证的是第 N 次调用得到相同数值不是苹果、铁胆、轨迹和奖励仍然得到原来的数值。业务含义来自调用位置而调用位置仍可能分叉。一个看似普通的实时生成器可能这样写function update(dt) { spawnTimer - dt; if (spawnTimer 0) { spawnTimer 0.6 random() * 0.3; const kind random() 0.12 ? bomb : fruit; const lane Math.floor(random() * 7); spawn(kind, lane); } }如果每帧只执行一次、没有暂停、没有补帧、没有别的分支调用random()它可以稳定运行。但真实页面常会继续增加逻辑if (pointerType touch random() 0.1) addAssist(); if (combo 10) velocity random() * 0.2; if (document.hidden) return;从这里开始seed 仍然一样随机数流也一样消费映射却不再一样。同一个 Seed 为什么仍然会分叉图 1相同随机数流与稳定事件计划的差别。左侧数值流没有变化但触屏分支改变了消费顺序右侧事件类型、时间与载荷已经在运行前固定帧率只影响事件何时被读取不再决定读取什么。实时页面里至少有五类常见分叉源。1. 帧率和补帧策略60 Hz 和 120 Hz 显示器会产生不同数量的更新调用。若随机函数放在每帧分支中即使总运行时间相同消费次数也可能不同。固定时间步可以缓解物理差异但它仍不能自动约束所有 UI 分支、粒子效果和异步回调。2. 输入事件密度鼠标移动可能每秒触发数十次事件触屏通常是另一种频率。若输入处理器会随机生成火花、辅助线或业务对象后续随机流就被设备类型影响。3. 分支提前返回一次碰撞可能命中铁胆后立即返回另一次则继续处理后面的目标。只要随机调用位于返回点之后两个状态就从此错位。4. 异步完成顺序图片加载、网络请求、音频解码和定时器没有稳定的完成顺序。谁先完成、谁先消费随机数不能只靠 seed 约束。5. 无关表现层偷走随机数最难排查的是业务逻辑和粒子效果共用同一个 PRNG。后来只增加几颗装饰粒子就可能改变关卡内容。所以更准确的公式是可复现结果 稳定算法 稳定 seed 稳定调用图 稳定时间语义 稳定输入上下文只保存 seed只保存了其中一项。把随机过程前移到编译阶段图 2从配置与局部 PRNG 到只读事件计划。业务配置和 seed 只进入buildSchedule()运行时循环按elapsed推进游标并复制事件不再读取全局随机状态。这次实现把内容分成四层层输入输出是否允许随机业务配置目标、时长、节奏、风险、首领层数一份任务描述否局部 PRNGseed、固定算法可重复数值流是计划构建器配置、局部 PRNG排序并编号的事件数组是运行时事件数组、经过时间、用户输入当前状态否关键不是项目里从此没有随机而是随机只能跨过一次明确边界。PRNG 必须是构建器的局部依赖页面使用一个显式 seed 创建局部随机函数function rng(seed) { let value seed 0; return () { value Math.imul(value ^ value 15, 1 | value); value ^ value Math.imul(value ^ value 7, 61 | value); return ((value ^ value 14) 0) / 4294967296; }; }构建每份计划时重新创建实例function buildSchedule(index) { const mission missions[index]; const random rng(mission.seed); const schedule []; // 生成目标事件、风险事件和分层事件…… return schedule .sort((a, b) a.time - b.time) .map((event, id) ({ ...event, id })); }它不读取模块级Math.random()也不与粒子、声音和 UI 动画共用随机状态。某一份计划多消费一次不会污染下一份计划。先满足业务约束再打乱表现顺序完全随机地抽事件容易出现看似变化丰富实际目标根本无法完成。这次构建顺序是根据订单数量创建全部必需事件添加少量非必需内容使用局部 PRNG 洗牌按任务参数插入风险事件在明确时间窗追加分层目标排序并分配稳定 ID。这样内容必须包含什么来自业务配置以什么顺序出现才交给随机过程。这也是确定性生成与纯随机生成最重要的区别前者首先满足约束后者只能事后祈祷。运行时只消费计划运行时循环不需要知道 seedwhile ( state.cursor schedule.length schedule[state.cursor].time state.elapsed ) { spawnEvent(state, schedule[state.cursor]); state.cursor 1; }60 Hz 与 120 Hz 可能在不同一帧读到事件但只要elapsed的定义相同读到的仍是同一个 ID、同一种类型和同一份载荷。事件进入活动状态时使用浅复制function spawnEvent(state, event) { state.objects.push({ ...event, y: 1.08, dead: false, cutCooldown: 0 }); }原计划继续作为只读内容运动位置、冷却和销毁标记进入运行时对象。否则一次游玩修改计划本身下一次重开就不再是同一份内容。一份事件计划至少需要哪些字段不是所有项目都需要同一套 schema但下面几类字段很常用{ id: 18, time: 1.87, type: bomb, x: 0.62, vx: -0.08, vy: -1.01, hp: 1, version: 1 }id稳定身份不要只把数组下标临时当身份。日志、错误报告和回放证据都更适合引用稳定 IDmission 04 / event 018 / bomb / 1.87 s如果以后需要在中间插入新事件最好使用构建期生成且版本内稳定的 ID而不是依赖当前排序位置。time业务时间不是墙上时间事件时间应基于任务经过时间不应直接保存Date.now()。暂停时业务时间停止恢复后继续推进。浮点时间还要明确比较规则。测试不应该期待1.87在二进制浮点里精确相等可以在序列化签名中固定小数位运行时则使用 elapsed epsilon或整数毫秒。type 与 payload区分路由与数据类型决定进入哪个规则入口载荷描述这次事件的具体参数。不要把行为函数直接塞入计划否则计划难以序列化、签名和跨线程传递。version内容协议会变化一旦计划需要长期保存、分享或与问题报告一起上传就应该记录PRNG 算法版本计划 schema 版本内容配置版本生成器版本或提交 SHA。相同 seed 配合不同生成器不应被描述成同一份计划。计划签名比只记录 Seed 更有诊断价值seed 能重新生成内容前提是生成器版本完全相同。实际排查中我更愿意同时记录一份计划摘要或哈希const signature schedule .map((event) [ event.id, event.time.toFixed(2), event.type, event.x.toFixed(2) ].join(:)) .join(|);它可以回答两个不同问题seed 是否相同最终生成的业务事件是否相同生产环境可以进一步对规范 JSON 计算 SHA-256。需要注意规范化必须先固定字段顺序、数字精度和缺省值否则同义对象也可能得到不同哈希。当前专项校验会为 12 份计划建立签名集合重复就立即失败if (signatures.has(signature)) { throw new Error(第 ${index 1} 份事件计划重复); } signatures.add(signature);seed 不同不能自动证明内容不同比较计划签名才是在比较业务结果。三层验证计划正确、规则可完成、用户入口可用图 3事件计划、参考消费者与浏览器回归的三层验证。计划层证明内容规模和唯一性参考消费者证明正式规则下存在完成路径Playwright 再证明鼠标、触屏、档案和 Canvas 入口真实可用。预编译事件计划只是结构改进不代表内容自动正确。至少要分三层验证。第一层计划本身满足内容契约真实校验结果为指标结果固定计划12总事件499普通业务事件421风险事件78分层目标33 层必需订单目标297重复计划签名0这一层检查数量、字段、目标覆盖和唯一性。它能发现漏配置、重复脚本和不可能满足的静态约束但不能证明运行规则真的允许完成。第二层参考消费者必须经过正式规则参考消费者逐帧等待目标进入有效区域再调用和玩家相同的线段命中函数function referenceSlash(state) { for (const object of state.objects) { if ( !object.dead !object.bomb object.y 0.12 object.y 0.94 object.cutCooldown 0 ) { slash( state, { x: object.x, y: object.y }, { x: object.x, y: object.y } ); break; } } }它没有直接把订单计数加满也没有删除风险对象。目标轨迹、重力、切口冷却、命中距离、计分和订单累计仍由正式函数处理。12 份计划最终全部完成风险事件命中为 0。这个结果证明存在一条参考完成路径不等于证明所有玩家策略都能完成也不等于难度已经完美平衡。第三层Playwright 必须执行真实输入模型通过以后浏览器测试仍然会打开真实 HTML点击覆盖层的开席按钮等待一个业务对象进入 Canvas根据 Canvas 坐标按下鼠标分五步拖过目标松开并等待正式状态中的切取数增加检查档案往返与篡改拒绝在 390×844 触屏上下文重新点击入口检查 Canvas 非空像素、颜色信号和横向溢出。专项命令cd promo-video node scripts/check-fruit-slice.mjs真实结果为{ checks: PASS, missions: 12, events: 499, referenceWins: 12, realSlash: true, archiveTamperRejected: true, desktopOverflow: false, mobileOverflow: false }随后全仓审计覆盖 95 个活跃页面的桌面与手机视口共 190 个页面组合加载失败、JavaScript 错误、控制台错误、横向溢出和视口缺失均为 0。参考消费者最容易变成哪种测试后门参考消费者很有用也很容易写成自我欺骗。下面这种写法没有证明任何规则function passReference(state) { state.progress { ...state.mission.orders }; state.won true; }它只是为结算 UI 准备状态最多适合测试胜利弹窗能否显示不能作为内容可完成性的证据。更可靠的参考消费者应该满足读取正式事件计划通过正式状态转移入口行动遵守冷却、资源、碰撞和时间不能直接写胜负字段失败时报告计划 ID、事件 ID 和剩余目标与一条真实用户输入测试配合。还要避免另一个极端为了让参考消费者通过把难度调成只适合自动瞄准。参考消费者证明的是下界不是玩家体验。人工试玩、非参考策略和失败分布仍然需要单独观察。八个容易踩到的边界1. 构建器偷偷读取全局随机状态如果buildSchedule()一部分使用局部random()另一部分仍调用Math.random()整个计划就不再可复现。可以在测试环境临时替换Math.random为抛错函数检查构建过程是否偷读全局随机。2. 运行时修改原计划事件进入活动对象时应复制。开发模式还可以Object.freeze(schedule); schedule.forEach(Object.freeze);浅冻结只适用于载荷没有嵌套可变对象的情况复杂 payload 需要深冻结或结构化克隆。3. 同时事件依赖排序偶然性如果两个事件时间相同排序规则必须明确schedule.sort((a, b) a.time - b.time || a.id - b.id);不要依赖旧引擎的排序实现细节也不要让 ID 在排序后才决定业务优先级。4. 浮点时间直接进入签名0.1 0.2并不精确等于0.3。事件时间可以使用整数毫秒或在规范化时固定精度。显示精度、签名精度和运行比较精度应分别定义。5. 计划暴露了未来信息客户端已经拥有整份计划不代表 UI 应该把它全部展示给玩家。测试接口、调试面板和正式提示必须分层避免可复现顺手变成透视未来。6. 计划太大几百个小事件直接保存在内存很轻数十万事件则不一定。大规模系统可以按章节预编译、分块加载或保存高层事件并在局部使用独立子 seed。关键是每个块仍有明确边界和签名。7. 生成器升级却没有版本修改一次洗牌算法同一个 seed 就可能产生全新内容。若旧计划需要继续回放应保存旧生成器版本或直接保存规范计划若不支持应明确拒绝不要静默生成看起来差不多的新内容。8. 把生成成功误当成体验成功计划字段合法、参考消费者通过只能说明内容存在且可完成。密度是否疲劳、风险是否可读、触屏是否易误触仍然需要实际截图、输入回归和人工体验判断。哪些随机适合预编译哪些不适合随机来源是否适合预编译更合适的做法教程提示顺序适合构建固定提示计划模拟告警和故障注入适合生成带时间和载荷的事件表演示数据、压测流量适合保存 seed、版本、计划签名关卡刷怪、节奏内容适合先满足业务约束再打乱顺序用户点击产生的粒子通常不必使用独立表现层 PRNG网络返回顺序不适合预测记录真实响应或使用测试夹具密码学随机数不适合使用 Web Crypto不追求可复现依赖实时传感器的数据不适合预知录制输入流用于复现目标不是把所有变化都冻结而是把会影响业务结算、又可以在运行前确定的随机内容变成显式输入。事件计划与命令回放不是一回事我之前写过命令日志与确定性回放。两种结构解决的是不同方向事件计划系统将向用户呈现什么 命令日志用户怎样响应这些内容完整复现包可以同时包含{ scheduleVersion: 2, scheduleHash: ..., seed: 1283, commands: [ { type: SLASH, from: [0.2, 0.7], to: [0.4, 0.6] } ] }只保存事件计划可以复现环境但不能复现用户行为只保存命令日志如果环境仍在临时随机也可能重放到另一套内容上。这两个方向合并后问题报告才能回答系统当时提供了哪些输入用户按什么顺序做了什么哪条规则把状态推进到错误结果可以直接复用的落地清单明确哪些随机内容会影响业务结果为每个内容单元保存 seed、配置和版本构建器只使用局部 PRNG先满足必需业务约束再随机排列表现顺序为事件分配稳定 ID明确同时事件的排序规则使用业务经过时间不直接依赖墙上时间运行时只按游标消费计划活动对象与原始计划分离为规范计划计算签名或哈希用内容契约检查数量、字段与唯一性用参考消费者经过正式规则证明存在完成路径保留至少一条真实鼠标或触屏测试不把参考消费者结果描述成难度或体验证明记录生成器、PRNG 和 schema 版本。文章配图可以复现本文封面和三张正文图读取docs/images/deterministic-schedules/article-data.json图片由 Pillow 脚本生成cd promo-video python scripts/create-deterministic-schedule-article-images.py脚本还会读取专项测试生成的真实桌面截图。测试数量、提交 SHA 或审计结果变化时先更新结构化数据并重新生成避免同一个数字散落在四张图片里分别手改。结语seed 很重要但它只是随机数流的起点不是业务结果的完整身份证。对实时前端来说更稳妥的做法是把随机过程前移使用局部 PRNG 和明确业务配置构建事件计划排序、编号、签名以后再交给运行时按游标消费。这样帧率、输入密度和表现层变化不再轻易改写内容本身。当计划契约、参考消费者和真实浏览器输入形成三层证据以后这次刚好能跑才有机会变成这份内容可以重复生成、重复完成、重复验证。本文对应页面、专项测试、文章数据与制图脚本位于开源仓库https://github.com/wangzifan396-wzf/mini-browser-games