☰
Cocos Creator麻将棋牌开发:规则层、胡牌算法与重连回放
2026/10/31 7:26:14 网站建设 项目流程

简介:《cocos creator达达麻将棋牌游戏》是一套面向棋牌游戏开发者、Cocos Creator学习者及需要完整前后端案例团队的项目源码,围绕麻将规则实现、用户交互、状态管理、Node.js服务端登录匹配与MySQL数据存储展开,可用于学习网络棋牌从客户端到服务端的组织方式。压缩包共2000个文件,以JSON配置、JS脚本、meta资源索引、PNG素材、Markdown文档、mp3音频及Prefab/Anim等为主,整体约51.62MB,目录中包含客户端资源、服务端代码与数据库相关文件。已有3264人学习下载,说明其在棋牌项目实战参考方面具有一定关注度。读者可据此梳理Cocos Creator场景与脚本分工、前后端通信和玩家积分数据表设计,并参考上线运营所需的安全、性能优化与兼容性测试思路,适合作为完整项目拆解与二次开发底稿。

1. 一套能直接跑起来的 Cocos Creator 麻将棋牌游戏工程,第一件事不是翻代码

压缩包解开是个能直接跑的 Cocos Creator 麻将棋牌游戏工程:牌桌、发牌、吃碰杠胡、房间匹配、结算面板都在里面,打开主场景点预览就能坐上四个人打一圈。这类资源最容易踩的误区是从第一个脚本开始读——两百多个文件翻完,你还是不知道改动该落在哪一层。我的习惯反过来:先跑通一局,把洗牌、发牌、出牌、判定、结算这五件事在控制台日志里从头跟一遍,再顺着日志回去找对应脚本,半小时就能画出自己的心智地图。这套工程对两类人比较友好:想快速摸清棋牌品类工程结构的 Creator 开发者,以及接了二次开发、要在原有玩法上换皮加规则的小团队。它把规则层和表现层切开了,这一点直接决定了你能把它改成什么样,后面几章都围绕这个划分展开。

2. 工程骨架与牌数据模型:34 种牌面怎么落成可序列化的结构

刚拿到工程,先别管牌桌怎么画,先搞清楚数据长什么样。棋牌类项目和普通休闲游戏最大的差别是:它的正确性可以被数学证明,也可以被数学证伪。牌墙是不是 136 张、一手牌能不能胡、听哪几张,这些都不是"看起来对"就行的东西。所以这一章先把骨架和数据结构定死,后面所有逻辑都建立在这上面。

2.1 目录骨架:规则层必须能脱离引擎单独跑

按 Creator 3.x 的组织方式,这套工程的 assets 下面大致分成三块:scenes放登录、大厅、牌桌、结算四个场景;scripts/game放表现层,跟节点、动画、音效绑定;scripts/core放规则层,只处理数字和数组。真正值钱的是第三块,因为它在整个项目里最不该被引擎污染。

判断一个棋牌工程写得好不好,我一般看一个指标:core目录下的文件能不能直接被 Node 加载执行。如果能,说明规则层没有import { _decorator } from 'cc',没有Node、没有Sprite、没有director,纯 TS 加数组运算;如果不能,说明规则和表现搅在一起了,后面想写自动化测试、想在服务端复用同一份判定逻辑,全得重写。

实际拆的时候,core里通常这么分:tile.ts管牌面编码,wall.ts管牌墙与洗牌,hand.ts管手牌增删和排序,rule.ts管胡牌、听牌、番型,fsm.ts管一局的状态机。表现层只做一件事:监听状态机抛出的事件,把事件翻译成动画。发牌事件来了就飞牌,出牌事件来了就落桌,别的什么都不算。

这条边界一旦守住,后面两个好处会立刻体现出来:一是可以写一个 Node 脚本,把线上牌谱灌进去跑回归,几秒钟跑完十万局;二是断线重连时客户端可以"快进重演",因为重演用的就是同一套纯函数,不需要加载任何预制体。

