mock:游戏分布式系统的仿真测试框架——原理、边界与代价
mock:游戏分布式系统的仿真测试框架——原理、边界与代价
面向读者:服务端/客户端工程团队、测试基建团队。
基线:mock/当前磁盘源码(2026-08-07)。本文所有行号、接口、测试数字均可回溯验证。
术语约定:文中"本游戏"指被测的分布式棋牌游戏(当前被测玩法为麻将);"被测系统"指"本游戏 + 其 C++ 引擎框架"的合称。
0. 前言:这篇文档回答什么问题
mock/ 是本游戏分布式系统的仿真测试框架(当前被测对象为麻将玩法;框架本身与具体玩法无关,畅玩阁、smallgame、automatch 系统已接入同一框架)。它不是单测框架、不是断言宏集合,也不是把真机环境"简化一下"的测试环境。它是一台可回放、可注入故障、可观察内部状态、加载真实业务代码的分布式系统模拟器。
这套框架累计约 200 个测试文件、上万个断言,靠它定位了 50 余个产品缺陷(含 11 项直接用户投诉的功能问题)。这些数字不是本文的重点。本文要回答的是五个问题:
- 被测对象是什么,为什么它难测;
- mock 的原理与方案是什么——每个设计决策从哪个物理约束推导出来;
- 它解决了什么问题;
- 它不能解决什么问题(与第 3 条同等重要);
- 为获得这一切付了什么代价,值不值。
论证结构:第 1 章建立被测系统的模型(六节点拓扑 + 分布式物理约束),第 2 章证明传统手段在此模型下必然失败,第 3-6 章给出 mock 的设计与验证成果,第 7-8 章如实列出边界与代价,第 9 章做最终论证。
1. 被测系统:分布式游戏架构
1.1 六节点拓扑与真实部署形态
本游戏不是单进程程序。一场对局横跨五个服务器角色加一个客户端:
| 节点 | 角色 | 真实部署 |
|---|---|---|
| SCENE(SceneServer) | 房间生命周期、匹配加入、结算重投 | 独立进程 |
| GC(GameCenter) | 玩家连接路由、跨服转发 | 与 MC 同进程 |
| MC(MatchCenter) | 匹配、房间分配、协议转发 | 与 GC 同进程 |
| PS(PlayServer) | 玩法状态机:发牌/定缺/换三张/摸打/碰杠胡/结算 | 与 GS 同进程 |
| GS(GameServer) | 玩家数据、成就、战绩、结算落账 | 与 PS 同进程 |
| CLIENT | 数据合并、事件、推荐算法、UI 渲染 | 每玩家一个实例 |
真实部署中 GS 与 PS 同进程、GC 与 MC 同进程,但逻辑上仍是独立节点:协议按 源_目标_功能名 命名(如 MatchCenter2PlayServer_CreateRoom),跨节点调用走 module:CallCenter/CallServer/CallSceneServer,参数以整表透传。
两个工程事实决定了测试的复杂度上限:
- 帧驱动:服务器 16 帧/秒(62.5ms/TICK),
StartActivate注册帧回调,所有逻辑在帧边界内串行执行; - 异步 Actor 模型:服务器间调用是单线程异步消息传递(非协程、非阻塞),调用方不保证响应,必须自行处理超时、重试、降级(APM 模式优先)。
1.2 一个对局的协议链
一次完整的"进房打牌结算"要经过 8 跳以上:
同一条状态变更可能通过多条路径到达客户端,且每条路径的物理延迟不同。这是本系统与 Web 后端(无状态、单请求-响应)的本质区别:它是帧同步强状态系统,任何一层状态变更都经协议扩散到所有层,且扩散顺序不确定。
1.3 分布式系统的物理约束:被测系统的工程法律
本项目的工程体系把分布式系统行为归纳为十一条物理定律(全部是不可协商的工程约束,非风格偏好)。本文后续每一章的设计决策都从这些定律推导,先列出与 mock 直接相关的七条:
| # | 定律 | 内容 | 对测试的推论 |
|---|---|---|---|
| 一 | 无实时全局状态 | 信息传播需要时间,任何观测都是过去 | 依赖"谁先到达"的测试是错的,必须验证任意到达顺序终态收敛 |
| 二 | 半开闭是常态 | 请求发出到响应到达之间,发送方一无所知 | 必须验证所有操作幂等或可补偿(防抖、去重) |
| 三 | 对等写入必发散 | 两节点对同一状态有对等写入权,冲突无终止条件 | 必须验证"方向约束":只允许权威源写入 |
| 四 | 不可撤回的外部效果 | UI 渲染后只能补偿不能回滚 | 必须验证 UI 渲染终态与服务器权威状态逐格一致 |
| 六 | 同步共振 | N 个组件同输入同控制律,副作用 N 倍叠加 | 必须验证去重 guard、白名单分支的防共振机制 |
| 七 | 单调锚点 | 等幂性的最小状态解是一个单调递增整数 | 必须验证结算 token、阶段去重锚点的单调性 |
| 十 | 事件先于权威数据 | 状态变更通知与权威数据走不同路径,顺序不保证 | 必须验证事件 handler 在权威数据到达前后的行为 |
(完整十一条见附录 C。定律五"共识是最后手段"、八"规则堆砌"、九"不对称安全"、十一"NULL 不可穿透"同样在本系统有工程投影,第 5.6 节逐一给出验证映射。)
1.4 测试难题的三个根源
把 1.1-1.3 压缩成三个不可绕过的测试约束:
约束一:顺序不确定。 客户端同一个状态(如 nGameStatus)从两条物理路径到达:SyncGameState(PS→MC→客户端)与 SuccessMatch(MC→GC→GS→客户端)。EndGame ↔ SyncGameState(WAITING_LOAD)、PullResult ↔ SyncGameState、GameAction ↔ SyncGameState 同理。到达顺序不是实现细节,而是正确性的一部分——定律一(无实时全局状态)规定:任何依赖顺序的代码都是错误的,正确代码必须任意顺序收敛。
约束二:多路径写入。 状态同时被多个节点观察和写入。定律三(对等写入必发散)规定:收敛的唯一方法是剥夺一方的写入权(方向约束——权威源唯一)。测试必须能验证"非权威源写入被拒绝"这个负反馈机制真实生效。
约束三:资金不可回放。 结算、充值、退款涉及真实货币。重复发奖是资产漏洞,少退一分是客诉。定律七(单调锚点)规定:精确一次语义必须由单调 token 实现。测试必须证明"这件事恰好发生了一次",且对任意重复投递、崩溃恢复、双通道竞争都成立。
这三个约束分别击穿了三种传统测试手段——下一章论证。
2. 传统测试手段为什么不够
2.1 真机环境:成本与不可得性
完整的真机测试需要:C++ 引擎(客户端 + 服务器框架)、完整网络拓扑(五类服务器进程)、账号服、数据库、可登录的账号体系。搭建一套意味着:
- 每台开发机跑六个进程,启动以分钟计;
- 场景不可控:牌墙随机、延迟随机、抖动随机,同一个 bug 复现不出第二次;
- 故障注入(断网、丢包、节点宕机)需要外挂工具,注入时机不可精确控制;
- 结果不可断言:日志是唯一观测手段,日志未覆盖的路径等于黑箱。
真机环境与约束一直接冲突:它无法控制到达顺序。对一个"对局结束后 100 局偶发一次重复发奖"的 bug(定律七被违反),真机复现概率低到不可接受。
2.2 单元测试:覆盖盲区
胡牌检测、计番、推荐算法这类纯函数可以单元测试(tests/mahjong/algorithm/,胡牌检测 60 断言、计番 96 断言)。但单元测试有结构性盲区:它测不了时序。
- "SyncGameState 先到、EndGame 后到"与反序是两种物理上不同的到达顺序,纯函数测试无从构造(约束一);
- 客户端 UI 状态机(换三张面板何时显示、何时隐藏)由一串异步事件驱动,单测一个
_RefreshAll函数没有意义(约束二); - 服务端玩法状态机有大量 local 函数和跨函数状态(
tRoom.nGameStatus、nCurrentSeat),单测很难逐步断言中间态(需要"开盒"能力); - 死代码路径(代码存在但触发条件永不满足)只在完整流程驱动下暴露——6.2 节的 D20 天胡 bug 即此类(定律六的工程投影:条件分支不可达 = 结构缺陷)。
2.3 手工回归:不可复现
手工测试最致命的问题是不可复现。一次对局 = 一次随机抽样。测试人员说"刚才结算不对",开发人员拿不到同一次抽样。而分布式 bug 恰恰是低概率路径——10 次里 1 次出错的那种,手工回归既不能保证测到,也不能保证测不到。定律一(无实时全局状态)在这里的具体化:观测不到的过去无法回放,无法回放的过去无法验证。
2.4 为什么不选其它替代方案
回测(backtesting)不止一种造法,逐一排除:
| 方案 | 为什么排除 |
|---|---|
| Docker 拉起真实六节点集群 | 仍需账号服/数据库/引擎;启动慢、牌墙与延迟不可控、故障注入需外部工具、结果不可断言。它解决"环境可得性",不解决"可回放性与可断言性" |
| 录制回放(录真机流量回放) | 只能回放"发生过的事",不能回放"想构造的事"。低概率路径录不到就测不到;场景仍不可控 |
| 属性测试/模糊测试(随机输入打 API) | 只能覆盖函数级输入空间,构造不了"两个节点之间的消息到达顺序"这种跨进程约束;断言停留在返回值层面 |
| 把分布式问题拍平为单体测试(同进程直接调函数) | 部分代码的真实形态确如此(GS=PS 同进程、GC=MC 同进程),但拍平后到达顺序不存在了——测不到竞态。mock 保留节点边界正是为了保留竞态 |
2.5 结论:回测
所有问题的共同根源:我们需要在受控条件下回放"同一次对局",能随时拨动它内部任何一根弦(延迟、丢包、崩溃、牌墙、时钟),并观察它任何一处内部状态。真机做不到(不可控),单元测试做不到(无时序),手工做不到(不可复现)。
这套能力在工程上有一个成熟的名字:回测(backtesting)——用受控的输入序列回放系统,验证其行为。与量化金融的回测同构:金融回测用历史行情序列验证策略,mock 回测用受控协议序列验证分布式系统;两者的共同前提都是"输入序列可构造、时间可重演、结果可断言"。
mock 的定位一句话概括:单进程内仿真六节点分布式系统,加载真实业务代码,用确定性虚拟时钟回放任意到达顺序,用注入 API 制造任意故障,用结构化 trace 观察每一步,用断言裁决每一个可观察行为。 第 3 章给出它的原理,第 4 章给出实现。
3. 原理:四个核心设计思想
3.1 模拟边界,不模拟业务
决策问题:mock 模拟什么?
| 路线 | 做法 | 失败模式 |
|---|---|---|
| 模拟业务 | 把游戏逻辑用测试代码重写一遍 | 测的是测试代码自己;业务逻辑一改,测试永久失真——违反定律十一的工程推论"接口存在性验证":测试代码引用的每一个产品接口都必须真实存在,否则就是幻觉污染 |
| 模拟边界 | 模拟 C++ 引擎对外接口(网络/定时器/控件/配置),把真实业务代码原样加载执行 | 业务逻辑一行不改,测试结论可外推回真实系统 |
mock 选第二条。framework.lua 仿真引擎的 Lua 接口(定时器、网络、玩家、事件、配置、随机、日志),framework_engine.lua 逐函数对照引擎源码实现 217 个引擎全局函数,framework_ui.lua 仿真控件系统,loader.lua 把 mahjong_play_server.lua、mahjong_client_core.lua、mahjong_main_panel.lua 这些产品文件原封不动地 load 进模拟环境执行。
这条路线有两个直接推论:
- 推论 A(结论可外推):mock 里执行的每一行产品代码就是真实系统的那一行。测试中发现的异常要么是产品 bug、要么是模拟失真——不存在"测试代码与产品代码分叉"的第三种情况,且两种都可以定位;
- 推论 B(接口真实性):mock 只能模拟"产品代码实际调用的接口"。产品接口清单来自对全部 Lua 文件的静态扫描(
compare_result.txt是这份对照的审计产物),任何产品代码调用了 mock 未实现的接口,加载阶段就会失败——这本身就是一次"接口存在性验证",把"编造接口"消灭在加载期。
3.2 相对时间因果不变量:虚拟时钟合法性的证明
决策问题:虚拟时钟能不能替代真实时间?
对服务器和客户端控制逻辑,底层实现(真实物理时钟、网络栈、DB 时序)不重要。在任务时间尺度(毫秒级以上的回合、倒计时、重试窗口)上,有一组命题不依赖任何底层实现:
对服务器和客户端控制逻辑,底层实现(真实物理时钟、网络栈、DB 时序)不重要。在任务时间尺度(毫秒级以上的回合、倒计时、重试窗口)上,有一组命题不依赖任何底层实现:
P1: cause≺effect P_1:\ \mathrm{cause}\prec\mathrm{effect} P1: cause≺effect
P2: rt+1≥rt, ct+1≤ct, kt+1>kt P_2:\ r_{t+1}\ge r_t,\ c_{t+1}\le c_t,\ k_{t+1}>k_t P2: rt+1≥rt, ct+1≤ct, kt+1>kt
P3: ∣{e}∣=ne P_3:\ |\{e\}|=n_e P3: ∣{e}∣=ne
- P1(因果序):因先于果,请求先于响应,广播先于接收,摸牌先于出牌,EndGame 先于发奖;
- P2(单调性):回合号只增不减,倒计时只减不增,结算 token 单调;
- P3(次数性):每件事恰好发生该发生的次数:恰好一次发奖、恰好一次弹窗、恰好一次去重。
三条命题在真实时钟和虚拟时钟下同真同假:
∀φ∈{P1,P2,P3}: Vreal(φ)=Vmock(φ) \forall\varphi\in\{P_1,P_2,P_3\}:\ V_{\mathrm{real}}(\varphi)=V_{\mathrm{mock}}(\varphi) ∀φ∈{P1,P2,P3}: Vreal(φ)=Vmock(φ)
因为因果顺序由消息发送-接收对定义(与时钟无关),单调性由状态机定义(与时钟无关),次数性由代码执行路径定义(与时钟无关)。因此 mock 用单个确定性虚拟时钟(Mock.nClock,毫秒)即可完整覆盖控制逻辑验证。这不是妥协,是精确的降维:把不可控的物理时间降为可推进的离散刻度,同时保留全部可观察语义。
这条思想与分布式因果律(Lamport 的 happens-before:事件序由消息传递定义,不由墙钟定义)同构——mock 的虚拟时钟正是"墙钟不可信、因果序可信"的工程实现。
3.3 问题发现机制:断言的三分类
决策问题:断言测什么?
mock 的断言机制浓缩为一句话:测试构造一个"应该发生的因果链",然后断言每件事是否发生、发生几次、以什么状态发生。所有 bug 归入三类:
| 类别 | 断言表现 | 真实案例 |
|---|---|---|
| 该发生的没发生 | 断言事件 0 次或状态未变 | D20:天胡检测在庄家 13 张时执行,而检测要求 14 张 → 天胡永不触发(功能缺失) |
| 发生时做少了 | 断言字段不足/次数不够 | D23:多回合房间第二局庄家恒为房主,"上局赢家坐庄"分支永远走不到 |
| 发生时做多了 | 断言次数超限/状态被重复应用 | D36b:EndGame 重放后 BI 对局日志写两次(非幂等,违反定律七) |
这套机制不预设答案:测试按 PRD 写期望,代码达不到期望是 bug 证据,代码达到 PRD 之外的期望(做多了)同样是 bug 证据。这与"测试标准原则"一致:测试按 PRD 构建,不按现有代码构建。
3.4 mock 的分层保证与失败归因
mock 对产品代码的保证分三层,层次不可混淆:
- 可编译:所有被测真实代码成功 load 并执行(
[MOCK LOAD OK]),不存在语法/引用缺失导致的加载失败——等价于"能编译"; - 可运行:被测代码被驱动到的每条路径都实际执行且不崩溃(崩溃被 pcall 捕获并记为 handler error / timer error / event error);
- 行为由断言裁决:代码"能跑"不等于"行为正确",正确与否只由断言判定。
因此失败归因有明确规则(这是工程上最重要的一条纪律):一套覆盖充分的测试失败时,优先怀疑测试目标写错了(断言与 PRD 不符、输入不符合产品契约、框架缺口 MOCK-GAP),其次才怀疑产品代码。产品代码行为对错由断言裁决,mock 的角色是如实加载并驱动它。
例外同样明确:mock 确实会发现真实产品 bug——那不是"mock 写错了",而是 mock 把真实代码跑出了它从未在真环境被观察到的路径。判定产品 bug 的硬标准:真实代码某行在 mock 中执行并产生违反文档语义的可观察结果,且有 [文件:行号] 证据。
4. 架构与实现
4.1 单进程六节点仿真
mock 在单进程内注册六个节点(Mock.Node(szName, tHandlers)),每个节点是协议处理函数表,消息在节点间通过统一总线投递:
节点装载分两代(见 4.10):PS/GS/CLIENT 真实代码加载;SCENE/MC/GC 早期以转发链/桩模拟,后已升级为真实代码 per-node env 加载。节点边界是刻意保留的——拍平节点边界(同进程直调)会消灭到达顺序,也就消灭了被测竞态(第 2.4 节论证)。
4.2 消息总线与虚拟时钟
消息生命周期:
关键语义(全部是测试实践踩出来的硬规则,违反任意一条 = 测试结果失真):
- 时钟可设但必须单调:
Mock.Tick(n)会把时钟设为 n(可以回退)。回退时钟让定时器nAt失真、消息乱序——这是测试自身最易犯的陷阱。纪律:投递后推进必须单调递增:
tk+1=tk+Δk,Δk>0 t_{k+1}=t_k+\Delta_k,\quad \Delta_k>0 tk+1=tk+Δk,Δk>0
- 消息先于定时器:Tick 先处理到期消息再触发定时器;定时器回调内发出的 C2S 请求要下一次 Tick 才被处理,断言前需排空。这条次序不是任意约定——它对齐引擎帧循环(帧内先收包后驱动逻辑);
- Flush 会无界跳时:
Mock.Flush(nMaxMs)直接跳到队列首条消息的到达时间,不受 nMaxMs 约束。"等待期"断言必须用Mock.Tick精确推进,否则延迟 3000ms 的响应会在只推进 2500ms 时被消费掉(D38 教训)——违反它就等于测试了自己不存在的时序; - 深拷贝投递:投递数据深拷贝(跨边界 passthrough 原则),测试侧构造数据不被 handler 污染,handler 修改不回灌测试。引用共享 = 两个模块共享可变状态 = 没有权威源 = 必然发散(定律三的工程推论);
- 确定性:默认延迟 0、顺序投递;乱序由测试显式构造或注入——确定性是回放的前提。
4.3 虚拟定时器
mahjong_timer.CreateTimer 映射到 Mock.TimerCreate,语义对齐引擎:
- 0/负延迟强制 1ms:
CreateTimer(0, fn)的触发时间被强制为下一毫秒(对齐引擎mahjong_timer.lua语义):
tfire=tnow+1 ms t_{\mathrm{fire}}=t_{\mathrm{now}}+1\ \mathrm{ms} tfire=tnow+1 ms
这是物理合理性约束:若"立即"定时器回调里再创建"立即"定时器会无限递归(同步共振的极端形式),强制 1ms 天然破环;
- 房间伴随:定时器携带
dwRoomID,RemoveTimersByRoom(dwRoomID)批量清除该房全部定时器(房间退出清理),可断言清除数——这是"边界熔断"(负反馈形态之一)的验证载体:退出房间必须切断全部定时器回路,不管它们在做什么; - 定时器继承创建者节点上下文:回调期间
GetCurrentTime/GetPlayer等 API 返回创建者节点语义——"定时器属于谁"必须一致,否则回调读到的是别的节点的视角(违反定律一:节点视角不可混淆)。
4.4 故障注入:扰动与收敛的验证器
Mock.Inject 是攻击测试的执行器,默认零注入(三张表全空,行为零变化——保证回归测试不受影响):
| 注入 | API | 语义 |
|---|---|---|
| 丢包 | Inject.Drop(dst, proto, nTimes) | 后续 n 条消息在处理时丢弃;不算失败,只记 trace |
| 延迟 | Inject.Lag(dst, proto, nDelayMs) | 强制投递延迟(造乱序/超时) |
| 节点宕机 | Inject.NodeCrash(szNode) / NodeRestore | 节点所有消息丢弃,恢复后可继续 |
| 快照/对比 | Inject.Snapshot(tData) / Compare(tA, tB) | 深拷贝快照 + 递归差异比较(重登一致性对比) |
注入在反馈博弈中的角色值得说清:注入制造正反馈扰动(丢包、延迟、重复、崩溃都是让系统偏离均衡的扰动源),断言验证负反馈收敛(方向约束拒绝非权威写入、序次收敛按锚点去重、边界熔断切断回路)。一套攻击测试 = 扰动注入 + 收敛断言,两者缺一不可——只有扰动没有收敛断言,测试只能证明"没崩溃",不能证明"收敛到正确终态"。
这套 API 是"到达顺序矩阵"测试的执行器:对同一状态变更,把故障注入到不同节点/协议上,穷举到达顺序,断言每种顺序下终态一致。黄金判据:两种顺序产生两种终态 = 有 bug:
∃ σ1≠σ2: F(σ1)≠F(σ2) ⇒ bug \exists\,\sigma_1\neq\sigma_2:\ F(\sigma_1)\neq F(\sigma_2)\ \Rightarrow\ \mathrm{bug} ∃σ1=σ2: F(σ1)=F(σ2) ⇒ bug
其中 F 是"到达顺序 → 终态"的函数,σ 为到达排列(定律一/三的直接推论)。
4.5 链路追踪:观测即干预的工程处置
每个分布式交互生成结构化事件(Trace.Event),按消息 ID 贯穿:deliver、handle(begin/end,含耗时与错误)、timer、state(关键状态字段 old→new——最有价值的观测,直接呈现"这条消息把状态改成了什么")、random、config_get、control_*、audio/voice、event_fire、drop_injected、node_down/node_up 等。
"谁在何时做了什么"由三个字段保证:t=(虚拟时钟时间,可复现)、by=文件:行号 函数(真实调用者定位,跳过 mock 内部帧)、stack=文件:行号:函数|...(完整调用堆栈——用 debug.sethook("call") 在首个产品函数入口捕获触发点到事件发布点的完整 Lua 调用链)。
观测在分布式系统中是危险的(定律十"观测即干预":任何观测都改变被观测系统)。mock 的 trace 层遵守四条铁律的工程版本:
- 观测在转发之后:trace 事件在
Mock.Deliver完成投递、handler 完成处理后记录,不插入转发路径中间; - 观测不修改数据包:trace 记录前对参数
tostring,不引用、不截断、不深拷贝后回灌; - 观测失败静默:trace 是
Mock.Trace.bEnabled门控的,关闭时零行为变化; - 转发不因观测改变行为:trace 数据不参与任何路由判断。
第 4.11 节的性能数据(trace 开启 44.5s vs 关闭 21.1s)就是这条定律的量化代价。
4.6 引擎模拟层
框架对引擎的模拟分三层:
- framework.lua(875 行):引擎特性——定时器、网络(
module:CallServer/CallClient/CallCenter/CallSceneServer/Broadcast*全套,含参数语义对齐)、玩家注册表(GetPlayingRole/GetRole/GetPlayer+ 属性容器rtPlayerAttr)、事件系统(RegisterEvent+FireModuleEvent真实分发,param 全局传参)、随机(确定性种子)、配置(真实 tab)、日志(分级捕获)、帧回调(StartActivate按帧驱动); - framework_engine.lua(2561 行):217 个引擎全局函数——时间、服务器身份、实体、角色、网络、HTTP、序列化、地图、市场、序列号、SDK,逐函数对照引擎源码实现,返回值类型/数量与 C++ 一致;
- framework_ui.lua:虚拟控件系统(230+ 方法)——控件树、XML 布局解析、事件注入(
this全局 = 控件本身)、SoundEngine音频(返回带IsPlaying/Stop/Release的声音对象,模拟 800ms 播放时长)、PlayPositionAnimate动画。
引擎语义精确项有专项测试验证(framework_semantics 20 断言):SetAlpha 单位 0.0-1.0 浮点、ON_ACTUAL_SHOW/HIDE 祖先链级联、BringToTop Z 序、param 注入与还原、bStrictFind 严格查找(找不到控件直接记失败,防"静默自动创建"掩盖缺失——定律十一的 UI 侧投影:缺失不能被悄悄吞掉)。
4.7 配置表真实化
规则配置不写死,走 tab_loader.lua 真实读取 settings/_tlbb/Mahjong/*.tab(TSV)+ *.desc(XML 结构描述):
- 真实格式:第 1 行列名、第 2-4 行类型/中文名、第 5 行起数据;
Map_*/Array_*/Set_*单元格解析为嵌套表(首等号切分 + 递归); - 单元格转义:
\i " \q ' \c , \s ; \e =,分割先于转义(防止转义分隔符被分裂——曾为此重写解析器); - 按
IndexTable建索引,对接产品mahjong_config_loader语义; - 规则注入优先级:
g_tTestRules[szRuleName](测试注入覆盖)> 真实 .tab。
配置真实化的价值:把 RoundBaseScore 从 200 改成 100,测试行为跟着变,不需要改测试代码。测试断言的是"产品的行为",不是"测试自己定义的行为"——这正是"测试标准原则"(按 PRD 构建)的配置侧落地。
4.8 UI 虚拟化
客户端 UI 脚本(mahjong_main_panel.lua、mahjong_main_exchange.lua、mahjong_ui.lua、mahjong_voice.lua、mahjong_changwange.lua)在纯 Lua 中运行:
- 控件树:每客户端独立
VirtualUI实例,FindWindow/FindEx深度搜索; - XML 同源:
xml_ui_loader.lua解析真实ui/mahjong/*.xml(9 个面板,按 config.xml 清单构建),控件几何(Size W/H、Anchors 锚点偏移、类型、可见性)与 UI 声明同源——FindEx直接命中真实几何控件(实测回归:auto_create=0、xml_create=810); - 行为 trace:每个控件方法调用记录
control_log事件,带控件完整路径、方法、参数、几何状态、调用者; - 事件:
SetWindowEvent注册 +FireEvent模拟点击(注入全局this); - 音频:
SoundEngine仿真,Play2DSound/PlayBGM/音量/开关全跟踪(防重叠依赖播放时长模拟)。
UI 层可断言:面板显隐、文本、几何位置、Z 序、透明度、动画次数、音频事件次数、事件订阅与清理。UI 状态断言遵守定律四(不可撤回的外部效果):断言的是渲染终态与权威状态的逐格一致,不断言中间闪变——中间态不可回滚,终态必须一致。
4.9 多客户端隔离
Mock_NewClientEnv(dwID) 为每个客户端创建独立 Lua env:
- env
__index对g_前缀返回 nil →g_tData = g_tData or {}在 env 内新建,多客户端不共享全局状态(定律一推论:每个客户端是独立的观测者,视角不可混用); - 预置本客户端模块表空表(
mahjong_client_core等),防止xxx = xxx or {}解析到 _G 的共享模块表——D22 bug(双客户端时 client1 UI 不刷新)的根因修复; - 每个客户端独立
VirtualUI、独立module(通信带客户端身份)、独立语音模块实例(self/other 判定依赖本客户端私有 g_tData); - 节点命名
CLIENT_{dwID},module:CallClient(dwID, ...)按 ID 路由到对应节点。
4.10 真实四节点接入(node_servers)
架构升级:SCENE/MC/GC/GS 从"桩/转发链"升级为真实代码加载(loader.lua Mock_LoadRealNodes + node_servers.lua):
- 每个节点加载到独立 env(
g_前缀隔离,解决与 play_server/client_core 的全局函数名冲突——Room_Get/GetRule等无法进 _G); - 协议前缀 → 目标节点路由表(
MatchCenter2PlayServer_*→PS、GameCenter2GameServer_*→GS、C2S_MAHJONG_*→GS…),接管module:CallServer/CallCenter/CallSceneServer/CallClient完成跨节点路由; - 玩家对象自动铸造:真实 GS 的 C2S handler 依赖 Player 对象(真实引擎在连接时创建),mock 在客户端首包到达时按引擎语义铸造最小玩家对象(含 SavedData 容器、货币镜像);
- 分布式覆写:真实节点全就绪后
GetServerIndex=99(≠ PS 的 1,强制 GS 走 GC→MC→PS 转发链而非同进程直调)、PS 节点上下文GetPlayer返回 nil(模拟异进程语义)、GS 直调包装转发到 GS 节点——保留进程边界 = 保留被测竞态(与 4.1 节同一原则); - 降级兜底:任一节点加载失败 → 该节点跳过,保持转发链模式(
bRealNodes=false,零回归); - 双形态 dispatch:真实链位置参数(
{_args})与测试直投(keyed 表)两种投递形态兼容。
收益:C2S 退出房间的 8 跳全链(GS→GC→MC→PS→MC→SCENE→GC→GS)全部走真实节点代码实测打通(91 断言),并因此发现 D51(等待房销毁后 PS 房间残留——跨节点状态不收敛,定律三被违反的直接证据)。
4.11 帧驱动与丢包重试
frame_driver.lua 补充两个能力:
- 帧推进器:
Mock.SetFrameStep(ms)/StepFrame()/RunFrames(n),每帧推进时钟→派发消息→触发定时器→推进动画(AnimStart注册,fnUpdate(进度, 已播放ms)每帧调用,fnFinish恰好一次,AnimStop取消——1:1 配对:stop 即无 finish); - 丢包重试:
Mock.EnableRetry(szProtocol, nMaxRetries, nBackoffMs),被Inject.Drop丢弃的消息按退避自动重发,重发次数可断言(RetryCount),trace 记录retry事件——复刻 SCENE 结算 pending 重投契约(4 次/5s 退避/暂停/重登重新触发)。这是定律二(半开闭是常态)的验证载体:请求发出后无响应,重试机制必须存在且次数有界。
5. 测试方法论
5.1 测试分层
测试按验证深度分四层,目录结构与分层一一对应(tests/<system>/<aspect>/):
| 层 | 测什么 | 断言风格 | 典型 |
|---|---|---|---|
| 单元 | 单个算法/函数 | 直调纯函数,输入→输出 | hu_detect / hu_fan / recommend |
| 开盒 | 真实函数的内部状态机 | 逐步调用函数、断言内部字段+中间态 | ps_openbox(Game_Start/Draw/Discard/Meld/Settle 逐步断言) |
| 集成 | 跨节点/跨模块全链路 | 驱动真实对局,断言终态+关键字段 | gameplay.chain / settle / integration |
| 攻击 | 乱序/丢包/延迟/崩溃/重复 | 注入故障,断言不变量收敛+次数恰好 | sync_attack / route / client_order / storm |
规模(磁盘实测):tests/ 下 209 个 Lua 文件、41 个分类目录,覆盖玩法(mahjong)、玩法 UI(mahjong_ui)、畅玩阁(changwange)、smallgame、automatch 五个系统。storm/ 单目录 16 个攻击测试文件合计约 8400 断言。
各层解决各层的问题,不可互相替代:单元层验证算法正确性(P3 次数性在函数内的投影),开盒层验证状态机中间态(P1 因果序在函数间的投影),集成层验证跨节点链路(P1 因果序在节点间的投影),攻击层验证任意顺序收敛(定律一/三的完整验证)。
5.2 攻击测试:到达顺序矩阵
攻击层的核心方法是对同一状态变更穷举协议到达顺序。以客户端 nGameStatus 为例:
| 状态 | 路径 1 | 路径 2 | 竞态 |
|---|---|---|---|
| 匹配成功 | SuccessMatch(MC→GC→GS→客户端) | SyncGameState(PS→MC→客户端) | nGameStatus 覆盖 |
| 游戏结束 | EndGame(PS 广播) | SyncGameState(PS 广播) | 结算结果与 WAITING_LOAD 顺序 |
| 重连拉取 | PullResult(SCENE→客户端) | SyncGameState(PS→客户端) | tPlayers 合并终态 |
每种顺序都必须验证终态一致。测试资产:
client_order(5 文件 140 断言):5 组竞态对的到达顺序全穷举;final_consistency(143 断言):随机故障(乱序 Lag 50-3000ms / Drop 1-3 次 / 重复广播 / 定时器延迟)大规模攻击 + 每轮后权威快照收敛断言;sync_attack(135 断言):状态清理攻击——跨局残留、倒计时回弹、定时器泄漏、重复投递、20 轮乱序+丢包+延迟+崩溃收敛;storm(16 文件 8400+ 断言):phase_matrix 8 阶段 UI 状态矩阵、proto_attack 7 竞态路径全到达顺序穷举、render_storm 渲染风暴等。
这些测试的产出是一批"负反馈机制"的实证:客户端对陈旧快照的反回归守卫(nGameStatus 回绕拒绝、回合号单调守卫、倒计时只减不增、DISCARD 去重)、服务器侧结算 token 单调性、EndGame 幂等去重。
5.3 PIN 方法论:先钉后修
对已确认的产品 bug,测试采用"钉 bug 行为 → 修复 → 翻转断言"的流程:
- 按 PRD 正确行为 写断言(不是按当前代码写);
- 跑测试,断言失败(FAIL = bug 证据),测试文件注释写明 bug 编号、位置、因果链;
- 修复产品代码;
- 翻转断言为正确行为,全绿。
要点:测试不是"验证修复"的工具,而是"先证明 bug 存在"的工具。先 RED 后 GREEN(bugfix 目录 4 测试 44 断言即此流程),修复后 PIN 断言自动翻转。实测:settle_matrix 22 处文本断言翻转、exchange_boundary 12 处、strict_scan 3 处。
这与"测试定位 BUG 原则"一致:测试失败时先怀疑被测代码与目标语义,禁止为了绿灯删断言、放宽范围。PIN 断言就是这条原则的机械化——FAIL 是设计出来的证据,不是需要修掉的噪音。
5.4 断言与报告
- 断言 API:
Mock.Assert/Equals/True/False/Fail;失败信息必须包含实际值((expected X, got Y))——AI 从失败信息直接看到差异,不需要读日志猜; Mock.Reset()归档本场景失败到tAllFailures,Mock.Report打印全部历史失败(早期只保留最后场景,多场景测试失败时看不到详情,已修复);- 退出码:
os.exit(Mock.nFail > 0 and 1 or 0),run 脚本据此判 PASS/FAIL(FAIL=0→ PASS); - 测试之间独立 lua53 进程,无跨测试污染;
Mock.Seed(n)固定种子保证可复现;MOCK_RANDOM_SEED环境变量支持 CI 随机回归(每轮不同种子捕获依赖随机序列的偶发失败)。
5.5 一条测试的完整生命周期
这是"测试为 AI 设计"原则的完整闭环:人给目标,AI 把目标翻译成断言,框架保证可复现可追溯,断言裁决行为。框架为此必须满足的结构性要求:结构化(目录分类 + 命名约定 + 共享基建)、可追溯(断言指向语义文档 [文件:行号] + trace 记录每一步)、断言明确(失败必打实际值)、确定性(固定种子 + 单调时钟)。
5.6 物理定律到验证手段的映射
这是本文最重要的方法论章节:把第 1.3 节的物理定律逐条映射到 mock 的验证手段,证明"mock 验证的就是这套定律,不是随机抽查"。
| 定律 | 工程投影(产品代码) | mock 验证手段 | 测试资产 |
|---|---|---|---|
| 一 无实时全局状态 | 客户端反回归守卫(nGameStatus 回绕拒绝、回合号单调、倒计时只减不增) | 构造陈旧快照注入,断言整包拒绝或字段恢复 | sync_attack converge / client_order T1-T5 |
| 二 半开闭是常态 | 请求幂等/防抖(g_bSendingReady)、SC 结算 pending 重投 | 重复投递断言恰好一次;丢包后重试断言次数有界 | settle idempotent 20 / settle_extreme 28 / frame_driver EnableRetry |
| 三 对等写入必发散 | 方向约束:nGameStatus 权威源 = SyncGameState,SuccessMatch 携带值不可覆盖 | 双路径竞争注入,断言非权威源写入被拒 | protocol reorder A / client_order T2 |
| 四 不可撤回的外部效果 | UI 渲染终态 = 服务器权威状态 | 三层一致断言(服务器 tResult == g_tData == UI) | storm fan_render 584 / settle_panel 47 |
| 五 共识最后手段 | 本系统不用共识,用方向约束+幂等+补偿 | 无分布式事务测试;验证补偿路径收敛 | route / settle_extreme |
| 六 同步共振 | 六 handler 汇聚 _CheckPhaseChange,去重 guard + 白名单 | 同一状态 N 次投递,断言副作用恰好一次 | final_consistency storm / phase_matrix |
| 七 单调锚点 | nSettleSeq / g_nLastCheckPhaseStatus / g_nLastAppliedRound | 重放/乱序注入,断言锚点单调推进、旧值拒绝 | settle / bugfix D30 / sync_attack |
| 八 规则堆砌 | 状态机 if-else 链增长 | 阶段矩阵穷举,断言白名单分支不误伤 | storm phase_matrix 254 |
| 九 不对称安全 | 不可信方向保守(宁可漏判不可误判) | 攻击测试断言"宁可拒绝不错收" | sync_attack / route lag |
| 十 事件先于权威数据 | 事件 handler 不得先于权威数据执行副作用 | EndGame↔Sync、SuccessMatch↔Sync 顺序穷举 | client_order T1/T2 / settle_exit |
| 十一 NULL 不可穿透 | 快照预清空 + 显式清理表(nil 携带"清零"语义) | 构造"服务器置 nil 字段",断言客户端字段被清空 | sync_attack newround / client_order stale |
这张表是测试套件的"需求规格":新增攻击测试时,从表中选择要验证的定律,再写注入与断言。测试不是为"代码行"写的,是为"定律的工程投影"写的——代码改了,定律不变,测试仍然有效。
6. 它解决了什么问题
6.1 分布式竞态与收敛:权威源矩阵的验证
问题:多路径写入 + 顺序不确定(定律一/三),客户端状态被过期快照覆盖。
解决:到达顺序矩阵穷举 + 方向约束断言。被测系统的权威源矩阵(工程约定的唯一写入方):
| 状态变量 | 权威源(唯一写入方) | 非权威源(只读,不得写入) |
|---|---|---|
nGameStatus | SyncGameState(PlayServer) | SuccessMatch 携带的 nGameStatus 不可覆盖 |
tPlayers[] | PlayServer/SceneServer | MatchCenter 不可写入;客户端只读 |
tEndGameResult | EndGame(PlayServer) | 其他协议不可覆盖 |
| 房间状态 | PlayServer | 客户端只能通过 C2S 请求 |
mock 验证的就是这张矩阵的每一行是否被代码遵守。真实案例:D30——SyncGameState 反回归守卫只保护 nGameStatus,nCurrentRound 和手牌被旧回合快照覆盖(实测旧 round1 快照把 round2 回退到 round1)。修复为回合号单调守卫 + 独立基线(g_nLastAppliedRound,只由实际应用的 SyncGameState 推进——SuccessMatch 携带 1 基回合号、服务器广播 0 基回合号,基线选错会把合法首局快照误判为回绕)。这个修复过程本身是"方向约束 + 序次收敛"双层负反馈的落地,而 mock 验证了它。
6.2 死代码路径:同步共振与不可达分支的发现
问题:代码存在但触发条件永不满足——功能缺失无法用代码审查发现。
解决:完整对局驱动 + 稀有路径注入。
真实案例:
- D20:天胡检测在庄家摸第 14 张之前执行,检测要求 14 张 → 天胡功能从未触发过。mock 注入牌墙复现后修复;
- D23:多回合房间
Game_End无条件重置nDealerSeat/nLastWinnerSeat/nCurrentRound=0,导致"上局赢家坐庄"分支永不可达,第二局庄家恒为房主。开盒测试逐步断言发现,修复为仅单局模式重置。
这类 bug 的特点是:单测测不到(算法层正常)、代码审查看不出来(逻辑自洽)、只有完整流程驱动才能暴露(触发条件在流程时序里)。它们本质是定律六(同步共振)的结构性缺陷:组件对触发条件做了相同的错误假设。
6.3 客户端-服务端一致性
问题:服务器结算与客户端计番算法分叉,玩家看到的倍率与服务器结算不一致。
解决:一致性搜索器——随机生成 9000 例/轮手牌,客户端与服务器算法分别结算,逐例比对(E1 测试),种子段参数化可复现偶发失败。
真实案例:D17 四类根统计不一致(根数统计口径、七对跳根番、龙七对牌集判定、fanNames 标签与倍率不符),修复后 1200 万例随机对拍全一致。1200 万这个量级是手工/真机测试不可能达到的——它是"P3 次数性 + 定律三(对等写入必发散)"在双实现(客户端/服务端各算一遍)场景下的机械化验证。
6.4 资金安全:先验后扣与冻结先行的验证
问题:结算、充值、退款涉及真实货币。工程铁律:涉及真实资产的异步操作必须冻结先行(在异步调用前冻结,消除资金可被花掉的延迟窗口——这是负反馈"边界熔断"的最高优先应用),失败由补偿路径解冻;先验后扣(检测通过 → 生效 → 扣除)。
解决:mock 验证的正是这些铁律的代码落地:
- 结算幂等:EndGame 重放 2 次不重复发奖(token key < expected → SKIP),客户端 S2C 去重(20 断言)——定律七(单调锚点);
- 崩溃退款:在玩充值 25000 → GS 节点崩溃 → 崩溃通道退款全额且不重复(key 单调,重复崩溃 SKIP)→ 恢复后新局正常(39 断言);
- 双通道竞争:Hu AutoTakeOut 通道与 EndGame 通道携带同一结算 key 竞争,两种到达顺序最终状态一致、恰好一次发奖(22 断言)——定律三/七的组合验证;
- 充值幂等:重复
szRechargeSerial去重 + 客户端绝对余额响应天然幂等,双层幂等(32 断言); - 入场冻结:ApplyMatch/CreateRoom 在异步 CallCenter 之前冻结货币,断言冻结先于请求发出(recharge 链路测试);
- SCENE 结算重投契约:4 次/5s 退避、暂停、重登重新触发(28 断言)——定律二(半开闭)的验证。
这些测试把"资金恰好流转一次"从口头保证变成了可重复验证的断言。
6.5 UI 状态一致性:不可撤回外部效果的验证
问题:服务器状态、客户端数据、UI 渲染三层必须一致(定律四:渲染后只能补偿不能回滚)。
解决:三层一致断言——对同一场景断言 服务器 tResult == 客户端 g_tData == UI 渲染。
真实案例:
- 番型渲染:16 番型 × 三层一致 + 一炮多响(storm fan_render 584 断言);
- 结算面板:EndGame/SettleHu 弹出+渲染、重复 EndGame 去重、Sync(WAITING_LOAD) 后结算结果保留(47 断言);
- 重连还原:全阶段断线重连后 UI 快照与断线前逐控件对比一致(141 断言);大重登后 UI 完全还原(94 断言)——定律十(事件先于权威数据)的验证:重连路径的渲染终态必须等于正常路径;
- 阶段矩阵:8 阶段 UI 可见性矩阵 + 两局切换无残留(254 断言)。
6.6 回归保障
问题:修复 A 破坏 B——分布式系统的修改无法单点验证。
解决:全量回归。修复任何产品代码后跑一键脚本(run_test_mahjong.ps1,动态发现所有 run_*.lua),全量约 120 个测试,退出码 0/1。Q1-Q11(11 项用户投诉功能问题)修复完成后全量回归 90/90 PASS。
6.7 为 AI 设计的工作流
这套框架的使用者是 AI 而非人。人类只写自然语言测试目标(“测断线重连”),AI 按以下路径工作:
- 读对应系统的语义文档(
docs/mahjong/SETTLE_SEMANTICS.md等),把目标拆成可观察行为 + 不变量 +[文件:行号]证据; - 在
tests/<system>/建目录,复制同类测试做模板(命名统一run_<scene>_<aspect>_test.lua); - 按模式写测试、跑通、断言全绿;
- 失败归因按第 3.4 节顺序。
直接产出:多轮并行子代理可以在互不知情的情况下各自编写测试并保持风格一致(storm/ 16 个文件由并行子代理产出,互不冲突)。这是"测试为 AI 设计"原则的最终验证——框架的结构化程度高到 AI 可以凭目录结构和模板完成专业级测试编写。
7. 它不能解决什么问题
这一章与第 6 章同等重要。mock 的边界在 docs/ARCHITECTURE.md 第 8 节有完整清单,这里按"物理根源"归纳——每条限制都能追溯到一条物理约束,不是工程偷懒。
7.1 物理层不能测:被模拟的边界本身就是黑箱
| 不能测 | 物理根源 | 后果 |
|---|---|---|
| 真实网络协议字节 | mock 无真实 socket/序列化/重传,延迟/乱序/丢包是注入抽象 | 不断言底层协议正确性(协议序列化由引擎层负责) |
| 渲染像素 | UI 全虚拟化,无真实渲染/布局引擎 | 断言控件显隐/几何/方法调用,不断言像素级效果 |
| DB 磁盘崩溃恢复 | 无真实数据库 | WAL/token/落账语义在内存态验证,不测磁盘损坏恢复 |
| 性能与规模 | 单进程仿真,非分布式 | 不做真实并发/压力测试;并发正确性由枚举到达顺序 + 收敛断言间接验证 |
这条限制的本质:模拟边界意味着边界以内(引擎、网络、DB)的行为是 mock 的假设,不是被测对象。mock 的信任链是"引擎模拟忠实 → 业务代码行为 = 真实行为"。引擎模拟的任何偏差都会污染结论——这正是 3.1 节推论 B 和"框架可改进原则"存在的原因。
7.2 时序失真:单线程同步分派的法律后果
mock 是单进程单线程同步分派。真实系统里事件异步投递、处理有真实间隙;mock 里是同步调用。这产生两类失真:
- 去重窗口折叠(D32):真实客户端两次 EndGame 之间有异步间隙,
_tEndGameResult去重窗口存在;mock 同步分派下第二次 EndGame 在同一调用栈内被消费,窗口被折叠。测试侧用Sync_DetachUI(摘除 UI 事件桥接)规避; - UI 自续定时器干扰(D33):UI 的 200ms/1000ms 自续倒计时定时器、语音模块的 1000ms 清理定时器在 mock 里真实存在,定时器计数断言必须用客户端类型注册表(
g_tTimerByType)而非全局定时器表。
这两条的物理根源是定律一(无实时全局状态)的反面:真实系统的"间隙"是信息传播时间的体现,mock 消除了传播时间,也就消除了间隙。这是"单进程仿真分布式系统"这一选择的固有代价,只能规避和文档化,不能消除——除非回到真机环境(第 2.1 节:不可控)。
7.3 绝对时间不可测:虚拟时钟的对称代价
多物理时钟折叠为单一虚拟时钟(第 3.2 节证明其合法性)。对称的代价是:所有断言必须基于相对时间因果,不能基于绝对时间。测试倒计时回弹守卫时,需要构造"陈旧值"断言守卫,因为 mock 内真实广播的 nRemainingMs 不随时间自然递减(服务端 Game_GetRemainingMs 基于存储值)。节点间时钟偏差、绝对时间语义不可测——而工程上它们也不可观察(只要倒计时/超时都以各自节点的时间源计算),所以这是可用性换精度的公平交易。
7.4 模拟精度的残余缝隙:已知且记录在案
- 小游戏/外部系统:smallgame/畅玩阁的服务端逻辑以 Include stub + 测试侧忠实复刻验证调用链,真实小游戏服务端不可测(BI 日志等以复刻 stub 验证);
- Mock.Reset 残留(已修复,20260807 D42):Reset 曾不清
g_MAHJONG_play_rooms/g_tRoomTimerIDs等 play_server 全局表,跨场景连续对局需测试自身手工清理(20+ 文件);现 Reset 统一清空 _G 层 play_server 全局表,历史手工清理代码保留但已冗余(幂等无害); - 广播过滤写死(已修复,20260807):node_ps 曾固定
MOCK_MY_PLAYER_ID(2001);现PrimaryPlayerID()动态读Mock.cfg.nPrimaryPlayerID(node_ps.lua:11-13),CallClient/CallCenter 均按主玩家路由,默认 2001 零行为变化; - 观测即干预的残余:框架给产品代码安装包装(语音行为跟踪、模块表补丁、C2S 路由接管),包装本身也是"干预"——虽然语义对齐,但任何包装 bug 都可能产生假象。这是"用仿真验证真实"的不可消除的哲学代价(定律十)。
7.5 测试结论的信任边界
mock 的保证是分层的(第 3.4 节):可编译 → 可运行 → 行为由断言裁决。它不保证:
- 断言覆盖之外的路径正确(未驱动到的代码 = 未验证);
- 模拟失真的路径结论可信(mock 失实 = 测试结论错误——所以"框架可改进原则"要求发现失实必须改 mock,不是绕过测试);
- 引擎本身正确(framework_engine 217 个函数是模拟实现,引擎真实行为以对照源码为准,存在人工对照错误的风险——
compare_result.txt是这份对照的审计产物)。
信任边界的一句话表述:mock 的结论是条件性的——“在模拟忠实的前提下,被测代码的行为是 X”。工程上接受这个条件性,因为它把"真实系统是否正确"转化为"模拟是否忠实 + 断言是否覆盖",两个问题都可检验。
8. 成本与代价
8.1 框架本身的规模
mock 不是"几十行的测试脚手架",它是一个持续演化的子系统:
| 维度 | 规模(磁盘实测) |
|---|---|
| 代码总量 | 366 个 .lua 文件,3619 KB |
| 框架核心 | core 887 行 + framework 875 行 + framework_engine 2561 行 + framework_ui + loader 728 行 + node_servers 487 行 + tab_loader + xml_ui_loader + frame_driver 339 行 |
| 测试资产 | 209 个测试/工具文件,41 个分类目录 |
| 引擎模拟 | 217 个引擎全局函数逐函数对照源码 |
维护成本是持续性的:引擎导出新增函数,mock 要跟着补;产品代码重构全局函数名,mock 的导出表要跟着改;每次框架改动都要跑全量回归证明"零行为变化"。这是一条与产品代码同步演进的生命线,不是一次性投资。
8.2 测试构造成本:契约对齐是硬约束
写一个真实对局测试的成本远高于单测:
- 契约对齐:测试数据必须符合产品契约。
tLastDiscard必须是{nSeat,nTile}或 nil(数字会让真实 client_core 崩溃);胡牌输入张数守恒:
nhand=14−3k,k∈{0,1,2,3,4} ⇒ nhand∈{14,11,8,5,2} n_{\mathrm{hand}}=14-3k,\quad k\in\{0,1,2,3,4\}\ \Rightarrow\ n_{\mathrm{hand}}\in\{14,11,8,5,2\} nhand=14−3k,k∈{0,1,2,3,4} ⇒ nhand∈{14,11,8,5,2}
(禁止 melds 与 14 张手牌同存);地胡场景必须好友房(szShareCode 非 nil 才房主坐庄)——这些契约都是测试过程中踩出来的,违反契约的测试要么崩溃要么假绿;
- 物理合理性:注入负时钟漂移前必须先推进总线(真实客户端 uptime 时钟不可能为负,负值会让毫秒时钟回绕失真):
Ttick=now mod 232 T_{\mathrm{tick}}=\mathrm{now}\bmod 2^{32} Ttick=nowmod232
- 确定性构造:牌墙注入(hook
MAHJONG_Shuffle固定牌序)、固定种子、受控决策器(AI 玩家决策可编程)。
一个成熟的 storm 测试文件(如 phase_matrix 754 行)包含完整的场景隔离基建(BeginScenario:每场景 Reset + 递增种子 + 顺序投递防同 nAt 排序竞态)。这部分基建成本是每类测试都要付的——它是"测试为 AI 设计"的结构化要求的代价。
8.3 运行性能:观测的量化代价
trace 是框架的核心观测手段,但观测有代价(定律十)。调用堆栈捕获(bTraceStack)实测:关闭 21.1s vs 开启 44.5s(全量回归基准,约 2 倍)。堆栈捕获默认开启(诚实性优先),性能开关可恢复基线。这个 2 倍开销换来的是每条 trace 事件携带完整调用链——观测者支付的账单。
8.4 方法论成本:断言翻转的裁决负担
PIN 方法论(第 5.3 节)有一个结构性成本:钉 bug 的断言会 FAIL,直到修复落地。多轮并行子代理模式下,断言翻转(先钉 bug 行为 → 修复后翻转)需要主代理统一裁决,否则会出现"测试 FAIL 但无人处理"的积压(项目记录中多次出现"断言翻转待主代理"的遗留项)。这是"测试不迁就代码"原则的必然代价:测试 FAIL 不一定是坏事,但需要有人判断这个 FAIL 是 bug 证据还是测试错误——裁决权是这套方法论的隐性成本。
8.5 学习的隐性成本
最后一项成本最容易被低估:这套框架的规则密度极高。测试编写者有大量的"陷阱"要记住:Flush 无界跳时、时钟单调纪律、消息先于定时器、0 延迟定时器 1ms、空 CLIENT 节点陷阱、双形态 dispatch……这些规则散落在框架注释、语义文档和 .opencode/ 记忆文件中。对新人(或新 AI 会话)而言,不从文档开始就写测试,产出基本会是坏的。这份成本由 docs/PRINCIPLES.md(11 条宪法级原则)+ docs/ARCHITECTURE.md(新增测试指南)+ 各系统语义文档共同支付——文档不是装饰,是框架的一部分。
9. 为什么需要它:最终论证
9.1 演绎论证
把全文压缩成五步演绎:
- 本游戏是五层六节点的分布式系统,状态经多路径异步协议传播,到达顺序不保证(定律一);
- 到达顺序是正确性的决定性因素——两种顺序两种终态 = bug(定律三),且这类 bug 在真机上不可复现、在单测里不可构造、靠人肉回归不可保证(第 2 章);
- 该系统涉及真实货币(结算/充值/退款,定律七要求精确一次)和玩家可感知的 UI 一致性(服务器/客户端/UI 三层,定律四要求渲染终态一致)——错误成本是资产事故和客诉;
- 唯一出路是回测:真实代码 + 受控时间 + 可注入故障 + 全量可观察(第 2.5 节);
- 而"真实代码"意味着引擎模拟必须达到工程级精度(217 函数逐函数对照,3.1 节推论 B),"受控时间"意味着必须证明相对时间因果不变量(3.2 节),"可注入"意味着故障注入默认零注入保证回归可信(4.4 节),"可观察"意味着调用堆栈诚实记录(4.5 节)。
第 5 步就是 mock 的全部设计:每一个设计决策都从分布式系统的物理约束推导出来,而不是从"测试框架应该有什么"推导出来。这是 mock 与通用测试框架(busted/luassert 等)的本质区别:通用框架提供断言语法,mock 提供分布式语义的仿真能力。
9.2 没有它的后果:违反的定律清单
如果砍掉 mock,用传统手段维护这套系统,每条物理定律都会在工程上被违反:
| 定律 | 没有 mock 时的后果 |
|---|---|
| 一 无实时全局状态 | 竞态 bug 靠线上事故发现,修一个炸一个 |
| 二 半开闭 | 重复请求/丢包无人验证,重试逻辑退化 |
| 三 对等写入 | 权威源约束无法验证,状态被过期快照覆盖 |
| 四 不可撤回外部效果 | UI 与服务器状态分叉,玩家看到错账 |
| 六 同步共振 | 去重失效,重复弹窗/重复发奖 |
| 七 单调锚点 | 结算重复发奖 = 资产漏洞 |
| 十 事件先于权威数据 | 重连路径终态 ≠ 正常路径终态 |
9.3 量化收益
实际收益的证据(全部来自项目记录):
- 定位并修复产品缺陷 50+(D14 群 13 处、D17 4 处、D19/D20/D23/D29/D30/D31/D34/D36a/D36b/D37/D44/D44b/D45/D46/D48/D50/D51、Q1-Q11 等);
- 其中至少 4 个(D19 AI 托管卡死、D20 天胡永不触发、D23 多回合庄家死码、D36b 日志重复)属于真机测试难以复现、代码审查难以发现的类别;
- 客户端-服务端计番一致性经 1200 万例随机对拍验证;
- 资金链路(充值/冻结/结算/退款/崩溃补偿)获得恰好一次语义的断言级证明;
- 全量回归约 120 个测试,修复产品代码后一键验证,退出码裁决。
10. 现状与展望
mock 已走完"从零到一":框架、测试分层、文档体系、运行脚本、真实节点接入、CI 随机回归(MOCK_RANDOM_SEED)均已就位。剩余路线:
- trace 可视化 + 随机回归 CI 基准(目前 trace 导出为文本/JSON/HTML,尚未接入可视化工具链);
- 其他系统测试目录 + run 脚本(smallgame/automatch 已有雏形,
run_test_automatch.ps1/run_test_smallgame.ps1已存在); - 已知框架缺陷的持续清理(20260807 已落地两项:node_ps 广播源可配置
Mock.cfg.nPrimaryPlayerID、Mock.Reset清空 play_server 全局表 D42); - 物理定律验证映射表(5.6 节)向新系统复制(20260807 已落地 smallgame/automatch:
docs/smallgame/LAW_MAPPING.md+docs/automatch/LAW_MAPPING.md,各含缺口清单待建测试);
两条长期约束不变:
- 测试结论可信度 = 模拟真实度(“框架可改进原则”):模拟失实 → 测试结论错误。框架不能支持某测试时,必须补框架,不能降级测试;
- 测试标准不因实现妥协:按 PRD 写断言,物理上不可能达成才允许调低,且必须写明理由。
附录 A:术语对照
| 术语 | 含义 |
|---|---|
| 节点 | mock 中的协议处理单元(SCENE/GC/MC/PS/GS/CLIENT) |
| 协议 | 节点间消息类型,命名 源_目标_功能名 或 C2S_/S2C_ 前缀 |
| 虚拟时钟 | Mock.nClock,毫秒级确定性时间源 |
| trace | 结构化事件流,记录每个投递/处理/定时器/状态变更 |
| 开盒测试 | 逐步调用真实函数并断言内部中间状态的测试 |
| 攻击测试 | 注入乱序/丢包/延迟/崩溃/重复并断言不变量收敛的测试 |
| PIN 断言 | 按 PRD 正确行为写的、当前代码必失败的断言(bug 证据) |
| MOCK-GAP | mock 框架自身缺口(测试目标问题,非产品 bug) |
| 方向约束 | 负反馈形态:规定状态只能由权威源写入 |
| 序次收敛 | 负反馈形态:用单调锚点去重,使接收方总能做唯一正确决策 |
| 边界熔断 | 负反馈形态:强制切断正反馈回路(退出房间清定时器、重连强制重置) |
附录 B:资料来源
本文所有事实、数字、行号均来自:
mock/README.md(235 行,定位/目录/接口映射表/运行方式)mock/docs/PRINCIPLES.md(11 条测试框架原则)、mock/docs/ARCHITECTURE.md(521 行架构文档)mock/core.lua(887 行内核)、mock/node_servers.lua(487 行真实节点管线)、mock/framework.lua(875 行引擎模拟)、mock/loader.lua(728 行加载器)、mock/frame_driver.lua(339 行帧驱动)、mock/framework_engine.lua(2561 行引擎函数层)mock/run/run_test_mahjong.ps1(动态测试发现机制).opencode/memory.md/plan.md/process.md/discover.md(历轮测试记录,含全部产品 bug 编号与修复证据)
所有 bug 编号(D14/D17/D19/D20/D23/D29/D30/D31/D34/D36a/D36b/D37/D44/D44b/D45/D46/D48/D50/D51、Q1-Q11、BUG#18/#30、SUSPECT-1/2/3)在 .opencode/ 四份文件中均有复现路径与因果链记录。
附录 C:分布式物理定律速查(十一条)
| # | 定律 | 一句话 |
|---|---|---|
| 一 | 无实时全局状态 | 信息传播需要时间,任何观测都是过去;不依赖到达顺序的代码才是正确代码 |
| 二 | 半开闭是常态 | 请求发出到响应到达之间,发送方一无所知;一切操作必须幂等或可补偿 |
| 三 | 对等写入必发散 | 两个节点对同一状态有对等写入权则冲突无终止条件;收敛唯一方法是剥夺一方写入权 |
| 四 | 不可撤回的外部效果 | UI 渲染后只能补偿不能回滚 |
| 五 | 共识是最后手段 | 能用方向约束/幂等+补偿解决,就不用分布式事务 |
| 六 | 同步共振 | N 个组件同输入同控制律,副作用 N 倍叠加;需要去重 guard 与白名单解耦 |
| 七 | 单调锚点 | 等幂性的最小状态解是一个单调递增整数,一个锚点替代无限集合 |
| 八 | 规则堆砌是模型失败 | 每次修复加固模型而不是加规则;规则越多行为越难预测 |
| 九 | 不对称安全 | 可信方向瞬时确认,不可信方向保守;宁可漏判不可误判 |
| 十 | 事件先于权威数据 | 状态变更通知与权威数据走不同路径;观测即干预,观测永远在转发之后 |
| 十一 | NULL 不可穿透 | 序列化吞掉 nil,服务器有意置空的字段不能默默丢失;快照预清空 + 显式清理表 |
更多推荐

所有评论(0)