2.2 牌 ID 编码:为什么用 0–33 而不是"一万"这种字符串

牌面表示是第一个要拍板的事。见过用字符串"1万"、"东"当 key 的工程,跑起来没问题,但一做排序就难受,一做网络传输就膨胀,一做位运算就彻底没戏。这套工程用的是整数编码:34 种牌面,0 到 33,每种 4 张副本,合计 136 张。

编码区间花色rank 计算方式备注
0–8万kind % 9 + 1序数牌,可组顺子
9–17条kind % 9 + 1序数牌,可组顺子
18–26筒kind % 9 + 1序数牌,可组顺子
27–33字牌kind - 26东南西北中发白,不可组顺子
// core/tile.ts —— 牌面编码约定,全工程唯一的真相来源 export const TILE_KINDS = 34; /** 花色:0=万 1=条 2=筒 3=字 */ export function suitOf(kind: number): number { return kind < 27 ? Math.floor(kind / 9) : 3; } /** 点数:序数牌 1..9,字牌 1..7 */ export function rankOf(kind: number): number { return kind < 27 ? (kind % 9) + 1 : kind - 26; } /** 是否序数牌:顺子只可能出现在序数牌里,这个判断是后面剪枝的关键 */ export function isSuited(kind: number): boolean { return kind < 27; }

这段代码本身没什么难度,值钱的是它带来的三个便利。第一,排序直接a - b就完了,不用写比较器,手牌天然按万条筒字排好。第二,网络传输一个字节就能表示一张牌,杠上开花时一次广播十几张牌也不心疼流量。第三,判定顺子时只需要判断kind % 9 <= 6,因为编码本身就保证了同一花色的牌是连续的九个整数,跨花色根本凑不出连续的三个。

手牌本身不建议用number[]存,用长度 34 的计数数组更划算:counts[k]表示第 k 种牌有几张,取值 0 到 4。删除一张就是counts[k]--,判断有没有就是counts[k] > 0,判断刻子就是counts[k] >= 3。胡牌判定里要反复做这几件事,用计数数组比用数组查找快一个数量级,代码也短。

2.3 牌墙生成与可复现洗牌:种子比随机数重要

发牌这件事有个隐藏要求:同一个种子必须洗出同一副牌墙。原因不复杂——断线重连要把玩家送回原来的牌局,牌谱回放要让客服能复现玩家投诉的那一手,自动化测试要能拿固定牌型去验证胡牌算法。这三件事全都依赖"洗牌可复现",而Math.random()一个都满足不了。

// core/wall.ts —— 牌墙生成 + 种子洗牌 import { TILE_KINDS } from './tile'; /** xorshift32,够快、够散,而且状态就是一个 32 位整数,方便序列化进快照 */ export class SeededRandom { private state: number; constructor(seed: number) { this.state = (seed >>> 0) || 0x9e3779b9; // seed 为 0 时退化,必须兜底 } next(): number { let x = this.state; x ^= x << 13; x >>>= 0; x ^= x >>> 17; x ^= x << 5; x >>>= 0; this.state = x; return x / 0x100000000; } } /** 生成一副 136 张的牌墙,元素是 0..33 的牌面编码 */ export function buildWall(): number[] { const wall: number[] = []; for (let k = 0; k < TILE_KINDS; k++) { for (let c = 0; c < 4; c++) wall.push(k); } return wall; } /** Fisher-Yates,从后往前换,保证每种排列等概率 */ export function shuffleWall(wall: number[], rng: SeededRandom): void { for (let i = wall.length - 1; i > 0; i--) { const j = Math.floor(rng.next() * (i + 1)); const tmp = wall[i]; wall[i] = wall[j]; wall[j] = tmp; } }

xorshift32选它的理由不是性能,是可序列化:整个随机状态就是一个数字,塞进重连快照里只用四个字节,客户端拿到之后接着往下洗,结果和服务端完全一致。用Math.random()就做不到这一点,你只能把整副牌墙的剩余部分打包下发,136 张牌多的时候几百字节,看着不多,但每局都要传一次就难受了。

洗牌算法本身也有讲究。早期见过有人写wall.sort(() => Math.random() - 0.5),这行代码在 V8 里不是等概率的——比较函数返回值不稳定时,排序结果会偏向原始顺序,表现就是"感觉好牌总在固定几个位置"。Fisher-Yates 从后往前遍历、每次和[0, i]区间内随机一个位置交换,才是数学上严格的等概率洗牌,代码还更短。

牌墙生成之后不要立刻把 136 张全发给四个人。标准流程是:庄家 14 张,其余三家各 13 张,剩下 83 张留在牌墙里按顺序摸。这套工程把"摸牌指针"单独存成一个整数wallCursor,摸一张就wall[wallCursor++],比每次shift()好——shift()是 O(n),一局下来累积的开销在低端机上会有感知。

3. 胡牌判定与听牌:递归拆解能不能扛住一局七十次调用

规则层里最容易被写错、也最容易被写得性能不够的,就是胡牌判定。它被调用的频率比想象中高得多:玩家每次出牌要判一次自己是否听牌,收到别人打的牌要判一次能不能胡,杠牌要判一次抢杠,结算要判一次番型,一局下来七十次往上很正常。写得好,这段代码全程不到 1 毫秒;写得随意,就是那种"平时没事,一到残局四家都在算听牌就掉帧"的玄学。

3.1 先算复杂度账:为什么纯暴力枚举会翻车

最朴素的思路是把 14 张手牌做全排列,然后每三张一组检查是不是面子,最后两张检查是不是将。14 张牌的全排列是 14!,约 870 亿种,就算加上去重也还是天文数字。这条路走不通,问题不在计算机慢,在于枚举了大量本质相同的组合——[1万,2万,3万]和[3万,1万,2万]在手里是完全一样的东西,枚举它们纯属浪费。

正确做法是按牌面从小到大拆,每次只处理"当前最小的那张牌"。这张牌要么出现在刻子里(三张一样),要么出现在顺子里(连同后面两张相邻的序数牌),要么它是将牌的一部分。三种可能,对应三个分支,处理完就把这几张牌从计数里扣掉,递归处理剩下的。因为每张牌最多四张,每次递归至少消耗两张牌(将)或三张牌(面子),递归深度不超过 5,分支数在实际牌型下也很有限。

实测下来,一次判定在普通手机上大约 20 到 60 微秒,也就是说一局七十次判定的总耗时不到 5 毫秒。这个量级完全不需要上查表或者预计算,除非你想做 AI 出牌推荐——那种场景要枚举所有可能的出牌组合,才需要另想办法。

3.2 一套带剪枝的递归判定

// core/rule.ts —— 胡牌判定:递归拆解,计数数组原地回滚 /** * 判定 counts 能否拆成 need 个面子(刻子或顺子) * counts 长度 34,元素 0..4,调用期间会被临时修改,返回前必定还原 */ function canFormMelds(counts: number[], need: number): boolean { // 找当前还有剩余的最小牌面 let i = 0; while (i < 34 && counts[i] === 0) i++; if (i === 34) return need === 0; // 牌用完了,面子够不够就是答案 // 分支一:刻子 if (counts[i] >= 3) { counts[i] -= 3; const ok = canFormMelds(counts, need - 1); counts[i] += 3; if (ok) return true; } // 分支二:顺子。只有序数牌能组,且 i%9 <= 6 保证不跨花色 if (i < 27 && i % 9 <= 6 && counts[i + 1] > 0 && counts[i + 2] > 0) { counts[i]--; counts[i + 1]--; counts[i + 2]--; const ok = canFormMelds(counts, need - 1); counts[i]++; counts[i + 1]++; counts[i + 2]++; if (ok) return true; } // 两个分支都不成立,说明这张最小的牌无处安放 return false; } /** 标准胡型:4 个面子 + 1 个将 */ export function isStandardWin(counts: number[]): boolean { for (let i = 0; i < 34; i++) { if (counts[i] < 2) continue; counts[i] -= 2; // 先定将 const ok = canFormMelds(counts, 4); counts[i] += 2; // 无论成败都还原 if (ok) return true; } return false; } /** 七对:部分玩法才有,用开关控制,别硬编码进主流程 */ export function isSevenPairs(counts: number[]): boolean { let pairs = 0; for (let i = 0; i < 34; i++) { if (counts[i] % 2 !== 0) return false; pairs += counts[i] / 2; } return pairs === 7; }

三个细节值得说。第一,canFormMelds每次只处理最小牌,这个顺序天然去重——同一组面子不会被拆成不同顺序重复计算,剪枝效果比随机挑牌好得多。第二,所有减法都配了对应的加法还原,这一点非常关键:counts是全局共享的手牌引用,一旦某条分支提前 return 忘了还原,后面所有判定都会带着脏数据跑,出现"明明没胡却提示胡了"这类血泪问题。第三,i % 9 <= 6这行判断省不掉,虽然编码保证了同花色连续,但 8 号(9万)和 10 号(1条)在整数上也是相邻的,不加这个判断就会拆出"9万+1条+2条"这种不存在的顺子。

规则开关建议做成配置对象而不是写死。七对、十三幺、清一色加番、是否允许吃、是否只能自摸,这些在不同玩法里差异很大,接二次开发时十有八九要改。做成ruleSet: { sevenPairs: boolean, thirteenOrphans: boolean, ... }传进判定函数,改玩法时只动配置,不动算法。

3.3 听牌提示:把 34 次判定压成一次遍历

听牌的计算很直接:往手牌里加一张,看能不能胡,能胡这张就是听的牌。34 种牌面全试一遍,最多 34 次判定,也就是 1 到 2 毫秒,完全可以放在出牌后的下一帧算。

// core/rule.ts —— 听牌列表,同时把"还剩几张"一起算出来 export function getTingTiles( counts: number[], ruleSet: { sevenPairs: boolean }, remain: number[] // remain[k] 表示第 k 种牌还有几张没露面 ): Array<{ kind: number; left: number }> { const result: Array<{ kind: number; left: number }> = []; for (let k = 0; k < 34; k++) { if (remain[k] <= 0) continue; // 四张全在桌上,听了也是死听,直接跳过 counts[k]++; const win = isStandardWin(counts) || (ruleSet.sevenPairs && isSevenPairs(counts)); counts[k]--; if (win) result.push({ kind: k, left: remain[k] }); } return result; }

remain这个数组容易被忽略,但它是"听牌提示"能不能用的分水岭。玩家看到"听 3 万",第一个反应是"还剩几张",如果你只告诉他听什么不告诉他剩几张,他会自己去数弃牌堆,体验就断了。remain[k] = 4 - 自己手上的 - 明牌副露里的 - 所有弃牌里的,这个值在每次出牌、碰杠之后增量更新一次,成本几乎为零。

还有个实用的小优化:算听牌前先判一次是不是已经胡了,胡了就不用算听牌;手牌数不是 13 张(或者 10 张、7 张这类副露后的形态)时直接返回空,避免在动画中途被误调用算出一堆垃圾结果。

4. 对局流程:服务端权威、断线重连快照与牌谱回放

客户端能算牌,但不能决定牌。棋牌游戏里客户端唯一可信的东西是"玩家点了哪张牌",剩下的全得服务器说了算。这不是技术洁癖,是因为棋牌游戏的作弊收益太高了——改一个本地变量就能看到别人的手牌,改一个判定返回值就能强行胡牌。所以这一章讲三件事:状态怎么切、重连怎么补、牌谱怎么留。

4.1 谁说了算:把"表现"和"状态"彻底切开

这套工程用的是标准的服务端权威模型。客户端只发意图,服务器校验后广播结果,客户端照着结果播动画。一次出牌的完整链路是这样的:

// scripts/net/protocol.ts —— 意图上行 / 事件下行,两边字段名必须完全一致 export interface DiscardIntent { type: 'discard'; seq: number; // 客户端收到的最后一个事件序号,服务器用来做幂等 kind: number; // 0..33 } export interface GameEvent { seq: number; // 全局单调递增,客户端按序 apply,跳号就说明丢包了 seat: number; // 事件发起方 action: 'draw' | 'discard' | 'peng' | 'gang' | 'hu' | 'pass'; kind?: number; // 涉及的牌 from?: number; // 碰杠胡的来源座位 deadline?: number; // 下一个行动者的超时时间(服务端时间戳) }

seq这个字段是整个同步机制的锚点。服务端每产生一个事件就加一,客户端收到后检查是不是本地 seq + 1,是就 apply,不是就说明丢包或者乱序,立刻走一次全量拉取。没有这个字段,你就只能靠"看起来对不对"来判断同步状态,出问题时根本没法定位。

本地预测要不要做,我的经验是克制着做。出牌动画可以本地先播——玩家点了牌,牌立刻抬起来往桌上飞,手感是顺的;但"这张牌已经落到弃牌堆、下家开始摸牌"这个状态必须等服务器广播。中间这段几十毫秒的空窗,用动画时长糊过去就够了。反过来,如果本地直接把牌落桌并推进到下一家,服务器一旦拒绝(比如这张牌其实不在你手上),你就得写一套回滚逻辑,那套逻辑的复杂度和 bug 密度远超它带来的手感提升。

输入锁也是同一件事的另一面。出牌动画播放期间把交互关掉,等seq推进到下一手再打开。看着简单,但不做的话玩家连点三下,客户端发出去三个意图,服务器只认第一个,剩下两个返回错误,界面上的牌和服务器上的牌就开始对不上了。

4.2 重连快照要存到什么粒度

断线重连是棋牌游戏里最容易做漏的功能。移动端网络切换、来电、切后台,随时会断,玩家回来的时候必须一秒钟之内看到完整的牌桌,而不是空白界面或者"重新开始"。

快照的关键在于粒度。只存手牌肯定不够,因为一局牌的状态不只包含手牌,还包含"谁该行动""牌墙还剩多少""有没有人杠了等着补牌""当前是不是抢杠窗口"。这些中间态漏一个,重连回来就会错位。

字段类型为什么必须存
seqnumber快照对应的操作序号,和增量事件对齐
handnumber[34]自己的手牌计数,别人的不下发
handCountsnumber[4]各家手牌数量,用来渲染牌背
meldsnumber[4][]各家已亮出的碰杠副露
discardsnumber[4][]各家弃牌堆,按时间顺序
wallCursornumber牌墙摸到第几张,决定剩余张数
turnSeatnumber当前行动者,-1 表示结算中
turnDeadlinenumber服务端时间戳,客户端要算时钟偏移
rngStatenumber种子随机状态,保证后续摸牌一致
pendingActionstring杠后补牌、抢杠窗口这类等待态

rngState是很多人会漏的一个。如果牌墙在服务端是"开局洗好一整副、按游标摸",那重连其实不需要它;但如果中途有"杠后从牌墙尾部补牌""流局重洗"这类操作,随机状态就必须跟着快照走,否则重连之后摸出来的牌和原来对不上,玩家会发现"我明明记得下一张是三条"。

turnDeadline用服务端时间戳,不要用服务器下发的"剩余秒数"。移动端从后台切回来,倒计时不会自己走,用剩余秒数会导致玩家回来发现还剩 15 秒,实际服务器那边早就超时托管了。正确做法是拿服务端时间戳减去本地时间算一个偏移,之后所有倒计时都基于偏移换算。

客户端收到快照后的处理顺序也有讲究:先把本地牌桌状态整体重置成快照内容(不做动画),再把快照seq之后收到的增量事件依次 apply(同样不做动画),最后才恢复正常渲染循环。整个快进过程应该在一帧内完成,玩家看到的是"加载一下,牌桌就回来了"。

4.3 牌谱回放:种子加操作序列就够了

牌谱别存整局的所有状态快照,那玩意儿一局能到几十 KB,存一百万局就是几十 GB。正确做法是只存两样:开局种子,和整局的操作序列。

回放的执行过程就是重演:用种子重建牌墙,按操作序列依次调用同一套规则函数,客户端拿到的是每一帧的完整状态。这套做法的前提是规则层必须是纯函数且确定性——同样的种子和同样的操作序列,跑出来必须逐帧一致。这也是第 2 章强调"规则层脱离引擎"的第二个理由:服务端和客户端各自跑一遍,结果能对上,才敢用来做客服举证。

回放功能的价值不只是"看录像"。玩家投诉"我明明胡了为什么不给我胡",客服把牌谱拉出来跑一遍,如果确实是误判,那就是规则 bug,修复之后这条牌谱就变成回归用例;如果不是,也能拿出证据。这套流程跑顺之后,规则相关的纠纷会少一大半。

5. 避坑:五条上线前必须自己踩一遍的坑

前面四章讲的是怎么把东西做出来,这一章讲的是怎么做完之后不被打脸。下面五条都是这类工程里反复出现的问题,按"现象、原因、解决"写,能对上号就直接照做。

5.1 热更之后进牌桌白屏,日志里一片 404

现象:小版本热更推上去,大厅能进,一点牌桌就白屏,控制台刷一堆资源加载失败。

原因基本是两个。一是 bundle 的版本号没跟 manifest 走,CDN 上还是旧资源,本地的配置已经指向新路径;二是配置表、预制体里写的是绝对路径db://assets/xxx,热更之后目录结构变了,路径直接失效。

解决:manifest 里的每个 bundle 绑定内容哈希,而不是手写版本号;资源引用统一走resources.load或 bundle 内的相对路径,禁止在预制体里挂绝对路径。发布前加一步校验,扫一遍所有.prefab和.json,把db://开头的字符串全部打出来,有一个算一个都改掉。另外热更完成后强制整包重启一次,不要指望清 JS 缓存,引擎侧的模块缓存比你想象的顽固。

5.2 杠完之后断线重连,手里少一张牌

现象:玩家杠了,等补牌的时候断线,重连回来手牌数量不对,后面所有的出牌判定都跟着错。

原因:快照只存了手牌和副露,没存"杠后补牌待办"这个中间态。服务端那边其实挂着一个待办任务,客户端不知道,重连之后状态机停在"等待出牌",玩家手里少一张,出牌按钮却是亮的。

解决:把一局的状态机完整序列化,包括pendingAction这类等待态。更彻底的做法是:重连不重建状态,而是走和正常流程完全一样的 apply 逻辑——把快照当初始状态,把seq之后的增量事件依次喂给状态机,状态机自己会走到正确的位置。这样一来,只有一套代码路径,不会出现"正常流程对、重连流程错"的分裂。

5.3 回放对不上,十有八九是Math.random

现象:客服拿牌谱回放,跑到中盘突然和玩家的录屏不一样了,牌墙里的牌对不上号。

原因:某个地方偷偷用了Math.random()——可能是洗牌、可能是 AI 出牌的随机种子、可能是发牌特效里的随机抖动被误写进了逻辑层。只要有一处,回放就必然分叉。

解决:在core目录下全局禁用Math.random,用 ESLint 规则或者简单的正则扫描卡在提交前。所有随机必须从SeededRandom实例取,而实例的种子必须来自服务端下发。加一条自动化检查:用同一份牌谱跑两遍,比对每一步的完整状态哈希,不一致就报错。这条检查跑在 CI 里,比人工回放靠谱得多。

5.4 打到第八局开始掉帧,越打越卡

现象:第一局很流畅,连打七八局之后帧率肉眼可见地掉,牌桌上的牌越铺越乱。

原因:弃牌节点没回收。每张打出去的牌都instantiate一个预制体,局末只做了active = false或者干脆从父节点移除但没销毁,节点还挂在内存里;同时每次开局给按钮绑的事件监听没有off,监听器随局数线性增长,一次点击触发八次回调。

解决:弃牌用对象池,局末全部put回池子;所有on必须在onDestroy里配一个off,或者用this.node.on配合节点销毁自动解绑。排查方法很土但有效:打开 Creator 的节点树面板,打三局,看节点数是不是单调上涨,涨了就是没回收。监听器泄漏看内存快照里的 listener 数量,同样看是不是单调上涨。

5.5 连点出牌,本地和服务端打起来了

现象:手速快的玩家连点两下出牌,界面上的牌飞出去了,但服务器只认第一张,第二张返回错误,之后牌桌上显示的和实际状态错开,必须刷新才好。

原因:输入没有锁。出牌动画播放期间按钮还是可点的,客户端把两个意图都发了出去。

解决:双保险。客户端在发出意图的那一刻就锁住输入,直到收到对应seq的广播再解锁;服务端对超出当前seq的意图直接丢弃,不做排队。两端都做,是因为客户端锁可能被各种异常绕过(比如动画被打断),服务端的丢弃才是最后一道闸。

6. 上线前的最后一道闸:用十万局牌谱把规则跑成回归用例

规则层的 bug 有个恶劣特性:它不会崩,只会悄悄算错。多算一番、少算一张、听牌漏了一个,界面上一切正常,只有玩家知道不对劲。这类问题靠人手点测试是发现不了的,只能靠批量跑。

我的做法是把线上牌谱导成 jsonl,一行一局,字段就三个:seed、ops、expect(这一局最终的结果)。然后用 Node 脚本加载core目录下的规则代码,逐局重演,把算出来的结果和expect比。

# tools/replay-check.sh —— 挂在发布流水线上,跑不过就不许发版 #!/bin/bash set -e # 1. 从牌谱里抽 10 万局,按 7:3 切成回归集和对照集 node tools/split-replay.js --input ./replay/2024*.jsonl --limit 100000 --ratio 7:3 # 2. 重演,任何一局结果不一致就非零退出 node tools/replay-runner.js --dir ./replay/regression --concurrency 8 # 3. 输出耗时分布,判定单次胡牌计算的 P99 有没有超过 2ms node tools/replay-bench.js --dir ./replay/regression --report ./out/bench.json

replay-runner.js内部干的事情很朴素:buildWall()加shuffleWall()造牌墙,按ops依次调用draw、discard、peng、gang、isStandardWin,最后比对番型和输赢。因为规则层不依赖引擎,这个脚本可以开 8 个并发进程跑,十万局大概两分钟出结果。

这套回归跑顺之后,我还习惯在上线前过一张检查表,逐项打勾,缺一项就不发:

检查项判定标准不通过的典型表现
牌墙完整性每局发完牌后四种花色的总数守恒某种牌出现 5 张
胡牌判定十万局回归零差异番型算错、漏胡
听牌提示与人工标注的 200 组牌型完全一致漏听、死听未过滤
断线重连随机断点重连 1000 次,状态哈希一致手牌数量错位
牌谱回放同局跑两遍逐帧哈希一致出现随机分叉
首包体积首包不超过约定阈值,牌桌资源在分包里首次进入等待超过 5 秒
长局内存连打 20 局,节点数与内存不单调上涨第八局开始掉帧

提示:回归集和对照集一定要分开。用回归集调完之后,拿对照集再验一遍,能挡掉一批"针对测试用例过拟合"的改动,尤其是改番型计算的时候。

现在这套流程已经变成固定习惯:每次动core/rule.ts里任何一行,先本地跑一遍小样本,提交之后流水线跑全量,跑不过直接拦下来。有次改七对的判定,本地小样本全绿,全量跑到第三万局才暴露出四张相同牌被算成两对的问题,如果靠人工点,这个 bug 大概会带着上线,然后被某个玩家在论坛上挂出来。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询