二七王:合入前端子项目 B(网络与数据镜像)
建立前端网络层与状态镜像层:state/(GameState 状态镜像、Events、RoomOptions)、 net/(Rpc 发包、Dispatcher 收包分发、10 个 handler)、SubGameHooks 平台接线。 核心是「增量回放 vs 全量重连」一致性测试——用服务端实跑一局导出的真包, 把逐包增量与断线重连两条路径逼到同一结果。据此在界面开发前修掉 12 个缺陷: 前端私存对局态 6 处、服务端漏发下发面 5 处,以及 mySeat 恒为 -1 (平台调 appStart 时 C_Player 尚未 new,致所有请求包带 seat:-1、开局快照整包丢弃)。 服务端 649 → 729 checks;泄露门禁从「只扫牌 id」扩到统计量重算与投降/解散路径。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -74,4 +74,6 @@ git config core.hooksPath .githooks
|
||||
## 测试与 Git
|
||||
|
||||
- **测试纪律**:细则见 client 05 §10 / server 04 §10。要点——测试代码可用现代语法(只跑 Node,不上线);**正式代码禁止为测试而加逻辑/放宽校验**,禁止为过测而改正式代码或降低测试标准(skip、软化断言、吞异常等);失败先用证据裁定「业务缺陷 vs 脚本缺陷」;正面/反面/边界用例都要覆盖。
|
||||
- **新增断言要反向验证**:写完一条守卫断言,**故意破坏它守的那处逻辑,确认它真会变红**——否则你加的可能是一条空转断言(「全绿但什么都没验」比红更有害)。
|
||||
- ⚠️ **本仓库文件是 CRLF 行尾**:任何基于行尾的脚本化改写(`sed`/正则批量删改)必须按 `\r?\n` 处理。写 `\n` 会**一行都匹配不上却静默成功**——曾据此得出「测试抓不住缺陷」的错误结论。脚本改完先 `git diff --stat` 确认文件真的变了,再据其结果下判断。
|
||||
- **Git 提交**(细则见 server 04 §11):完成一个可独立成立的逻辑改动即可**自动提交、无需逐次确认**(`push` 按需);提交信息用中文、聚焦一件事、结尾保留 `Co-Authored-By` 署名;不用 `git add -A`/`git add .`、不跳过 hooks、不做 `reset --hard`/`push --force`。
|
||||
|
||||
+18
-1
@@ -236,7 +236,24 @@
|
||||
<script type="text/javascript" src="js/01_SubGame/codes/core/SpriteIndex.js"></script>
|
||||
<!-- 5.4 布局求解 -->
|
||||
<script type="text/javascript" src="js/01_SubGame/codes/ui/LayoutSolver.js"></script>
|
||||
<!-- 5.5 平台接入(须在依赖之后、三文件转发壳之前) -->
|
||||
<!-- 5.5 对局状态与事件 -->
|
||||
<script type="text/javascript" src="js/01_SubGame/codes/state/Events.js"></script>
|
||||
<script type="text/javascript" src="js/01_SubGame/codes/state/GameState.js"></script>
|
||||
<script type="text/javascript" src="js/01_SubGame/codes/state/RoomOptions.js"></script>
|
||||
<!-- 5.6 网络:handler 在前,Dispatcher 在后(它注册时要引用全部 handler) -->
|
||||
<script type="text/javascript" src="js/01_SubGame/codes/net/handlers/FailHandler.js"></script>
|
||||
<script type="text/javascript" src="js/01_SubGame/codes/net/handlers/DealHandler.js"></script>
|
||||
<script type="text/javascript" src="js/01_SubGame/codes/net/handlers/CallHandler.js"></script>
|
||||
<script type="text/javascript" src="js/01_SubGame/codes/net/handlers/MainHandler.js"></script>
|
||||
<script type="text/javascript" src="js/01_SubGame/codes/net/handlers/BuryHandler.js"></script>
|
||||
<script type="text/javascript" src="js/01_SubGame/codes/net/handlers/PlayHandler.js"></script>
|
||||
<script type="text/javascript" src="js/01_SubGame/codes/net/handlers/QueryHandler.js"></script>
|
||||
<script type="text/javascript" src="js/01_SubGame/codes/net/handlers/ReadyHandler.js"></script>
|
||||
<script type="text/javascript" src="js/01_SubGame/codes/net/handlers/ResultHandler.js"></script>
|
||||
<script type="text/javascript" src="js/01_SubGame/codes/net/handlers/ResyncHandler.js"></script>
|
||||
<script type="text/javascript" src="js/01_SubGame/codes/net/Dispatcher.js"></script>
|
||||
<script type="text/javascript" src="js/01_SubGame/codes/net/Rpc.js"></script>
|
||||
<!-- 5.7 平台接入(须在依赖之后、三文件转发壳之前) -->
|
||||
<script type="text/javascript" src="js/01_SubGame/codes/SubGameHooks.js"></script>
|
||||
|
||||
<!-- 子游戏 Hooks 实现(转发壳三文件转发到此;须在依赖的 controllers/handlers 之后、三文件之前)-->
|
||||
|
||||
@@ -122,8 +122,7 @@ SubGameHooks.gamemydraw = function (gameid, spid, times, timelong, no2, no3, no4
|
||||
// [A] Free(_msg):投票解散确认回调。
|
||||
// SubGameHooks.Free = function (_msg) { /* ... */ };
|
||||
|
||||
// [A] DeskInfo(_msg):未开战状态下加入牌桌的数据同步。
|
||||
// SubGameHooks.DeskInfo = function (_msg) { /* ... */ };
|
||||
// [A] DeskInfo(_msg):牌局已开始时加入牌桌的数据同步(实现见文末「网络与对局」一段)。
|
||||
|
||||
// [A] breakRoom():开局后自己退出房间触发。
|
||||
// SubGameHooks.breakRoom = function () { /* ... */ };
|
||||
@@ -246,5 +245,105 @@ SubGameHooks.gamemydraw = function (gameid, spid, times, timelong, no2, no3, no4
|
||||
// [D] gameui_stop_voice(uiIndex):停止桌面语音气泡。
|
||||
// SubGameHooks.gameui_stop_voice = function (uiIndex) { /* ... */ };
|
||||
|
||||
//—— 网络与对局(B 阶段接线)——
|
||||
//平台入口只解包转交,业务全在 net/ 的 handler 里。
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 【room.mySeat 的唯一写入处】(SSOT)
|
||||
//
|
||||
// 为什么不能只在 appStart 里取一次:
|
||||
// 平台 12_Logic.js:389 调 Game_Modify.appStart() 时,C_Player 还只是一个
|
||||
// `var C_Player;`——`C_Player = new Player(-1)` 要到【同一个函数】的第 480 行才执行。
|
||||
// 此刻 typeof C_Player === 'undefined',守卫为假、整个 appStart 空转;
|
||||
// 之后玩家登录、07_Desk.js 里 C_Player.SetSeat() 拿到真座位,但 appStart 再也不会被调用,
|
||||
// 于是 mySeat 永远停在 -1:发包带 seat:-1(服务端 check_player 必然拒绝)、
|
||||
// StartWar 差异化下发找不到本座位而整包丢弃、SeatMap.toDisplay(-1) 抛错。
|
||||
// ——而且【全程无报错】,这正是它能潜伏下来的原因。
|
||||
//
|
||||
// 因此改为:单一写入处 + 多个调用点。凡是平台已经确定座位的入口都同步一次,
|
||||
// 不赌「所有路径都会经过某一个入口」。
|
||||
//
|
||||
// 幂等:可重复调用;C_Player 不可用或座位非法时【保持原值不写】——
|
||||
// 绝不用 -1 覆盖已同步好的正确座位。
|
||||
// ---------------------------------------------------------------------------
|
||||
SubGameHooks._syncMySeat = function () {
|
||||
if (typeof C_Player === 'undefined' || !C_Player) { return; }
|
||||
var seat = C_Player.seat;
|
||||
if (seat !== 0 && seat !== 1 && seat !== 2) { return; }
|
||||
EQW_GameState.room.mySeat = seat;
|
||||
};
|
||||
|
||||
// 对局推送。_msg 是完整包 {app, route, rpc, data}
|
||||
// (平台 12_Logic.js 按 route 分流,只有子游戏路由 erqiwang 才到这里)
|
||||
SubGameHooks._ReceiveData = function (_msg) {
|
||||
if (!_msg) { return; }
|
||||
EQW_Dispatcher.dispatch({ rpc: _msg.rpc, data: _msg.data });
|
||||
};
|
||||
|
||||
// 开局。可能差异化下发(sendtype:1 + seatlist[])
|
||||
// 【必须先同步座位】handleStartWar 要按 mySeat 在 seatlist[] 里认领自己那份
|
||||
SubGameHooks.StartWar = function (_msg) {
|
||||
SubGameHooks._syncMySeat();
|
||||
EQW_ResyncHandler.handleStartWar(_msg);
|
||||
};
|
||||
|
||||
// 断线重连:拿到 get_deskinfo 的全量快照
|
||||
// (handleDeskinfo 内的 GameState.reset() 不清 room.mySeat,此处同步的值不会被冲掉)
|
||||
SubGameHooks.Reconnect = function (_deskinfo) {
|
||||
SubGameHooks._syncMySeat();
|
||||
EQW_ResyncHandler.handleDeskinfo(_deskinfo);
|
||||
};
|
||||
|
||||
// 中途进桌:牌局【已经开始】,但自己走的是「加入房间」而非「重连」这条路径。
|
||||
// 平台 07_Desk.js 在 self_join_room 且 battlestate==1 时走 Game_Modify.DeskInfo(_msg.data.deskinfo),
|
||||
// 与 self_login 的 Game_Modify.Reconnect(_msg.data.deskinfo) 是两条并列分支——
|
||||
// 不接这个 hook,整份 deskinfo 会被 02 转发壳静默丢弃(无 hook 即 no-op),界面全空且不报错。
|
||||
//
|
||||
// 【入参与 Reconnect 完全同源、且已按【自己的】座位裁剪】:
|
||||
// 两条路径的 deskinfo 都出自服务端 server/youle/server_room/class.return.js —— 进房走
|
||||
// self_join_room 的 get_deskinfo(o_room, o_player.gameinfo.seat),登录走 self_login 的
|
||||
// get_deskinfo(o_player.gameinfo.o_room, o_player.gameinfo.seat),同一个函数、同一个
|
||||
// 「本人座位」入参。get_deskinfo 内部的可见性裁剪(底牌/埋牌底牌只给庄家、亮牌只给闲家、
|
||||
// mustcard 只给轮到的那家)因此对中途进桌一样生效,不会把别人的视角发过来。
|
||||
// 故直接复用同一条重画路径(前端 04 §3「重连 = 重画」),不为它单写一套渲染。
|
||||
SubGameHooks.DeskInfo = function (_deskinfo) {
|
||||
SubGameHooks._syncMySeat();
|
||||
EQW_ResyncHandler.handleDeskinfo(_deskinfo);
|
||||
};
|
||||
|
||||
// 重连但未开战:没有牌局数据,无需重画对局
|
||||
SubGameHooks.ReconnectNoMakewar = function () {
|
||||
SubGameHooks._syncMySeat();
|
||||
console.log('[EQW] 重连(未开战)');
|
||||
};
|
||||
|
||||
// 解散。_msg 即 Desk.deskfree,可能为 null(首局发牌前解散)
|
||||
SubGameHooks.Free = function (_msg) {
|
||||
EQW_ResultHandler.handleFree(_msg);
|
||||
};
|
||||
|
||||
// 房间信息(房号 / 总局数 / roomtype 位串)
|
||||
// 平台三条进桌路径(07_Desk.js:358 登录 / 510 建房 / 631 进房)都在 C_Player.SetSeat() 之后、
|
||||
// Reconnect / StartWar / DeskInfo 之前调它——是覆盖面最广的一个同步点
|
||||
SubGameHooks.setRoomDes = function (roomcode, asetcount, roomtype) {
|
||||
SubGameHooks._syncMySeat();
|
||||
EQW_ResyncHandler.handleSetRoomDes(roomcode, asetcount, roomtype);
|
||||
};
|
||||
|
||||
// 换座(平台 07_Desk.js:186-191 已先把 C_Player.seat 改成新座位再回调这里)。
|
||||
// 这是唯一「座位会中途变化、且不重走 setRoomDes」的路径,漏掉这里 mySeat 会静默过期
|
||||
SubGameHooks.changeSeat = function (seat1, seat2) {
|
||||
SubGameHooks._syncMySeat();
|
||||
};
|
||||
|
||||
// 启动编排。B 阶段只记下自己的座位;UI 初始化留给 C 阶段
|
||||
// 注意:此刻 C_Player 通常还没创建(见 _syncMySeat 上方注释),座位靠后续入口补齐
|
||||
SubGameHooks.appStart = function () {
|
||||
SubGameHooks._syncMySeat();
|
||||
if (typeof Desk !== 'undefined' && Desk.roomtype) {
|
||||
EQW_GameState.room.options = EQW_RoomOptions_Parse.parse(Desk.roomtype);
|
||||
}
|
||||
};
|
||||
|
||||
window.SubGameHooks = SubGameHooks;
|
||||
if (typeof module !== 'undefined' && module.exports) { module.exports = SubGameHooks; }
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
///////////////////////////////////////////////////////////////
|
||||
////////// EQW_Dispatcher: 唯一收包入口 + 纯路由表 ////////////
|
||||
///////////////////////////////////////////////////////////////
|
||||
// 前端 04 §2:收包统一分发,一 rpc 一处理器。
|
||||
// 【本文件只分发,不写业务】——新增一种服务端推送 = 注册一条 rpc → handler。
|
||||
//
|
||||
// 判定顺序(关键):先看 data.success,为 false 一律交 FailHandler、不进业务 handler。
|
||||
// 因为协议 §0.2 规定失败回包的 rpc 与【请求】同名(chupai 失败回 chupai,
|
||||
// 而成功走 chupai1/2/3),不先分流的话每个业务 handler 都得自己判一遍。
|
||||
var EQW_Dispatcher = EQW_Dispatcher || {
|
||||
|
||||
_handlers: {},
|
||||
|
||||
//注册一条 rpc → handler
|
||||
register: function (rpc, handler) {
|
||||
if (typeof rpc !== 'string' || typeof handler !== 'function') {
|
||||
throw new Error('[EQW_Dispatcher] register 参数非法: ' + rpc);
|
||||
}
|
||||
if (this._handlers.hasOwnProperty(rpc)) {
|
||||
throw new Error('[EQW_Dispatcher] rpc 重复注册: ' + rpc);
|
||||
}
|
||||
this._handlers[rpc] = handler;
|
||||
},
|
||||
|
||||
hasHandler: function (rpc) {
|
||||
return this._handlers.hasOwnProperty(rpc);
|
||||
},
|
||||
|
||||
//唯一收包入口。msg = { rpc, data }
|
||||
dispatch: function (msg) {
|
||||
if (!msg || typeof msg.rpc !== 'string') {
|
||||
console.warn('[EQW_Dispatcher] 畸形数据包,已忽略');
|
||||
return;
|
||||
}
|
||||
var data = msg.data || {};
|
||||
|
||||
//成败只认 data.success(前端 04 §4):失败包一律先分流
|
||||
if (data.success === false) {
|
||||
EQW_FailHandler.handle(msg.rpc, data);
|
||||
return;
|
||||
}
|
||||
|
||||
var h = this._handlers[msg.rpc];
|
||||
if (h) {
|
||||
h(data);
|
||||
} else {
|
||||
//平台日后新增推送不该让前端崩
|
||||
console.warn('[EQW_Dispatcher] 未处理的 rpc: ' + msg.rpc);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
//—— 路由表:一 rpc 一处理器(协议里每个服务端推送都要在这里注册)——
|
||||
//新增一种推送 = 加一条注册 + 写对应 handler;本文件不写业务。
|
||||
EQW_Dispatcher.register('fapai', function (d) { EQW_DealHandler.handle(d); });
|
||||
EQW_Dispatcher.register('jiaofen', function (d) { EQW_CallHandler.handleJiaofen(d); });
|
||||
EQW_Dispatcher.register('shangzhuang', function (d) { EQW_CallHandler.handleShangzhuang(d); });
|
||||
EQW_Dispatcher.register('xuanzhu', function (d) { EQW_MainHandler.handleXuanzhu(d); });
|
||||
EQW_Dispatcher.register('maipai', function (d) { EQW_BuryHandler.handle(d); });
|
||||
EQW_Dispatcher.register('chupai1', function (d) { EQW_PlayHandler.handle(d, 1); });
|
||||
EQW_Dispatcher.register('chupai2', function (d) { EQW_PlayHandler.handle(d, 2); });
|
||||
EQW_Dispatcher.register('chupai3', function (d) { EQW_PlayHandler.handle(d, 3); });
|
||||
EQW_Dispatcher.register('mingpai', function (d) { EQW_QueryHandler.handleMingpai(d); });
|
||||
EQW_Dispatcher.register('tishi', function (d) { EQW_QueryHandler.handleTishi(d); });
|
||||
EQW_Dispatcher.register('jiesuan', function (d) { EQW_ResultHandler.handleJiesuan(d); });
|
||||
EQW_Dispatcher.register('zhunbei', function (d) { EQW_ReadyHandler.handle(d); });
|
||||
@@ -0,0 +1,123 @@
|
||||
///////////////////////////////////////////////////////////////
|
||||
////////// EQW_Rpc: 语义化发包(一操作一方法)/////////////////
|
||||
///////////////////////////////////////////////////////////////
|
||||
// 前端 04 §1:业务不直接拼包,走两层封装——
|
||||
// 业务 → EQW_Rpc.xxx(意图) → RpcHelper(注入平台字段)→ 引擎发送
|
||||
//
|
||||
// 【route 必须是 erqiwang】RpcHelper.sendGameRpc 预设 route="room"(平台房间模块),
|
||||
// 用它包会被路由到平台、永远到不了子游戏 mod.js,而且不会有任何报错。
|
||||
//
|
||||
// 【请求包只带意图,不带结论】(前端 04 §1 / 服务端 04 §8)
|
||||
// 可以带:操作类型、目标标识(牌 id / 花色 / 提示类型)、座位号(仅供一致性校验)。
|
||||
// 禁止带:分数、番数、判定结果、阶段推进指令、nextSeat、handCards。
|
||||
// 前端 shared/ 的计算只用于本地提示与预校验,【结果不回传】。
|
||||
// 一句话:前端能自己推出来的东西,就是玩家能改的东西——这是防作弊的根本。
|
||||
//
|
||||
// 本文件只做【形状】校验(牌 id 范围/去重/张数),不做【规则】校验
|
||||
// (这手牌能不能出、叫分够不够低由服务端裁定)。形状不合法就 fail-fast 抛错,
|
||||
// 不发出残缺包——服务端会按 PARAM 拒绝,但让错误暴露在最近处更省事。
|
||||
var EQW_Rpc = EQW_Rpc || {
|
||||
|
||||
APP: 'youle',
|
||||
ROUTE: 'erqiwang',
|
||||
|
||||
//—— 内部:取自己的座位(fail-fast)——
|
||||
//room.mySeat 由 SubGameHooks._syncMySeat 从平台 C_Player.seat 同步,初始值是 -1。
|
||||
//【为什么不能透传】seat:-1 发出去,服务端 check_player 必然返回 falsy → 每个操作都回 ERR.PLAYER,
|
||||
//叫分/选主/埋牌/出牌全部发不出去,而前端一声不吭。无声透传正是这类缺陷能长期潜伏的原因,
|
||||
//故与本文件其余形状校验一样在最近处显式失败。
|
||||
_seat: function () {
|
||||
var seat = EQW_GameState.room.mySeat;
|
||||
if (seat !== 0 && seat !== 1 && seat !== 2) {
|
||||
throw new Error('[EQW_Rpc] 座位未同步: ' + seat +
|
||||
'(须为 0/1/2;检查 SubGameHooks._syncMySeat 是否已由平台入口调用)');
|
||||
}
|
||||
return seat;
|
||||
},
|
||||
|
||||
//—— 内部:发送 ——
|
||||
_send: function (rpc, data) {
|
||||
var pack = { seat: this._seat() };
|
||||
var key;
|
||||
for (key in data) {
|
||||
if (data.hasOwnProperty(key)) {
|
||||
pack[key] = data[key];
|
||||
}
|
||||
}
|
||||
RpcHelper.sendRpc(this.APP, this.ROUTE, rpc, pack);
|
||||
},
|
||||
|
||||
//—— 内部:牌 id 列表形状校验(协议 §0.2)——
|
||||
//必须是非空数组,元素是 0–107 的整数,互不重复
|
||||
_checkCards: function (cards, expectCount) {
|
||||
if (Object.prototype.toString.call(cards) !== '[object Array]' || cards.length === 0) {
|
||||
throw new Error('[EQW_Rpc] cards 必须是非空数组');
|
||||
}
|
||||
if (typeof expectCount === 'number' && cards.length !== expectCount) {
|
||||
throw new Error('[EQW_Rpc] cards 张数应为 ' + expectCount + ',实际 ' + cards.length);
|
||||
}
|
||||
var seen = {};
|
||||
for (var i = 0; i < cards.length; i++) {
|
||||
var c = cards[i];
|
||||
if (typeof c !== 'number' || c !== Math.floor(c) || c < 0 || c > 107) {
|
||||
throw new Error('[EQW_Rpc] 非法牌 id: ' + c + '(须为 0–107 的整数)');
|
||||
}
|
||||
if (seen[c]) { throw new Error('[EQW_Rpc] 牌 id 重复: ' + c); }
|
||||
seen[c] = true;
|
||||
}
|
||||
},
|
||||
|
||||
//—— 叫分(call: 5–70 的 5 的倍数;0 = 不叫)——
|
||||
jiaofen: function (call) {
|
||||
if (typeof call !== 'number' || call !== Math.floor(call)) {
|
||||
throw new Error('[EQW_Rpc] call 必须是整数');
|
||||
}
|
||||
if (call !== 0 && (call < 5 || call > 70 || call % 5 !== 0)) {
|
||||
throw new Error('[EQW_Rpc] 非法叫分: ' + call + '(须为 0 或 5–70 的 5 的倍数)');
|
||||
}
|
||||
this._send('jiaofen', { call: call });
|
||||
},
|
||||
|
||||
//—— 投降(仅 70 分坐庄、选主阶段;合法性由服务端裁定)——
|
||||
touxiang: function () {
|
||||
this._send('touxiang', {});
|
||||
},
|
||||
|
||||
//—— 选主(flower: 1方块 2梅花 3红心 4黑桃)——
|
||||
xuanzhu: function (flower) {
|
||||
if (typeof flower !== 'number' || flower < 1 || flower > 4) {
|
||||
throw new Error('[EQW_Rpc] 非法花色: ' + flower + '(须为 1–4)');
|
||||
}
|
||||
this._send('xuanzhu', { flower: flower });
|
||||
},
|
||||
|
||||
//—— 埋牌(恒 8 张)——
|
||||
maipai: function (cards) {
|
||||
this._checkCards(cards, 8);
|
||||
this._send('maipai', { cards: cards });
|
||||
},
|
||||
|
||||
//—— 出牌 ——
|
||||
chupai: function (cards) {
|
||||
this._checkCards(cards);
|
||||
this._send('chupai', { cards: cards });
|
||||
},
|
||||
|
||||
//—— 明牌(查看他家未出主牌)——
|
||||
mingpai: function () {
|
||||
this._send('mingpai', {});
|
||||
},
|
||||
|
||||
//—— 出牌提示(tip: 1踩 2没分 3有分)——
|
||||
tishi: function (tip) {
|
||||
if (tip !== 1 && tip !== 2 && tip !== 3) {
|
||||
throw new Error('[EQW_Rpc] 非法提示类型: ' + tip + '(须为 1踩 2没分 3有分)');
|
||||
}
|
||||
this._send('tishi', { tip: tip });
|
||||
},
|
||||
|
||||
//—— 准备 ——
|
||||
zhunbei: function () {
|
||||
this._send('zhunbei', {});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,56 @@
|
||||
///////////////////////////////////////////////////////////////
|
||||
////////// EQW_BuryHandler: 埋牌(maipai)////////////////////
|
||||
///////////////////////////////////////////////////////////////
|
||||
// 协议 §9:cards 埋牌后手牌(仅庄家) / burycards 埋牌底牌(仅庄家) /
|
||||
// step 阶段(=5 出牌) / seat 控制权=首出者(必为庄家) / countdown 出牌倒计时 /
|
||||
// seatlist 三家牌况(仅可查牌模式) /
|
||||
// liangpai 亮牌(仅闲家 + 可查牌模式 + 庄家固定主牌达门槛)
|
||||
//
|
||||
// 注意 burycards(埋牌底牌,庄家埋下的 8 张)与 bottomcards(底牌,发牌留桌的 8 张)
|
||||
// 是两批不同的牌,协议 §0.0 与验收清单专门警告过别写反。
|
||||
//
|
||||
// 【seatlist 此刻是全初值,但必须接】:埋牌完成时这张表刚初始化
|
||||
// (每家 [[0,0],[0,0],[0,0],[0,0],[-1,-1]]),界面上看不出差别——但「埋牌完成 → 庄家首出」
|
||||
// 这段窗口内重连拿到的 PushCards.seatlist 就是它,漏接会让增量路径此刻为空、重连路径却有表。
|
||||
//
|
||||
// 【bottomcards 到此失效,要清空】:底牌在上庄时已翻给玩家看并并入手牌,埋牌完成后
|
||||
// 只剩「埋牌底牌 burycards」有意义;deskinfo 的 PushCards 不带 bottomcards,重连重建恒为 []。
|
||||
//
|
||||
// 【playproc 是服务端的「本轮进行态」,原样拷贝】:埋牌完成时服务端已就地开好 round-1
|
||||
// 的进行态(庄家首出、桌面全空),恒有、三家同值,与 chupai1/2/3.playproc / 重连包
|
||||
// PushCards.playproc 同源同结构(服务端同一个 get_playproc())。没有它,「埋牌完成 →
|
||||
// 庄家首出」这段窗口增量路径 table.playproc 会停在上一阶段的 null,与重连路径不一致。
|
||||
var EQW_BuryHandler = EQW_BuryHandler || {
|
||||
|
||||
handle: function (data) {
|
||||
if (!data.hasOwnProperty('seat')) {
|
||||
console.error('[EQW_BuryHandler] maipai 缺 seat,已跳过该包');
|
||||
return;
|
||||
}
|
||||
|
||||
//庄家才有的两项,闲家缺字段时保持原值、不抹手牌
|
||||
EQW_GameState._apply(EQW_GameState.my, data, {
|
||||
cards: 'cards',
|
||||
buryCards: 'burycards'
|
||||
});
|
||||
//底牌已并入手牌、埋牌完成即失效(重连的 PushCards 不带它),显式清空保持两条路径一致
|
||||
EQW_GameState.my.bottomCards = [];
|
||||
//三家牌况(仅可查牌模式)、闲家才可能有的亮牌、本轮进行态(恒有,见上方注释)
|
||||
EQW_GameState._apply(EQW_GameState.table, data, {
|
||||
seatlist: 'seatlist',
|
||||
liangpai: 'liangpai',
|
||||
playproc: 'playproc'
|
||||
});
|
||||
//阶段只读包字段(服务端权威),不按 rpc 名硬编码 step=5
|
||||
EQW_GameState._apply(EQW_GameState.aset, data, { step: 'step' });
|
||||
//控制权:本包的 seat 就是「轮到谁」(即将首出的庄家,服务端取自 playproc.currseat)
|
||||
EQW_GameState._apply(EQW_GameState.turn, data, {
|
||||
seat: 'seat',
|
||||
countdown: 'countdown'
|
||||
});
|
||||
|
||||
EventBus.emit(EQW_Events.EQW_BURY_DONE);
|
||||
if (data.hasOwnProperty('cards')) { EventBus.emit(EQW_Events.EQW_HAND_CHANGED); }
|
||||
EventBus.emit(EQW_Events.EQW_TURN_CHANGED);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,78 @@
|
||||
///////////////////////////////////////////////////////////////
|
||||
////////// EQW_CallHandler: 叫分(jiaofen)与上庄(shangzhuang)
|
||||
///////////////////////////////////////////////////////////////
|
||||
// 协议 §3 jiaofen:seat 叫分者 / call 分数(0=不叫) / currcall 当前叫到的分 /
|
||||
// multiple 该叫分对应基础子数 / step 阶段(仍=1) /
|
||||
// nextseat 下一个叫分者(控制权) / countdown
|
||||
// 协议 §4 shangzhuang:seat / call / banker / grade 庄家叫分 / multiple /
|
||||
// step 阶段(=2 选主/投降) / nextseat 控制权(=庄家) /
|
||||
// bottomcards 底牌(庄家恒有,闲家仅 70 分时有) / ancard3s 开底标志 /
|
||||
// cards 摸底后的 36 张(仅庄家) / countdown 选主倒计时 /
|
||||
// touxiang 是否允许投降 / curmultiple 当前抓分倍数(带符号)
|
||||
//
|
||||
// 【shangzhuang 的 seat 与 nextseat 别混用】seat 是【最后一个叫分者】,nextseat 才是
|
||||
// 「轮到谁」(选主/投降只由庄家做,服务端 mod.xuanzhu/mod.touxiang 以 banker 为准)。
|
||||
// 曾经这里写 turn.seat = aset.banker 自己推——那是把「选主 = 庄家」这条规则搬到了前端;
|
||||
// 同一条规则在 ResyncHandler 里还有一份,同一份数据两个写入处(SSOT)。现一律读包字段。
|
||||
var EQW_CallHandler = EQW_CallHandler || {
|
||||
|
||||
handleJiaofen: function (data) {
|
||||
if (!data.hasOwnProperty('seat')) {
|
||||
console.error('[EQW_CallHandler] jiaofen 缺 seat,已跳过该包');
|
||||
return;
|
||||
}
|
||||
|
||||
//记录这一家叫了多少:0 = 不叫,与「还没叫」(null) 区分
|
||||
if (data.hasOwnProperty('call') && data.seat >= 0 && data.seat < 3) {
|
||||
EQW_GameState.call.calls[data.seat] = data.call;
|
||||
}
|
||||
EQW_GameState._apply(EQW_GameState.call, data, { currcall: 'currcall' });
|
||||
EQW_GameState._apply(EQW_GameState.aset, data, {
|
||||
multiple: 'multiple',
|
||||
step: 'step'
|
||||
});
|
||||
EQW_GameState._apply(EQW_GameState.turn, data, {
|
||||
seat: 'nextseat',
|
||||
countdown: 'countdown'
|
||||
});
|
||||
|
||||
EventBus.emit(EQW_Events.EQW_CALL_CHANGED);
|
||||
EventBus.emit(EQW_Events.EQW_TURN_CHANGED);
|
||||
},
|
||||
|
||||
handleShangzhuang: function (data) {
|
||||
if (!data.hasOwnProperty('banker')) {
|
||||
console.error('[EQW_CallHandler] shangzhuang 缺 banker,已跳过该包');
|
||||
return;
|
||||
}
|
||||
|
||||
EQW_GameState._apply(EQW_GameState.aset, data, {
|
||||
banker: 'banker',
|
||||
call: 'grade', //协议里庄家叫分叫 grade
|
||||
multiple: 'multiple',
|
||||
touxiang: 'touxiang',
|
||||
curmultiple:'curmultiple',
|
||||
step: 'step' //阶段服务端权威给出,不按 rpc 名硬编码
|
||||
});
|
||||
EQW_GameState._apply(EQW_GameState.table, data, { ancard3s: 'ancard3s' });
|
||||
//底牌与摸底后的手牌只有庄家(及 70 分时的闲家)才有,缺就不写、不抹已有值
|
||||
EQW_GameState._apply(EQW_GameState.my, data, {
|
||||
bottomCards: 'bottomcards',
|
||||
cards: 'cards'
|
||||
});
|
||||
//控制权:读包里的 nextseat(服务端权威),不用本地的 banker 反推
|
||||
EQW_GameState._apply(EQW_GameState.turn, data, {
|
||||
seat: 'nextseat',
|
||||
countdown: 'countdown'
|
||||
});
|
||||
|
||||
//叫分过程量(currcall/calls)step2 起无语义:deskinfo 从 ChooseMain 起就不再带 CallRun,
|
||||
//这里清成 reset() 的默认值,避免前端留着一份 deskinfo 不会再给的数据(视图=f(快照))
|
||||
EQW_GameState.call.currcall = 0;
|
||||
EQW_GameState.call.calls = [null, null, null];
|
||||
|
||||
EventBus.emit(EQW_Events.EQW_BANKER_SET);
|
||||
if (data.hasOwnProperty('cards')) { EventBus.emit(EQW_Events.EQW_HAND_CHANGED); }
|
||||
EventBus.emit(EQW_Events.EQW_TURN_CHANGED);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,37 @@
|
||||
///////////////////////////////////////////////////////////////
|
||||
////////// EQW_DealHandler: 发牌(fapai)//////////////////////
|
||||
///////////////////////////////////////////////////////////////
|
||||
// 协议 §1:asetidx 当前局数 / asetcount 总局数 / cards 自己的 28 张 /
|
||||
// step 阶段(=1 叫分) / seat 当前等待叫分者 / countdown 叫分倒计时
|
||||
//
|
||||
// 【step 只读包字段,不按 rpc 硬编码】阶段与控制权由服务端唯一维护、逐包显式下发
|
||||
//(前端红线「数据驱动、前端无对局状态机」/ 服务端红线「任何状态变更都要有包承载」)。
|
||||
// 曾经这里写死 aset.step = 1:服务端一旦插入中间阶段,前端不会红、只会静默画错。
|
||||
//
|
||||
// fapai 是一局的起点,先 reset 清掉上一局的残留(reset 保留 mySeat 与 options)。
|
||||
var EQW_DealHandler = EQW_DealHandler || {
|
||||
|
||||
handle: function (data) {
|
||||
if (!data.hasOwnProperty('cards')) {
|
||||
console.error('[EQW_DealHandler] fapai 缺 cards,已跳过该包');
|
||||
return;
|
||||
}
|
||||
|
||||
EQW_GameState.reset();
|
||||
|
||||
EQW_GameState._apply(EQW_GameState.room, data, {
|
||||
asetIdx: 'asetidx',
|
||||
asetCount: 'asetcount'
|
||||
});
|
||||
EQW_GameState._apply(EQW_GameState.my, data, { cards: 'cards' });
|
||||
EQW_GameState._apply(EQW_GameState.aset, data, { step: 'step' });
|
||||
EQW_GameState._apply(EQW_GameState.turn, data, {
|
||||
seat: 'seat',
|
||||
countdown: 'countdown'
|
||||
});
|
||||
|
||||
EventBus.emit(EQW_Events.EQW_RESET);
|
||||
EventBus.emit(EQW_Events.EQW_HAND_CHANGED);
|
||||
EventBus.emit(EQW_Events.EQW_TURN_CHANGED);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,24 @@
|
||||
///////////////////////////////////////////////////////////////
|
||||
////////// EQW_FailHandler: 失败回包 //////////////////////////
|
||||
///////////////////////////////////////////////////////////////
|
||||
// 协议 §0.2:服务端受理请求时任一校验不通过,回一个【rpc 与请求同名】的失败包,
|
||||
// data 只含 success:false 与 errcode,且只回给请求者。
|
||||
//
|
||||
// errcode(服务端 youle_erqiwang.ERR):
|
||||
// 1 PLAYER 玩家/房间/座位校验不通过 2 NODESK 牌桌或牌局不存在
|
||||
// 3 STEP 当前阶段不允许该操作 4 SEAT 位置不符或还没轮到
|
||||
// 5 PARAM 参数非法 6 RULE 规则不允许
|
||||
//
|
||||
// 失败信息是【一次性】的,不属于对局状态,故不进 GameState,只随事件带出去。
|
||||
var EQW_FailHandler = EQW_FailHandler || {
|
||||
|
||||
ERR: {
|
||||
PLAYER: 1, NODESK: 2, STEP: 3, SEAT: 4, PARAM: 5, RULE: 6
|
||||
},
|
||||
|
||||
handle: function (rpc, data) {
|
||||
var errcode = (data && typeof data.errcode === 'number') ? data.errcode : 0;
|
||||
console.warn('[EQW] 请求失败: rpc=' + rpc + ' errcode=' + errcode);
|
||||
EventBus.emit(EQW_Events.EQW_RPC_FAILED, { rpc: rpc, errcode: errcode });
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,39 @@
|
||||
///////////////////////////////////////////////////////////////
|
||||
////////// EQW_MainHandler: 选主(xuanzhu)///////////////////
|
||||
///////////////////////////////////////////////////////////////
|
||||
// 协议 §7:banker 庄家 / flower 主牌花色(1方块 2梅花 3红心 4黑桃) /
|
||||
// step 阶段(=3 埋牌) / nextseat 控制权(=庄家,埋牌由庄家做) /
|
||||
// countdown 埋牌倒计时 / cards 选主后自己手上的牌
|
||||
//
|
||||
// 【step 与 nextseat 只读包字段】曾经这里写死 step=3 且 turn.seat = aset.banker:
|
||||
// 阶段与「埋牌 = 庄家」两条规则都被搬到了前端(前端红线「数据驱动、前端无对局状态机」)。
|
||||
//
|
||||
// 选主后正 2 / 正 7 升格、主花色普通牌并入主牌段(design §3),手牌需整体重排——
|
||||
// 但【重排是渲染时的事】,由 core/CardOrder 现算,不在这里落地成状态。
|
||||
//
|
||||
// 投降(touxiang)没有成功推送:点投降后服务端直接结算,前端收到的是 jiesuan。
|
||||
// 失败时才回一个同名的 touxiang 失败包,由 FailHandler 统一处理。
|
||||
var EQW_MainHandler = EQW_MainHandler || {
|
||||
|
||||
handleXuanzhu: function (data) {
|
||||
if (!data.hasOwnProperty('flower')) {
|
||||
console.error('[EQW_MainHandler] xuanzhu 缺 flower,已跳过该包');
|
||||
return;
|
||||
}
|
||||
|
||||
EQW_GameState._apply(EQW_GameState.aset, data, {
|
||||
banker: 'banker',
|
||||
flower: 'flower',
|
||||
step: 'step'
|
||||
});
|
||||
EQW_GameState._apply(EQW_GameState.my, data, { cards: 'cards' });
|
||||
EQW_GameState._apply(EQW_GameState.turn, data, {
|
||||
seat: 'nextseat',
|
||||
countdown: 'countdown'
|
||||
});
|
||||
|
||||
EventBus.emit(EQW_Events.EQW_MAIN_SET);
|
||||
if (data.hasOwnProperty('cards')) { EventBus.emit(EQW_Events.EQW_HAND_CHANGED); }
|
||||
EventBus.emit(EQW_Events.EQW_TURN_CHANGED);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,109 @@
|
||||
///////////////////////////////////////////////////////////////
|
||||
////////// EQW_PlayHandler: 出牌(chupai1 / 2 / 3)///////////
|
||||
///////////////////////////////////////////////////////////////
|
||||
// 协议 §11–§13。三个包字段不完全相同:
|
||||
// chupai1 首家独有:shuaicuo 甩错标志 / count 出牌数量 / flower 出牌花色 /
|
||||
// cardtype 牌型 / shuai 甩牌分量构成
|
||||
// chupai3 末家独有:maxseat 本轮谁最大 / grade 本轮闲家得分
|
||||
// 共有:seat / cards / seatlist(仅可查牌) / countdown / baozhu / nextseat /
|
||||
// cardsinhand(仅出牌者自己有) / curmultiple(1 和 3 有)
|
||||
// mustcard:三个包都可能带,但只发给 nextseat 那一家(其余两家无此属性)
|
||||
//
|
||||
// 【cardsinhand 只有出牌者自己有】:收到就更新自己的手牌;别人出牌时没这个字段,
|
||||
// 绝不能抹掉自己的手牌——这正是「缺字段不兜底」要防的。
|
||||
//
|
||||
// 【mustcard 没有就要清空】:它是「本轮跟牌的必出牌」,只对当前这一轮有效。
|
||||
// 上一轮的建议残留下来会让界面自动选中错误的牌。
|
||||
//
|
||||
// 【playproc 是服务端的「本轮进行态」,原样拷贝】:它的结构是
|
||||
// {round, start, currseat, startcount, startflower, starttype, maxseat, maxcard, cards, shuai_demand}
|
||||
// (协议 §11),与重连包 PushCards.playproc 同源同结构(服务端同一个 get_playproc())。
|
||||
// 注意它的 cards 是【本轮三家各自出的牌、下标 = 座位】,与本包的 cards(这一手出的牌)
|
||||
// 语义相反,绝不能混写。轮次/最大者由服务端给,前端不累积、不推导。
|
||||
//
|
||||
// 【这一手的信息走事件载荷,不进 GameState】:谁出了什么、几张、什么牌型是【一次性表现】
|
||||
// (落牌动画),不是对局状态;deskinfo 也不下发它。放进 GameState 会立刻制造一处
|
||||
// 增量/全量不一致。故随 EQW_CARD_PLAYED 事件下发给 UI。
|
||||
// 【本墩结果同理】:chupai3 的 maxseat / grade(谁最大、这墩闲家得几分)也是一次性表现,
|
||||
// 随 EQW_TRICK_END 事件载荷下发,同样不进 GameState。
|
||||
var EQW_PlayHandler = EQW_PlayHandler || {
|
||||
|
||||
//order: 1/2/3,对应 chupai1/2/3
|
||||
handle: function (data, order) {
|
||||
if (!data.hasOwnProperty('seat') || !data.hasOwnProperty('cards')) {
|
||||
console.error('[EQW_PlayHandler] chupai' + order + ' 缺 seat 或 cards,已跳过该包');
|
||||
return;
|
||||
}
|
||||
|
||||
//—— 本轮进行态:服务端权威快照,原样拷贝,前端零推导 ——
|
||||
EQW_GameState._apply(EQW_GameState.table, data, { playproc: 'playproc' });
|
||||
|
||||
//—— 出牌历史:把这一手记进本轮(chupai1 = 一轮的第一手,开新的一轮)——
|
||||
EQW_GameState.pushPlay(data.seat, data.cards, order === 1);
|
||||
|
||||
//—— 自己的手牌:只有出牌者本人才收到 cardsinhand ——
|
||||
EQW_GameState._apply(EQW_GameState.my, data, { cards: 'cardsinhand' });
|
||||
|
||||
//—— 必出牌:本轮有效,没给就清空 ——
|
||||
if (data.hasOwnProperty('mustcard')) {
|
||||
EQW_GameState.my.mustCard = data.mustcard;
|
||||
} else {
|
||||
EQW_GameState.my.mustCard = [];
|
||||
}
|
||||
|
||||
//—— 牌况与报无主(仅可查牌模式下发)——
|
||||
EQW_GameState._apply(EQW_GameState.table, data, { seatlist: 'seatlist' });
|
||||
EQW_GameState._apply(EQW_GameState.aset, data, {
|
||||
baozhu: 'baozhu',
|
||||
curmultiple: 'curmultiple'
|
||||
});
|
||||
|
||||
//—— 控制权与倒计时 ——
|
||||
EQW_GameState._apply(EQW_GameState.turn, data, {
|
||||
seat: 'nextseat',
|
||||
countdown: 'countdown'
|
||||
});
|
||||
|
||||
EventBus.emit(EQW_Events.EQW_CARD_PLAYED, this._playPayload(data, order));
|
||||
if (data.hasOwnProperty('cardsinhand')) { EventBus.emit(EQW_Events.EQW_HAND_CHANGED); }
|
||||
|
||||
//—— 末家出完:本轮结束 ——
|
||||
//【本墩结果走事件载荷,不进 GameState】谁最大、这墩闲家得了几分,是【一次性表现】
|
||||
//(收牌动画、得分飘字),与 EQW_CARD_PLAYED 同类;任何阶段的 deskinfo 都不下发它,
|
||||
//写进 GameState 就多出一处「只活在前端、服务端不知道」的对局态——出牌中途断线重连
|
||||
//即归零,违反前端红线「丢弃 this.data、仅凭最近一次服务端快照重画,界面必须一致」。
|
||||
//小局累计捡分 aset.grade 不同:它有服务端来源(PushCards.grade / chupai3.grade),
|
||||
//属于镜像量,仍留在 GameState。
|
||||
if (order === 3) {
|
||||
//本轮闲家得分累加进小局捡分;没得分时不带 grade,累计值保持不变
|
||||
if (data.hasOwnProperty('grade')) {
|
||||
EQW_GameState.aset.grade = EQW_GameState.aset.grade + data.grade;
|
||||
}
|
||||
EventBus.emit(EQW_Events.EQW_TRICK_END, this._trickPayload(data));
|
||||
}
|
||||
|
||||
EventBus.emit(EQW_Events.EQW_TURN_CHANGED);
|
||||
},
|
||||
|
||||
//EQW_CARD_PLAYED 的事件载荷:这一手的一次性信息(落牌动画用),不落 GameState。
|
||||
//【缺字段不兜底】:包里没有的键就不带(count/flower/cardtype 只有 chupai1 有,
|
||||
//shuai 仅合法甩牌时有,shuaicuo 仅甩错时有),订阅方按 hasOwnProperty 判断。
|
||||
_playPayload: function (data, order) {
|
||||
var payload = { order: order, seat: data.seat, cards: data.cards };
|
||||
var keys = ['count', 'flower', 'cardtype', 'shuai', 'shuaicuo'];
|
||||
for (var i = 0; i < keys.length; i++) {
|
||||
if (data.hasOwnProperty(keys[i])) { payload[keys[i]] = data[keys[i]]; }
|
||||
}
|
||||
return payload;
|
||||
},
|
||||
|
||||
//EQW_TRICK_END 的事件载荷:这一墩的一次性结果(收牌动画、得分飘字用),不落 GameState。
|
||||
//【缺字段不兜底】grade 只在这墩被闲家收走且确实有分时才有,包里没有就不带这个键。
|
||||
//与 ResultHandler 为收尾墩发的那份载荷字段一致(收尾墩不发 chupai3,整包换成 jiesuan)。
|
||||
_trickPayload: function (data) {
|
||||
var payload = { seat: data.seat, cards: data.cards };
|
||||
if (data.hasOwnProperty('maxseat')) { payload.maxseat = data.maxseat; }
|
||||
if (data.hasOwnProperty('grade')) { payload.grade = data.grade; }
|
||||
return payload;
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,33 @@
|
||||
///////////////////////////////////////////////////////////////
|
||||
////////// EQW_QueryHandler: 明牌(mingpai)与提示(tishi)///
|
||||
///////////////////////////////////////////////////////////////
|
||||
// 协议 §13.6 mingpai(只回给请求者):
|
||||
// seat 请求者 / others 另外两家各自未出的全部主牌 [{seat, zhucards}]
|
||||
// 受理条件:可查牌模式 + 出牌阶段 + 已有【任一】玩家报无主(design §9.3)
|
||||
//
|
||||
// 协议 §13.8 tishi(只转发给对家,即另一闲家):
|
||||
// seat 发出提示的闲家 / tip 1=踩 2=没分 3=有分
|
||||
// 注意:成功时【不给发送者回执】,只转发给对家;只有失败才回给发送者。
|
||||
//
|
||||
// 明牌数据进 GameState(面板可反复开关查看),提示不进——
|
||||
// 提示是一次性的通知、不是对局状态,重连也不重放,存下来只会变成幽灵数据。
|
||||
var EQW_QueryHandler = EQW_QueryHandler || {
|
||||
|
||||
handleMingpai: function (data) {
|
||||
if (!data.hasOwnProperty('others')) {
|
||||
console.error('[EQW_QueryHandler] mingpai 缺 others,已跳过该包');
|
||||
return;
|
||||
}
|
||||
EQW_GameState.table.mingpai = data.others;
|
||||
EventBus.emit(EQW_Events.EQW_MINGPAI);
|
||||
},
|
||||
|
||||
handleTishi: function (data) {
|
||||
if (!data.hasOwnProperty('seat') || !data.hasOwnProperty('tip')) {
|
||||
console.error('[EQW_QueryHandler] tishi 缺 seat 或 tip,已跳过该包');
|
||||
return;
|
||||
}
|
||||
//一次性通知:不进 GameState,随事件把内容带给订阅者
|
||||
EventBus.emit(EQW_Events.EQW_TIP, { seat: data.seat, tip: data.tip });
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,16 @@
|
||||
///////////////////////////////////////////////////////////////
|
||||
////////// EQW_ReadyHandler: 准备(zhunbei)//////////////////
|
||||
///////////////////////////////////////////////////////////////
|
||||
// 协议 §16:seat 位置序号。
|
||||
// 准备状态由平台的玩家信息 UI 呈现(清单 T-15:准备标识用平台的),
|
||||
// 子游戏只需把事件发出去,让关心的组件(如结算面板的「下一局」按钮)响应。
|
||||
var EQW_ReadyHandler = EQW_ReadyHandler || {
|
||||
|
||||
handle: function (data) {
|
||||
if (!data.hasOwnProperty('seat')) {
|
||||
console.error('[EQW_ReadyHandler] zhunbei 缺 seat,已跳过该包');
|
||||
return;
|
||||
}
|
||||
EventBus.emit(EQW_Events.EQW_READY_CHANGED, { seat: data.seat });
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,121 @@
|
||||
///////////////////////////////////////////////////////////////
|
||||
////////// EQW_ResultHandler: 结算(jiesuan)与解散(Free)///
|
||||
///////////////////////////////////////////////////////////////
|
||||
// 协议 §14:结算包由 chupai + bottom + aset 拼装,末局再加 account。
|
||||
// 三种来源的组成不同:
|
||||
// 正常出牌结算:chupai + bottom + aset(末局加 account)
|
||||
// 投降结算: 只有 aset(末局加 account),无 chupai、无 bottom
|
||||
// 解散结算: 只有 aset + account,且【走另一个入口】,见下
|
||||
//
|
||||
// 【解散的特殊性】(协议 §14.1,已对平台代码核实):
|
||||
// 解散包的 route 是平台的 "room",平台 12_Logic.js 按 route 分流时把它交给
|
||||
// Net[rpc] 而【不是】Game_Modify._ReceiveData——所以它根本进不了子游戏的分发表。
|
||||
// 平台另有专门入口:07_Desk.js 的 Game_Modify.Free(Desk.deskfree)。
|
||||
// 两个后果:
|
||||
// 1. 取值路径比协议文档少一层——平台已把 deskfree 取出来传进来,
|
||||
// 所以是 deskfree.data.aset,不是 data.deskfree.data.aset;
|
||||
// 2. 参数可能是 null——解散若发生在开战后、首局发牌前,服务端 get_disbandRoom
|
||||
// 返回 null,平台走「不带 deskfree」分支。这时只做房间收尾、不弹结算。
|
||||
var EQW_ResultHandler = EQW_ResultHandler || {
|
||||
|
||||
handleJiesuan: function (data) {
|
||||
if (!data.hasOwnProperty('aset')) {
|
||||
console.error('[EQW_ResultHandler] jiesuan 缺 aset,已跳过该包');
|
||||
return;
|
||||
}
|
||||
this._applyResult(data);
|
||||
},
|
||||
|
||||
//平台解散入口。deskfree = { rpc:"jiesuan", data:{ success, aset, account } },可能为 null
|
||||
handleFree: function (deskfree) {
|
||||
if (!deskfree || !deskfree.data) {
|
||||
//首局发牌前解散:没有结算数据,只做房间收尾,不弹结算面板
|
||||
console.warn('[EQW_ResultHandler] 解散包无 deskfree 数据(首局发牌前解散)');
|
||||
return;
|
||||
}
|
||||
var d = deskfree.data;
|
||||
if (!d.hasOwnProperty('aset')) {
|
||||
console.error('[EQW_ResultHandler] 解散包缺 aset,已跳过');
|
||||
return;
|
||||
}
|
||||
this._applyResult(d);
|
||||
},
|
||||
|
||||
//三种来源共用的落地逻辑:有哪组就写哪组,缺的保持 null
|
||||
_applyResult: function (d) {
|
||||
//—— 收尾轮的第三手:牌局在最后一手打完时【不发 chupai3】,整包换成 jiesuan(协议 §13/§14)——
|
||||
//夹具实跑一局的包数就是证据:chupai1×28 / chupai2×28 / chupai3×27。
|
||||
//只在 chupai3 归档会永远丢掉最后一轮,故在这里补齐——它必是本轮第三手,落进当前这一轮。
|
||||
//jiesuan.chupai 的字段与 chupai3 不同:只有 seat / cards / maxseat(+ 有分时的 grade),
|
||||
//没有 order / playproc / cardsinhand。
|
||||
//归档随后会被下面的结算收敛清掉(deskinfo step6 不带 PushCards),但 emit 是同步的:
|
||||
//落牌与收牌表现在清空之前就已取到完整的收尾轮。
|
||||
//【本墩结果只走事件载荷、不进 GameState】:谁最大、这墩得几分是一次性表现,
|
||||
//deskinfo 的 Balance 不带它(它属于「出牌过程」而非结算数据),写进 GameState
|
||||
//就会多出一处服务端不知道的对局态。收尾墩与 chupai3 走同一对事件、同一份载荷字段。
|
||||
if (d.chupai && d.chupai.hasOwnProperty('seat') && d.chupai.hasOwnProperty('cards')) {
|
||||
EQW_GameState.pushPlay(d.chupai.seat, d.chupai.cards, false);
|
||||
var lastPlay = { seat: d.chupai.seat, cards: d.chupai.cards };
|
||||
if (d.chupai.hasOwnProperty('maxseat')) { lastPlay.maxseat = d.chupai.maxseat; }
|
||||
if (d.chupai.hasOwnProperty('grade')) { lastPlay.grade = d.chupai.grade; }
|
||||
EventBus.emit(EQW_Events.EQW_CARD_PLAYED, lastPlay);
|
||||
EventBus.emit(EQW_Events.EQW_TRICK_END, lastPlay);
|
||||
}
|
||||
|
||||
//结算是"清空过程量、只留结算数据"的快照点:deskinfo step6 只带 Balance(=aset),
|
||||
//其余分组一律回到 reset() 默认值——jiesuan 也要收敛到同一状态,否则重连前后画面不一致
|
||||
//(清单以 ResyncHandler.handleDeskinfo 在 step6 时实际重建出的字段集为准,不多清不少清)。
|
||||
EQW_GameState.aset.banker = -1;
|
||||
EQW_GameState.aset.call = -1;
|
||||
EQW_GameState.aset.multiple = 0;
|
||||
EQW_GameState.aset.flower = 0;
|
||||
EQW_GameState.aset.curmultiple = 0;
|
||||
EQW_GameState.aset.grade = 0;
|
||||
EQW_GameState.aset.baozhu = 0;
|
||||
EQW_GameState.aset.touxiang = 0;
|
||||
EQW_GameState.turn.seat = -1;
|
||||
EQW_GameState.turn.countdown = 0;
|
||||
EQW_GameState.call.currcall = 0;
|
||||
EQW_GameState.call.calls = [null, null, null];
|
||||
//末轮若由 jiesuan.chupai 带出(无 cardsinhand),出牌者自己的手牌不会被清空,这里显式清掉,
|
||||
//不留幽灵牌——deskinfo 在结算阶段的 MyCards 恒为 []
|
||||
EQW_GameState.my.cards = [];
|
||||
EQW_GameState.my.mustCard = [];
|
||||
EQW_GameState.my.bottomCards = [];
|
||||
EQW_GameState.my.buryCards = [];
|
||||
EQW_GameState.table.ancard3s = 0;
|
||||
EQW_GameState.table.playproc = null;
|
||||
EQW_GameState.table.pushlist = [];
|
||||
EQW_GameState.table.seatlist = [];
|
||||
EQW_GameState.table.liangpai = null;
|
||||
EQW_GameState.table.mingpai = null;
|
||||
|
||||
//【不写 chupai】收尾墩已在上面随事件载荷发走;deskinfo step6 的 Balance 只有
|
||||
//aset / bottom / account 三份,多存一个 chupai 就是增量路径独有的私有对局态
|
||||
EQW_GameState._apply(EQW_GameState.result, d, {
|
||||
bottom: 'bottom',
|
||||
aset: 'aset',
|
||||
account: 'account'
|
||||
});
|
||||
//阶段只读包字段:jiesuan 的 step 由服务端 get_paiju_account 统一给出(三种结算来源恒为 6),
|
||||
//前端不按 rpc 名硬编码——解散结算走的是另一条投递路径,同样带着这个字段
|
||||
EQW_GameState._apply(EQW_GameState.aset, d, { step: 'step' });
|
||||
|
||||
//三家总积分:jiesuan 的 aset.seatlist[i].score 就是"累积得分"(协议 §14),
|
||||
//与 deskinfo.PlayerInfo 同源同值——deskinfo 恒有的 PlayerInfo 在增量路径上没有对应来源,
|
||||
//只能从这里回填,否则一局中段(fapai 后至 jiesuan 前)room.playerScores 永远追不上服务端
|
||||
if (d.aset && Object.prototype.toString.call(d.aset.seatlist) === '[object Array]') {
|
||||
var scores = [];
|
||||
for (var i = 0; i < d.aset.seatlist.length; i++) {
|
||||
scores.push(d.aset.seatlist[i] && d.aset.seatlist[i].score);
|
||||
}
|
||||
EQW_GameState.room.playerScores = scores;
|
||||
}
|
||||
|
||||
EventBus.emit(EQW_Events.EQW_ASET_RESULT);
|
||||
//account 只在末局或解散时才有
|
||||
if (d.hasOwnProperty('account')) {
|
||||
EventBus.emit(EQW_Events.EQW_ACCOUNT_RESULT);
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,159 @@
|
||||
///////////////////////////////////////////////////////////////
|
||||
////////// EQW_ResyncHandler: 重连(deskinfo)与开局(StartWar)
|
||||
///////////////////////////////////////////////////////////////
|
||||
// 前端 04 §3:重连 = 重画。断线重连与硬刷新本质相同,复用同一条路径:
|
||||
// 填 GameState → emit EQW_RESYNC_ALL → 各组件据数据重建界面
|
||||
// 【绝不为重连单写一套渲染】。
|
||||
//
|
||||
// deskinfo 按 step 只带对应阶段的分组(协议文末):
|
||||
// 恒有 count / idx / PlayerInfo / step / MyCards
|
||||
// CallRun(step1) ChooseMain(step2) BuryCards(step3) PushCards(step5) Balance(step6)
|
||||
//
|
||||
// 【重连不重放一次性事件】:70 分坐庄的 3 秒开底是上庄时的一次性事件,
|
||||
// deskinfo 里没有 ancard3s,前端也不得补播。
|
||||
var EQW_ResyncHandler = EQW_ResyncHandler || {
|
||||
|
||||
//平台房间信息(roomcode / 总局数 / roomtype 位串)
|
||||
handleSetRoomDes: function (roomcode, asetcount, roomtype) {
|
||||
if (typeof asetcount === 'number') { EQW_GameState.room.asetCount = asetcount; }
|
||||
EQW_GameState.room.options = EQW_RoomOptions_Parse.parse(roomtype);
|
||||
},
|
||||
|
||||
//开局:makewar 可能差异化下发(sendtype:1 + seatlist[]),先取自己那份
|
||||
handleStartWar: function (msg) {
|
||||
if (!msg || !msg.data) {
|
||||
console.warn('[EQW_ResyncHandler] StartWar 无数据,已忽略');
|
||||
return;
|
||||
}
|
||||
var raw = msg.data.deskwar || msg.data;
|
||||
var mine = raw;
|
||||
if (raw && raw.sendtype === 1 && Object.prototype.toString.call(raw.seatlist) === '[object Array]') {
|
||||
mine = null;
|
||||
for (var i = 0; i < raw.seatlist.length; i++) {
|
||||
if (raw.seatlist[i] && raw.seatlist[i].seat === EQW_GameState.room.mySeat) {
|
||||
mine = raw.seatlist[i].data;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!mine) {
|
||||
console.error('[EQW_ResyncHandler] StartWar 差异化下发里没有本座位的数据');
|
||||
return;
|
||||
}
|
||||
}
|
||||
this.handleDeskinfo(mine);
|
||||
},
|
||||
|
||||
//重连:deskinfo 全量快照
|
||||
handleDeskinfo: function (info) {
|
||||
if (!info) {
|
||||
console.warn('[EQW_ResyncHandler] deskinfo 为空,已忽略');
|
||||
return;
|
||||
}
|
||||
|
||||
EQW_GameState.reset();
|
||||
|
||||
//—— 恒有的部分 ——
|
||||
EQW_GameState._apply(EQW_GameState.room, info, {
|
||||
asetCount: 'count',
|
||||
asetIdx: 'idx',
|
||||
playerScores: 'PlayerInfo'
|
||||
});
|
||||
EQW_GameState._apply(EQW_GameState.aset, info, { step: 'step' });
|
||||
EQW_GameState._apply(EQW_GameState.my, info, { cards: 'MyCards' });
|
||||
|
||||
//—— 按阶段分组 ——
|
||||
if (info.CallRun) { this._applyCallRun(info.CallRun); }
|
||||
if (info.ChooseMain) { this._applyChooseMain(info.ChooseMain); }
|
||||
if (info.BuryCards) { this._applyBuryCards(info.BuryCards); }
|
||||
if (info.PushCards) { this._applyPushCards(info.PushCards); }
|
||||
if (info.Balance) { this._applyBalance(info.Balance); }
|
||||
|
||||
EventBus.emit(EQW_Events.EQW_RESYNC_ALL);
|
||||
},
|
||||
|
||||
_applyCallRun: function (g) {
|
||||
EQW_GameState._apply(EQW_GameState.call, g, {
|
||||
currcall: 'nowcall',
|
||||
calls: 'call'
|
||||
});
|
||||
EQW_GameState._apply(EQW_GameState.aset, g, { multiple: 'multiple' });
|
||||
EQW_GameState._apply(EQW_GameState.turn, g, {
|
||||
seat: 'seat',
|
||||
countdown: 'countdown'
|
||||
});
|
||||
},
|
||||
|
||||
_applyChooseMain: function (g) {
|
||||
EQW_GameState._apply(EQW_GameState.aset, g, {
|
||||
banker: 'banker',
|
||||
call: 'call',
|
||||
multiple: 'multiple',
|
||||
touxiang: 'touxiang',
|
||||
curmultiple: 'curmultiple' //与 shangzhuang 推送同源同值,漏读会让「抓分」角标掉档
|
||||
});
|
||||
EQW_GameState._apply(EQW_GameState.my, g, { bottomCards: 'bottomcards' });
|
||||
//控制权由服务端在分组里显式给出(与 shangzhuang.nextseat 同源同值);
|
||||
//曾经这里写 turn.seat = aset.banker 自推,与 CallHandler 里那份是同一条规则的第二个写入处
|
||||
EQW_GameState._apply(EQW_GameState.turn, g, {
|
||||
seat: 'seat',
|
||||
countdown: 'countdown'
|
||||
});
|
||||
},
|
||||
|
||||
_applyBuryCards: function (g) {
|
||||
EQW_GameState._apply(EQW_GameState.aset, g, {
|
||||
banker: 'banker',
|
||||
call: 'call',
|
||||
multiple: 'multiple',
|
||||
flower: 'flower',
|
||||
curmultiple: 'curmultiple' //同上:一张牌未出时恒为 +3(大光),也必须重建
|
||||
});
|
||||
EQW_GameState._apply(EQW_GameState.my, g, { bottomCards: 'bottomcards' });
|
||||
//控制权同上,读服务端给的 seat(与 xuanzhu.nextseat 同源同值)
|
||||
EQW_GameState._apply(EQW_GameState.turn, g, {
|
||||
seat: 'seat',
|
||||
countdown: 'countdown'
|
||||
});
|
||||
},
|
||||
|
||||
_applyPushCards: function (g) {
|
||||
EQW_GameState._apply(EQW_GameState.aset, g, {
|
||||
banker: 'banker',
|
||||
call: 'call',
|
||||
multiple: 'multiple',
|
||||
flower: 'flower',
|
||||
grade: 'grade',
|
||||
curmultiple: 'curmultiple',
|
||||
//报无主:明牌按钮与余主公示的开关,增量路径由 chupai1/2/3 的 baozhu 维护,
|
||||
//重连路径必须从 PushCards.baozhu 一起恢复,否则重连后按钮凭空消失
|
||||
baozhu: 'baozhu'
|
||||
});
|
||||
EQW_GameState._apply(EQW_GameState.my, g, {
|
||||
buryCards: 'burycards',
|
||||
mustCard: 'mustcard'
|
||||
});
|
||||
EQW_GameState._apply(EQW_GameState.table, g, {
|
||||
playproc: 'playproc',
|
||||
pushlist: 'pushlist',
|
||||
seatlist: 'seatlist',
|
||||
liangpai: 'liangpai'
|
||||
});
|
||||
EQW_GameState._apply(EQW_GameState.turn, g, { countdown: 'countdown' });
|
||||
//当前该谁出牌由 playproc.currseat 给出(服务端权威,前端不推导)
|
||||
if (g.playproc && typeof g.playproc.currseat === 'number') {
|
||||
EQW_GameState.turn.seat = g.playproc.currseat;
|
||||
}
|
||||
},
|
||||
|
||||
//结算阶段(step6):Balance 与 jiesuan 推送给的是同一份结算数据(服务端同源快照)。
|
||||
//bottom(抠底明细)只有正常出牌结算才有、account(大局结算)只有末局/解散才有——
|
||||
//缺就不写、保持 reset() 的 null,与 jiesuan 那条路径的取舍完全一致。
|
||||
//【为什么必须接】结算面板还开着时断线重连/硬刷新,只恢复 aset 会让抠底明细与末局大结算空白。
|
||||
_applyBalance: function (g) {
|
||||
EQW_GameState._apply(EQW_GameState.result, g, {
|
||||
aset: 'aset',
|
||||
bottom: 'bottom',
|
||||
account: 'account'
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,41 @@
|
||||
///////////////////////////////////////////////////////////////
|
||||
////////// EQW_Events: 玩法事件常量 ///////////////////////////
|
||||
///////////////////////////////////////////////////////////////
|
||||
// 前端红线:框架保持游戏中立,玩法专属事件由子游戏追加到 EventBus.Events。
|
||||
//
|
||||
// 【对局状态不进事件载荷】——凡是 GameState 里有的,订阅者一律从 EQW_GameState 读,
|
||||
// 同一份数据不在事件载荷与状态里各存一份(SSOT)。
|
||||
// 例外只有「一次性、不属于对局状态、deskinfo 也不下发」的信息,它们不该进 GameState
|
||||
// (进了就会立刻制造一处增量/全量不一致),只能随事件载荷走:
|
||||
// EQW_RPC_FAILED {rpc, errcode} 一次性错误信息
|
||||
// EQW_CARD_PLAYED {order, seat, cards, count?, flower?, cardtype?, shuai?, shuaicuo?}
|
||||
// 这一手的落牌信息(一次性表现)。带 ? 的键【缺字段不兜底】,
|
||||
// 包里没有就不带;收尾轮由 jiesuan.chupai 带出时只有 {seat, cards, maxseat?}
|
||||
var EQW_Events = EQW_Events || {
|
||||
|
||||
EQW_RESET: 'eqw.reset', //新一局发牌,全部清场
|
||||
EQW_HAND_CHANGED: 'eqw.hand_changed', //自己手牌变化
|
||||
EQW_CALL_CHANGED: 'eqw.call_changed', //叫分推进
|
||||
EQW_BANKER_SET: 'eqw.banker_set', //上庄
|
||||
EQW_MAIN_SET: 'eqw.main_set', //选主
|
||||
EQW_BURY_DONE: 'eqw.bury_done', //埋牌完成
|
||||
EQW_CARD_PLAYED: 'eqw.card_played', //有人出牌
|
||||
EQW_TRICK_END: 'eqw.trick_end', //一墩结束;载荷 {seat,cards,maxseat[,grade]},不落 GameState
|
||||
EQW_TURN_CHANGED: 'eqw.turn_changed', //控制权或倒计时变化
|
||||
EQW_MINGPAI: 'eqw.mingpai', //收到明牌数据
|
||||
EQW_TIP: 'eqw.tip', //收到对家提示
|
||||
EQW_ASET_RESULT: 'eqw.aset_result', //小局结算
|
||||
EQW_ACCOUNT_RESULT: 'eqw.account_result', //大局/解散结算
|
||||
EQW_READY_CHANGED: 'eqw.ready_changed', //有人准备
|
||||
EQW_RESYNC_ALL: 'eqw.resync_all', //重连/开局全量重画
|
||||
EQW_RPC_FAILED: 'eqw.rpc_failed' //失败回包 {rpc, errcode}
|
||||
};
|
||||
|
||||
//追加到框架事件总线的事件表(框架不认识玩法事件,由子游戏注册)
|
||||
if (typeof EventBus !== 'undefined' && EventBus.Events) {
|
||||
for (var _eqwEvtKey in EQW_Events) {
|
||||
if (EQW_Events.hasOwnProperty(_eqwEvtKey)) {
|
||||
EventBus.Events[_eqwEvtKey] = EQW_Events[_eqwEvtKey];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
///////////////////////////////////////////////////////////////
|
||||
////////// EQW_GameState: 服务端快照的镜像(前端 SSOT)////////
|
||||
///////////////////////////////////////////////////////////////
|
||||
// 前端红线「视图 = f(服务端快照)」的落点:本对象只存【服务端下发过的字段】,
|
||||
// 不存任何前端算出来的结论。手牌排序、牌面标记、花色统计等由 core/ 的纯函数
|
||||
// 在渲染时现算,不落地成状态。
|
||||
//
|
||||
// 三条硬约束:
|
||||
// 1. 只镜像不派生——存进来的必须是服务端给过的
|
||||
// 2. 字段名对齐协议——协议叫 currcall 就叫 currcall,便于逐条核对
|
||||
// 3. 缺字段不兜底——包里没有的保持原值,不填默认值掩盖;必需字段缺失
|
||||
// 则 console.error 并跳过该包(服务端漏发,修在服务端)
|
||||
var EQW_GameState = EQW_GameState || {
|
||||
|
||||
//数据分组名(snapshot / reset 按此遍历;新增分组要同步加进来)
|
||||
_GROUPS: ['room', 'aset', 'turn', 'call', 'my', 'table', 'result'],
|
||||
|
||||
//—— 房间级(跨小局)——
|
||||
room: {
|
||||
asetCount: 0, //总局数 ← fapai.asetcount / deskinfo.count / setRoomDes
|
||||
asetIdx: 0, //当前第几局 ← fapai.asetidx / deskinfo.idx
|
||||
mySeat: -1, //自己的座位 ← 平台 C_Player.seat
|
||||
playerScores: [], //三家总积分 ← deskinfo.PlayerInfo / jiesuan.aset.seatlist[].score,跨局不清
|
||||
options: null //roomtype 解析结果
|
||||
},
|
||||
|
||||
//—— 小局级 ——
|
||||
aset: {
|
||||
step: 0, //1叫分 2选主/投降 3埋牌 5出牌 6结算
|
||||
banker: -1,
|
||||
call: -1, //庄家叫分
|
||||
multiple: 0, //基础子数
|
||||
flower: 0, //主牌花色(0 = 未选主)
|
||||
curmultiple: 0, //当前抓分倍数(带符号,服务端权威,前端不自算)
|
||||
grade: 0, //闲家已捡分
|
||||
baozhu: 0, //是否已有人报无主
|
||||
touxiang: 0 //是否允许投降
|
||||
},
|
||||
|
||||
//—— 当前控制权与倒计时(任何阶段都读这里)——
|
||||
turn: {
|
||||
seat: -1, //该谁操作
|
||||
countdown: 0 //展示用秒数;服务端无对应定时器,归零【不做任何界面推进】
|
||||
},
|
||||
|
||||
//—— 叫分过程 ——
|
||||
call: {
|
||||
currcall: 0,
|
||||
calls: [null, null, null] //null 未叫 / 0 不叫 / >0 叫了多少
|
||||
},
|
||||
|
||||
//—— 自己 ——
|
||||
my: {
|
||||
cards: [],
|
||||
mustCard: [], //本轮必出牌(服务端建议,只发给轮到的那家)
|
||||
bottomCards: [], //底牌(庄家恒有;闲家仅 70 分坐庄时有)
|
||||
buryCards: [] //埋牌底牌(仅庄家)
|
||||
},
|
||||
|
||||
//—— 桌面 ——
|
||||
table: {
|
||||
ancard3s: 0, //开底标志(70 分坐庄)
|
||||
playproc: null,
|
||||
pushlist: [],
|
||||
seatlist: [],
|
||||
liangpai: null,
|
||||
mingpai: null
|
||||
},
|
||||
|
||||
//—— 结算 ——
|
||||
//字段集与 deskinfo step6 的 Balance 严格对齐(aset / bottom / account)。
|
||||
//【不存 chupai】收尾墩的「谁最大、得几分」是一次性表现,随 EQW_TRICK_END 事件载荷走,
|
||||
//任何 deskinfo 都不下发它——存进来就是一处服务端不知道的私有对局态(见 ResultHandler / PlayHandler)
|
||||
result: {
|
||||
bottom: null,
|
||||
aset: null,
|
||||
account: null
|
||||
},
|
||||
|
||||
//新一局开局时清空对局态;room.mySeat / room.options / room.playerScores 跨局不变,不清
|
||||
//(playerScores 由 deskinfo.PlayerInfo 或 jiesuan.aset.seatlist[].score 增量更新,见 ResyncHandler / ResultHandler)
|
||||
reset: function () {
|
||||
this.room.asetIdx = 0;
|
||||
|
||||
this.aset = { step: 0, banker: -1, call: -1, multiple: 0, flower: 0,
|
||||
curmultiple: 0, grade: 0, baozhu: 0, touxiang: 0 };
|
||||
this.turn = { seat: -1, countdown: 0 };
|
||||
this.call = { currcall: 0, calls: [null, null, null] };
|
||||
this.my = { cards: [], mustCard: [], bottomCards: [], buryCards: [] };
|
||||
this.table = { ancard3s: 0, playproc: null, pushlist: [], seatlist: [],
|
||||
liangpai: null, mingpai: null };
|
||||
this.result = { bottom: null, aset: null, account: null };
|
||||
},
|
||||
|
||||
//深拷贝快照(只含数据分组,不含方法),供测试做「增量 vs 全量」比对
|
||||
snapshot: function () {
|
||||
var out = {};
|
||||
for (var i = 0; i < this._GROUPS.length; i++) {
|
||||
var g = this._GROUPS[i];
|
||||
out[g] = JSON.parse(JSON.stringify(this[g]));
|
||||
}
|
||||
return out;
|
||||
},
|
||||
|
||||
//把「一手牌」记进出牌历史 table.pushlist。
|
||||
//结构与 deskinfo.PushCards.pushlist 完全一致:外层下标 = 轮次-1,内层恒 3 个数组、
|
||||
//下标 = 座位序号(协议 §断线重连 pushlist)。
|
||||
//
|
||||
//【只记录服务端发来的事实】:谁、出了哪几张。轮次的推进不在这里推导——
|
||||
//startRound 由调用方按【包类型】给出:chupai1 就是「一轮的第一手」(服务端定义的包,
|
||||
//不是前端算出来的),其余各手落进当前这一轮。
|
||||
//
|
||||
//【为什么不能读 chupai3.playproc.cards 直接归档】:服务端 do_playcard 在第三家出完后
|
||||
//就地调用 new_playround,chupai3 带的是【下一轮】的进行态(round+1、cards 全空,
|
||||
//协议 §13 明标),本轮那三手只在 chupai1/2/3 各自的 seat + cards 里。
|
||||
//
|
||||
//【仅可查牌模式累积】:pushlist 是「查牌」历史,不查牌模式下服务端的 deskinfo 压根
|
||||
//不下发它(design §9),前端也不能自己攒一份——否则增量路径会凭空多出重连路径没有的历史。
|
||||
pushPlay: function (seat, cards, startRound) {
|
||||
var opts = this.room.options;
|
||||
if (!opts || opts.nocheck) { return; }
|
||||
var list = this.table.pushlist;
|
||||
if (startRound || list.length === 0) { list.push([[], [], []]); }
|
||||
list[list.length - 1][seat] = cards;
|
||||
},
|
||||
|
||||
//把包里的字段写进目标分组。map = { 目标键: 源键 }。
|
||||
//【缺字段不兜底】源键在包里不存在就不写,保持原值——不填默认值掩盖漏发。
|
||||
_apply: function (target, src, map) {
|
||||
for (var dst in map) {
|
||||
if (!map.hasOwnProperty(dst)) { continue; }
|
||||
var srcKey = map[dst];
|
||||
if (src.hasOwnProperty(srcKey)) { target[dst] = src[srcKey]; }
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,34 @@
|
||||
///////////////////////////////////////////////////////////////
|
||||
////////// EQW_RoomOptions_Parse: roomtype 位串解析 ///////////
|
||||
///////////////////////////////////////////////////////////////
|
||||
// 协议 §0.5:roomtype 是建房时拼好的定长数字位串(字符串),每一位 '0'/'1'。
|
||||
// 服务端解析入口是 class.config.js 的 parse(),本文件必须与之【同规则】。
|
||||
//
|
||||
// 位0 局数 '0'=6局(缺省) '1'=12局
|
||||
// 位1 扣卡 '0'=房主扣卡 '1'=AA 每人扣卡
|
||||
// 位2 傍王 '0'=关 '1'=开
|
||||
// 位3 爬坡 '0'=常规算子 '1'=爬坡
|
||||
// 位4 查牌 '0'=可查牌 '1'=不查牌
|
||||
//
|
||||
// 对【缺失、非字符串、过短】的 roomtype 一律按 '0' 处理(与服务端一致),
|
||||
// 这是协议明文规定的行为,不是隐式兜底。
|
||||
var EQW_RoomOptions_Parse = (function() {
|
||||
//取第 idx 位,越界或非字符串时按 '0'
|
||||
function _bit(roomtype, idx) {
|
||||
if (typeof roomtype !== 'string') { return 0; }
|
||||
if (idx >= roomtype.length) { return 0; }
|
||||
return roomtype.charAt(idx) === '1' ? 1 : 0;
|
||||
}
|
||||
|
||||
return {
|
||||
parse: function (roomtype) {
|
||||
return {
|
||||
asetCount: _bit(roomtype, 0) ? 12 : 6,
|
||||
deductAA: _bit(roomtype, 1),
|
||||
bangwang: _bit(roomtype, 2),
|
||||
climb: _bit(roomtype, 3),
|
||||
nocheck: _bit(roomtype, 4)
|
||||
};
|
||||
}
|
||||
};
|
||||
})();
|
||||
+319
@@ -0,0 +1,319 @@
|
||||
// 导出脚本(测试代码,跑在 Node,非正式代码):
|
||||
// 用服务端 test/_rpc.js + class.desk.js 的既有测试脚手架跑一局完整流程
|
||||
//(发牌 fapai → 叫分 jiaofen → 上庄 shangzhuang → 选主 xuanzhu → 埋牌 maipai
|
||||
// → 出牌 chupai1/2/3 若干轮 → 结算 jiesuan),把真实下发包按座位快照导出成
|
||||
// client/tests/fixtures/packets.json,供前端测试回放(不手写假包,测的是真实契约)。
|
||||
//
|
||||
// 用法:node client/tests/fixtures/export_packets.js
|
||||
//
|
||||
// 装配方式参照服务端既有测试的两种建立法:
|
||||
// - _rpc.js 的 setup() 只接受"已构造好的 o_paiju",不走 class.desk.js,
|
||||
// 因此不会产生 fapai(发牌)包——它是给"单个 RPC 处理器"用的轻量桩。
|
||||
// - test_flow.js / test_endgame.js 的 mkRoom() 模式改用真实 class.desk.js
|
||||
// 的 D.new()/D.do_new_paiju(),才会真的下发 fapai;本脚本需要完整流程
|
||||
// (含 fapai),故照抄这一种既有用法,而不是 setup()。
|
||||
// 两条路径殊途同归:都是把 mod.app / mod.import 指向同一个 sent 捕获数组,
|
||||
// 区别只在"谁负责建立 o_room/o_desk/o_paiju"。
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
// 装配 mod.js 及其依赖类,把关键类挂到 global(与 _rpc.js 内部做法一致)
|
||||
const R = require('../../../server/games/erqiwang/test/_rpc.js');
|
||||
const mod = R.mod;
|
||||
const P = global.cls_youle_erqiwang_paiju;
|
||||
const A = global.cls_youle_erqiwang_arith;
|
||||
const D = require('../../../server/games/erqiwang/class.desk.js');
|
||||
const E = require('../../../server/games/erqiwang/class.export.js').new();
|
||||
|
||||
const ROOT = path.resolve(__dirname, '..', '..', '..');
|
||||
const OUT = path.join(ROOT, 'client/tests/fixtures/packets.json');
|
||||
|
||||
// 常规房:可查牌、不爬坡、不傍王(roomtype 位串全 0,见 class.config.js)
|
||||
const ROOMTYPE = '00000';
|
||||
|
||||
// 固定种子:只在本导出脚本进程内替换 global.min_random(不改 server/_shim.js),
|
||||
// 让「同一份脚本重复运行」产出逐字节相同的 packets.json,便于 review diff 与回归。
|
||||
// 写法与服务端既有测试(test_flow.js/test_fuzz.js/test_leak.js)的 xorshift 种子一致。
|
||||
// 改这个值会重新生成整局牌(发牌结果、叫分走向、出牌过程全变),产物 diff 会很大。
|
||||
const DEAL_SEED = 0x5A5A2026;
|
||||
(function seedDeal(seed) {
|
||||
let state = seed >>> 0;
|
||||
const rnd = function (n) {
|
||||
state ^= state << 13; state >>>= 0;
|
||||
state ^= state >>> 17;
|
||||
state ^= state << 5; state >>>= 0;
|
||||
return state % n;
|
||||
};
|
||||
global.min_random = function (min, max) { return min + rnd(max - min + 1); };
|
||||
})(DEAL_SEED);
|
||||
|
||||
const clone = function (m) { return JSON.parse(JSON.stringify(m)); };
|
||||
|
||||
// 装配一个"真实牌桌":D.new() 建牌桌,global.youle_erqiwang.app/import 与
|
||||
// mod.app/mod.import 同时指向同一个 sent 捕获数组——class.desk.js 的 fapai/zhunbei
|
||||
// 走前者(bare 全局引用),mod.js 的 RPC handler 走后者(mod 自身的模块变量),
|
||||
// 两条路径必须都接上,否则 fapai 会走到 _shim.js 的默认空实现,捕获不到。
|
||||
function makeRoom(roomtype) {
|
||||
const sent = [];
|
||||
const o_room = {
|
||||
roomtype: roomtype, asetcount: 6, roomcode: 1, createtime: 'T', makewartime: 'T',
|
||||
seatlist: [0, 1, 2].map(function (i) {
|
||||
return { conmode: 0, fromid: i, playerid: 100 + i, nickname: 'P' + i, avatar: '', gameinfo: {} };
|
||||
}),
|
||||
method: { sendpack_toother: function (m) { sent.push(clone(m)); } }
|
||||
};
|
||||
const desk = D.new(o_room);
|
||||
o_room.o_desk = desk;
|
||||
global.youle_erqiwang.app = { SendPack: function (m) { sent.push(clone(m)); } };
|
||||
global.youle_erqiwang.import = { check_player: function () { return o_room; }, deduct_roomcard: function () { }, save_grade: function () { } };
|
||||
mod.import = global.youle_erqiwang.import;
|
||||
mod.app = global.youle_erqiwang.app;
|
||||
return { o_room: o_room, desk: desk, sent: sent };
|
||||
}
|
||||
|
||||
const pk = function (seat, extra) {
|
||||
return {
|
||||
conmode: 0, fromid: seat,
|
||||
data: Object.assign({ agentid: 'a', playerid: seat, gameid: 'g', roomcode: 1, seat: seat }, extra || {})
|
||||
};
|
||||
};
|
||||
|
||||
const room = makeRoom(ROOMTYPE);
|
||||
const o_room = room.o_room;
|
||||
const desk = room.desk;
|
||||
const sent = room.sent;
|
||||
|
||||
// ---- 按座位收集下发包(保留差异化下发:谁收到什么、谁没收到) ----
|
||||
// sent 里每一项要么带明确的 fromid(0/1/2,逐座位分别下发,内容可能互不相同),
|
||||
// 要么是 sendpack_toother(msg, -1) 的整体广播(未逐座位改写 conmode/fromid,
|
||||
// fromid 为 undefined)——广播内容三家相同,按"三家都收到"处理。
|
||||
const seatPackets = [[], [], []];
|
||||
const deskinfo = [];
|
||||
let drained = 0;
|
||||
function drain() {
|
||||
for (; drained < sent.length; drained++) {
|
||||
const raw = sent[drained];
|
||||
const packet = { rpc: raw.rpc, data: raw.data };
|
||||
if (raw.fromid === 0 || raw.fromid === 1 || raw.fromid === 2) {
|
||||
seatPackets[raw.fromid].push(packet);
|
||||
} else {
|
||||
seatPackets[0].push(packet);
|
||||
seatPackets[1].push(packet);
|
||||
seatPackets[2].push(packet);
|
||||
}
|
||||
}
|
||||
}
|
||||
// packetIndex 是关键对齐锚点:取快照时先 drain,记下该座位此刻已收到的包数,
|
||||
// 与 Task 12 一致性测试"喂完 seats[seat] 的前 packetIndex 个包"对齐。
|
||||
//
|
||||
// 【必须 clone】get_deskinfo 的返回值里仍有若干【无拷贝的活引用赋值】——
|
||||
// 例如 Balance.readystate = o_desk.prepare、Balance.aset/bottom/account 三份结算快照。
|
||||
// (PushCards.playproc / PushCards.seatlist / pushlist / burycards 已各自走深拷贝快照函数,
|
||||
// 这段注释早先把它们也算在内,已过时,现更正。)
|
||||
// 真实链路里这份返回值被平台立刻序列化下发,活引用不会被观测到;但本脚本要跑完整局之后才
|
||||
// 统一 JSON.stringify 写盘,若不在这里当场拷贝一份,写盘时这些字段会被后续操作篡改成终局
|
||||
// 状态,导致同一座位不同 packetIndex 下的快照"逐字节相同"——这是本脚本自身的缺陷,与服务端
|
||||
// 无关(服务端没有"延后序列化"这个用法)。
|
||||
function snap(step, seat) {
|
||||
drain();
|
||||
deskinfo.push({
|
||||
step: step,
|
||||
seat: seat,
|
||||
packetIndex: seatPackets[seat].length,
|
||||
info: clone(E.get_deskinfo(o_room, seat))
|
||||
});
|
||||
}
|
||||
|
||||
// ================= 跑一局 =================
|
||||
|
||||
// 发牌(fapai):class.desk.js 内部随机发牌 + 广播
|
||||
D.do_new_paiju(desk, 0);
|
||||
const pj = desk.method.curr_paiju();
|
||||
|
||||
// StartWar(开局握手,前端 04/05 讲的"增量路径真实起点"):真实链路里客户端开局收到的
|
||||
// 第一包,结构就是 deskwar —— 即 get_deskinfo() 的返回值(见 server/youle/server_room/
|
||||
// rpc.js 里 pack.data.deskwar = youle_room.import.makewar_deskwar(o_room) 一段)。
|
||||
// 当前 erqiwang 的 exp.makewar(class.export.js)尚未把 get_deskinfo 接成返回值,这是
|
||||
// 另一项任务;本脚本独立调用 get_deskinfo 反映"接线后客户端此刻会收到什么",让前端测试
|
||||
// 能提前对齐这条起点(尤其是 room.playerScores 这类 fapai 包不带、只有 StartWar 能给的初值)。
|
||||
// 与紧随其后的 step1 快照取同一时刻(do_new_paiju 之后、任何 jiaofen 之前),按座位各存
|
||||
// 一份(三家 MyCards 视角不同),套一层 { deskwar: ... } 外壳与生产环境 pack.data.deskwar
|
||||
// 的包裹方式对齐,供 EQW_ResyncHandler.handleStartWar 直接消费。
|
||||
const startwar = [0, 1, 2].map(function (seat) {
|
||||
return { deskwar: clone(E.get_deskinfo(o_room, seat)) };
|
||||
});
|
||||
|
||||
snap(1, 0); snap(1, 1); snap(1, 2); // 叫分阶段(step1):三家快照,验证 MyCards 各不相同
|
||||
|
||||
// 叫分:0 号位叫 65,其余不叫 → 0 号位上庄(触发 jiaofen 广播 + shangzhuang 差异化下发)
|
||||
mod.jiaofen(pk(pj.method.get_callgrade_seat(), { call: 65 }));
|
||||
mod.jiaofen(pk(pj.method.get_callgrade_seat(), { call: 0 }));
|
||||
mod.jiaofen(pk(pj.method.get_callgrade_seat(), { call: 0 }));
|
||||
snap(2, 0); snap(2, 1); snap(2, 2); // 选主阶段(step2):庄家有 cards/bottomcards,闲家没有
|
||||
|
||||
// 选主:庄家选方块为主
|
||||
mod.xuanzhu(pk(pj.banker, { flower: 1 }));
|
||||
snap(3, pj.banker); // 埋牌阶段(step3):庄家视角
|
||||
|
||||
// 埋牌:庄家埋掉当前手上最后 8 张(get_seat_cards 已按主牌排序,任取8张即可)
|
||||
mod.maipai(pk(pj.banker, { cards: P.get_seat_cards(pj, pj.banker).slice(-8) }));
|
||||
snap(5, 0); snap(5, 1); snap(5, 2); // 出牌阶段开局(step5):出牌者 cardsinhand、下一位 mustcard 差异
|
||||
|
||||
// ================= 出牌策略 =================
|
||||
// 【为什么不再是"全出单张"】:单张一手无所谓顺序,服务端「这一手打出的牌」的两个顺序口径
|
||||
// (chupai/playproc.cards 与 deskinfo.PushCards.pushlist)在单张局面下永远相等,夹具测不出
|
||||
// 分岔。甩牌(design §5.4)与领对子会产生"一手多张",才真正压到这条契约。
|
||||
//
|
||||
// 策略(确定性,不含随机):
|
||||
// 首家:优先甩牌(多分量主牌,且服务端 can_playcard 判定合法)→ 其次领最小的一对
|
||||
// → 否则最小单张
|
||||
// 跟家:先取 get_followcard 的必出牌,再从可出牌里补足张数,逐个组合试到
|
||||
// can_followcard(+ 甩牌时的 flush_follow_ok)通过为止
|
||||
//
|
||||
// 【提交顺序刻意用升序】:真实客户端提交的是玩家的点击顺序,服务端不得依赖它
|
||||
//(server 04 §8「前端不是数据源」;class.arith.js can_followcard 里也有同样的告诫)。
|
||||
// 服务端的权威展示顺序是 order_cards 的【降序】,所以这里一律把选好的一手【反转成升序】
|
||||
// 再提交,模拟"玩家从小到大点选"。这不是伪造数据——包仍然是服务端真实产出的,
|
||||
// 只是让请求侧的顺序与权威顺序刻意不同,把"下发顺序是否被请求顺序污染"暴露出来。
|
||||
const asc = function (cards) {
|
||||
return A.order_cards(pj.flower, cards.concat()).reverse();
|
||||
};
|
||||
|
||||
// 两名对手未出的主牌(供甩牌最大性判定),与 class.paiju.js do_playcard 的取法一致
|
||||
function oppZhuList(seat) {
|
||||
const list = [];
|
||||
for (let os = 0; os < 3; os++) {
|
||||
if (os !== seat) { list.push(P.get_seat_zhucards(pj, os)); }
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
// 首家:尝试甩牌——把手上主牌分解成分量,按分量牌力从大到小取前 k 个(k 从多到少),
|
||||
// 交给服务端 can_playcard 判定;只接受 result===true 的(合法甩牌),
|
||||
// 不提交会被判"甩错"的组合(那会被服务端收回成单张,反而回到单张局面)。
|
||||
// 注意 can_playcard 会【就地排序】入参数组,故一律传副本。
|
||||
function tryShuai(seat, hand) {
|
||||
const trumps = hand.filter(function (c) { return A.id_to_code(pj.flower, c) > 1000; });
|
||||
if (trumps.length < 2) { return null; }
|
||||
const comps = A.decompose_trump(pj.flower, trumps).slice()
|
||||
.sort(function (a, b) { return b.value - a.value; });
|
||||
const opp = oppZhuList(seat);
|
||||
for (let k = Math.min(comps.length, 3); k >= 2; k--) {
|
||||
let pick = [];
|
||||
for (let i = 0; i < k; i++) { pick = pick.concat(comps[i].cards); }
|
||||
if (A.can_playcard(pj.flower, pick.concat(), seat, pj.seatlist, opp).result) {
|
||||
return pick;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// 首家:尝试领一对。取【最大的一对】——甩牌合法的前提是两名对手都压不过任一分量
|
||||
//(§5.4.2 opp_can_beat_flush),大牌先走能更快拔掉对手的主牌,后续局面才会出现合法甩牌。
|
||||
// 这不是"为了凑测试"的取巧:本夹具的目的就是覆盖甩牌,而甩牌本来就只在这种局面下成立。
|
||||
// 同一颗种子下:领最小的一对 → 全局只有 1 手合法甩牌;领最大的一对 → 4 手。
|
||||
function tryLeadPair(seat, hand) {
|
||||
const pairs = A.get_pairlist(pj.flower, hand.concat());
|
||||
if (!pairs.length) { return null; }
|
||||
const pick = pairs[0].concat();
|
||||
if (A.can_playcard(pj.flower, pick.concat(), seat, pj.seatlist, oppZhuList(seat)).result) {
|
||||
return pick;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// 跟家:这一手是否被服务端接受(与 class.paiju.js do_playcard 的跟牌校验同一组判定)
|
||||
function followOk(hand, cand) {
|
||||
const proc = pj.playproc;
|
||||
if (!A.can_followcard(pj.flower, hand, cand, proc.startcount, proc.startflower, proc.starttype).result) {
|
||||
return false;
|
||||
}
|
||||
if (proc.shuai_demand && !A.flush_follow_ok(pj.flower, hand, cand, proc.shuai_demand)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// 跟家:凑出 startcount 张的合法跟牌
|
||||
function pickFollow(seat, hand) {
|
||||
const proc = pj.playproc;
|
||||
const n = proc.startcount;
|
||||
const get = A.get_followcard(pj.flower, hand.concat(), n, proc.startflower, proc.starttype);
|
||||
const base = (get && get.mustcard) ? get.mustcard.concat() : [];
|
||||
if (base.length === n) {
|
||||
return followOk(hand, base) ? base : null;
|
||||
}
|
||||
const need = n - base.length;
|
||||
if (need < 0) { return null; }
|
||||
// 候选池:优先服务端给出的"可出的牌",为空则退回整手牌
|
||||
let pool = (get && get.cancard && get.cancard.length) ? get.cancard.concat() : hand.concat();
|
||||
pool = pool.filter(function (c) { return base.indexOf(c) < 0; });
|
||||
let found = null;
|
||||
const combo = [];
|
||||
(function walk(start) {
|
||||
if (found) { return; }
|
||||
if (combo.length === need) {
|
||||
const cand = base.concat(combo);
|
||||
if (followOk(hand, cand)) { found = cand; }
|
||||
return;
|
||||
}
|
||||
for (let i = start; i < pool.length && !found; i++) {
|
||||
combo.push(pool[i]);
|
||||
walk(i + 1);
|
||||
combo.pop();
|
||||
}
|
||||
})(0);
|
||||
return found;
|
||||
}
|
||||
|
||||
// 打到一半时的 step5 快照锚点(按已出手数计)。
|
||||
// 【为什么要不止一个】:出牌阶段开局那三张 step5 快照的 pushlist 都还是空的,
|
||||
// 只有"打到一半"的快照才真的比对到出牌历史(一致性测试里唯一压到 pushlist 的点)。
|
||||
// 一个锚点=一个比对点,太薄;多取几个覆盖不同轮次/不同座位/轮中不同位次。
|
||||
const MID_SNAP_AT = [4, 9, 16, 25, 34];
|
||||
let guard = 0;
|
||||
let shuaiHands = 0; // 统计:合法甩牌手数
|
||||
let multiHands = 0; // 统计:一手多张的手数(含跟牌)
|
||||
while (pj.step === 5 && guard < 400) {
|
||||
guard++;
|
||||
const seat = pj.playproc.currseat;
|
||||
const hand = P.get_seat_cards(pj, seat);
|
||||
let pick = null;
|
||||
if (seat === pj.playproc.start) {
|
||||
pick = tryShuai(seat, hand);
|
||||
if (pick) { shuaiHands++; }
|
||||
if (!pick) { pick = tryLeadPair(seat, hand); }
|
||||
if (!pick) { pick = [hand[hand.length - 1]]; }
|
||||
} else {
|
||||
pick = pickFollow(seat, hand);
|
||||
}
|
||||
// 找不到合法出牌属脚本缺陷(服务端保证当前座位总有牌可出),显式失败、不静默跳过
|
||||
if (!pick) {
|
||||
throw new Error('export_packets: 座位 ' + seat + ' 第 ' + guard + ' 手凑不出合法出牌');
|
||||
}
|
||||
if (pick.length > 1) { multiHands++; }
|
||||
mod.chupai(pk(seat, { cards: asc(pick) }));
|
||||
// 出牌进行到中段时额外拍 step5 快照,覆盖"打到一半"的重连场景
|
||||
if (MID_SNAP_AT.indexOf(guard) >= 0 && pj.step === 5) {
|
||||
snap(5, pj.playproc.currseat);
|
||||
}
|
||||
}
|
||||
snap(6, 0); snap(6, 1); snap(6, 2); // 结算阶段(step6):jiesuan 广播后
|
||||
|
||||
drain(); // 保险:吸收循环末尾可能残留的未 drain 包
|
||||
|
||||
const fixture = {
|
||||
roomtype: ROOMTYPE,
|
||||
startwar: startwar,
|
||||
seats: seatPackets,
|
||||
deskinfo: deskinfo
|
||||
};
|
||||
|
||||
fs.writeFileSync(OUT, JSON.stringify(fixture, null, 2));
|
||||
console.log('已写入 ' + OUT);
|
||||
console.log('座位包数: ' + seatPackets.map(function (a) { return a.length; }).join(' / '));
|
||||
console.log('deskinfo 快照数: ' + deskinfo.length);
|
||||
console.log('出牌手数: ' + guard + '(其中一手多张 ' + multiHands + ' 手,合法甩牌 ' + shuaiHands + ' 手)');
|
||||
console.log('paiju.step(结束时)=' + pj.step + ' result=' + pj.result);
|
||||
Vendored
+19669
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,150 @@
|
||||
// 【C-1 回归】room.mySeat 的同步必须跑在【真实的平台初始化时序】上。
|
||||
//
|
||||
// 平台 12_Logic.js 的 Logic.AppStart 跨 332–531 行,里面:
|
||||
// line 389: Game_Modify.appStart() ← 此时 C_Player 只是 `var C_Player;`,未赋值
|
||||
// line 480: C_Player = new Player(-1) ← 对象到这里才存在,seat 仍是 -1
|
||||
// 之后玩家登录,07_Desk.js 的 C_Player.SetSeat(真座位),
|
||||
// 但 Game_Modify.appStart() 【再也不会被调用】。
|
||||
//
|
||||
// 所以「只在 appStart 里读一次 C_Player.seat」必然拿不到座位,mySeat 永远停在 -1:
|
||||
// 发包带 seat:-1(服务端 check_player 必拒,叫分/选主/埋牌/出牌全发不出去)、
|
||||
// StartWar 差异化下发认领不到本座位而整包丢弃、SeatMap.toDisplay(-1) 抛错。
|
||||
// ——全程无一处报错。本测试就是拿这条时序当尺子量。
|
||||
//
|
||||
// 本文件【不允许】用 `S.room.mySeat = N` 直接赋值来制造前提(其余 8 个测试文件那样做,
|
||||
// 正是它们全部漏掉 C-1 的原因)。座位只能经由平台入口同步进来。
|
||||
const { load, throws } = require('./_load');
|
||||
const t = require('./_assert')();
|
||||
|
||||
global.window = global;
|
||||
|
||||
load('client/js/gameabc-framework/system/EventBus.js');
|
||||
load('client/js/gameabc-framework/system/SpriteEventController.js');
|
||||
load('client/js/01_SubGame/codes/state/Events.js');
|
||||
load('client/js/01_SubGame/codes/state/GameState.js');
|
||||
load('client/js/01_SubGame/codes/state/RoomOptions.js');
|
||||
['DealHandler','CallHandler','MainHandler','BuryHandler','PlayHandler',
|
||||
'QueryHandler','ReadyHandler','ResultHandler','ResyncHandler','FailHandler']
|
||||
.forEach(h => load('client/js/01_SubGame/codes/net/handlers/' + h + '.js'));
|
||||
load('client/js/01_SubGame/codes/net/Dispatcher.js');
|
||||
load('client/js/01_SubGame/codes/core/SeatMap.js');
|
||||
|
||||
const sent = [];
|
||||
global.RpcHelper = { sendRpc: (app, route, rpc, data) => sent.push({ rpc, data }) };
|
||||
load('client/js/01_SubGame/codes/net/Rpc.js');
|
||||
|
||||
load('client/js/01_SubGame/codes/SubGameHooks.js');
|
||||
|
||||
const S = EQW_GameState;
|
||||
|
||||
// 平台 06_Player.js 的最小复刻:只有 seat 与 SetSeat 与本用例相关
|
||||
function Player(seat) { this.seat = seat; }
|
||||
Player.prototype.SetSeat = function (seat) { this.seat = seat; };
|
||||
|
||||
// ============================================================================
|
||||
// 阶段 1:Logic.AppStart 第 389 行——C_Player 尚未 new
|
||||
// ============================================================================
|
||||
t.eq('前提:C_Player 此刻确实不存在', typeof global.C_Player, 'undefined');
|
||||
t.eq('前提:mySeat 初始值是 -1', S.room.mySeat, -1);
|
||||
|
||||
let boom = false;
|
||||
try { SubGameHooks.appStart(); } catch (e) { boom = true; }
|
||||
t.eq('appStart 在 C_Player 缺席时不崩', boom, false);
|
||||
t.eq('appStart 拿不到座位,mySeat 仍是 -1(不是缺陷,是时序事实)', S.room.mySeat, -1);
|
||||
|
||||
// 这个 -1 一旦被发包无声带出去就是 C-1 的第一重后果
|
||||
t.eq('座位未同步时发包 fail-fast', throws(() => EQW_Rpc.zhunbei()), true);
|
||||
t.eq('座位未同步时一个包都没发出去', sent.length, 0);
|
||||
|
||||
// ============================================================================
|
||||
// 阶段 2:Logic.AppStart 第 480 行——C_Player = new Player(-1)
|
||||
// ============================================================================
|
||||
global.C_Player = new Player(-1);
|
||||
SubGameHooks.setRoomDes(1234, 6, '00010');
|
||||
t.eq('C_Player 存在但座位还是 -1 时,不写坏值', S.room.mySeat, -1);
|
||||
t.eq('setRoomDes 该干的正事照干(解析 roomtype)', S.room.options.climb, 1);
|
||||
|
||||
// ============================================================================
|
||||
// 阶段 3:玩家登录,07_Desk.js:320 C_Player.SetSeat(真座位)
|
||||
// 紧接着 07_Desk.js:358 Game_Modify.setRoomDes(...)
|
||||
// ============================================================================
|
||||
C_Player.SetSeat(2);
|
||||
t.eq('SetSeat 本身不写 GameState(平台不知道子游戏)', S.room.mySeat, -1);
|
||||
|
||||
SubGameHooks.setRoomDes(1234, 6, '00010');
|
||||
t.eq('【核心】走完平台时序后 mySeat = 真座位', S.room.mySeat, 2);
|
||||
|
||||
// ---- 第一重后果解除:发包带真座位 ----
|
||||
sent.length = 0;
|
||||
EQW_Rpc.zhunbei();
|
||||
t.eq('发包带上真座位', sent[0].data.seat, 2);
|
||||
|
||||
// ---- 第二重后果解除:StartWar 差异化下发能认领到自己那份 ----
|
||||
const share = (seat, cards) => ({ seat, data: { count: 6, idx: 1, PlayerInfo: [0,0,0], step: 1, MyCards: cards } });
|
||||
SubGameHooks.StartWar({ data: { deskwar: { sendtype: 1, seatlist: [
|
||||
share(0, [10, 11]), share(1, [20, 21]), share(2, [30, 31])
|
||||
] } } });
|
||||
t.eq('StartWar 认领到本座位那份手牌', S.my.cards, [30, 31]);
|
||||
|
||||
// ---- 第三重后果解除:SeatMap 不再对 -1 抛错 ----
|
||||
t.eq('SeatMap.toDisplay 用 mySeat 不抛错', throws(() => EQW_SeatMap.toDisplay(0, S.room.mySeat)), false);
|
||||
t.eq('自己映射到 SELF', EQW_SeatMap.toDisplay(S.room.mySeat, S.room.mySeat), 'SELF');
|
||||
|
||||
// ============================================================================
|
||||
// 阶段 4:换座(07_Desk.js:186-191 先改 C_Player.seat,再回调 changeSeat)
|
||||
// 这是唯一「座位中途变化且不重走 setRoomDes」的路径
|
||||
// ============================================================================
|
||||
C_Player.SetSeat(0);
|
||||
SubGameHooks.changeSeat(2, 0);
|
||||
t.eq('换座后 mySeat 跟着变', S.room.mySeat, 0);
|
||||
sent.length = 0;
|
||||
EQW_Rpc.zhunbei();
|
||||
t.eq('换座后发包带新座位', sent[0].data.seat, 0);
|
||||
|
||||
// ============================================================================
|
||||
// 阶段 5:断线重连(07_Desk.js:320 SetSeat → 358 setRoomDes → 423 Reconnect)
|
||||
// 这里单独校验 Reconnect 自身也同步,不依赖前面的入口是否跑过
|
||||
// ============================================================================
|
||||
S.room.mySeat = -1; // 人为退回未同步态,验证 Reconnect 能独立自愈
|
||||
C_Player.SetSeat(1);
|
||||
SubGameHooks.Reconnect({ count: 6, idx: 3, PlayerInfo: [0,0,0], step: 1, MyCards: [5,6] });
|
||||
t.eq('Reconnect 自身也同步座位', S.room.mySeat, 1);
|
||||
t.eq('Reconnect 的 reset() 不会把刚同步的座位清掉', S.room.mySeat, 1);
|
||||
t.eq('Reconnect 正常填状态', S.my.cards, [5, 6]);
|
||||
|
||||
S.room.mySeat = -1;
|
||||
C_Player.SetSeat(2);
|
||||
SubGameHooks.ReconnectNoMakewar();
|
||||
t.eq('ReconnectNoMakewar 也同步座位', S.room.mySeat, 2);
|
||||
|
||||
S.room.mySeat = -1;
|
||||
C_Player.SetSeat(0);
|
||||
SubGameHooks.StartWar({ data: { deskwar: { sendtype: 1, seatlist: [
|
||||
share(0, [7, 8]), share(1, [9]), share(2, [1]) ] } } });
|
||||
t.eq('StartWar 自身也同步座位', S.room.mySeat, 0);
|
||||
t.eq('StartWar 同步后认领正确的那份', S.my.cards, [7, 8]);
|
||||
|
||||
// ============================================================================
|
||||
// 幂等 / 防倒退:_syncMySeat 可重复调用,且绝不用坏值覆盖好值
|
||||
// ============================================================================
|
||||
SubGameHooks._syncMySeat();
|
||||
SubGameHooks._syncMySeat();
|
||||
t.eq('重复同步结果不变(幂等)', S.room.mySeat, 0);
|
||||
|
||||
C_Player.SetSeat(-1); // 平台 new Player(-1) 的初值又冒出来
|
||||
SubGameHooks._syncMySeat();
|
||||
t.eq('座位为 -1 时保持原值,不倒退', S.room.mySeat, 0);
|
||||
C_Player.SetSeat(3);
|
||||
SubGameHooks._syncMySeat();
|
||||
t.eq('座位越界时保持原值,不倒退', S.room.mySeat, 0);
|
||||
C_Player.SetSeat('1');
|
||||
SubGameHooks._syncMySeat();
|
||||
t.eq('座位是字符串时保持原值,不倒退', S.room.mySeat, 0);
|
||||
|
||||
delete global.C_Player;
|
||||
boom = false;
|
||||
try { SubGameHooks._syncMySeat(); } catch (e) { boom = true; }
|
||||
t.eq('C_Player 消失时同步不崩', boom, false);
|
||||
t.eq('C_Player 消失时保持原值', S.room.mySeat, 0);
|
||||
|
||||
process.exit(t.done('appstart_timing') ? 0 : 1);
|
||||
@@ -0,0 +1,114 @@
|
||||
// 【核心验收】增量累积的 GameState 必须与 deskinfo 全量重建的完全相等
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { ROOT, load } = require('./_load');
|
||||
const t = require('./_assert')();
|
||||
|
||||
load('client/js/gameabc-framework/system/EventBus.js');
|
||||
load('client/js/01_SubGame/codes/state/Events.js');
|
||||
load('client/js/01_SubGame/codes/state/GameState.js');
|
||||
load('client/js/01_SubGame/codes/state/RoomOptions.js');
|
||||
load('client/js/01_SubGame/codes/net/handlers/DealHandler.js');
|
||||
load('client/js/01_SubGame/codes/net/handlers/CallHandler.js');
|
||||
load('client/js/01_SubGame/codes/net/handlers/MainHandler.js');
|
||||
load('client/js/01_SubGame/codes/net/handlers/BuryHandler.js');
|
||||
load('client/js/01_SubGame/codes/net/handlers/PlayHandler.js');
|
||||
load('client/js/01_SubGame/codes/net/handlers/QueryHandler.js');
|
||||
load('client/js/01_SubGame/codes/net/handlers/ReadyHandler.js');
|
||||
load('client/js/01_SubGame/codes/net/handlers/ResultHandler.js');
|
||||
load('client/js/01_SubGame/codes/net/handlers/ResyncHandler.js');
|
||||
load('client/js/01_SubGame/codes/net/Dispatcher.js');
|
||||
|
||||
const fx = JSON.parse(fs.readFileSync(path.join(ROOT, 'client/tests/fixtures/packets.json'), 'utf8'));
|
||||
|
||||
// 这些字段是「一次性事件」或「重连不重放」的,不参与一致性比对。
|
||||
// 每加一条都必须写清理由——这个白名单是本测试唯一的松口处,滥用它等于废掉这条守卫。
|
||||
//
|
||||
// 【清单历史】result.bottom / result.account 已随服务端 Balance 补发而删除;
|
||||
// result.chupai 已随「本墩结果改走 EQW_TRICK_END 事件载荷、不进 GameState」而删除。
|
||||
// 现在只剩下面这一条。
|
||||
const EXCLUDE = [
|
||||
// 70 分坐庄的 3 秒开底是上庄时的一次性事件,协议明确 deskinfo 不重放。
|
||||
// ⚠️ 当前夹具无法触发(65 分坐庄、单局),此条未经验证——保留是因为删掉之后
|
||||
// 将来真出现不一致会静默通过;但它此刻并没有在守住任何东西,不要当成已验证的豁免。
|
||||
'table.ancard3s'
|
||||
];
|
||||
|
||||
function pick(snap, exclude) {
|
||||
const out = JSON.parse(JSON.stringify(snap));
|
||||
exclude.forEach(p => {
|
||||
const parts = p.split('.');
|
||||
let o = out;
|
||||
for (let i = 0; i < parts.length - 1; i++) { o = o && o[parts[i]]; }
|
||||
if (o) { delete o[parts[parts.length - 1]]; }
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
// 每个对齐点独立起步,不共用进程内任何残留(下面两个 build 函数各自从
|
||||
// EQW_GameState.reset() + 显式赋值房间级字段开始,互不依赖调用顺序/次数):
|
||||
//
|
||||
// 【曾经的假绿】EQW_GameState.reset() 按设计不清 room.playerScores(它是"跨局不清"的
|
||||
// 房间级累积量,见 GameState.js 注释)。旧版路径 A 从不写 playerScores(fapai 包不带),
|
||||
// 于是它的值全靠"上一次由谁最后写过"——如果上一次恰好是路径 B(deskinfo 带 PlayerInfo)
|
||||
// 跑过,路径 A 就会【继承】那份残留,凑巧与路径 B 的期望值相等,测试假绿;换一个座位
|
||||
// 顺序、或把它放到第一个跑,残留没了就立刻转红。
|
||||
// 真实客户端不存在这个残留——它在 fapai 之前先收到 StartWar(deskwar,见夹具补捕),
|
||||
// 由它给出 room.playerScores 的开局初值。所以路径 A 的真实起点也改成先喂 StartWar,
|
||||
// 每次都显式重新赋值,不再依赖任何"恰好还没被冲掉的旧值"。
|
||||
function buildIncremental(seat, packetIndex) {
|
||||
EQW_GameState.reset();
|
||||
EQW_GameState.room.mySeat = seat;
|
||||
EQW_GameState.room.options = EQW_RoomOptions_Parse.parse(fx.roomtype);
|
||||
EQW_ResyncHandler.handleStartWar({ data: fx.startwar[seat] });
|
||||
fx.seats[seat].slice(0, packetIndex).forEach(p => {
|
||||
EQW_Dispatcher.dispatch({ rpc: p.rpc, data: p.data });
|
||||
});
|
||||
return EQW_GameState.snapshot();
|
||||
}
|
||||
|
||||
function buildFull(seat, info) {
|
||||
EQW_GameState.reset();
|
||||
EQW_GameState.room.mySeat = seat;
|
||||
EQW_GameState.room.options = EQW_RoomOptions_Parse.parse(fx.roomtype);
|
||||
EQW_ResyncHandler.handleDeskinfo(info);
|
||||
return EQW_GameState.snapshot();
|
||||
}
|
||||
|
||||
// 对每个座位、每个 deskinfo 快照点做比对
|
||||
let compared = 0;
|
||||
fx.deskinfo.forEach(snapPoint => {
|
||||
const seat = snapPoint.seat;
|
||||
|
||||
const incremental = buildIncremental(seat, snapPoint.packetIndex);
|
||||
const full = buildFull(seat, snapPoint.info);
|
||||
|
||||
t.eq('座位' + seat + ' step' + snapPoint.step + ' 增量 == 全量',
|
||||
pick(incremental, EXCLUDE), pick(full, EXCLUDE));
|
||||
compared++;
|
||||
});
|
||||
|
||||
t.eq('比对点数量 > 0', compared > 0, true);
|
||||
|
||||
// ================= Ruling 3:step3(埋牌)deskinfo 直读断言 =================
|
||||
// 一致性比对无法单独覆盖 ResyncHandler._applyBuryCards 的字段映射正确性——
|
||||
// 若增量路径压根不写某字段,两边可能「同为初始值」而比对照样绿。
|
||||
// 这里独立取 step3 快照,逐字段直读 GameState 并核对具体值。
|
||||
const step3 = fx.deskinfo.filter(d => d.step === 3)[0];
|
||||
if (!step3) {
|
||||
console.warn('[test_consistency] 夹具里没有 step3 快照,Ruling 3 的直读断言无法执行');
|
||||
} else {
|
||||
EQW_GameState.reset();
|
||||
EQW_ResyncHandler.handleDeskinfo(step3.info);
|
||||
const bc = step3.info.BuryCards;
|
||||
t.eq('step3 直读 aset.banker', EQW_GameState.aset.banker, bc.banker);
|
||||
t.eq('step3 直读 aset.call', EQW_GameState.aset.call, bc.call);
|
||||
t.eq('step3 直读 aset.multiple', EQW_GameState.aset.multiple, bc.multiple);
|
||||
t.eq('step3 直读 aset.flower', EQW_GameState.aset.flower, bc.flower);
|
||||
t.eq('step3 直读 my.bottomCards', EQW_GameState.my.bottomCards, bc.bottomcards);
|
||||
t.eq('step3 直读 turn.countdown', EQW_GameState.turn.countdown, bc.countdown);
|
||||
t.eq('step3 直读 turn.seat(埋牌由庄家做)', EQW_GameState.turn.seat, bc.banker);
|
||||
t.eq('step3 直读 aset.step', EQW_GameState.aset.step, step3.info.step);
|
||||
}
|
||||
|
||||
process.exit(t.done('consistency') ? 0 : 1);
|
||||
@@ -0,0 +1,59 @@
|
||||
// 分发器:success 优先分流、未知 rpc 不崩、路由表完整性
|
||||
const { load } = require('./_load');
|
||||
const t = require('./_assert')();
|
||||
|
||||
load('client/js/gameabc-framework/system/EventBus.js');
|
||||
load('client/js/01_SubGame/codes/state/Events.js');
|
||||
load('client/js/01_SubGame/codes/state/GameState.js');
|
||||
load('client/js/01_SubGame/codes/state/RoomOptions.js');
|
||||
load('client/js/01_SubGame/codes/net/handlers/FailHandler.js');
|
||||
load('client/js/01_SubGame/codes/net/handlers/DealHandler.js');
|
||||
load('client/js/01_SubGame/codes/net/handlers/CallHandler.js');
|
||||
load('client/js/01_SubGame/codes/net/handlers/MainHandler.js');
|
||||
load('client/js/01_SubGame/codes/net/handlers/BuryHandler.js');
|
||||
load('client/js/01_SubGame/codes/net/handlers/PlayHandler.js');
|
||||
load('client/js/01_SubGame/codes/net/handlers/QueryHandler.js');
|
||||
load('client/js/01_SubGame/codes/net/handlers/ReadyHandler.js');
|
||||
load('client/js/01_SubGame/codes/net/handlers/ResultHandler.js');
|
||||
load('client/js/01_SubGame/codes/net/handlers/ResyncHandler.js');
|
||||
load('client/js/01_SubGame/codes/net/Dispatcher.js');
|
||||
|
||||
// ---- 注册与分发 ----
|
||||
const got = [];
|
||||
EQW_Dispatcher.register('testRpc', function (d) { got.push(d); });
|
||||
EQW_Dispatcher.dispatch({ rpc: 'testRpc', data: { success: true, v: 1 } });
|
||||
t.eq('成功包进业务 handler', got, [{ success: true, v: 1 }]);
|
||||
|
||||
// ---- success:false 优先走 FailHandler,不进业务 handler ----
|
||||
const failed = [];
|
||||
EventBus.on(EQW_Events.EQW_RPC_FAILED, function (e) { failed.push(e); });
|
||||
got.length = 0;
|
||||
EQW_Dispatcher.dispatch({ rpc: 'testRpc', data: { success: false, errcode: 5 } });
|
||||
t.eq('失败包不进业务 handler', got, []);
|
||||
t.eq('失败包 emit RPC_FAILED', failed, [{ rpc: 'testRpc', errcode: 5 }]);
|
||||
|
||||
// ---- 失败包即使 rpc 没注册过也要被处理(如 chupai 只会以失败包出现)----
|
||||
failed.length = 0;
|
||||
EQW_Dispatcher.dispatch({ rpc: 'chupai', data: { success: false, errcode: 6 } });
|
||||
t.eq('未注册 rpc 的失败包也处理', failed, [{ rpc: 'chupai', errcode: 6 }]);
|
||||
|
||||
// ---- 未知 rpc 的成功包:只警告、不抛错 ----
|
||||
let threw = false;
|
||||
try { EQW_Dispatcher.dispatch({ rpc: 'brandNewRpc', data: { success: true } }); }
|
||||
catch (e) { threw = true; }
|
||||
t.eq('未知 rpc 不抛错', threw, false);
|
||||
|
||||
// ---- 畸形入参不崩 ----
|
||||
[null, undefined, {}, { rpc: 'testRpc' }, { data: {} }].forEach((bad, i) => {
|
||||
let boom = false;
|
||||
try { EQW_Dispatcher.dispatch(bad); } catch (e) { boom = true; }
|
||||
t.eq('畸形入参 #' + i + ' 不崩', boom, false);
|
||||
});
|
||||
|
||||
// ---- 路由表完整性:协议里每个服务端推送 rpc 都要有 handler ----
|
||||
['fapai','jiaofen','shangzhuang','xuanzhu','maipai',
|
||||
'chupai1','chupai2','chupai3','mingpai','tishi','jiesuan','zhunbei'].forEach(rpc => {
|
||||
t.eq('已注册 handler: ' + rpc, EQW_Dispatcher.hasHandler(rpc), true);
|
||||
});
|
||||
|
||||
process.exit(t.done('dispatcher') ? 0 : 1);
|
||||
@@ -0,0 +1,111 @@
|
||||
// 夹具自检:真包必须覆盖全部阶段,且保留座位差异
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { ROOT } = require('./_load');
|
||||
const t = require('./_assert')();
|
||||
|
||||
const fx = JSON.parse(fs.readFileSync(path.join(ROOT, 'client/tests/fixtures/packets.json'), 'utf8'));
|
||||
|
||||
t.eq('三个座位', fx.seats.length, 3);
|
||||
|
||||
const rpcsOf = seat => fx.seats[seat].map(p => p.rpc);
|
||||
const allRpcs = [].concat(rpcsOf(0), rpcsOf(1), rpcsOf(2));
|
||||
|
||||
['fapai','jiaofen','shangzhuang','xuanzhu','maipai','chupai1','chupai2','chupai3','jiesuan']
|
||||
.forEach(rpc => t.eq('夹具覆盖 ' + rpc, allRpcs.indexOf(rpc) >= 0, true));
|
||||
|
||||
// 差异化下发确实存在:bottomcards 恰好只发给庄家一人(不是"少于三家"这种可被空集合蒙混过去的弱断言)
|
||||
const withBottom = [0,1,2].filter(s =>
|
||||
fx.seats[s].some(p => p.rpc === 'shangzhuang' && p.data.hasOwnProperty('bottomcards')));
|
||||
t.eq('底牌恰好发给1个座位(庄家)', withBottom.length, 1);
|
||||
|
||||
// deskinfo 快照必须覆盖全部 5 个阶段(下游任务要用 step3 埋牌快照做回归断言)
|
||||
const steps = fx.deskinfo.map(d => d.step).filter((v, i, a) => a.indexOf(v) === i).sort((a,b) => a - b);
|
||||
t.eq('deskinfo 覆盖全部阶段 1/2/3/5/6', steps, [1, 2, 3, 5, 6]);
|
||||
|
||||
// roomtype 必须存在且非空,Task 12 要用它还原房间选项
|
||||
t.eq('roomtype 存在且非空', typeof fx.roomtype === 'string' && fx.roomtype.length > 0, true);
|
||||
|
||||
// startwar 是增量路径的真实起点(开局握手,结构=deskwar=deskinfo),必须每座位各一份,
|
||||
// 且不得混进 seats[] 数组头部——那会让全部 packetIndex 集体错位一位(见夹具补捕说明)
|
||||
t.eq('startwar 存在且为 3 份', Array.isArray(fx.startwar) && fx.startwar.length, 3);
|
||||
[0, 1, 2].forEach(s => {
|
||||
const dw = fx.startwar[s] && fx.startwar[s].deskwar;
|
||||
t.eq('startwar[' + s + '].deskwar 存在', !!dw, true);
|
||||
t.eq('startwar[' + s + '] 结构=deskinfo(带 step/PlayerInfo/MyCards)',
|
||||
dw && dw.hasOwnProperty('step') && dw.hasOwnProperty('PlayerInfo') && dw.hasOwnProperty('MyCards'), true);
|
||||
t.eq('startwar[' + s + '].PlayerInfo 为开局初值(三家均 0)', dw && dw.PlayerInfo, [0, 0, 0]);
|
||||
});
|
||||
// seats[] 的第一包必须仍是 fapai——确认 startwar 没有被误插进 seats[] 头部
|
||||
[0, 1, 2].forEach(s => {
|
||||
t.eq('seats[' + s + '][0] 仍是 fapai(startwar 未混入 seats)', fx.seats[s][0].rpc, 'fapai');
|
||||
});
|
||||
|
||||
// packetIndex 必须落在 (0, 该座位包总数] 区间内,且与 info.step 自洽
|
||||
fx.deskinfo.forEach(d => {
|
||||
const inRange = d.packetIndex > 0 && d.packetIndex <= fx.seats[d.seat].length;
|
||||
t.eq('packetIndex 落在有效区间 (step' + d.step + ' seat' + d.seat + ')', inRange, true);
|
||||
t.eq('deskinfo.step 与 info.step 一致 (seat' + d.seat + ' pi' + d.packetIndex + ')', d.step, d.info.step);
|
||||
});
|
||||
|
||||
// C-1 回归锁:同一座位的两条 step5 快照,playproc(当前轮桌面牌/currseat/round)不得相同——
|
||||
// 否则说明 deskinfo 快照存的是活引用,被后续出牌篡改成了终局状态(曾经的真实缺陷)
|
||||
const step5BySeat = {};
|
||||
fx.deskinfo.filter(d => d.step === 5).forEach(d => {
|
||||
(step5BySeat[d.seat] = step5BySeat[d.seat] || []).push(d);
|
||||
});
|
||||
Object.keys(step5BySeat).forEach(seat => {
|
||||
const list = step5BySeat[seat];
|
||||
if (list.length < 2) { return; }
|
||||
for (let i = 1; i < list.length; i++) {
|
||||
const same = JSON.stringify(list[0].info.PushCards.playproc) === JSON.stringify(list[i].info.PushCards.playproc);
|
||||
t.eq('seat' + seat + ' 两条 step5 快照 playproc 不相同(未被活引用污染)', same, false);
|
||||
}
|
||||
});
|
||||
|
||||
// I-3:推进阶段的下发包必须自带 step,且与该时刻的 deskinfo.step 属于同一套取值。
|
||||
// 前端不得按 rpc 名硬编码阶段(前端红线「数据驱动、前端无对局状态机」)。
|
||||
const STEP_OF = { fapai: 1, jiaofen: 1, shangzhuang: 2, xuanzhu: 3, maipai: 5, jiesuan: 6 };
|
||||
const noStep = [], badStep = [];
|
||||
[0, 1, 2].forEach(s => fx.seats[s].forEach(p => {
|
||||
if (!STEP_OF.hasOwnProperty(p.rpc)) { return; }
|
||||
if (!p.data.hasOwnProperty('step')) { noStep.push(s + ':' + p.rpc); return; }
|
||||
if (p.data.step !== STEP_OF[p.rpc]) { badStep.push(s + ':' + p.rpc + '=' + p.data.step); }
|
||||
}));
|
||||
t.eq('推进阶段的下发包都带 step', noStep, []);
|
||||
t.eq('各包的 step 就是该时点的真实阶段', badStep, []);
|
||||
|
||||
// I-3:选主/埋牌阶段的「轮到谁」必须由服务端显式给出(增量 nextseat / 重连分组的 seat)
|
||||
const noNext = [];
|
||||
[0, 1, 2].forEach(s => fx.seats[s].forEach(p => {
|
||||
if ((p.rpc === 'shangzhuang' || p.rpc === 'xuanzhu') && !p.data.hasOwnProperty('nextseat')) {
|
||||
noNext.push(s + ':' + p.rpc);
|
||||
}
|
||||
}));
|
||||
t.eq('shangzhuang/xuanzhu 都带 nextseat(控制权)', noNext, []);
|
||||
fx.deskinfo.filter(d => d.step === 2).forEach(d => {
|
||||
t.eq('重连 ChooseMain 带控制权 seat (seat' + d.seat + ')', typeof d.info.ChooseMain.seat, 'number');
|
||||
});
|
||||
fx.deskinfo.filter(d => d.step === 3).forEach(d => {
|
||||
t.eq('重连 BuryCards 带控制权 seat (seat' + d.seat + ')', typeof d.info.BuryCards.seat, 'number');
|
||||
});
|
||||
|
||||
// I-2:结算阶段的重连包必须与 jiesuan 推送给同一份结算数据(抠底明细 bottom 恒有;
|
||||
// account 只在末局,本夹具是第 1/6 局,故两条路径都没有)
|
||||
const jiesuanPk = fx.seats[0].filter(p => p.rpc === 'jiesuan')[0];
|
||||
t.eq('夹具的 jiesuan 带抠底明细 bottom', !!(jiesuanPk && jiesuanPk.data.bottom), true);
|
||||
fx.deskinfo.filter(d => d.step === 6).forEach(d => {
|
||||
t.eq('重连 Balance.bottom == jiesuan.bottom (seat' + d.seat + ')',
|
||||
JSON.stringify(d.info.Balance.bottom), JSON.stringify(jiesuanPk.data.bottom));
|
||||
t.eq('非末局:两条路径都不带 account (seat' + d.seat + ')',
|
||||
[d.info.Balance.hasOwnProperty('account'), jiesuanPk.data.hasOwnProperty('account')], [false, false]);
|
||||
});
|
||||
|
||||
// 每个包都有 success(协议 §0.1:每个下发包的 data 必带 success)
|
||||
const noSuccess = [];
|
||||
[0,1,2].forEach(s => fx.seats[s].forEach(p => {
|
||||
if (!p.data || !p.data.hasOwnProperty('success')) { noSuccess.push(s + ':' + p.rpc); }
|
||||
}));
|
||||
t.eq('每个下发包都带 success', noSuccess, []);
|
||||
|
||||
process.exit(t.done('fixture') ? 0 : 1);
|
||||
@@ -0,0 +1,56 @@
|
||||
// GameState 骨架:初始值、reset 的清空范围、snapshot 深拷贝
|
||||
const { load } = require('./_load');
|
||||
const t = require('./_assert')();
|
||||
|
||||
load('client/js/gameabc-framework/system/EventBus.js');
|
||||
load('client/js/01_SubGame/codes/state/Events.js');
|
||||
load('client/js/01_SubGame/codes/state/GameState.js');
|
||||
|
||||
// ---- 事件常量已追加进 EventBus.Events ----
|
||||
t.eq('事件常量非空', Object.keys(EQW_Events).length > 0, true);
|
||||
t.eq('RESET 已入 EventBus.Events', EventBus.Events.EQW_RESET, EQW_Events.EQW_RESET);
|
||||
t.eq('事件名互不重复',
|
||||
Object.keys(EQW_Events).length,
|
||||
Object.keys(EQW_Events).map(k => EQW_Events[k]).filter((v, i, a) => a.indexOf(v) === i).length);
|
||||
|
||||
// ---- 初始值 ----
|
||||
t.eq('初始 mySeat', EQW_GameState.room.mySeat, -1);
|
||||
t.eq('初始 banker', EQW_GameState.aset.banker, -1);
|
||||
t.eq('初始 flower', EQW_GameState.aset.flower, 0);
|
||||
t.eq('初始手牌', EQW_GameState.my.cards, []);
|
||||
t.eq('初始三家叫分', EQW_GameState.call.calls, [null, null, null]);
|
||||
|
||||
// ---- snapshot 是深拷贝、且不含方法 ----
|
||||
EQW_GameState.my.cards = [1, 2, 3];
|
||||
const snap = EQW_GameState.snapshot();
|
||||
t.eq('snapshot 取到值', snap.my.cards, [1, 2, 3]);
|
||||
EQW_GameState.my.cards.push(4);
|
||||
t.eq('snapshot 是深拷贝(不随后续修改)', snap.my.cards, [1, 2, 3]);
|
||||
t.eq('snapshot 不含方法', typeof snap.reset, 'undefined');
|
||||
t.eq('snapshot 含全部分组',
|
||||
Object.keys(snap).sort(),
|
||||
['aset', 'call', 'my', 'result', 'room', 'table', 'turn']);
|
||||
|
||||
// ---- reset:清对局态,但 mySeat / options 不清(跨局不变)----
|
||||
EQW_GameState.room.mySeat = 2;
|
||||
EQW_GameState.room.options = { climb: 1 };
|
||||
EQW_GameState.aset.banker = 1;
|
||||
EQW_GameState.aset.flower = 3;
|
||||
EQW_GameState.table.pushlist = [[1], [2]];
|
||||
EQW_GameState.result.aset = { grade: 5 };
|
||||
EQW_GameState.reset();
|
||||
|
||||
t.eq('reset 后 mySeat 保留', EQW_GameState.room.mySeat, 2);
|
||||
t.eq('reset 后 options 保留', EQW_GameState.room.options, { climb: 1 });
|
||||
t.eq('reset 后 banker 清空', EQW_GameState.aset.banker, -1);
|
||||
t.eq('reset 后 flower 清空', EQW_GameState.aset.flower, 0);
|
||||
t.eq('reset 后 pushlist 清空', EQW_GameState.table.pushlist, []);
|
||||
t.eq('reset 后 result 清空', EQW_GameState.result.aset, null);
|
||||
t.eq('reset 后手牌清空', EQW_GameState.my.cards, []);
|
||||
|
||||
// ---- reset 两次结果一致(幂等)----
|
||||
const afterFirst = EQW_GameState.snapshot();
|
||||
EQW_GameState.reset();
|
||||
t.eq('reset 幂等', EQW_GameState.snapshot(), afterFirst);
|
||||
|
||||
process.exit(t.done('gamestate') ? 0 : 1);
|
||||
@@ -0,0 +1,85 @@
|
||||
// 选主 / 埋牌 handler
|
||||
const { load } = require('./_load');
|
||||
const t = require('./_assert')();
|
||||
|
||||
load('client/js/gameabc-framework/system/EventBus.js');
|
||||
load('client/js/01_SubGame/codes/state/Events.js');
|
||||
load('client/js/01_SubGame/codes/state/GameState.js');
|
||||
load('client/js/01_SubGame/codes/net/handlers/MainHandler.js');
|
||||
load('client/js/01_SubGame/codes/net/handlers/BuryHandler.js');
|
||||
|
||||
const S = EQW_GameState;
|
||||
const evts = [];
|
||||
Object.keys(EQW_Events).forEach(k => EventBus.on(EQW_Events[k], () => evts.push(k)));
|
||||
|
||||
// ================= xuanzhu =================
|
||||
S.reset(); evts.length = 0;
|
||||
EQW_MainHandler.handleXuanzhu({ success: true, banker: 1, flower: 3, step: 3, nextseat: 1,
|
||||
countdown: 30, cards: [5, 6, 7] });
|
||||
t.eq('主牌花色', S.aset.flower, 3);
|
||||
t.eq('庄家', S.aset.banker, 1);
|
||||
t.eq('选主后手牌', S.my.cards, [5, 6, 7]);
|
||||
t.eq('step 读包字段(埋牌=3)', S.aset.step, 3);
|
||||
t.eq('控制权读包里的 nextseat', S.turn.seat, 1);
|
||||
t.eq('倒计时', S.turn.countdown, 30);
|
||||
t.eq('选主发事件',
|
||||
['EQW_MAIN_SET','EQW_HAND_CHANGED'].every(e => evts.indexOf(e) >= 0), true);
|
||||
|
||||
// 闲家视角:没有 cards,不抹已有手牌
|
||||
S.reset(); S.my.cards = [1, 2];
|
||||
EQW_MainHandler.handleXuanzhu({ success: true, banker: 0, flower: 2, step: 3, nextseat: 0, countdown: 30 });
|
||||
t.eq('闲家手牌不被抹', S.my.cards, [1, 2]);
|
||||
t.eq('闲家也拿到花色', S.aset.flower, 2);
|
||||
|
||||
// 【守卫】阶段与控制权都必须来自包,不得按 rpc 名硬编码、也不得用本地 banker 反推。
|
||||
// 真实链路里 nextseat 恒 == banker,相等时"读哪个"分辨不出来;这里刻意让它们不同,
|
||||
// 退回 `aset.step = 3` / `turn.seat = aset.banker` 的写法必转红。
|
||||
S.reset();
|
||||
EQW_MainHandler.handleXuanzhu({ success: true, banker: 0, flower: 2, step: 4, nextseat: 2, countdown: 30 });
|
||||
t.eq('xuanzhu step 照读包字段', S.aset.step, 4);
|
||||
t.eq('xuanzhu 控制权取 nextseat,不取 banker', S.turn.seat, 2);
|
||||
|
||||
// ================= maipai =================
|
||||
S.reset(); evts.length = 0;
|
||||
EQW_BuryHandler.handle({ success: true, cards: [1,2,3], burycards: [9,10,11,12,13,14,15,16],
|
||||
step: 5, seat: 1, countdown: 20 });
|
||||
t.eq('埋牌后手牌', S.my.cards, [1,2,3]);
|
||||
t.eq('埋牌底牌', S.my.buryCards, [9,10,11,12,13,14,15,16]);
|
||||
t.eq('step 读包字段(出牌=5)', S.aset.step, 5);
|
||||
t.eq('首出者(控制权读包里的 seat)', S.turn.seat, 1);
|
||||
t.eq('埋牌发事件',
|
||||
['EQW_BURY_DONE','EQW_HAND_CHANGED','EQW_TURN_CHANGED'].every(e => evts.indexOf(e) >= 0), true);
|
||||
|
||||
// 闲家视角:无 cards/burycards,但可能有 liangpai
|
||||
S.reset(); S.my.cards = [4, 5]; evts.length = 0;
|
||||
EQW_BuryHandler.handle({ success: true, step: 5, seat: 0, countdown: 20,
|
||||
liangpai: { cards: [52, 53, 2, 15] } });
|
||||
t.eq('闲家手牌不被抹', S.my.cards, [4, 5]);
|
||||
t.eq('闲家无埋牌底牌', S.my.buryCards, []);
|
||||
t.eq('亮牌数据', S.table.liangpai, { cards: [52, 53, 2, 15] });
|
||||
|
||||
// 不达标/不查牌:无 liangpai 字段,保持 null
|
||||
S.reset();
|
||||
EQW_BuryHandler.handle({ success: true, step: 5, seat: 0, countdown: 20 });
|
||||
t.eq('无亮牌时保持 null', S.table.liangpai, null);
|
||||
|
||||
// 【守卫】maipai 的 step 同样只读包字段
|
||||
S.reset();
|
||||
EQW_BuryHandler.handle({ success: true, step: 4, seat: 0, countdown: 20 });
|
||||
t.eq('maipai step 照读包字段(不按 rpc 名硬编码)', S.aset.step, 4);
|
||||
|
||||
// playproc:埋牌完成时服务端已就地开好 round-1 的进行态,恒有、原样拷贝进 table.playproc,
|
||||
// 与 chupai1/2/3.playproc 同源同结构(协议 §9),漏接会让「埋牌完成→庄家首出」这段窗口
|
||||
// 增量路径 table.playproc 停在上一阶段的 null、与重连路径不一致
|
||||
S.reset();
|
||||
EQW_BuryHandler.handle({ success: true, step: 5, seat: 0, countdown: 20, cards: [1, 2, 3],
|
||||
burycards: [9, 10, 11, 12, 13, 14, 15, 16],
|
||||
playproc: { round: 1, start: 0, currseat: 0, startcount: -1,
|
||||
startflower: -1, starttype: -1, maxseat: -1, maxcard: -1,
|
||||
cards: [null, null, null], shuai_demand: null } });
|
||||
t.eq('埋牌 playproc 原样拷贝服务端本轮进行态', S.table.playproc, {
|
||||
round: 1, start: 0, currseat: 0, startcount: -1, startflower: -1, starttype: -1,
|
||||
maxseat: -1, maxcard: -1, cards: [null, null, null], shuai_demand: null
|
||||
});
|
||||
|
||||
process.exit(t.done('handlers_bury') ? 0 : 1);
|
||||
@@ -0,0 +1,96 @@
|
||||
// 发牌 / 叫分 / 上庄 handler
|
||||
const { load } = require('./_load');
|
||||
const t = require('./_assert')();
|
||||
|
||||
load('client/js/gameabc-framework/system/EventBus.js');
|
||||
load('client/js/01_SubGame/codes/state/Events.js');
|
||||
load('client/js/01_SubGame/codes/state/GameState.js');
|
||||
load('client/js/01_SubGame/codes/net/handlers/DealHandler.js');
|
||||
load('client/js/01_SubGame/codes/net/handlers/CallHandler.js');
|
||||
|
||||
const S = EQW_GameState;
|
||||
const evts = [];
|
||||
Object.keys(EQW_Events).forEach(k => EventBus.on(EQW_Events[k], () => evts.push(k)));
|
||||
|
||||
// ================= fapai =================
|
||||
S.reset(); S.room.mySeat = 1; evts.length = 0;
|
||||
EQW_DealHandler.handle({ success: true, asetidx: 2, asetcount: 6,
|
||||
cards: [1, 2, 3], step: 1, seat: 0, countdown: 15 });
|
||||
|
||||
t.eq('局数', S.room.asetIdx, 2);
|
||||
t.eq('总局数', S.room.asetCount, 6);
|
||||
t.eq('手牌', S.my.cards, [1, 2, 3]);
|
||||
t.eq('当前叫分者', S.turn.seat, 0);
|
||||
t.eq('倒计时', S.turn.countdown, 15);
|
||||
t.eq('step 读包字段(叫分=1)', S.aset.step, 1);
|
||||
t.eq('mySeat 未被 reset 冲掉', S.room.mySeat, 1);
|
||||
t.eq('fapai 发了 RESET/HAND/TURN 事件',
|
||||
['EQW_RESET','EQW_HAND_CHANGED','EQW_TURN_CHANGED'].every(e => evts.indexOf(e) >= 0), true);
|
||||
|
||||
// fapai 必须先 reset:上一局的残留要清掉
|
||||
S.aset.banker = 2; S.table.pushlist = [[9]];
|
||||
EQW_DealHandler.handle({ success: true, asetidx: 3, asetcount: 6, cards: [4], step: 1, seat: 1, countdown: 15 });
|
||||
t.eq('fapai 清掉上局 banker', S.aset.banker, -1);
|
||||
t.eq('fapai 清掉上局 pushlist', S.table.pushlist, []);
|
||||
|
||||
// 【守卫】step 必须来自包,不得按 rpc 名硬编码。这里刻意给一个"服务端将来新插的阶段"值:
|
||||
// 前端应照单收下(服务端权威),而不是把它改回 1。退回 `aset.step = 1` 的写法必转红。
|
||||
EQW_DealHandler.handle({ success: true, asetidx: 3, asetcount: 6, cards: [4], step: 4, seat: 1, countdown: 15 });
|
||||
t.eq('fapai step 照读包字段(不按 rpc 名硬编码)', S.aset.step, 4);
|
||||
|
||||
// ================= jiaofen =================
|
||||
S.reset(); evts.length = 0;
|
||||
EQW_CallHandler.handleJiaofen({ success: true, seat: 0, call: 60, currcall: 60,
|
||||
multiple: 3, step: 1, nextseat: 1, countdown: 15 });
|
||||
t.eq('记录该家叫分', S.call.calls, [60, null, null]);
|
||||
t.eq('当前叫到的分', S.call.currcall, 60);
|
||||
t.eq('基础子数', S.aset.multiple, 3);
|
||||
t.eq('jiaofen step 读包字段(仍在叫分=1)', S.aset.step, 1);
|
||||
t.eq('下一个叫分者', S.turn.seat, 1);
|
||||
t.eq('jiaofen 发事件', evts.indexOf('EQW_CALL_CHANGED') >= 0, true);
|
||||
|
||||
// 不叫(call = 0)也要记下来,与「还没叫」(null) 区分
|
||||
EQW_CallHandler.handleJiaofen({ success: true, seat: 1, call: 0, currcall: 60,
|
||||
multiple: 3, step: 1, nextseat: 2, countdown: 15 });
|
||||
t.eq('不叫记为 0,与未叫 null 区分', S.call.calls, [60, 0, null]);
|
||||
|
||||
// ================= shangzhuang =================
|
||||
S.reset(); evts.length = 0;
|
||||
EQW_CallHandler.handleShangzhuang({ success: true, seat: 0, call: 70, banker: 0, grade: 70,
|
||||
multiple: 2, step: 2, nextseat: 0,
|
||||
bottomcards: [1,2,3,4,5,6,7,8], ancard3s: 1,
|
||||
cards: [1,2,3], countdown: 20, touxiang: 1, curmultiple: 3 });
|
||||
t.eq('庄家', S.aset.banker, 0);
|
||||
t.eq('庄家叫分', S.aset.call, 70);
|
||||
t.eq('基础子数', S.aset.multiple, 2);
|
||||
t.eq('允许投降', S.aset.touxiang, 1);
|
||||
t.eq('抓分倍数', S.aset.curmultiple, 3);
|
||||
t.eq('开底标志', S.table.ancard3s, 1);
|
||||
t.eq('底牌', S.my.bottomCards, [1,2,3,4,5,6,7,8]);
|
||||
t.eq('摸底后手牌', S.my.cards, [1,2,3]);
|
||||
t.eq('step 读包字段(选主=2)', S.aset.step, 2);
|
||||
t.eq('控制权读包里的 nextseat', S.turn.seat, 0);
|
||||
t.eq('shangzhuang 发事件',
|
||||
['EQW_BANKER_SET','EQW_HAND_CHANGED'].every(e => evts.indexOf(e) >= 0), true);
|
||||
|
||||
// 【守卫】控制权必须来自包里的 nextseat,不得用本地 banker 反推。
|
||||
// 真实链路里 nextseat 恒 == banker,两者相等时"读哪个"分辨不出来;这里刻意让它们不同,
|
||||
// 退回 `turn.seat = EQW_GameState.aset.banker` 的写法必转红。
|
||||
// 同理 data.seat(=最后一个叫分者)也不是控制权,这里也给成第三个值。
|
||||
S.reset();
|
||||
EQW_CallHandler.handleShangzhuang({ success: true, seat: 1, call: 55, banker: 0, grade: 55,
|
||||
multiple: 4, step: 2, nextseat: 2,
|
||||
countdown: 20, touxiang: 0, curmultiple: 3 });
|
||||
t.eq('控制权取 nextseat,不取 banker', S.turn.seat, 2);
|
||||
t.eq('控制权不取 data.seat(最后一个叫分者)', S.turn.seat !== 1, true);
|
||||
|
||||
// 闲家视角:没有 cards / bottomcards(非 70 分),缺字段不能把已有值抹掉
|
||||
S.reset(); S.my.cards = [7, 8, 9];
|
||||
EQW_CallHandler.handleShangzhuang({ success: true, seat: 0, call: 55, banker: 0, grade: 55,
|
||||
multiple: 4, step: 2, nextseat: 0,
|
||||
countdown: 20, touxiang: 0, curmultiple: 3 });
|
||||
t.eq('闲家手牌不被抹', S.my.cards, [7, 8, 9]);
|
||||
t.eq('闲家无底牌', S.my.bottomCards, []);
|
||||
t.eq('非 70 分无开底标志', S.table.ancard3s, 0);
|
||||
|
||||
process.exit(t.done('handlers_call') ? 0 : 1);
|
||||
@@ -0,0 +1,107 @@
|
||||
// 出牌 handler(chupai1/2/3)
|
||||
const { load } = require('./_load');
|
||||
const t = require('./_assert')();
|
||||
|
||||
load('client/js/gameabc-framework/system/EventBus.js');
|
||||
load('client/js/01_SubGame/codes/state/Events.js');
|
||||
load('client/js/01_SubGame/codes/state/GameState.js');
|
||||
load('client/js/01_SubGame/codes/net/handlers/PlayHandler.js');
|
||||
|
||||
const S = EQW_GameState;
|
||||
const evts = [];
|
||||
Object.keys(EQW_Events).forEach(k => EventBus.on(EQW_Events[k], () => evts.push(k)));
|
||||
|
||||
// ================= chupai1(首家出牌,自己出的)=================
|
||||
S.reset(); S.room.mySeat = 0; S.my.cards = [1,2,3,4]; evts.length = 0;
|
||||
EQW_PlayHandler.handle({ success: true, seat: 0, cards: [1,2], count: 2, flower: 3,
|
||||
cardtype: 201, nextseat: 1, countdown: 15, baozhu: 0,
|
||||
cardsinhand: [3,4], curmultiple: 2,
|
||||
seatlist: [[0,0,0,0,[5,2]],[0,0,0,0,[6,1]],[0,0,0,0,[7,3]]] }, 1);
|
||||
|
||||
t.eq('出牌者手牌被更新', S.my.cards, [3,4]);
|
||||
t.eq('下一个出牌者', S.turn.seat, 1);
|
||||
t.eq('倒计时', S.turn.countdown, 15);
|
||||
t.eq('抓分倍数', S.aset.curmultiple, 2);
|
||||
t.eq('牌况表', S.table.seatlist.length, 3);
|
||||
t.eq('出牌发事件', evts.indexOf('EQW_CARD_PLAYED') >= 0, true);
|
||||
|
||||
// ================= chupai2(别人出牌)=================
|
||||
// 别人出牌时没有 cardsinhand,不能抹掉自己的手牌
|
||||
S.reset(); S.room.mySeat = 0; S.my.cards = [3,4]; evts.length = 0;
|
||||
EQW_PlayHandler.handle({ success: true, seat: 1, cards: [5,6], nextseat: 2,
|
||||
countdown: 15, baozhu: 0 }, 2);
|
||||
t.eq('别人出牌不抹自己手牌', S.my.cards, [3,4]);
|
||||
t.eq('控制权推进', S.turn.seat, 2);
|
||||
|
||||
// mustcard 只发给下一个出牌者
|
||||
S.reset(); S.room.mySeat = 2;
|
||||
EQW_PlayHandler.handle({ success: true, seat: 1, cards: [5,6], nextseat: 2,
|
||||
countdown: 15, mustcard: [7,8] }, 2);
|
||||
t.eq('必出牌', S.my.mustCard, [7,8]);
|
||||
|
||||
// 没有 mustcard 时要清空——否则上一轮的建议会残留
|
||||
S.reset(); S.my.mustCard = [7,8];
|
||||
EQW_PlayHandler.handle({ success: true, seat: 1, cards: [5,6], nextseat: 0, countdown: 15 }, 2);
|
||||
t.eq('无 mustcard 时清空', S.my.mustCard, []);
|
||||
|
||||
// ================= chupai3(末家出牌,本轮结束)=================
|
||||
// 真实的 chupai3 也带 nextseat(下一轮首家):服务端在 switch 前统一设置,
|
||||
// 只有整轮结束进入结算时才会整体换成 jiesuan rpc(不会以 chupai3 到达 handler)
|
||||
// 本墩结果(谁最大 / 这墩得几分)是一次性表现,走 EQW_TRICK_END 事件载荷,【不进 GameState】——
|
||||
// 任何 deskinfo 都不下发它,存进来就是一处服务端不知道的私有对局态(出牌中途重连即归零)
|
||||
S.reset(); evts.length = 0;
|
||||
let lastTrick = null;
|
||||
EventBus.on(EQW_Events.EQW_TRICK_END, function (p) { lastTrick = p; });
|
||||
EQW_PlayHandler.handle({ success: true, seat: 2, cards: [9,10], maxseat: 0, grade: 25,
|
||||
nextseat: 0, countdown: 15, baozhu: 1, curmultiple: -2 }, 3);
|
||||
t.eq('本墩结果走事件载荷', lastTrick, { seat: 2, cards: [9,10], maxseat: 0, grade: 25 });
|
||||
t.eq('本墩结果不进 GameState', S.result.hasOwnProperty('chupai'), false);
|
||||
t.eq('累计捡分', S.aset.grade, 25);
|
||||
t.eq('报无主标志', S.aset.baozhu, 1);
|
||||
t.eq('抓分倍数(带符号)', S.aset.curmultiple, -2);
|
||||
t.eq('下一轮首家', S.turn.seat, 0);
|
||||
t.eq('本轮结束发事件', evts.indexOf('EQW_TRICK_END') >= 0, true);
|
||||
|
||||
// 本轮闲家没得分时不带 grade,累计分不该被抹;事件载荷也不得凭空补 grade(缺字段不兜底)
|
||||
S.reset(); S.aset.grade = 40; lastTrick = null;
|
||||
EQW_PlayHandler.handle({ success: true, seat: 2, cards: [9,10], maxseat: 1,
|
||||
nextseat: 1, countdown: 15 }, 3);
|
||||
t.eq('无 grade 时累计分保持', S.aset.grade, 40);
|
||||
t.eq('无 grade 时事件载荷不带该键', lastTrick.hasOwnProperty('grade'), false);
|
||||
t.eq('无 grade 时仍推进控制权', S.turn.seat, 1);
|
||||
|
||||
// grade 是「本轮」得分,要跨包累加进小局捡分,不是赋值——
|
||||
// 若实现改成 aset.grade = data.grade,这条会把 25 覆盖成 15 而非累加到 25
|
||||
S.reset();
|
||||
EQW_PlayHandler.handle({ success: true, seat: 2, cards: [1,2], maxseat: 0, grade: 10,
|
||||
nextseat: 0, countdown: 15 }, 3);
|
||||
t.eq('第一轮捡分', S.aset.grade, 10);
|
||||
t.eq('第一轮下一轮首家', S.turn.seat, 0);
|
||||
EQW_PlayHandler.handle({ success: true, seat: 2, cards: [3,4], maxseat: 1, grade: 15,
|
||||
nextseat: 1, countdown: 15 }, 3);
|
||||
t.eq('第二轮跨包累加捡分', S.aset.grade, 25);
|
||||
t.eq('第二轮下一轮首家(证明第二个包确被处理)', S.turn.seat, 1);
|
||||
|
||||
// ================= 甩错 =================
|
||||
// shuaicuo 是「这一手」的一次性信息,走 EQW_CARD_PLAYED 事件载荷,不进 GameState;
|
||||
// playproc 是服务端下发的本轮进行态,原样拷贝,两者结构不同、不得混写(见 PlayHandler 顶部注释)。
|
||||
S.reset(); evts.length = 0;
|
||||
var lastPlayed = null;
|
||||
EventBus.on(EQW_Events.EQW_CARD_PLAYED, function (p) { lastPlayed = p; });
|
||||
EQW_PlayHandler.handle({ success: true, seat: 0, cards: [11], shuaicuo: 1, count: 1,
|
||||
flower: 3, cardtype: 101, nextseat: 1, countdown: 15,
|
||||
playproc: { round: 2, start: 0, currseat: 1, cards: [[11], null, null] } }, 1);
|
||||
t.eq('甩错标志走事件载荷', lastPlayed.shuaicuo, 1);
|
||||
t.eq('本手信息走事件载荷', [lastPlayed.order, lastPlayed.seat, lastPlayed.cards,
|
||||
lastPlayed.count, lastPlayed.flower, lastPlayed.cardtype],
|
||||
[1, 0, [11], 1, 3, 101]);
|
||||
t.eq('本手信息不进 GameState', S.table.playproc.hasOwnProperty('shuaicuo'), false);
|
||||
t.eq('playproc 原样拷贝服务端本轮进行态', S.table.playproc,
|
||||
{ round: 2, start: 0, currseat: 1, cards: [[11], null, null] });
|
||||
|
||||
// ================= 缺必需字段 =================
|
||||
S.reset();
|
||||
EQW_PlayHandler.handle({ success: true, cards: [1] }, 1); //缺 seat
|
||||
t.eq('缺 seat 时跳过该包', S.turn.seat, -1);
|
||||
|
||||
process.exit(t.done('handlers_play') ? 0 : 1);
|
||||
@@ -0,0 +1,47 @@
|
||||
// 明牌 / 提示 / 准备 handler
|
||||
const { load } = require('./_load');
|
||||
const t = require('./_assert')();
|
||||
|
||||
load('client/js/gameabc-framework/system/EventBus.js');
|
||||
load('client/js/01_SubGame/codes/state/Events.js');
|
||||
load('client/js/01_SubGame/codes/state/GameState.js');
|
||||
load('client/js/01_SubGame/codes/net/handlers/QueryHandler.js');
|
||||
load('client/js/01_SubGame/codes/net/handlers/ReadyHandler.js');
|
||||
|
||||
const S = EQW_GameState;
|
||||
const payloads = [];
|
||||
EventBus.on(EQW_Events.EQW_TIP, d => payloads.push(d));
|
||||
const evts = [];
|
||||
Object.keys(EQW_Events).forEach(k => EventBus.on(EQW_Events[k], () => evts.push(k)));
|
||||
|
||||
// ================= mingpai =================
|
||||
S.reset(); evts.length = 0;
|
||||
EQW_QueryHandler.handleMingpai({ success: true, seat: 1,
|
||||
others: [{ seat: 0, zhucards: [52, 2] }, { seat: 2, zhucards: [53, 15] }] });
|
||||
t.eq('明牌数据入状态', S.table.mingpai,
|
||||
[{ seat: 0, zhucards: [52, 2] }, { seat: 2, zhucards: [53, 15] }]);
|
||||
t.eq('明牌发事件', evts.indexOf('EQW_MINGPAI') >= 0, true);
|
||||
|
||||
// ================= tishi =================
|
||||
// 提示是一次性的,不进 GameState,只随事件带出去
|
||||
S.reset(); payloads.length = 0; evts.length = 0;
|
||||
const before = S.snapshot(); // 处理前的状态快照
|
||||
EQW_QueryHandler.handleTishi({ success: true, seat: 2, tip: 1 });
|
||||
t.eq('提示发事件', evts.indexOf('EQW_TIP') >= 0, true);
|
||||
t.eq('提示内容随事件带出', payloads, [{ seat: 2, tip: 1 }]);
|
||||
t.eq('提示未改动 GameState 任何字段', S.snapshot(), before);
|
||||
|
||||
// ================= zhunbei =================
|
||||
S.reset(); evts.length = 0;
|
||||
EQW_ReadyHandler.handle({ success: true, seat: 1 });
|
||||
t.eq('准备发事件', evts.indexOf('EQW_READY_CHANGED') >= 0, true);
|
||||
|
||||
// ================= 缺必需字段 =================
|
||||
S.reset();
|
||||
EQW_QueryHandler.handleMingpai({ success: true }); //缺 others
|
||||
t.eq('明牌缺 others 时不写', S.table.mingpai, null);
|
||||
payloads.length = 0;
|
||||
EQW_QueryHandler.handleTishi({ success: true, seat: 0 }); //缺 tip
|
||||
t.eq('提示缺 tip 时不发事件', payloads, []);
|
||||
|
||||
process.exit(t.done('handlers_query') ? 0 : 1);
|
||||
@@ -0,0 +1,85 @@
|
||||
// 结算 / 解散 handler
|
||||
const { load } = require('./_load');
|
||||
const t = require('./_assert')();
|
||||
|
||||
load('client/js/gameabc-framework/system/EventBus.js');
|
||||
load('client/js/01_SubGame/codes/state/Events.js');
|
||||
load('client/js/01_SubGame/codes/state/GameState.js');
|
||||
load('client/js/01_SubGame/codes/net/handlers/ResultHandler.js');
|
||||
|
||||
const S = EQW_GameState;
|
||||
const evts = [];
|
||||
Object.keys(EQW_Events).forEach(k => EventBus.on(EQW_Events[k], () => evts.push(k)));
|
||||
let lastTrick = null;
|
||||
EventBus.on(EQW_Events.EQW_TRICK_END, p => { lastTrick = p; });
|
||||
|
||||
const ASET = { banker: 0, call: 60, multiple: 3, flower: 3, grade: 45, upgrade: 1,
|
||||
bangwang: 0, climb: 0,
|
||||
seatlist: [{ grade: 6, score: 6 }, { grade: -3, score: -3 }, { grade: -3, score: -3 }] };
|
||||
|
||||
// ================= 正常出牌结算 =================
|
||||
S.reset(); evts.length = 0; lastTrick = null;
|
||||
EQW_ResultHandler.handleJiesuan({ success: true, step: 6,
|
||||
chupai: { seat: 2, cards: [9], maxseat: 0, grade: 10 },
|
||||
bottom: { cards: [1,2,3,4,5,6,7,8], multiple: 2, grade1: 20, grade2: 40 },
|
||||
aset: ASET });
|
||||
|
||||
t.eq('小局结算入状态', S.result.aset, ASET);
|
||||
t.eq('抠底入状态', S.result.bottom.grade2, 40);
|
||||
// 收尾墩(服务端不发 chupai3,整包换成 jiesuan)走事件载荷,与 chupai3 同一对事件、同一份字段;
|
||||
// 【不进 GameState】——deskinfo step6 的 Balance 只有 aset/bottom/account,多存就是私有对局态
|
||||
t.eq('收尾墩走事件载荷', lastTrick, { seat: 2, cards: [9], maxseat: 0, grade: 10 });
|
||||
t.eq('收尾墩也发本墩结束事件', evts.indexOf('EQW_TRICK_END') >= 0, true);
|
||||
t.eq('收尾墩不进 GameState', S.result.hasOwnProperty('chupai'), false);
|
||||
t.eq('step 读包字段(结算=6)', S.aset.step, 6);
|
||||
t.eq('结算发事件', evts.indexOf('EQW_ASET_RESULT') >= 0, true);
|
||||
t.eq('非末局不发大局结算事件', evts.indexOf('EQW_ACCOUNT_RESULT') < 0, true);
|
||||
|
||||
// ================= 投降结算(无 chupai / 无 bottom)=================
|
||||
S.reset(); evts.length = 0; lastTrick = null;
|
||||
EQW_ResultHandler.handleJiesuan({ success: true, step: 6, aset: { banker: 0, call: 70, multiple: 1,
|
||||
upgrade: -99, seatlist: [] } });
|
||||
t.eq('投降结算入状态', S.result.aset.upgrade, -99);
|
||||
t.eq('投降无 chupai → 不发本墩结束事件', lastTrick, null);
|
||||
t.eq('投降无 bottom', S.result.bottom, null);
|
||||
t.eq('投降也发结算事件', evts.indexOf('EQW_ASET_RESULT') >= 0, true);
|
||||
|
||||
// ================= 末局:带 account =================
|
||||
const ACCOUNT = [{ score: 12, grades: [6,6], grade_jf_total: 8, grade_cg_total: 4, grade_bw_total: 0 },
|
||||
{ score: -6, grades: [-3,-3], grade_jf_total: -4, grade_cg_total: -2, grade_bw_total: 0 },
|
||||
{ score: -6, grades: [-3,-3], grade_jf_total: -4, grade_cg_total: -2, grade_bw_total: 0 }];
|
||||
S.reset(); evts.length = 0;
|
||||
EQW_ResultHandler.handleJiesuan({ success: true, step: 6, aset: ASET, account: ACCOUNT });
|
||||
t.eq('大局结算入状态', S.result.account, ACCOUNT);
|
||||
t.eq('末局发大局结算事件', evts.indexOf('EQW_ACCOUNT_RESULT') >= 0, true);
|
||||
|
||||
// ================= 解散:走 Free 入口,取值少一层 =================
|
||||
S.reset(); evts.length = 0;
|
||||
EQW_ResultHandler.handleFree({ rpc: 'jiesuan',
|
||||
data: { success: true, step: 6, aset: { banker: -1, call: -1, multiple: 0, upgrade: 0, seatlist: [] },
|
||||
account: ACCOUNT } });
|
||||
t.eq('解散取到 aset', S.result.aset.multiple, 0);
|
||||
t.eq('解散取到 account', S.result.account, ACCOUNT);
|
||||
t.eq('解散发大局结算事件', evts.indexOf('EQW_ACCOUNT_RESULT') >= 0, true);
|
||||
t.eq('解散包的 step 也读包字段', S.aset.step, 6);
|
||||
|
||||
// 【守卫】结算 step 只读包字段,不按 rpc 名硬编码为 6。
|
||||
// 退回 `aset.step = 6` 的写法必转红。
|
||||
S.reset();
|
||||
EQW_ResultHandler.handleJiesuan({ success: true, step: 4, aset: ASET });
|
||||
t.eq('jiesuan step 照读包字段', S.aset.step, 4);
|
||||
|
||||
// ================= 解散:deskfree 为 null(首局发牌前解散)=================
|
||||
S.reset(); evts.length = 0;
|
||||
let boom = false;
|
||||
try { EQW_ResultHandler.handleFree(null); } catch (e) { boom = true; }
|
||||
t.eq('deskfree 为 null 不崩', boom, false);
|
||||
t.eq('deskfree 为 null 不写状态', S.result.aset, null);
|
||||
t.eq('deskfree 为 null 不发结算事件', evts.indexOf('EQW_ACCOUNT_RESULT') < 0, true);
|
||||
|
||||
// deskfree 存在但 data 缺失也要容忍
|
||||
boom = false;
|
||||
try { EQW_ResultHandler.handleFree({ rpc: 'jiesuan' }); } catch (e) { boom = true; }
|
||||
t.eq('deskfree.data 缺失不崩', boom, false);
|
||||
|
||||
process.exit(t.done('handlers_result') ? 0 : 1);
|
||||
@@ -0,0 +1,72 @@
|
||||
// SubGameHooks 接线:平台入口 → 新架构
|
||||
const { load } = require('./_load');
|
||||
const t = require('./_assert')();
|
||||
|
||||
global.window = global; // 转发壳用 window.SubGameHooks 判定
|
||||
|
||||
load('client/js/gameabc-framework/system/EventBus.js');
|
||||
load('client/js/gameabc-framework/system/SpriteEventController.js');
|
||||
load('client/js/01_SubGame/codes/state/Events.js');
|
||||
load('client/js/01_SubGame/codes/state/GameState.js');
|
||||
load('client/js/01_SubGame/codes/state/RoomOptions.js');
|
||||
['DealHandler','CallHandler','MainHandler','BuryHandler','PlayHandler',
|
||||
'QueryHandler','ReadyHandler','ResultHandler','ResyncHandler','FailHandler']
|
||||
.forEach(h => load('client/js/01_SubGame/codes/net/handlers/' + h + '.js'));
|
||||
load('client/js/01_SubGame/codes/net/Dispatcher.js');
|
||||
load('client/js/01_SubGame/codes/SubGameHooks.js');
|
||||
|
||||
const S = EQW_GameState;
|
||||
|
||||
// ---- _ReceiveData:完整包 → 分发 ----
|
||||
S.reset(); S.room.mySeat = 0;
|
||||
SubGameHooks._ReceiveData({ app: 'youle', route: 'erqiwang', rpc: 'fapai',
|
||||
data: { success: true, asetidx: 1, asetcount: 6,
|
||||
cards: [1,2], seat: 0, countdown: 15 } });
|
||||
t.eq('_ReceiveData 分发到 handler', S.my.cards, [1,2]);
|
||||
|
||||
// ---- setRoomDes ----
|
||||
SubGameHooks.setRoomDes(123, 12, '00010');
|
||||
t.eq('setRoomDes 解析选项', S.room.options.climb, 1);
|
||||
|
||||
// ---- Free:null 不崩 ----
|
||||
let boom = false;
|
||||
try { SubGameHooks.Free(null); } catch (e) { boom = true; }
|
||||
t.eq('Free(null) 不崩', boom, false);
|
||||
|
||||
// ---- Reconnect ----
|
||||
S.reset(); S.room.mySeat = 1;
|
||||
SubGameHooks.Reconnect({ count: 6, idx: 3, PlayerInfo: [0,0,0], step: 1, MyCards: [5,6] });
|
||||
t.eq('Reconnect 填状态', S.my.cards, [5,6]);
|
||||
t.eq('Reconnect 填局数', S.room.asetIdx, 3);
|
||||
|
||||
// ---- DeskInfo(中途进桌)----
|
||||
// 平台 07_Desk.js 在「牌局已开始 + 走加入房间而非重连」时调 Game_Modify.DeskInfo(_msg.data.deskinfo)。
|
||||
// 入参结构与 Reconnect 完全一致(服务端两条路径都是 get_deskinfo(o_room, 本人座位)),
|
||||
// 故必须走同一条重画路径;不接线的话整份 deskinfo 被转发壳静默丢弃、界面空白且不报错。
|
||||
const MIDJOIN = { count: 6, idx: 2, PlayerInfo: [3,-1,-2], step: 5, MyCards: [10,11],
|
||||
PushCards: { banker: 1, call: 60, multiple: 3, flower: 2, grade: 25,
|
||||
curmultiple: -1, baozhu: 0, countdown: 30,
|
||||
playproc: { round: 4, start: 1, currseat: 2, cards: [null,[12],null] } } };
|
||||
S.reset(); S.room.mySeat = 2;
|
||||
SubGameHooks.DeskInfo(MIDJOIN);
|
||||
t.eq('DeskInfo 已接线(不再静默丢包)', S.my.cards, [10,11]);
|
||||
t.eq('DeskInfo 复用重连的重画路径(阶段)', S.aset.step, 5);
|
||||
t.eq('DeskInfo 复用重连的重画路径(控制权读 playproc.currseat)', S.turn.seat, 2);
|
||||
t.eq('DeskInfo 与 Reconnect 落地结果逐字段相同',
|
||||
(function () { const a = JSON.stringify(EQW_GameState.snapshot());
|
||||
S.reset(); S.room.mySeat = 2; SubGameHooks.Reconnect(MIDJOIN);
|
||||
return a === JSON.stringify(EQW_GameState.snapshot()); })(), true);
|
||||
|
||||
// ---- 畸形入参一律不崩 ----
|
||||
boom = false;
|
||||
try {
|
||||
SubGameHooks._ReceiveData(null);
|
||||
SubGameHooks._ReceiveData({});
|
||||
SubGameHooks.StartWar(null);
|
||||
SubGameHooks.Reconnect(null);
|
||||
SubGameHooks.DeskInfo(null);
|
||||
SubGameHooks.setRoomDes();
|
||||
} catch (e) { boom = true; }
|
||||
t.eq('畸形入参不崩', boom, false);
|
||||
|
||||
process.exit(t.done('hooks') ? 0 : 1);
|
||||
@@ -0,0 +1,109 @@
|
||||
// index.html 加载顺序自检(task-13 步骤6守卫):
|
||||
// 按 index.html 里 <script src> 的实际先后顺序,抽取「codes/ 全部文件 + 它们依赖的
|
||||
// 3 个框架文件(EventBus/SpriteEventController/AlignmentUtils)」,依次用 vm 加载,
|
||||
// 确认:1) 无异常抛出(顺序错了会因引用未定义全局而报错)
|
||||
// 2) 关键全局全部就绪
|
||||
// 3) EQW_Dispatcher 的路由表确已注册(hasHandler('chupai1') === true)
|
||||
// 这份顺序即是 index.html 的真实加载顺序,不是我们另行假设的一份。
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const vm = require('vm');
|
||||
const t = require('./_assert')();
|
||||
|
||||
const ROOT = path.resolve(__dirname, '..', '..');
|
||||
const indexHtml = fs.readFileSync(path.join(ROOT, 'client', 'index.html'), 'utf8');
|
||||
|
||||
// 抽取全部本地 <script src="..."> 路径,按文档出现顺序
|
||||
const scriptRe = /<script[^>]*\ssrc="([^"]+)"[^>]*><\/script>/g;
|
||||
const allSrcs = [];
|
||||
let m;
|
||||
while ((m = scriptRe.exec(indexHtml)) !== null) {
|
||||
allSrcs.push(m[1]);
|
||||
}
|
||||
t.eq('index.html 至少解析出脚本标签', allSrcs.length > 0, true);
|
||||
|
||||
// 只保留:codes/ 下全部文件 + 3 个框架依赖文件,其余(vendor/引擎/大厅平台代码等)
|
||||
// 无法在 Node 里单独跑,本自检不覆盖,留给「浏览器人工验证」。
|
||||
const NEEDED_FRAMEWORK = [
|
||||
'js/gameabc-framework/system/EventBus.js',
|
||||
'js/gameabc-framework/system/SpriteEventController.js',
|
||||
'js/gameabc-framework/ui/AlignmentUtils.js'
|
||||
];
|
||||
const relevant = allSrcs.filter(function (src) {
|
||||
return NEEDED_FRAMEWORK.indexOf(src) !== -1 || src.indexOf('js/01_SubGame/codes/') === 0;
|
||||
});
|
||||
|
||||
// 期望齐全的清单:本任务新增/相关的 state/ net/ 文件,一个都不能漏
|
||||
const EXPECT_PRESENT = [
|
||||
'js/01_SubGame/codes/state/Events.js',
|
||||
'js/01_SubGame/codes/state/GameState.js',
|
||||
'js/01_SubGame/codes/state/RoomOptions.js',
|
||||
'js/01_SubGame/codes/net/handlers/FailHandler.js',
|
||||
'js/01_SubGame/codes/net/handlers/DealHandler.js',
|
||||
'js/01_SubGame/codes/net/handlers/CallHandler.js',
|
||||
'js/01_SubGame/codes/net/handlers/MainHandler.js',
|
||||
'js/01_SubGame/codes/net/handlers/BuryHandler.js',
|
||||
'js/01_SubGame/codes/net/handlers/PlayHandler.js',
|
||||
'js/01_SubGame/codes/net/handlers/QueryHandler.js',
|
||||
'js/01_SubGame/codes/net/handlers/ReadyHandler.js',
|
||||
'js/01_SubGame/codes/net/handlers/ResultHandler.js',
|
||||
'js/01_SubGame/codes/net/handlers/ResyncHandler.js',
|
||||
'js/01_SubGame/codes/net/Dispatcher.js',
|
||||
'js/01_SubGame/codes/net/Rpc.js',
|
||||
'js/01_SubGame/codes/SubGameHooks.js'
|
||||
];
|
||||
EXPECT_PRESENT.forEach(function (src) {
|
||||
t.eq('index.html 含脚本 ' + src, allSrcs.indexOf(src) !== -1, true);
|
||||
});
|
||||
|
||||
// 相对顺序断言:handler 全部排在 Dispatcher 之前;state/ 排在 net/handlers/ 之前
|
||||
const idx = {};
|
||||
relevant.forEach(function (src, i) { idx[src] = i; });
|
||||
var dispatcherIdx = idx['js/01_SubGame/codes/net/Dispatcher.js'];
|
||||
[
|
||||
'js/01_SubGame/codes/net/handlers/FailHandler.js',
|
||||
'js/01_SubGame/codes/net/handlers/DealHandler.js',
|
||||
'js/01_SubGame/codes/net/handlers/CallHandler.js',
|
||||
'js/01_SubGame/codes/net/handlers/MainHandler.js',
|
||||
'js/01_SubGame/codes/net/handlers/BuryHandler.js',
|
||||
'js/01_SubGame/codes/net/handlers/PlayHandler.js',
|
||||
'js/01_SubGame/codes/net/handlers/QueryHandler.js',
|
||||
'js/01_SubGame/codes/net/handlers/ReadyHandler.js',
|
||||
'js/01_SubGame/codes/net/handlers/ResultHandler.js',
|
||||
'js/01_SubGame/codes/net/handlers/ResyncHandler.js'
|
||||
].forEach(function (h) {
|
||||
t.eq('handler 排在 Dispatcher 之前: ' + h, idx[h] < dispatcherIdx, true);
|
||||
});
|
||||
t.eq('state/ 排在 net/handlers/ 之前', idx['js/01_SubGame/codes/state/GameState.js'] < idx['js/01_SubGame/codes/net/handlers/FailHandler.js'], true);
|
||||
t.eq('SubGameHooks 排在 Dispatcher 之后', idx['js/01_SubGame/codes/SubGameHooks.js'] > dispatcherIdx, true);
|
||||
|
||||
// 依次实际加载,确认不抛异常,且顺序自洽
|
||||
global.window = global;
|
||||
let loadErr = null;
|
||||
try {
|
||||
relevant.forEach(function (src) {
|
||||
const abs = path.join(ROOT, 'client', src);
|
||||
const code = fs.readFileSync(abs, 'utf8');
|
||||
vm.runInThisContext(code, { filename: src });
|
||||
});
|
||||
} catch (e) {
|
||||
loadErr = e;
|
||||
}
|
||||
t.eq('按 index.html 顺序加载无异常', loadErr ? (loadErr.stack || String(loadErr)) : null, null);
|
||||
|
||||
// 关键全局就绪
|
||||
[
|
||||
'EQW_Events', 'EQW_GameState', 'EQW_RoomOptions_Parse', 'EQW_Dispatcher', 'EQW_Rpc',
|
||||
'EQW_DealHandler', 'EQW_CallHandler', 'EQW_MainHandler', 'EQW_BuryHandler', 'EQW_PlayHandler',
|
||||
'EQW_QueryHandler', 'EQW_ReadyHandler', 'EQW_ResultHandler', 'EQW_ResyncHandler', 'EQW_FailHandler',
|
||||
'SubGameHooks'
|
||||
].forEach(function (name) {
|
||||
t.eq('全局就绪: ' + name, typeof global[name] !== 'undefined', true);
|
||||
});
|
||||
|
||||
// 路由表确已注册
|
||||
if (typeof global.EQW_Dispatcher !== 'undefined') {
|
||||
t.eq("EQW_Dispatcher.hasHandler('chupai1')", global.EQW_Dispatcher.hasHandler('chupai1'), true);
|
||||
}
|
||||
|
||||
process.exit(t.done('index_load_order') ? 0 : 1);
|
||||
@@ -0,0 +1,158 @@
|
||||
// 重连(deskinfo)与开局(StartWar)
|
||||
const { load } = require('./_load');
|
||||
const t = require('./_assert')();
|
||||
|
||||
load('client/js/gameabc-framework/system/EventBus.js');
|
||||
load('client/js/01_SubGame/codes/state/Events.js');
|
||||
load('client/js/01_SubGame/codes/state/GameState.js');
|
||||
load('client/js/01_SubGame/codes/state/RoomOptions.js');
|
||||
load('client/js/01_SubGame/codes/net/handlers/ResyncHandler.js');
|
||||
|
||||
const S = EQW_GameState;
|
||||
const evts = [];
|
||||
Object.keys(EQW_Events).forEach(k => EventBus.on(EQW_Events[k], () => evts.push(k)));
|
||||
|
||||
// ================= setRoomDes:房间选项 =================
|
||||
S.reset(); S.room.options = null;
|
||||
EQW_ResyncHandler.handleSetRoomDes(123456, 12, '10100');
|
||||
t.eq('总局数', S.room.asetCount, 12);
|
||||
t.eq('傍王开', S.room.options.bangwang, 1);
|
||||
t.eq('可查牌', S.room.options.nocheck, 0);
|
||||
|
||||
// ================= deskinfo:step 5 出牌阶段 =================
|
||||
S.reset(); S.room.mySeat = 1; evts.length = 0;
|
||||
EQW_ResyncHandler.handleDeskinfo({
|
||||
count: 6, idx: 2, PlayerInfo: [10, -5, -5], step: 5, MyCards: [1,2,3],
|
||||
PushCards: {
|
||||
banker: 0, call: 60, multiple: 3, flower: 3, countdown: 15,
|
||||
grade: 25, playproc: { round: 4, currseat: 1, cards: [[9],[],[]] },
|
||||
seatlist: [[0,0,0,0,[5,2]],[0,0,0,0,[6,1]],[0,0,0,0,[7,3]]],
|
||||
liangpai: { cards: [52,53] }, pushlist: [[[1],[2],[3]]],
|
||||
curmultiple: 2, mustcard: [4,5], baozhu: 1
|
||||
}
|
||||
});
|
||||
t.eq('总局数', S.room.asetCount, 6);
|
||||
t.eq('当前局', S.room.asetIdx, 2);
|
||||
t.eq('三家总分', S.room.playerScores, [10, -5, -5]);
|
||||
t.eq('阶段', S.aset.step, 5);
|
||||
t.eq('手牌', S.my.cards, [1,2,3]);
|
||||
t.eq('庄家', S.aset.banker, 0);
|
||||
t.eq('主牌花色', S.aset.flower, 3);
|
||||
t.eq('捡分', S.aset.grade, 25);
|
||||
t.eq('抓分倍数', S.aset.curmultiple, 2);
|
||||
t.eq('当前轮出牌情况', S.table.playproc.round, 4);
|
||||
t.eq('出牌历史', S.table.pushlist.length, 1);
|
||||
t.eq('亮牌', S.table.liangpai, { cards: [52,53] });
|
||||
t.eq('必出牌', S.my.mustCard, [4,5]);
|
||||
t.eq('控制权(由 playproc.currseat 给出)', S.turn.seat, 1);
|
||||
t.eq('重连发全量重画事件', evts.indexOf('EQW_RESYNC_ALL') >= 0, true);
|
||||
// 报无主:明牌按钮与余主公示的开关(协议 PushCards.baozhu)。
|
||||
// 增量路径由 chupai1/2/3 的 baozhu 维护,重连路径必须一并恢复,
|
||||
// 否则重连后按钮凭空消失(两条路径对同一状态给出不同结果)。
|
||||
t.eq('报无主(PushCards.baozhu)', S.aset.baozhu, 1);
|
||||
// 反面:不查牌房服务端恒发 0,前端照写不反推
|
||||
S.reset(); S.room.mySeat = 1;
|
||||
EQW_ResyncHandler.handleDeskinfo({
|
||||
count: 6, idx: 2, PlayerInfo: [0,0,0], step: 5, MyCards: [1],
|
||||
PushCards: { banker: 0, flower: 3, playproc: { round: 1, currseat: 1, cards: [[],[],[]] }, baozhu: 0 }
|
||||
});
|
||||
t.eq('不查牌 baozhu 恒 0', S.aset.baozhu, 0);
|
||||
|
||||
// ================= deskinfo:step 1 叫分阶段 =================
|
||||
S.reset(); evts.length = 0;
|
||||
EQW_ResyncHandler.handleDeskinfo({
|
||||
count: 6, idx: 1, PlayerInfo: [0,0,0], step: 1, MyCards: [7,8],
|
||||
CallRun: { seat: 2, countdown: 15, nowcall: 65, multiple: 2, call: [null, 0, 65] }
|
||||
});
|
||||
t.eq('叫分阶段', S.aset.step, 1);
|
||||
t.eq('当前叫分者', S.turn.seat, 2);
|
||||
t.eq('当前叫到的分', S.call.currcall, 65);
|
||||
t.eq('三家叫分', S.call.calls, [null, 0, 65]);
|
||||
|
||||
// ================= deskinfo:step 2 选主阶段(庄家有 bottomcards)=================
|
||||
S.reset(); evts.length = 0;
|
||||
EQW_ResyncHandler.handleDeskinfo({
|
||||
count: 6, idx: 1, PlayerInfo: [0,0,0], step: 2, MyCards: [1,2],
|
||||
ChooseMain: { seat: 0, banker: 0, call: 70, multiple: 2, countdown: 20,
|
||||
bottomcards: [1,2,3,4,5,6,7,8], touxiang: 1 }
|
||||
});
|
||||
t.eq('选主阶段', S.aset.step, 2);
|
||||
t.eq('底牌', S.my.bottomCards, [1,2,3,4,5,6,7,8]);
|
||||
t.eq('允许投降', S.aset.touxiang, 1);
|
||||
t.eq('控制权读 ChooseMain.seat', S.turn.seat, 0);
|
||||
|
||||
// 重连【不重放】70 分开底:deskinfo 里没有 ancard3s
|
||||
t.eq('重连不重放开底', S.table.ancard3s, 0);
|
||||
|
||||
// 【守卫】控制权取分组里的 seat(服务端权威),不用本地 banker 反推。
|
||||
// 真实链路里 ChooseMain.seat 恒 == banker,相等时"读哪个"分辨不出来;这里刻意让它们不同,
|
||||
// 退回 `turn.seat = aset.banker` 的写法必转红。
|
||||
S.reset();
|
||||
EQW_ResyncHandler.handleDeskinfo({
|
||||
count: 6, idx: 1, PlayerInfo: [0,0,0], step: 2, MyCards: [1,2],
|
||||
ChooseMain: { seat: 2, banker: 0, call: 70, multiple: 2, countdown: 20, touxiang: 1 }
|
||||
});
|
||||
t.eq('ChooseMain 控制权取 seat,不取 banker', S.turn.seat, 2);
|
||||
|
||||
// ================= deskinfo:step 3 埋牌阶段 =================
|
||||
S.reset();
|
||||
EQW_ResyncHandler.handleDeskinfo({
|
||||
count: 6, idx: 1, PlayerInfo: [0,0,0], step: 3, MyCards: [1,2],
|
||||
BuryCards: { seat: 0, banker: 0, call: 65, multiple: 2, flower: 1, countdown: 25,
|
||||
bottomcards: [1,2,3,4,5,6,7,8], curmultiple: 3 }
|
||||
});
|
||||
t.eq('埋牌阶段', S.aset.step, 3);
|
||||
t.eq('控制权读 BuryCards.seat', S.turn.seat, 0);
|
||||
// 同样的守卫:seat 与 banker 刻意不同
|
||||
S.reset();
|
||||
EQW_ResyncHandler.handleDeskinfo({
|
||||
count: 6, idx: 1, PlayerInfo: [0,0,0], step: 3, MyCards: [1,2],
|
||||
BuryCards: { seat: 1, banker: 0, call: 65, multiple: 2, flower: 1, countdown: 25 }
|
||||
});
|
||||
t.eq('BuryCards 控制权取 seat,不取 banker', S.turn.seat, 1);
|
||||
|
||||
// ================= deskinfo:step 6 结算阶段 =================
|
||||
S.reset(); evts.length = 0;
|
||||
EQW_ResyncHandler.handleDeskinfo({
|
||||
count: 6, idx: 6, PlayerInfo: [12,-6,-6], step: 6, MyCards: [],
|
||||
Balance: { readystate: [1,0,0], aset: { banker: 0, upgrade: 1, seatlist: [] },
|
||||
bottom: { cards: [1,2,3,4,5,6,7,8], multiple: 2, grade1: 20, grade2: 40 },
|
||||
account: [{ score: 12 }, { score: -6 }, { score: -6 }] }
|
||||
});
|
||||
t.eq('结算阶段', S.aset.step, 6);
|
||||
t.eq('小局结算数据', S.result.aset.upgrade, 1);
|
||||
// I-2:结算面板还开着时重连,抠底明细与大局结算必须一并恢复(与 jiesuan 推送同一份数据)。
|
||||
// 漏接会让重连后抠底明细、末局大结算全部空白
|
||||
t.eq('重连恢复抠底明细', S.result.bottom.grade2, 40);
|
||||
t.eq('重连恢复大局结算', S.result.account.length, 3);
|
||||
|
||||
// 反面:非末局 / 投降局的 Balance 没有 bottom / account,缺字段保持 null,不兜底造分组
|
||||
S.reset();
|
||||
EQW_ResyncHandler.handleDeskinfo({
|
||||
count: 6, idx: 2, PlayerInfo: [0,0,0], step: 6, MyCards: [],
|
||||
Balance: { readystate: [0,0,0], aset: { banker: 0, upgrade: -99, seatlist: [] } }
|
||||
});
|
||||
t.eq('无抠底分组时保持 null', S.result.bottom, null);
|
||||
t.eq('无大局结算时保持 null', S.result.account, null);
|
||||
|
||||
// ================= StartWar:差异化下发 =================
|
||||
S.reset(); S.room.mySeat = 2; evts.length = 0;
|
||||
EQW_ResyncHandler.handleStartWar({ data: { deskwar: { sendtype: 1, seatlist: [
|
||||
{ seat: 0, data: { count: 6, idx: 1, PlayerInfo: [0,0,0], step: 1, MyCards: [90] } },
|
||||
{ seat: 2, data: { count: 6, idx: 1, PlayerInfo: [0,0,0], step: 1, MyCards: [11,12] } }
|
||||
] } } });
|
||||
t.eq('按本座位取到自己那份', S.my.cards, [11,12]);
|
||||
|
||||
// 非差异化下发:直接用 data
|
||||
S.reset(); S.room.mySeat = 0;
|
||||
EQW_ResyncHandler.handleStartWar({ data: { count: 6, idx: 1, PlayerInfo: [0,0,0],
|
||||
step: 1, MyCards: [3,4] } });
|
||||
t.eq('非差异化下发', S.my.cards, [3,4]);
|
||||
|
||||
// 畸形入参不崩
|
||||
let boom = false;
|
||||
try { EQW_ResyncHandler.handleStartWar(null); EQW_ResyncHandler.handleDeskinfo(null); }
|
||||
catch (e) { boom = true; }
|
||||
t.eq('畸形入参不崩', boom, false);
|
||||
|
||||
process.exit(t.done('resync') ? 0 : 1);
|
||||
@@ -0,0 +1,34 @@
|
||||
// roomtype 位串解析(协议 §0.5),必须与服务端 class.config.js 的 parse() 同规则
|
||||
const { load } = require('./_load');
|
||||
const t = require('./_assert')();
|
||||
|
||||
load('client/js/01_SubGame/codes/state/RoomOptions.js');
|
||||
|
||||
const P = EQW_RoomOptions_Parse.parse;
|
||||
|
||||
// ---- 缺省 "00000" = 6局 / 房主扣卡 / 无傍王 / 常规算子 / 可查牌 ----
|
||||
t.eq('缺省', P('00000'), { asetCount: 6, deductAA: 0, bangwang: 0, climb: 0, nocheck: 0 });
|
||||
|
||||
// ---- 协议 §0.5 的示例:10100 = 12局 / 房主扣卡 / 傍王开 / 常规算子 / 可查牌 ----
|
||||
t.eq('示例 10100', P('10100'), { asetCount: 12, deductAA: 0, bangwang: 1, climb: 0, nocheck: 0 });
|
||||
|
||||
// ---- 逐位 ----
|
||||
t.eq('位0 局数', P('10000').asetCount, 12);
|
||||
t.eq('位1 AA扣卡', P('01000').deductAA, 1);
|
||||
t.eq('位2 傍王', P('00100').bangwang, 1);
|
||||
t.eq('位3 爬坡', P('00010').climb, 1);
|
||||
t.eq('位4 不查牌', P('00001').nocheck, 1);
|
||||
t.eq('全开', P('11111'), { asetCount: 12, deductAA: 1, bangwang: 1, climb: 1, nocheck: 1 });
|
||||
|
||||
// ---- 反面:缺失/非字符串/过短一律按 '0' ----
|
||||
t.eq('undefined', P(undefined), { asetCount: 6, deductAA: 0, bangwang: 0, climb: 0, nocheck: 0 });
|
||||
t.eq('null', P(null), { asetCount: 6, deductAA: 0, bangwang: 0, climb: 0, nocheck: 0 });
|
||||
t.eq('空串', P(''), { asetCount: 6, deductAA: 0, bangwang: 0, climb: 0, nocheck: 0 });
|
||||
t.eq('过短只有两位', P('11'), { asetCount: 12, deductAA: 1, bangwang: 0, climb: 0, nocheck: 0 });
|
||||
t.eq('非字符串(数字)', P(11111), { asetCount: 6, deductAA: 0, bangwang: 0, climb: 0, nocheck: 0 });
|
||||
t.eq('非字符串(数组)', P([]), { asetCount: 6, deductAA: 0, bangwang: 0, climb: 0, nocheck: 0 });
|
||||
|
||||
// ---- 超长不报错,只取前 5 位 ----
|
||||
t.eq('超长', P('1111199999').bangwang, 1);
|
||||
|
||||
process.exit(t.done('roomoptions') ? 0 : 1);
|
||||
@@ -0,0 +1,110 @@
|
||||
// 语义化发包:route 正确、只带意图、参数形状校验
|
||||
const { load, throws } = require('./_load');
|
||||
const t = require('./_assert')();
|
||||
|
||||
load('client/js/01_SubGame/codes/state/GameState.js');
|
||||
|
||||
// 桩掉 RpcHelper,捕获发出的包
|
||||
const sent = [];
|
||||
global.RpcHelper = {
|
||||
sendRpc: function (app, route, rpc, data) { sent.push({ app, route, rpc, data }); }
|
||||
};
|
||||
|
||||
load('client/js/01_SubGame/codes/net/Rpc.js');
|
||||
|
||||
EQW_GameState.room.mySeat = 2;
|
||||
|
||||
// ---- route 必须是 erqiwang,不是 room ----
|
||||
sent.length = 0;
|
||||
EQW_Rpc.jiaofen(60);
|
||||
t.eq('发了一个包', sent.length, 1);
|
||||
t.eq('app', sent[0].app, 'youle');
|
||||
t.eq('route 是 erqiwang', sent[0].route, 'erqiwang');
|
||||
t.eq('rpc', sent[0].rpc, 'jiaofen');
|
||||
t.eq('业务字段', sent[0].data, { seat: 2, call: 60 });
|
||||
|
||||
// ---- seat 自动从 GameState 带上 ----
|
||||
EQW_GameState.room.mySeat = 0;
|
||||
sent.length = 0;
|
||||
EQW_Rpc.zhunbei();
|
||||
t.eq('seat 自动带上', sent[0].data.seat, 0);
|
||||
EQW_GameState.room.mySeat = 2;
|
||||
|
||||
// ---- 各方法的字段集合 ----
|
||||
const call = (fn) => { sent.length = 0; fn(); return sent[0]; };
|
||||
|
||||
t.eq('不叫', call(() => EQW_Rpc.jiaofen(0)).data, { seat: 2, call: 0 });
|
||||
t.eq('投降', call(() => EQW_Rpc.touxiang()).data, { seat: 2 });
|
||||
t.eq('选主', call(() => EQW_Rpc.xuanzhu(3)).data, { seat: 2, flower: 3 });
|
||||
t.eq('埋牌', call(() => EQW_Rpc.maipai([1,2,3,4,5,6,7,8])).data, { seat: 2, cards: [1,2,3,4,5,6,7,8] });
|
||||
t.eq('出牌', call(() => EQW_Rpc.chupai([10,11])).data, { seat: 2, cards: [10,11] });
|
||||
t.eq('明牌', call(() => EQW_Rpc.mingpai()).data, { seat: 2 });
|
||||
t.eq('提示', call(() => EQW_Rpc.tishi(1)).data, { seat: 2, tip: 1 });
|
||||
t.eq('准备', call(() => EQW_Rpc.zhunbei()).data, { seat: 2 });
|
||||
|
||||
// ---- 【核心】只带意图:所有发包的字段集合不含任何结论字段 ----
|
||||
// FORBIDDEN 列表:协议中服务端→客户端方向的字段名。原则上都不该出现在客户端→服务端的包里。
|
||||
// 作为回归网:新增发包方法时若引入了服务端字段名会被它拦下。
|
||||
const FORBIDDEN = ['score', 'isWin', 'phase', 'nextSeat', 'nextseat', 'handCards',
|
||||
'grade', 'multiple', 'result', 'banker', 'curmultiple', 'upgrade',
|
||||
'cardsinhand', 'mustcard', 'seatlist', 'baozhu', 'liangpai', 'bottomcards', 'burycards',
|
||||
'ancard3s', 'countdown', 'currcall', 'chongguan', 'wang', 'naward',
|
||||
'grade_aw', 'grade_cg', 'grade_bw', 'grade_jf', 'account', 'readystate',
|
||||
'shuai', 'shuaicuo', 'cardtype', 'maxseat', 'gradecards', 'others', 'zhucards',
|
||||
'playproc', 'pushlist', 'touxiang'];
|
||||
sent.length = 0;
|
||||
EQW_Rpc.jiaofen(60); EQW_Rpc.touxiang(); EQW_Rpc.xuanzhu(1);
|
||||
EQW_Rpc.maipai([1,2,3,4,5,6,7,8]); EQW_Rpc.chupai([9]);
|
||||
EQW_Rpc.mingpai(); EQW_Rpc.tishi(2); EQW_Rpc.zhunbei();
|
||||
|
||||
const leaked = [];
|
||||
sent.forEach(p => Object.keys(p.data).forEach(k => {
|
||||
if (FORBIDDEN.indexOf(k) >= 0) { leaked.push(p.rpc + '.' + k); }
|
||||
}));
|
||||
t.eq('发包不含任何结论字段', leaked, []);
|
||||
|
||||
// ---- 参数形状校验:非法即抛错,不发残缺包 ----
|
||||
sent.length = 0;
|
||||
t.eq('牌 id 非数组抛错', throws(() => EQW_Rpc.chupai('5')), true);
|
||||
t.eq('空数组抛错', throws(() => EQW_Rpc.chupai([])), true);
|
||||
t.eq('字符串牌 id 抛错', throws(() => EQW_Rpc.chupai(['5'])), true);
|
||||
t.eq('小数牌 id 抛错', throws(() => EQW_Rpc.chupai([1.5])), true);
|
||||
t.eq('越界牌 id 抛错', throws(() => EQW_Rpc.chupai([108])), true);
|
||||
t.eq('负数牌 id 抛错', throws(() => EQW_Rpc.chupai([-1])), true);
|
||||
t.eq('重复牌 id 抛错', throws(() => EQW_Rpc.chupai([5, 5])), true);
|
||||
t.eq('埋牌非 8 张抛错', throws(() => EQW_Rpc.maipai([1,2,3])), true);
|
||||
t.eq('叫分越界抛错', throws(() => EQW_Rpc.jiaofen(71)), true);
|
||||
t.eq('叫分非 5 的倍数抛错', throws(() => EQW_Rpc.jiaofen(7)), true);
|
||||
t.eq('花色越界抛错', throws(() => EQW_Rpc.xuanzhu(5)), true);
|
||||
t.eq('提示类型越界抛错', throws(() => EQW_Rpc.tishi(4)), true);
|
||||
t.eq('校验失败时一个包都没发', sent.length, 0);
|
||||
|
||||
// ---- 合法边界值不抛 ----
|
||||
t.eq('牌 id 0 合法', throws(() => EQW_Rpc.chupai([0])), false);
|
||||
t.eq('牌 id 107 合法', throws(() => EQW_Rpc.chupai([107])), false);
|
||||
t.eq('叫分 5 合法', throws(() => EQW_Rpc.jiaofen(5)), false);
|
||||
t.eq('叫分 70 合法', throws(() => EQW_Rpc.jiaofen(70)), false);
|
||||
|
||||
// ---- 【C-1】座位校验:非 0/1/2 一律 fail-fast,绝不无声透传 ----
|
||||
// mySeat 初始值是 -1(平台 appStart 早于 C_Player 创建,见 test_appstart_timing.js)。
|
||||
// seat:-1 发出去,服务端 check_player 必然返回 falsy → 每个操作都回 ERR.PLAYER,
|
||||
// 而前端一声不吭——整局玩不了却查不出原因。所以在最近处抛错。
|
||||
const badSeats = [-1, 3, 1.5, undefined, null, '1', NaN];
|
||||
badSeats.forEach(s => {
|
||||
sent.length = 0;
|
||||
EQW_GameState.room.mySeat = s;
|
||||
t.eq('座位 ' + JSON.stringify(s) + ' 发包抛错', throws(() => EQW_Rpc.zhunbei()), true);
|
||||
t.eq('座位 ' + JSON.stringify(s) + ' 时一个包都没发', sent.length, 0);
|
||||
});
|
||||
// 非法座位对每个发包方法都拦(校验在 _send 的公共入口,不是逐方法漏配)
|
||||
EQW_GameState.room.mySeat = -1;
|
||||
t.eq('非法座位时出牌也抛错', throws(() => EQW_Rpc.chupai([1, 2])), true);
|
||||
t.eq('非法座位时叫分也抛错', throws(() => EQW_Rpc.jiaofen(5)), true);
|
||||
t.eq('非法座位时埋牌也抛错', throws(() => EQW_Rpc.maipai([1,2,3,4,5,6,7,8])), true);
|
||||
|
||||
[0, 1, 2].forEach(s => {
|
||||
EQW_GameState.room.mySeat = s;
|
||||
t.eq('座位 ' + s + ' 合法不抛', throws(() => EQW_Rpc.zhunbei()), false);
|
||||
});
|
||||
|
||||
process.exit(t.done('rpc_send') ? 0 : 1);
|
||||
@@ -81,7 +81,10 @@ var cls_youle_erqiwang_desk = cls_youle_erqiwang_desk || {
|
||||
o_desk.prepare = [0,0,0];
|
||||
|
||||
if (o_desk.paiju_list.length > 0){
|
||||
//上一局的结算快照三件套一并清除(新局开始后重连不该再看到上一局的结算面板)
|
||||
delete o_desk.paiju_list[o_desk.paiju_list.length - 1].tmp_jiesuan_aset;
|
||||
delete o_desk.paiju_list[o_desk.paiju_list.length - 1].tmp_jiesuan_bottom;
|
||||
delete o_desk.paiju_list[o_desk.paiju_list.length - 1].tmp_jiesuan_account;
|
||||
}
|
||||
|
||||
//新开一局
|
||||
@@ -95,6 +98,10 @@ var cls_youle_erqiwang_desk = cls_youle_erqiwang_desk || {
|
||||
msg.data.success = true;
|
||||
msg.data.asetidx = o_desk.paiju_list.length;
|
||||
msg.data.asetcount = o_desk.o_room.asetcount;
|
||||
//阶段:发完牌即 step1(叫分)。由服务端权威给出、不让前端按 rpc 推——
|
||||
//没有包承载的状态变化等于逼前端去猜(server 03 §1.4 / 红线「状态机唯一在服务端」)
|
||||
msg.data.step = o_desk.method.curr_paiju().step;
|
||||
//控制权:当前等待叫分者。与重连包 CallRun.seat 同一个 get_callgrade_seat()(SSOT)
|
||||
msg.data.seat = o_desk.method.curr_paiju().method.get_callgrade_seat();
|
||||
msg.data.countdown = o_desk.method.get_countdown_jiaofen();
|
||||
for (var i = 0; i < o_desk.o_room.seatlist.length; i++) {
|
||||
|
||||
@@ -6,6 +6,7 @@ if (typeof require !== "undefined"){
|
||||
var cls_youle_erqiwang_config = require("./class.config.js");
|
||||
var cls_youle_erqiwang_arith = require("./class.arith.js");
|
||||
var cls_youle_erqiwang_desk = require("./class.desk.js");
|
||||
var cls_youle_erqiwang_paiju = require("./class.paiju.js");
|
||||
}
|
||||
|
||||
var cls_youle_erqiwang_export = cls_youle_erqiwang_export || {
|
||||
@@ -94,6 +95,10 @@ var cls_youle_erqiwang_export = cls_youle_erqiwang_export || {
|
||||
break;
|
||||
case 2: //选主
|
||||
deskinfo.ChooseMain = {};
|
||||
//控制权:选主/投降都只由庄家做(mod.xuanzhu / mod.touxiang 的 SEAT 校验以 banker 为准)。
|
||||
//与 shangzhuang 推送的 nextseat 同源同值——两条路径都由服务端算好给出,
|
||||
//前端不再自己套「选主 = 庄家」这条规则(字段名与 CallRun.seat 一致:分组内的 seat = 轮到谁)
|
||||
deskinfo.ChooseMain.seat = paiju.banker;
|
||||
//庄家
|
||||
deskinfo.ChooseMain.banker = paiju.banker;
|
||||
//庄家叫分
|
||||
@@ -112,9 +117,16 @@ var cls_youle_erqiwang_export = cls_youle_erqiwang_export || {
|
||||
} else {
|
||||
deskinfo.ChooseMain.touxiang = 1;
|
||||
}
|
||||
//当前抓分倍数(design §7.2.0):与 shangzhuang 推送同源同值。
|
||||
//选主阶段一张牌都还没出,捡分恒为 0 → 必为 +3(大光);
|
||||
//漏发会让重连后顶部「抓分」角标掉回 0,与增量路径不一致
|
||||
deskinfo.ChooseMain.curmultiple = paiju.method.get_curmultiple();
|
||||
break;
|
||||
case 3: //埋牌
|
||||
deskinfo.BuryCards = {};
|
||||
//控制权:埋牌只由庄家做(mod.maipai 的 SEAT 校验以 banker 为准)。
|
||||
//与 xuanzhu 推送的 nextseat 同源同值
|
||||
deskinfo.BuryCards.seat = paiju.banker;
|
||||
//庄家
|
||||
deskinfo.BuryCards.banker = paiju.banker;
|
||||
//庄家叫分
|
||||
@@ -129,6 +141,8 @@ var cls_youle_erqiwang_export = cls_youle_erqiwang_export || {
|
||||
}
|
||||
//主牌花色
|
||||
deskinfo.BuryCards.flower = paiju.flower;
|
||||
//当前抓分倍数(design §7.2.0):埋牌阶段同样一张牌未出,捡分恒为 0 → 必为 +3
|
||||
deskinfo.BuryCards.curmultiple = paiju.method.get_curmultiple();
|
||||
//埋牌阶段无投降(投降是选主阶段与选主互斥的选择)
|
||||
break;
|
||||
case 5: //出牌
|
||||
@@ -157,8 +171,9 @@ var cls_youle_erqiwang_export = cls_youle_erqiwang_export || {
|
||||
deskinfo.PushCards.grade = jian_grade.grade;
|
||||
//当前抓分倍数(design §7.2.0):与 chupai 包同源同值,重连后角标立即正确
|
||||
deskinfo.PushCards.curmultiple = paiju.method.get_curmultiple();
|
||||
//当前的出牌情况
|
||||
deskinfo.PushCards.playproc = paiju.playproc;
|
||||
//当前的出牌情况:与 chupai1/2/3 共用同一处快照函数(结构一致、且是深拷贝,
|
||||
//不把活对象挂进下发包,见 class.paiju.js get_playproc)
|
||||
deskinfo.PushCards.playproc = cls_youle_erqiwang_paiju.get_playproc(paiju);
|
||||
//本轮跟牌的必出牌(design §5.2):只在【轮到本座位】且本轮已有人出牌时才有,
|
||||
//算的也只是本座位自己的手牌,不涉及他家(server 红线:按可见性下发)
|
||||
if (paiju.playproc && paiju.playproc.currseat == seat){
|
||||
@@ -167,9 +182,17 @@ var cls_youle_erqiwang_export = cls_youle_erqiwang_export || {
|
||||
deskinfo.PushCards.mustcard = _must;
|
||||
}
|
||||
}
|
||||
//报无主:与 chupai1/2/3 的 baozhu 同源同门控(mod.chupai)。
|
||||
//【必须带】它是「余主公示 + 明牌按钮」的开关(design §9,前端
|
||||
//Sprites_Table.js 的明牌按钮据此显隐)。只在 chupai 包里给、重连包不给,
|
||||
//等于重连后按钮凭空消失——状态变更没有包承载(server 红线:发全下发面)。
|
||||
//不查牌模式恒 0(与 chupai 一致,协议 §11–§13 baozhu 条目)
|
||||
deskinfo.PushCards.baozhu = (!cfg.nocheck && paiju.method.have_baofu()) ? 1 : 0;
|
||||
//座位列表(报副统计)与亮牌:仅可查牌模式下展示(§9/§8.2)
|
||||
if (!cfg.nocheck){
|
||||
deskinfo.PushCards.seatlist = paiju.seatlist;
|
||||
//【深拷贝】与 maipai / chupai1/2/3 共用同一处快照函数,且不把活表挂进下发包
|
||||
//(paiju.seatlist 是 do_playcard 每手就地改写的活对象,见 class.paiju.js get_seatlist)
|
||||
deskinfo.PushCards.seatlist = cls_youle_erqiwang_paiju.get_seatlist(paiju);
|
||||
if (seat != paiju.banker){
|
||||
var _lp = paiju.method.get_liangpai();
|
||||
if (_lp){
|
||||
@@ -181,45 +204,39 @@ var cls_youle_erqiwang_export = cls_youle_erqiwang_export || {
|
||||
//不查牌模式下不带此属性——当前这一轮桌面上的牌由上面的 playproc 恢复,两种模式都有,
|
||||
//否则后出的人无从跟牌(design §9 末尾的边界说明)
|
||||
if (!cfg.nocheck){
|
||||
deskinfo.PushCards.pushlist = [];
|
||||
for (var i = 0; i < paiju.cards.length; i++) {
|
||||
var pai = paiju.cards[i];
|
||||
if (pai.playround > 0){
|
||||
if (deskinfo.PushCards.pushlist.length < pai.playround){
|
||||
deskinfo.PushCards.pushlist.length = pai.playround;
|
||||
}
|
||||
if (!deskinfo.PushCards.pushlist[pai.playround - 1]){
|
||||
deskinfo.PushCards.pushlist[pai.playround - 1] = [[], [], []];
|
||||
}
|
||||
|
||||
var owner = pai.dealowner;
|
||||
if (owner == 0){
|
||||
owner = paiju.banker + 1;
|
||||
}
|
||||
deskinfo.PushCards.pushlist[pai.playround - 1][owner - 1].push(i);
|
||||
}
|
||||
}
|
||||
//按大小排序:外层下标=轮次、内层恒为 3 个座位;排序须按本局主牌花色
|
||||
//(内层曾误用轮次数作上界,会把内层数组撑成轮次长度并填入 undefined;
|
||||
// 且曾误用上一个循环泄漏出来的 pai.flower——那是最后一张牌大王的花色 5,
|
||||
// 等于按「无主牌」排序)
|
||||
for (var i = 0; i < deskinfo.PushCards.pushlist.length; i++) {
|
||||
if (!deskinfo.PushCards.pushlist[i]){
|
||||
continue;
|
||||
}
|
||||
for (var j = 0; j < 3; j++) {
|
||||
deskinfo.PushCards.pushlist[i][j] = cls_youle_erqiwang_arith.order_cards(paiju.flower, deskinfo.PushCards.pushlist[i][j]);
|
||||
}
|
||||
}
|
||||
//【直读归档、不重建】pushlist 的唯一来源是 do_playcard 出牌当场写下的
|
||||
//paiju.playhistory(见 class.paiju.js)。旧实现是从 paiju.cards 的 playround/dealowner
|
||||
//反查再自己排一次序——那是同一份数据「这一手打出的牌」的第二个写入处:
|
||||
//一手多张(甩牌/对子/拖拉机)时它与 chupai1/2/3 的 data.cards 会给出不同顺序,
|
||||
//前端增量回放与重连重建因此对不上(同编码的一对牌尤其:排序无法区分它们)。
|
||||
deskinfo.PushCards.pushlist = paiju.method.get_pushlist();
|
||||
}
|
||||
break;
|
||||
case 6: //结算
|
||||
deskinfo.Balance = {};
|
||||
//玩家的准备状态
|
||||
deskinfo.Balance.readystate = o_room.o_desk.prepare;
|
||||
//【返回副本】o_room.o_desk.prepare 是一张全程就地改写的活数组
|
||||
//(do_prepare 每次准备就地把某一格置 1,do_new_paiju 又就地重建),
|
||||
//把它本体挂进下发包会让「已发出的包」随后续准备操作被回改。
|
||||
//与 get_playproc / get_seatlist / get_pushlist / get_bottomcards 同一条标准:
|
||||
//平台真实链路虽然立刻序列化看不出来,但这是隐患,不留。
|
||||
deskinfo.Balance.readystate = o_room.o_desk.prepare.concat();
|
||||
if (o_room.o_desk.prepare[seat] == 0){
|
||||
//结算面板的三件套:与 jiesuan 推送【同源同值】(都取 get_paiju_account 末尾
|
||||
//冻结的那份快照)。只给 aset 会让「结算面板还开着时断线重连/硬刷新」丢掉
|
||||
//抠底明细与末局大结算——增量路径有、重连路径空白,正是「同一份数据两条路径
|
||||
//给的不一样」(server 红线:发全下发面,漏发修在服务端发包处)。
|
||||
//结算情况
|
||||
deskinfo.Balance.aset = paiju.tmp_jiesuan_aset;
|
||||
//抠底明细:仅正常出牌结算存在(投降/解散没有抠底这一步),与 jiesuan.bottom 同一份
|
||||
if (paiju.tmp_jiesuan_bottom){
|
||||
deskinfo.Balance.bottom = paiju.tmp_jiesuan_bottom;
|
||||
}
|
||||
//大局结算:仅末局或解散时存在,与 jiesuan.account 同一份。
|
||||
//「有就带、没有就不带」与 jiesuan 推送的取舍完全一致,前端两条路径同一套解析
|
||||
if (paiju.tmp_jiesuan_account){
|
||||
deskinfo.Balance.account = paiju.tmp_jiesuan_account;
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -116,6 +116,24 @@ var cls_youle_erqiwang_paiju = cls_youle_erqiwang_paiju || {
|
||||
tmpdeal.length = tmpdeal.length - 1;
|
||||
}
|
||||
|
||||
//冻结底牌快照(发牌留桌的 8 张)
|
||||
//——内容在发牌结束时就已确定(dealowner==0 之后不再变),【显示顺序也必须一并冻结】。
|
||||
//理由(SSOT):排序用的 order_cards 依赖主牌花色,而底牌是在【选主之前】的上庄时刻
|
||||
//就翻给庄家看的——那时 paiju.flower 还是 -1,根本还没有主牌。若每次调用都按「当时的
|
||||
//flower」重排,上庄推送(flower=-1)与重连 BuryCards(step3,flower 已定)会对同一份数据
|
||||
//给出两种顺序(实测:主花色的那张 K 被排到队首),前端增量路径与重连路径因此对不上。
|
||||
//故在发牌结束时按【翻底那一刻的口径】算一次并冻结,之后所有下发点只取这份快照的副本。
|
||||
var init_bottomcards = function(){
|
||||
var ary = [];
|
||||
for (var i = 0; i < paiju.cards.length; i++){
|
||||
if (paiju.cards[i].dealowner == 0){
|
||||
ary.push(paiju.cards[i].id);
|
||||
}
|
||||
}
|
||||
//此处 paiju.flower 必为 -1(选主在 step2 才发生),即「无主牌」序 = 翻底时的顺序
|
||||
paiju.bottomcards = cls_youle_erqiwang_arith.order_cards(paiju.flower, ary);
|
||||
}
|
||||
|
||||
//进入叫分阶段
|
||||
var start_call = function(){
|
||||
paiju.step = 1;
|
||||
@@ -149,17 +167,35 @@ var cls_youle_erqiwang_paiju = cls_youle_erqiwang_paiju || {
|
||||
paiju.call = -1;
|
||||
//主牌花色
|
||||
paiju.flower = -1;
|
||||
//底牌快照(发牌留桌的 8 张,含显示顺序):发牌结束时冻结,见 init_bottomcards
|
||||
paiju.bottomcards = [];
|
||||
//当前出牌情况
|
||||
paiju.playproc = {};
|
||||
//出牌历史归档:外层下标 = 轮次-1,内层恒 3 个数组、下标 = 座位;每一项就是那一手
|
||||
//【实际打出、且已归一成权威顺序】的牌。唯一写入处是 do_playcard 的 doplay()。
|
||||
//【为什么要存】重连包 PushCards.pushlist 需要往轮的出牌历史,而牌一旦落地就只剩
|
||||
//「第几轮 + 属于谁」(cards[i].playround/dealowner),一手之内的顺序无从恢复——
|
||||
//从那里反查就必须自己再排一次序,那是同一份数据的第二个写入处(SSOT 违反),
|
||||
//且遇到同编码的两张牌(一对)时两处必然分岔。改为出牌当场归档、下游只读。
|
||||
paiju.playhistory = [];
|
||||
//输赢结果 0:庄赢 1:闲赢 2:庄投降 3:中途解散
|
||||
paiju.result = null;
|
||||
//结算包,供断线重连时使用,新开一局时清除
|
||||
//结算包快照,供断线重连时使用,新开一局时清除。三件套与 jiesuan 推送【同源同值】:
|
||||
// tmp_jiesuan_aset 单局结算(三种结算来源恒有)
|
||||
// tmp_jiesuan_bottom 抠底明细(仅正常出牌结算有;投降/解散为 null)
|
||||
// tmp_jiesuan_account 大局结算(仅末局或解散有;其余为 null)
|
||||
//只存 aset 会让「结算面板还开着时重连/硬刷新」丢掉抠底明细与大局结算(增量路径有、
|
||||
//重连路径没有),即同一份数据两条路径给的不一样。唯一写入处是 get_paiju_account 末尾。
|
||||
paiju.tmp_jiesuan_aset = null;
|
||||
paiju.tmp_jiesuan_bottom = null;
|
||||
paiju.tmp_jiesuan_account = null;
|
||||
|
||||
//初始化座位列表
|
||||
init_seatlist();
|
||||
//初始化牌列表并发牌
|
||||
init_cards();
|
||||
//冻结底牌快照(必须在发牌之后、选主之前)
|
||||
init_bottomcards();
|
||||
//进入叫分阶段
|
||||
start_call();
|
||||
|
||||
@@ -261,6 +297,16 @@ var cls_youle_erqiwang_paiju = cls_youle_erqiwang_paiju || {
|
||||
return cls_youle_erqiwang_paiju.get_curmultiple(paiju);
|
||||
}
|
||||
|
||||
//获取出牌历史快照(重连包 PushCards.pushlist)
|
||||
paiju.method.get_pushlist = function(){
|
||||
return cls_youle_erqiwang_paiju.get_pushlist(paiju);
|
||||
}
|
||||
|
||||
//获取三家座位牌况快照(maipai / chupai1/2/3 / 重连包 PushCards.seatlist)
|
||||
paiju.method.get_seatlist = function(){
|
||||
return cls_youle_erqiwang_paiju.get_seatlist(paiju);
|
||||
}
|
||||
|
||||
//获取某座位本轮跟牌的必出牌(design §5.2)
|
||||
paiju.method.get_mustcard = function(seat){
|
||||
return cls_youle_erqiwang_paiju.get_mustcard(paiju, seat);
|
||||
@@ -445,15 +491,90 @@ var cls_youle_erqiwang_paiju = cls_youle_erqiwang_paiju || {
|
||||
}
|
||||
},
|
||||
|
||||
//获取底牌
|
||||
//获取底牌(发牌留桌的 8 张):唯一来源是发牌时冻结的快照 o_paiju.bottomcards(见 init_bottomcards)。
|
||||
//【返回副本】——order_cards 是原地排序,把快照本体交出去会被调用方就地重排,冻结即失效。
|
||||
get_bottomcards: function(o_paiju){
|
||||
var aryCardIDs = [];
|
||||
for (var i = 0; i < o_paiju.cards.length; i++){
|
||||
if (o_paiju.cards[i].dealowner == 0){
|
||||
aryCardIDs.push(o_paiju.cards[i].id);
|
||||
return o_paiju.bottomcards.concat();
|
||||
},
|
||||
|
||||
//获取【供下发的】当前出牌情况快照:deskinfo.PushCards 与 chupai1/2/3 共用这一处,
|
||||
//两条路径因此结构完全一致(前端增量回放与重连重建可复用同一份解析)。
|
||||
//必须【深拷贝】:playproc 是一个全程复用的活对象(new_playround 就地重置同一个对象,
|
||||
//do_playcard 也就地改 cards/maxseat/maxcard),直接把它挂进 msg 会让「已发出的包」
|
||||
//随后续出牌被回改;平台真实链路虽然立刻序列化看不出来,但这是隐患,不留。
|
||||
//structure:round/start/currseat/startcount/startflower/starttype/maxseat/maxcard/
|
||||
// cards(定长3、下标=座位)/shuai_demand
|
||||
//获取【供下发的】出牌历史快照(重连包 PushCards.pushlist)。
|
||||
//唯一来源是 do_playcard 当场归档的 playhistory,这里只拷贝、不重算也不重排:
|
||||
//它与 chupai1/2/3 的 data.cards、playproc.cards 是同一次 order_playcards 的产物,
|
||||
//因此两条路径逐元素相等(前端增量回放与重连重建必定一致)。
|
||||
//【返回深拷贝】不把活数组挂进下发包(同 get_playproc)。
|
||||
get_pushlist: function(o_paiju){
|
||||
var re = [];
|
||||
for (var i = 0; i < o_paiju.playhistory.length; i++){
|
||||
var _round = o_paiju.playhistory[i];
|
||||
var _one = [];
|
||||
for (var s = 0; s < 3; s++){
|
||||
_one.push(_round[s].concat());
|
||||
}
|
||||
re.push(_one);
|
||||
}
|
||||
return re;
|
||||
},
|
||||
|
||||
//获取【供下发的】三家座位牌况快照(maipai / chupai1/2/3 / 重连包 PushCards.seatlist)。
|
||||
//结构:外层定长 3(下标=座位),每家 5 个二元数组——前 4 个是花色1~4 的 [无该花色, 无对],
|
||||
//第 5 个是报副 [剩余主牌数, 剩余主对数]。
|
||||
//【返回深拷贝】o_paiju.seatlist 是一张全程就地改写的活表(do_playcard 里按缺门/无对/报无主
|
||||
//逐格赋值),把它本体挂进下发包会让「已发出的包」随后续出牌被回改;平台真实链路虽然立刻
|
||||
//序列化看不出来,但这是隐患,与 get_playproc / get_pushlist / get_bottomcards 同一条标准。
|
||||
get_seatlist: function(o_paiju){
|
||||
var re = [];
|
||||
for (var s = 0; s < o_paiju.seatlist.length; s++){
|
||||
var _one = [];
|
||||
for (var f = 0; f < o_paiju.seatlist[s].length; f++){
|
||||
_one.push(o_paiju.seatlist[s][f].concat());
|
||||
}
|
||||
re.push(_one);
|
||||
}
|
||||
return re;
|
||||
},
|
||||
|
||||
get_playproc: function(o_paiju){
|
||||
var _p = o_paiju.playproc;
|
||||
if (!_p){
|
||||
return null;
|
||||
}
|
||||
var re = {};
|
||||
re.round = _p.round;
|
||||
re.start = _p.start;
|
||||
re.currseat = _p.currseat;
|
||||
re.startcount = _p.startcount;
|
||||
re.startflower = _p.startflower;
|
||||
re.starttype = _p.starttype;
|
||||
re.maxseat = _p.maxseat;
|
||||
re.maxcard = _p.maxcard;
|
||||
//本轮各位置出的牌:定长 3、下标 = 座位序号;尚未出牌的位置留空(序列化后为 null)
|
||||
re.cards = [];
|
||||
re.cards.length = 3;
|
||||
if (_p.cards){
|
||||
for (var i = 0; i < 3; i++){
|
||||
if (_p.cards[i]){
|
||||
re.cards[i] = _p.cards[i].concat();
|
||||
}
|
||||
}
|
||||
return cls_youle_erqiwang_arith.order_cards(o_paiju.flower, aryCardIDs);
|
||||
}
|
||||
//首家甩牌的分量需求(§5.4.4);tractors 是数组,同样要拷贝,非甩牌为 null
|
||||
if (_p.shuai_demand){
|
||||
var _sd = {};
|
||||
_sd.tractors = _p.shuai_demand.tractors ? _p.shuai_demand.tractors.concat() : [];
|
||||
_sd.pairs = _p.shuai_demand.pairs;
|
||||
_sd.singles = _p.shuai_demand.singles;
|
||||
re.shuai_demand = _sd;
|
||||
} else {
|
||||
re.shuai_demand = null;
|
||||
}
|
||||
return re;
|
||||
},
|
||||
|
||||
//选主
|
||||
@@ -541,6 +662,23 @@ var cls_youle_erqiwang_paiju = cls_youle_erqiwang_paiju || {
|
||||
o_paiju.playproc.shuai_demand= null; //首家甩牌的分量需求(供跟牌逐分量匹配),非甩牌为null
|
||||
},
|
||||
|
||||
//【一手出牌的顺序:唯一口径】把一手牌规整成本局的权威展示顺序
|
||||
//(按主牌花色从大到小)。只在 do_playcard 落牌那一刻调一次,之后全体下游只读:
|
||||
// · playproc.cards[座位] → chupai1/2/3 与重连包的 playproc
|
||||
// · do_playcard 返回的 re.cards → chupai1/2/3 的 data.cards
|
||||
// · playhistory[轮次][座位] → 重连包的 PushCards.pushlist
|
||||
//三者同源同数组内容,所以前端“增量回放”与“重连重建”必定逐元素相等。
|
||||
//
|
||||
//为什么不能用客户端提交的原序:那是玩家的点击顺序,同一手牌每次都可能不同,
|
||||
//服务端不得依赖、也不回显(server 04 §8:前端不是数据源)。
|
||||
//【返回新数组】——order_cards 是原地排序,直接排入参会就地改写调用方(含 pack.data.cards)。
|
||||
order_playcards: function(o_paiju, cards){
|
||||
if (!cards){
|
||||
return cards;
|
||||
}
|
||||
return cls_youle_erqiwang_arith.order_cards(o_paiju.flower, cards.concat());
|
||||
},
|
||||
|
||||
//在牌列表中获取分牌和分值
|
||||
get_grade_incard: function(o_paiju, cards){
|
||||
var re = {};
|
||||
@@ -557,6 +695,12 @@ var cls_youle_erqiwang_paiju = cls_youle_erqiwang_paiju || {
|
||||
|
||||
//检查选中的牌是否可出,并出牌
|
||||
do_playcard: function(o_paiju, cards){
|
||||
//【顺序归一:唯一写入处】入参是客户端提交的点击顺序,服务端不采信、不下发。
|
||||
//在这里一次性转成权威顺序(order_playcards),此后 playproc.cards / 返回给下发包的
|
||||
//re.cards / 重连包 pushlist 全都是同一个口径,不会再有第二处排序(SSOT)。
|
||||
//顺带隔离了 can_playcard 的【原地排序】——否则它会就地改写 pack.data.cards。
|
||||
cards = cls_youle_erqiwang_paiju.order_playcards(o_paiju, cards);
|
||||
|
||||
//出牌
|
||||
var doplay = function(playindex){
|
||||
for (var i = 0; i < cards.length; i++) {
|
||||
@@ -564,6 +708,11 @@ var cls_youle_erqiwang_paiju = cls_youle_erqiwang_paiju || {
|
||||
o_paiju.cards[cards[i]].playindex = playindex;
|
||||
};
|
||||
o_paiju.playproc.cards[o_paiju.playproc.currseat] = cards;
|
||||
//归档这一手,供重连包 PushCards.pushlist 直读(唯一写入处,见 paiju.playhistory)
|
||||
while (o_paiju.playhistory.length < o_paiju.playproc.round){
|
||||
o_paiju.playhistory.push([[], [], []]);
|
||||
}
|
||||
o_paiju.playhistory[o_paiju.playproc.round - 1][o_paiju.playproc.currseat] = cards.concat();
|
||||
|
||||
//进入出牌阶段
|
||||
o_paiju.step = 5;
|
||||
@@ -615,7 +764,7 @@ var cls_youle_erqiwang_paiju = cls_youle_erqiwang_paiju || {
|
||||
if (can.shuaicuo){
|
||||
//甩错惩罚(§5.4.5):收回甩牌,本轮只强制打出最小一张
|
||||
_shuaicuo = true;
|
||||
cards = [can.smallest];
|
||||
cards = cls_youle_erqiwang_paiju.order_playcards(o_paiju, [can.smallest]);
|
||||
can = cls_youle_erqiwang_arith.can_playcard(o_paiju.flower, cards, o_paiju.playproc.currseat, o_paiju.seatlist, opp_zhulist);
|
||||
if (!can.result){
|
||||
var reErr = {};
|
||||
@@ -646,7 +795,10 @@ var cls_youle_erqiwang_paiju = cls_youle_erqiwang_paiju || {
|
||||
re1.count = cards.length;
|
||||
re1.flower = can.flower;
|
||||
re1.cardtype = can.cardtype;
|
||||
re1.cards = cards; //实际打出的牌(甩错时为单张)
|
||||
//实际打出的牌(甩错时为单张),已是权威顺序。
|
||||
//【给副本】cards 本体已挂在 playproc.cards[座位] 上,直接交出去会让「已发出的包」
|
||||
//随后续改动被回改(同 get_playproc 的深拷贝理由)
|
||||
re1.cards = cards.concat();
|
||||
re1.shuaicuo = _shuaicuo;
|
||||
//合法甩牌的分量构成:cardtype 只能表达单一牌型,甩牌(单张/对子/拖拉机自由混搭,
|
||||
//design §5.4.3)会被压平成 1xx/2xx,客户端无从据此还原分组,故另行下发分量需求
|
||||
@@ -698,6 +850,7 @@ var cls_youle_erqiwang_paiju = cls_youle_erqiwang_paiju || {
|
||||
var re2 = {};
|
||||
re2.idx = 2;
|
||||
re2.result = true;
|
||||
re2.cards = cards.concat(); //实际打出的牌,权威顺序(理由同 re1.cards)
|
||||
return re2;
|
||||
}
|
||||
|
||||
@@ -715,6 +868,7 @@ var cls_youle_erqiwang_paiju = cls_youle_erqiwang_paiju || {
|
||||
var re3 = {};
|
||||
re3.result = true;
|
||||
re3.idx = 3;
|
||||
re3.cards = cards.concat(); //实际打出的牌,权威顺序(理由同 re1.cards)
|
||||
re3.maxseat = o_paiju.playproc.maxseat;
|
||||
if (o_paiju.playproc.maxseat != o_paiju.banker){
|
||||
re3.grade = 0;
|
||||
@@ -904,6 +1058,13 @@ var cls_youle_erqiwang_paiju = cls_youle_erqiwang_paiju || {
|
||||
//主动推送的 data 必须自带成败标志(dev-guide server 03 §4:成败唯一是 data.success)
|
||||
msg.data.success = true;
|
||||
|
||||
//【阶段唯一写入处】结算包一出,本局即进入 step6:
|
||||
// 正常出牌结算 do_playcard 已置 6、投降结算 mod.touxiang 已置 6,此处是幂等重申;
|
||||
// 解散结算此前没有任何地方推进过 step(可能停在 1/2/3/5),由这里落定。
|
||||
//随后显式下发(见下方 msg.data.step):阶段变更必须有包承载,不让前端按 rpc 猜(server 03 §1.4)
|
||||
o_paiju.step = 6;
|
||||
msg.data.step = o_paiju.step;
|
||||
|
||||
//读取房间可选规则(缺省:常规算子、不傍王;见 class.config.js 位串约定)
|
||||
var o_room = o_paiju.o_desk.o_room;
|
||||
var _cfg = cls_youle_erqiwang_config.parse(o_room.roomtype);
|
||||
@@ -1043,8 +1204,14 @@ var cls_youle_erqiwang_paiju = cls_youle_erqiwang_paiju || {
|
||||
msg = o_paiju.o_desk.method.get_desk_account(msg);
|
||||
}
|
||||
|
||||
//保存结算包,供断线重连时使用
|
||||
//保存结算包,供断线重连时使用(get_deskinfo 的 case 6 直读这三份,不重算)。
|
||||
//必须与本次下发的 jiesuan 推送【同源】——取的就是刚组好的同一份 msg.data.*,
|
||||
//这样「增量收到 jiesuan」与「结算面板开着时重连」得到的结算数据逐字段相等。
|
||||
//bottom 只有正常出牌结算才有(get_bottom_account 组的)、account 只有末局/解散才有,
|
||||
//没有的情形显式置 null(不是漏发,是本来就不存在;下发侧据此决定带不带该分组)
|
||||
o_paiju.tmp_jiesuan_aset = msg.data.aset;
|
||||
o_paiju.tmp_jiesuan_bottom = msg.data.bottom ? msg.data.bottom : null;
|
||||
o_paiju.tmp_jiesuan_account = msg.data.account ? msg.data.account : null;
|
||||
|
||||
return msg;
|
||||
}
|
||||
|
||||
@@ -126,6 +126,27 @@
|
||||
| AA 每人 `'1'` | 6 局 | 1 张 | 1 张 |
|
||||
| AA 每人 `'1'` | 12 局 | 2 张 | 2 张 |
|
||||
|
||||
### 0.6 「一手出牌」的顺序口径
|
||||
|
||||
同一份业务数据「某一手打出的牌」会出现在**三个下发位置**:
|
||||
|
||||
| 位置 | 字段 |
|
||||
|---|---|
|
||||
| 出牌推送 | `chupai1` / `chupai2` / `chupai3` 的 `data.cards` |
|
||||
| 本轮进行态 | `playproc.cards[座位]`(`chupai*` 与重连包 `PushCards` 都带)|
|
||||
| 重连出牌历史 | `PushCards.pushlist[轮次-1][座位]` |
|
||||
|
||||
**这三处的数组内容逐元素完全相等**——顺序统一为「按**本局主牌花色**从大到小」的**权威顺序**。
|
||||
服务端只在落牌那一刻归一化一次(`class.paiju.js` 的 `order_playcards`),随后:
|
||||
出牌推送读它、`playproc.cards` 存它、出牌历史 `playhistory` 归档它,下游一律**只读、不重排**。
|
||||
|
||||
因此前端**「增量回放」与「重连重建」得到的出牌历史必定一致**,两条路径可以复用同一份解析。
|
||||
|
||||
> ⚠️ **请求包里的 `cards` 顺序无意义**:那是玩家的点击顺序,同一手牌每次都可能不同。
|
||||
> 服务端不采信、不回显、也不据它判定牌型(server dev-guide 04 §8「前端不是数据源」)。
|
||||
> 一手单张时看不出差别;**一手多张(甩牌 / 对子 / 拖拉机)时才会显形**,尤其是同编码的一对牌
|
||||
> ——排序本身区分不了它们,只有「出牌当场归档」这一个写入处才能保证两条路径给出同一个数组。
|
||||
|
||||
---
|
||||
|
||||
## 1. 发牌(fapai)
|
||||
@@ -137,7 +158,8 @@
|
||||
| asetidx | 整数 | 当前局数 |
|
||||
| asetcount | 整数 | 总局数 |
|
||||
| cards | 数组 | 自己得到的牌id列表 |
|
||||
| seat | 整数 | 当前等待叫分者的位置 |
|
||||
| step | 整数 | **本局阶段**(发完牌恒为 `1` 叫分;取值 1叫分 2选主/投降 3埋牌 5出牌 6结算)。阶段由服务端唯一维护、逐包显式给出,客户端**不得按 rpc 名反推**(server 03 §1.4「任何状态变更都要有包承载」/ 前端红线「数据驱动、前端无对局状态机」)。与同一时刻的重连包 `deskinfo.step` 同源同值 |
|
||||
| seat | 整数 | **控制权**:当前等待叫分者的位置。与重连包 `CallRun.seat` 同源(`get_callgrade_seat()`)|
|
||||
| countdown | 整数 | 叫分倒计时 |
|
||||
|
||||
---
|
||||
@@ -167,7 +189,8 @@
|
||||
| call | 整数 | 分数,0表示不叫 |
|
||||
| currcall | 整数 | 当前叫到的分数 |
|
||||
| multiple | 整数 | 叫分对应的基础子数 `get_base_bycall(call, climb)`,**随房间「爬坡」开关取值**(roomtype 位3):常规算子 65→2 60→3 55→4 50及以下→6;爬坡 65→2 60→3 55→4 50→6 45→7 40→8 35→9 30→10 25→11 20→12 15→13 10→14 5→15;两种模式下 70→2;无叫分→0。与结算包 `aset.multiple` 同源同值(design §7.1/§7.3.1)|
|
||||
| nextseat | 整数 | 下一个叫分者的位置序号 |
|
||||
| step | 整数 | **本局阶段**(叫分尚未结束,恒为 `1`;取值 1叫分 2选主/投降 3埋牌 5出牌 6结算)。阶段由服务端唯一维护、逐包显式给出,客户端**不得按 rpc 名反推**(server 03 §1.4「任何状态变更都要有包承载」/ 前端红线「数据驱动、前端无对局状态机」)。与同一时刻的重连包 `deskinfo.step` 同源同值 |
|
||||
| nextseat | 整数 | **控制权**:下一个叫分者的位置序号,即本包之后「轮到谁」。与重连包 `CallRun.seat` 同源(`get_callgrade_seat()`)|
|
||||
| countdown | 整数 | 叫分倒计时 |
|
||||
|
||||
---
|
||||
@@ -182,8 +205,10 @@
|
||||
| call | 整数 | 分数,0表示不叫 |
|
||||
| banker | 整数 | 庄家的位置序号 |
|
||||
| grade | 整数 | 庄家的叫分 |
|
||||
| step | 整数 | **本局阶段**(叫分结束、进入选主/投降,恒为 `2`;取值 1叫分 2选主/投降 3埋牌 5出牌 6结算)。阶段由服务端唯一维护、逐包显式给出,客户端**不得按 rpc 名反推**(server 03 §1.4「任何状态变更都要有包承载」/ 前端红线「数据驱动、前端无对局状态机」)。与同一时刻的重连包 `deskinfo.step` 同源同值 |
|
||||
| nextseat | 整数 | **控制权**:本包之后「轮到谁」——恒为 `banker`,因为选主与投降都只由庄家做(服务端 `mod.xuanzhu`/`mod.touxiang` 的座位校验同样以 `banker` 为准)。<br>⚠️ **别和本包的 `seat` 混用**:`seat` 是**最后一个叫分者**,不是控制权。与重连包 `ChooseMain.seat` 同源同值 |
|
||||
| multiple | 整数 | 叫分对应的基础子数 `get_base_bycall(call, climb)`,**随房间「爬坡」开关取值**(roomtype 位3):常规算子 65→2 60→3 55→4 50及以下→6;爬坡 65→2 60→3 55→4 50→6 45→7 40→8 35→9 30→10 25→11 20→12 15→13 10→14 5→15;两种模式下 70→2;无叫分→0。与结算包 `aset.multiple` 同源同值(design §7.1/§7.3.1)|
|
||||
| bottomcards | 数组 | 8 张**底牌**(发牌时没发给玩家、扣在桌面的 8 张)。**庄家恒有**;**闲家仅当 70 分坐庄时才有**(供 3 秒亮牌用),非 70 分时闲家没有该属性(底牌只有庄家可见,design §4)|
|
||||
| bottomcards | 数组 | 8 张**底牌**(发牌时没发给玩家、扣在桌面的 8 张)。**庄家恒有**;**闲家仅当 70 分坐庄时才有**(供 3 秒亮牌用),非 70 分时闲家没有该属性(底牌只有庄家可见,design §4)。<br>**顺序在发牌结束时即冻结**(服务端 `paiju.bottomcards`,按「还没有主牌」的口径排一次):底牌是在**选主之前**翻给庄家看的,那时主牌花色尚不存在,故不按主牌花色排。本包与重连包 `ChooseMain.bottomcards` / `BuryCards.bottomcards` 三处**同序**,客户端存一次即可全程复用(含「查底牌」回看)|
|
||||
| ancard3s | 整数 | **开底**标志。仅 70 分坐庄时出现且为 `1`:表示庄家**摸底**之前,需将 `bottomcards` 这 8 张**底牌**向所有玩家翻开 3 秒(design §4/§7.1);非 70 分无此属性 |
|
||||
| cards | 数组 | 拿了底牌后手上的牌(36 张,含底牌),庄家才有此属性,闲家没有该属性 |
|
||||
| countdown | 整数 | 选主倒计时 |
|
||||
@@ -229,6 +254,8 @@
|
||||
| --- | --- | --- |
|
||||
| banker | 整数 | 庄家的位置序号 |
|
||||
| flower | 整数 | 花色 1方块 2梅花 3红心 4黑桃 |
|
||||
| step | 整数 | **本局阶段**(选主完成、进入埋牌,恒为 `3`;取值 1叫分 2选主/投降 3埋牌 5出牌 6结算)。阶段由服务端唯一维护、逐包显式给出,客户端**不得按 rpc 名反推**(server 03 §1.4「任何状态变更都要有包承载」/ 前端红线「数据驱动、前端无对局状态机」)。与同一时刻的重连包 `deskinfo.step` 同源同值 |
|
||||
| nextseat | 整数 | **控制权**:本包之后「轮到谁」——恒为 `banker`,埋牌只由庄家做(服务端 `mod.maipai` 的座位校验以 `banker` 为准)。与重连包 `BuryCards.seat` 同源同值 |
|
||||
| countdown | 整数 | 埋牌倒计时 |
|
||||
| cards | 数组 | 选主后自己手上的牌id列表 |
|
||||
|
||||
@@ -256,8 +283,11 @@
|
||||
| 参数名 | 类型 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| cards | 数组 | 埋牌后手上的牌,去掉了埋牌,庄家才有此属性,闲家没有该属性 |
|
||||
| burycards | 数组 | **埋牌底牌**(庄家埋下的 8 张),庄家才有此属性,闲家没有该属性。注意与 `shangzhuang.bottomcards`(**底牌**,发牌留桌 8 张)是两批不同的牌,见 §0.0 |
|
||||
| seat | 整数 | 出牌者的位置序号(即将首出的庄家)|
|
||||
| burycards | 数组 | **埋牌底牌**(庄家埋下的 8 张),庄家才有此属性,闲家没有该属性。注意与 `shangzhuang.bottomcards`(**底牌**,发牌留桌 8 张)是两批不同的牌,见 §0.0。<br>**取服务端权威快照 `get_burycard()`,不是客户端请求包里 `cards` 的原序**:按本局主牌花色从大到小排好,与重连包 `PushCards.burycards` **同源同序** |
|
||||
| seatlist | 数组 | 三家座位牌况,**仅可查牌模式下发**(不查牌无此属性,design §9),结构与门控同 `chupai1/2/3.seatlist` 与重连包 `PushCards.seatlist`。<br>埋牌完成时它是刚初始化的**空表**(每家 `[[0,0],[0,0],[0,0],[0,0],[-1,-1]]`)——下发它是为了让「埋牌完成 → 庄家首出」这段窗口内,增量路径与重连路径拿到同一张表,客户端无需为这段窗口特判 |
|
||||
| playproc | json | **本轮进行态**(design §5.1),**恒有、三家同值**,结构与门控同 [`chupai1.playproc`](#11-第一个玩家出牌chupai1)/重连包 `PushCards.playproc`(同一个快照函数 `get_playproc()`)。`do_burycard` 内部已 `new_playround` 就地初始化好 round-1 的进行态,此包带的正是这份初值:`round=1`、`start`/`currseat` 均为即将首出的庄家、`cards` 全空、`shuai_demand` 为 `null`。<br>**可见性**:全部字段由桌面公开信息推出(此刻尚无一张牌打出),三家整体下发、不逐座位裁剪 |
|
||||
| step | 整数 | **本局阶段**(埋牌完成、进入出牌,恒为 `5`;取值 1叫分 2选主/投降 3埋牌 5出牌 6结算)。阶段由服务端唯一维护、逐包显式给出,客户端**不得按 rpc 名反推**(server 03 §1.4「任何状态变更都要有包承载」/ 前端红线「数据驱动、前端无对局状态机」)。与同一时刻的重连包 `deskinfo.step` 同源同值 |
|
||||
| seat | 整数 | **控制权**:出牌者的位置序号(即将首出的庄家)。服务端取自权威的 `playproc.currseat`,与重连包 `PushCards.playproc.currseat` 同源同值 |
|
||||
| countdown | 整数 | 出牌倒计时 |
|
||||
| liangpai | json | **亮牌**(design §8.2)。**只有闲家、且可查牌模式、且庄家达门槛时才有**;不达标或不查牌则无此属性。结构:`{ cards: [牌id...] }`——**庄家手中全部固定主牌的具体牌面**,按本局主牌序从大到小排好。<br>**固定主牌 = 双王 + 全部花色的 2 + 全部花色的 7**,**不含**主花色的普通牌 A/K/Q/J/10/9/8/6/5。<br>**门槛**(任一满足即下发):固定主牌总数 ≥10 / 王 ≥3 / 7 ≥6 / 2 ≥6。**不限叫分**。<br>亮出的是**固定的一份**,不随被哪条门槛触发而增减;也**不给数量统计**——数量前端自己数 `cards.length` 即可。<br>口径是庄家**埋牌后的静态快照**(排除已埋的 8 张,但包含之后已打出的牌),全局固定不随出牌缩水,故重连包里取值一致 |
|
||||
|
||||
@@ -285,7 +315,7 @@
|
||||
| 参数名 | 类型 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| seat | 整数 | 出牌者的位置序号 |
|
||||
| cards | 数组 | 实际打出的牌id列表;**甩错时**(见 `shuaicuo`)为被强制打出的那一张最小主牌单张 |
|
||||
| cards | 数组 | 实际打出的牌id列表;**甩错时**(见 `shuaicuo`)为被强制打出的那一张最小主牌单张。**顺序:按本局主牌花色从大到小(权威顺序)**,不是客户端提交时的点击顺序——请求包里的 `cards` 顺序服务端不采信、不回显(见 [§0.6](#06-一手出牌的顺序口径))|
|
||||
| shuaicuo | 整数 | 甩错标志,仅甩错时出现且为 `1`(design §5.4.5:甩牌未通过最大性判定,整套甩牌收回,本轮只强制打出最小一张、失去本轮甩牌资格);正常出牌无此属性 |
|
||||
| seatlist | 数组 | 三家座位牌况 `o_paiju.seatlist`(长度 3,下标 = 座位序号),**仅可查牌模式下发**(不查牌无此属性,design §9)。每个座位为 5 元素数组:前 4 个对应花色1~4 的 `[无该花色标志, 该花色无对标志]`(0/1),第 5 个为**余主公示**数据 `[剩余主牌数, 剩余主对数]`(初始 `[-1,-1]`,代码内旧称「报副」)。**一旦全场有人报无主,服务端会按各家实际手牌同时刷新三个座位并整表下发**(design §9「为全体三人显示另外两家」),无需等另两家各自出牌才补。字段名与结构同重连包 `PushCards.seatlist` |
|
||||
| count | 整数 | 出牌数量(甩错时为 1) |
|
||||
@@ -297,6 +327,7 @@
|
||||
| baozhu | 整数 | 是否已有玩家报无主(主牌出空)0否 1是;对应服务端 `have_baofu()`(任一玩家 `seatlist[seat][4][0]==0`)。**仅可查牌模式**下才可能为 1,是**余主公示**与"明牌"按钮的开关;**不查牌模式恒为 0**(design §9)|
|
||||
| cardsinhand | 数组 | 出牌者出牌后手上剩下的牌id列表,只有出牌者才有此属性 |
|
||||
| curmultiple | 整数 | **当前抓分倍数**(design §7.2.0):**带符号**,与结算包 `aset.upgrade` 同口径(`3`/`2`/`1` 庄家大光/小光/过庄,`-N` 闲家升 N 级,`0` 叫分未定)。同源同算法,差别仅在此处**不含扣底**。三家同值,随本包下发、不另开推送。符号是「谁赢」的区分,客户端判定动画靠它分辨 |
|
||||
| playproc | json | <a id="playproc-def"></a>**本轮进行态**(design §5.1)。**`chupai1/2/3` 三个包恒有,三家同值**;结构与重连包 [`PushCards.playproc`](#断线重连deskinfo) **完全一致**(服务端同一个快照函数 `get_playproc()`,前端增量回放与重连重建复用同一份解析)。<br>字段:`round` 第几轮、`start` 本轮首出位置、`currseat` 当前该谁出、`startcount` 首出张数、`startflower` 首出花色、`starttype` 首出牌型、`maxseat` 本轮暂时最大者、`maxcard` 其牌编码、`cards` 本轮三家各自出的牌(**定长 3,下标 = 座位序号**,未出的位置为 `null`)、`shuai_demand` 首家甩牌的分量需求 `{tractors:[连对数...],pairs,singles}`(非甩牌为 `null`)。<br>⚠️ **`chupai3` 带的是【下一轮】的进行态**(`round+1`、`cards` 全空、`currseat == nextseat == maxseat`):本轮第三家一出完,服务端就地开了新一轮。这与「此刻断线重连拿到的 `PushCards.playproc`」完全相同——本轮那三手牌客户端已由 `chupai1/2/3` 各自的 `seat`+`cards` 收到,收牌动画后即清台。**唯一例外**:`chupai3` 打完最后一张牌时本包会转成 `jiesuan`(见 §14),那种情况下**不带** `playproc`。<br>**可见性**:全部字段都由桌面公开信息推出(`cards` 就是已摊在桌上的牌,`shuai_demand` 与 `chupai1.shuai` 等价且甩出的牌本身已公开),故三家整体下发、不逐座位裁剪 |
|
||||
| mustcard | 数组 | **下一个出牌者本轮跟牌的必出牌**(design §5.2),供其客户端自动选中。**只发给 `nextseat` 那一家**,其余两家无此属性——它是该玩家自己手牌的子集,整表下发会泄露他家手牌结构。以下情形不下发:`nextseat` 是本轮首家、首家为**甩牌**(甩牌跟牌走逐分量匹配,见 design §5.4.4)、或算出的必出牌为空 |
|
||||
|
||||
---
|
||||
@@ -308,12 +339,13 @@
|
||||
| 参数名 | 类型 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| seat | 整数 | 出牌者的位置序号 |
|
||||
| cards | 数组 | 出的牌id列表 |
|
||||
| cards | 数组 | 出的牌id列表。**顺序:按本局主牌花色从大到小(权威顺序)**,不是客户端提交时的点击顺序(见 [§0.6](#06-一手出牌的顺序口径))|
|
||||
| seatlist | 数组 | 三家座位牌况 `o_paiju.seatlist`(长度 3,下标 = 座位序号),**仅可查牌模式下发**(不查牌无此属性,design §9)。每个座位为 5 元素数组:前 4 个对应花色1~4 的 `[无该花色标志, 该花色无对标志]`(0/1),第 5 个为**余主公示**数据 `[剩余主牌数, 剩余主对数]`(初始 `[-1,-1]`,代码内旧称「报副」)。**一旦全场有人报无主,服务端会按各家实际手牌同时刷新三个座位并整表下发**(design §9「为全体三人显示另外两家」),无需等另两家各自出牌才补。字段名与结构同重连包 `PushCards.seatlist` |
|
||||
| nextseat | 整数 | 下一个出牌者的位置序号 |
|
||||
| countdown | 整数 | 出牌倒计时 |
|
||||
| baozhu | 整数 | 是否已有玩家报无主(主牌出空)0否 1是;对应服务端 `have_baofu()`(任一玩家 `seatlist[seat][4][0]==0`)。**仅可查牌模式**下才可能为 1,是**余主公示**与"明牌"按钮的开关;**不查牌模式恒为 0**(design §9)|
|
||||
| cardsinhand | 数组 | 出牌后出牌者手上剩下的牌id列表,只有出牌者才有此属性 |
|
||||
| playproc | json | **本轮进行态**,恒有、三家同值,结构见 [§11 `playproc`](#11-第一个玩家出牌chupai1)。此时 `cards` 已含首家与本家两手牌,`currseat` 指向第三家 |
|
||||
| mustcard | 数组 | **下一个出牌者本轮跟牌的必出牌**(design §5.2),供其客户端自动选中。**只发给 `nextseat` 那一家**,其余两家无此属性——它是该玩家自己手牌的子集,整表下发会泄露他家手牌结构。以下情形不下发:`nextseat` 是本轮首家、首家为**甩牌**、或算出的必出牌为空 |
|
||||
|
||||
---
|
||||
@@ -325,7 +357,7 @@
|
||||
| 参数名 | 类型 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| seat | 整数 | 出牌者的位置序号 |
|
||||
| cards | 数组 | 出的牌id列表 |
|
||||
| cards | 数组 | 出的牌id列表。**顺序:按本局主牌花色从大到小(权威顺序)**,不是客户端提交时的点击顺序(见 [§0.6](#06-一手出牌的顺序口径))|
|
||||
| seatlist | 数组 | 三家座位牌况 `o_paiju.seatlist`(长度 3,下标 = 座位序号),**仅可查牌模式下发**(不查牌无此属性,design §9)。每个座位为 5 元素数组:前 4 个对应花色1~4 的 `[无该花色标志, 该花色无对标志]`(0/1),第 5 个为**余主公示**数据 `[剩余主牌数, 剩余主对数]`(初始 `[-1,-1]`,代码内旧称「报副」)。**一旦全场有人报无主,服务端会按各家实际手牌同时刷新三个座位并整表下发**(design §9「为全体三人显示另外两家」),无需等另两家各自出牌才补。字段名与结构同重连包 `PushCards.seatlist` |
|
||||
| nextseat | 整数 | 下一个出牌者的位置序号 |
|
||||
| countdown | 整数 | 出牌倒计时 |
|
||||
@@ -334,6 +366,7 @@
|
||||
| maxseat | 整数 | 本轮出牌谁最大,只有本轮最后一个玩家出牌后才有此属性 |
|
||||
| grade | 整数 | 本轮闲家得分,只有本轮最后一个玩家出牌后且闲家有得分才有此属性 |
|
||||
| curmultiple | 整数 | **当前抓分倍数**(design §7.2.0):**带符号**,口径同 `aset.upgrade`。同源同算法,差别仅在此处**不含扣底**。三家同值,随本包下发、不另开推送 |
|
||||
| playproc | json | **本轮进行态**,三家同值,结构见 [§11 `playproc`](#11-第一个玩家出牌chupai1)。⚠️ 本包带的是**下一轮**的进行态(`round+1`、`cards` 全空、`currseat == nextseat == maxseat`),与此刻重连拿到的 `PushCards.playproc` 一致。**牌局在本包打完(转为 `jiesuan`)时不带此属性** |
|
||||
|
||||
---
|
||||
|
||||
@@ -425,6 +458,13 @@
|
||||
- `aset` / `account` 的字段结构与下面 §14 的表完全一致(`aset.multiple = 0`、`upgrade = 0`、各家 `grade = 0`,即解散局不结算子数与算奖;`account` 恒有,见 design §12.2「按当前累计分结算」)。
|
||||
- 若解散发生在**开战后、首局牌发出前**的窗口内(本游戏首局由 `makewar` 延迟 1 秒创建),`get_disbandRoom` 返回 `null`,平台走「不带 `deskfree`」分支——客户端需容忍 `data.deskfree` 缺失。
|
||||
|
||||
**顶层字段(三种结算来源恒有)**
|
||||
|
||||
| 参数名 | 类型 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| success | 布尔 | 恒 `true`(§0.1)|
|
||||
| step | 整数 | **本局阶段**,结算包恒为 `6`。三种结算来源(正常/投降/解散)统一由 `get_paiju_account` 给出——解散在此之前 `step` 可能还停在 1/2/3/5,由它落定为 6。客户端**不得按 rpc 名硬编码**,一律读此字段 |
|
||||
|
||||
**chupai(出牌包,仅正常出牌结算存在)**
|
||||
|
||||
| 参数名 | 类型 | 说明 |
|
||||
@@ -564,12 +604,14 @@
|
||||
|
||||
| 参数名 | 类型 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| seat | 整数 | **控制权**:此刻轮到谁操作——恒为 `banker`(选主与投降都只由庄家做)。字段名与 `CallRun.seat` 一致:**各阶段分组里的 `seat` 统一表示「轮到谁」**。与增量推送 `shangzhuang.nextseat` 同源同值;客户端据此设控制权,**不要自己用 `banker` 反推**(那是把「选主=庄家」这条规则搬到前端)|
|
||||
| banker | 整数 | 庄家 |
|
||||
| call | 整数 | 叫分 |
|
||||
| multiple | 整数 | 叫分对应的基础子数 `get_base_bycall(call, climb)`,**随房间「爬坡」开关取值**(roomtype 位3):常规算子 65→2 60→3 55→4 50及以下→6;爬坡 65→2 60→3 55→4 50→6 45→7 40→8 35→9 30→10 25→11 20→12 15→13 10→14 5→15;两种模式下 70→2;无叫分→0。与结算包 `aset.multiple` 同源同值(design §7.1/§7.3.1)|
|
||||
| countdown | 整数 | 选主倒计时 |
|
||||
| bottomcards | 数组 | 8 张**底牌**(发牌留桌的 8 张),**只有庄家有此属性**(底牌仅庄家可见;70 分的 3 秒亮牌是上庄时的一次性事件,重连不重放)|
|
||||
| bottomcards | 数组 | 8 张**底牌**(发牌留桌的 8 张),**只有庄家有此属性**(底牌仅庄家可见;70 分的 3 秒亮牌是上庄时的一次性事件,重连不重放)。顺序取发牌时冻结的快照,与 `shangzhuang.bottomcards`、`BuryCards.bottomcards` **完全同序**(见 §4)|
|
||||
| touxiang | 整数 | 是否允许投降 0:不允许 1:允许(仅 70 分坐庄为 1)。投降与选主互斥、同一决策点,见 touxiang 包与 design §4 |
|
||||
| curmultiple | 整数 | **当前抓分倍数**(design §7.2.0):**带符号**,与 `shangzhuang.curmultiple` 同源同值。选主阶段一张牌都还没出、捡分恒为 0,故**必为 `3`**(大光)。三家同值。有此字段,重连后顶部「抓分」角标才不会掉回 0 |
|
||||
|
||||
> 选主阶段前端需在每个花色按钮上显示"该花色在庄家手中的对子数"(design §4/§11)——庄家的完整手牌由 `MyCards` 提供(庄家为 36 张),对子数由前端据此计算,服务端不额外下发。
|
||||
|
||||
@@ -577,12 +619,14 @@
|
||||
|
||||
| 参数名 | 类型 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| seat | 整数 | **控制权**:此刻轮到谁操作——恒为 `banker`(埋牌只由庄家做)。与增量推送 `xuanzhu.nextseat` 同源同值 |
|
||||
| banker | 整数 | 庄家 |
|
||||
| call | 整数 | 叫分 |
|
||||
| multiple | 整数 | 叫分对应的基础子数 `get_base_bycall(call, climb)`,**随房间「爬坡」开关取值**(roomtype 位3):常规算子 65→2 60→3 55→4 50及以下→6;爬坡 65→2 60→3 55→4 50→6 45→7 40→8 35→9 30→10 25→11 20→12 15→13 10→14 5→15;两种模式下 70→2;无叫分→0。与结算包 `aset.multiple` 同源同值(design §7.1/§7.3.1)|
|
||||
| flower | 整数 | 主牌花色 |
|
||||
| countdown | 整数 | 埋牌倒计时 |
|
||||
| bottomcards | 数组 | 8 张**底牌**(发牌留桌的 8 张),**只有庄家有此属性**(底牌仅庄家可见;70 分的 3 秒亮牌是上庄时的一次性事件,重连不重放)|
|
||||
| bottomcards | 数组 | 8 张**底牌**(发牌留桌的 8 张),**只有庄家有此属性**(底牌仅庄家可见;70 分的 3 秒亮牌是上庄时的一次性事件,重连不重放)。顺序取发牌时冻结的快照,与 `shangzhuang.bottomcards`、`ChooseMain.bottomcards` **完全同序**——**不**按本局主牌花色重排(见 §4)|
|
||||
| curmultiple | 整数 | **当前抓分倍数**(design §7.2.0):**带符号**,与 `shangzhuang.curmultiple` 同源同值。埋牌阶段同样一张牌未出、捡分恒为 0,故**必为 `3`**(大光)。三家同值 |
|
||||
|
||||
> 埋牌阶段无投降(投降是 step2 与选主互斥的选择,选主后即不可再投降)。
|
||||
|
||||
@@ -595,13 +639,14 @@
|
||||
| multiple | 整数 | 叫分对应的基础子数 `get_base_bycall(call, climb)`,**随房间「爬坡」开关取值**(roomtype 位3):常规算子 65→2 60→3 55→4 50及以下→6;爬坡 65→2 60→3 55→4 50→6 45→7 40→8 35→9 30→10 25→11 20→12 15→13 10→14 5→15;两种模式下 70→2;无叫分→0。与结算包 `aset.multiple` 同源同值(design §7.1/§7.3.1)|
|
||||
| flower | 整数 | 主牌花色 |
|
||||
| countdown | 整数 | 出牌倒计时 |
|
||||
| burycards | 数组 | **埋牌底牌**(庄家埋下的 8 张),只有庄家有此属性。注意与 `ChooseMain`/`BuryCards.bottomcards`(**底牌**)是两批不同的牌,见 §0.0 |
|
||||
| burycards | 数组 | **埋牌底牌**(庄家埋下的 8 张),只有庄家有此属性。注意与 `ChooseMain`/`BuryCards.bottomcards`(**底牌**)是两批不同的牌,见 §0.0。与 `maipai.burycards` **同源同序**(同一个 `get_burycard()`,按本局主牌花色从大到小)|
|
||||
| grade | 整数 | 当前的捡分分数 |
|
||||
| gradecards | 数组 | 当前的捡分分牌,只有闲家有此属性 |
|
||||
| playproc | json | 当前出牌情况 `o_paiju.playproc`:`round` 第几轮、`start` 本轮首出位置、`currseat` 当前出牌位置、`startcount` 首出张数、`startflower` 首出花色、`starttype` 首出牌型、`maxseat` 本轮最大者位置、`maxcard` 最大牌编码、`cards` 本轮三家各自出的牌(数组下标=位置序号)、`shuai_demand` 首家甩牌的分量需求 `{tractors:[连对数...],pairs,singles}`(非甩牌为 null,供跟牌逐分量强制匹配,design §5.4.4)。**两种查牌模式下恒有此属性**——`cards` 是当前这一轮桌面上的牌,不属于「查牌」,屏蔽了后出的人就无从跟牌(design §9 末尾)|
|
||||
| seatlist | 数组 | 三家座位牌况,与上文 chupai 包的 `seatlist` 同名同结构(每个玩家一个 5 元素数组:4 个花色的 `[无该花色,无对]` + 报副 `[剩余主牌数,剩余主对数]`)。**仅可查牌模式下有此属性**(design §9)|
|
||||
| playproc | json | **本轮进行态**,与 `chupai1/2/3.playproc` **同源同结构**(服务端同一个 `get_playproc()` 快照函数,见 [§11 `playproc`](#11-第一个玩家出牌chupai1)):`round` 第几轮、`start` 本轮首出位置、`currseat` 当前出牌位置、`startcount` 首出张数、`startflower` 首出花色、`starttype` 首出牌型、`maxseat` 本轮最大者位置、`maxcard` 最大牌编码、`cards` 本轮三家各自出的牌(**定长 3**,下标=位置序号,未出为 `null`)、`shuai_demand` 首家甩牌的分量需求 `{tractors:[连对数...],pairs,singles}`(非甩牌为 null,供跟牌逐分量强制匹配,design §5.4.4)。**两种查牌模式下恒有此属性**——`cards` 是当前这一轮桌面上的牌,不属于「查牌」,屏蔽了后出的人就无从跟牌(design §9 末尾)|
|
||||
| baozhu | 整数 | 是否已有玩家报无主(主牌出空)0否 1是,**恒有此属性**;与 `chupai1/2/3` 的 `baozhu` **同源同值同门控**(同一个 `have_baofu()` + 同一个查牌位)。它是**余主公示**与“明牌”按钮的开关,重连必须一并恢复,否则重连后按钮凭空消失;**不查牌模式恒为 0**(design §9)|
|
||||
| seatlist | 数组 | 三家座位牌况,与上文 `maipai` / `chupai1/2/3` 包的 `seatlist` 同名同结构(每个玩家一个 5 元素数组:4 个花色的 `[无该花色,无对]` + 报副 `[剩余主牌数,剩余主对数]`)。**仅可查牌模式下有此属性**(design §9)|
|
||||
| liangpai | json | **亮牌**,结构同 maipai 包的 `liangpai`(`{ cards: [牌id...] }`);**仅可查牌模式、且请求者为闲家、且庄家达标时有**(供闲家重连后仍能看到,design §8.2)|
|
||||
| pushlist | 数组 | 出牌历史 `[[[], [], []], [[], [], []], ...]`,外层下标=轮次(从第 1 轮起,含进行中的当前轮),内层**恒为 3 个数组**、按位置序号存该轮各家出的牌id,各自已按**本局主牌花色**从大到小排序。**仅可查牌模式下有此属性**——往轮打出、已被收走的牌属于「查牌」范畴,不查牌模式一律不下发(design §9)。注意当前这一轮桌面上的牌由 `playproc.cards` 恢复,**两种模式下都有**,否则后出的人无从跟牌 |
|
||||
| pushlist | 数组 | 出牌历史 `[[[], [], []], [[], [], []], ...]`,外层下标=轮次(从第 1 轮起,含进行中的当前轮),内层**恒为 3 个数组**、按位置序号存该轮各家出的牌id。**每一手与当时那个 `chupai1/2/3` 包的 `cards` 逐元素完全相等**(同一份数据、同一个顺序口径,见 [§0.6](#06-一手出牌的顺序口径)),因此前端“增量回放”与“重连重建”得到的出牌历史必定一致。**仅可查牌模式下有此属性**——往轮打出、已被收走的牌属于「查牌」范畴,不查牌模式一律不下发(design §9)。注意当前这一轮桌面上的牌由 `playproc.cards` 恢复,**两种模式下都有**,否则后出的人无从跟牌 |
|
||||
| curmultiple | 整数 | **当前抓分倍数**(design §7.2.0):**带符号**,与 `chupai1/2/3` 同源同值,供重连后顶部「抓分」角标与判定动画状态立即正确 |
|
||||
| mustcard | 数组 | **本轮跟牌的必出牌**(design §5.2)。**仅当 `playproc.currseat == 请求者座位` 时才有**,且只算请求者自己的手牌;未轮到本家、本轮首家、甩牌局面、必出牌为空时均无此属性 |
|
||||
|
||||
@@ -611,6 +656,8 @@
|
||||
| --- | --- | --- |
|
||||
| readystate | 数组 | 所有玩家的准备状态 |
|
||||
| aset | json | 单局结算包,同上面结算包中的 `aset`,只有当自己的准备状态为0时才有此属性 |
|
||||
| bottom | json | **抠底包**,结构同 §14 的 `bottom`,与该局 `jiesuan` 推送的 `data.bottom` **同源同值**(服务端在 `get_paiju_account` 末尾冻结的同一份快照)。**仅正常出牌结算才有**(投降/解散没有抠底这一步);同样受「自己的准备状态为 0」门控。<br>**为什么必须有**:结算面板还开着时断线重连/硬刷新,只恢复 `aset` 会让抠底明细整块空白——同一份数据两条路径给的不一样(server 红线「发全下发面」)。<br>**可见性**:埋牌底牌在本局结算时已随 `jiesuan` 广播给三家(design §11 结束亮底),此处不构成额外泄露 |
|
||||
| account | json | **大局结算包**,结构同 §14 的 `account`,与该局 `jiesuan` 推送的 `data.account` **同源同值**。**仅末局或中途解散才有**,与 `jiesuan` 的取舍完全一致(有就带、没有就不带)|
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -134,6 +134,10 @@ youle_erqiwang.jiaofen = function(pack){
|
||||
msg.data.call = call;
|
||||
msg.data.currcall = o_paiju.method.get_callgrade_value();
|
||||
msg.data.multiple = cls_youle_erqiwang_arith.get_base_bycall(msg.data.currcall, _cfg.climb);
|
||||
//阶段:叫分尚未结束,仍是 step1。阶段唯一由服务端维护、并在包里显式给出——
|
||||
//前端不得按 rpc 名反推(server 红线「状态机唯一在服务端 / 任何状态变更都要有包承载」)
|
||||
msg.data.step = o_paiju.step;
|
||||
//控制权:下一个叫分者(本包的「轮到谁」就是它,与重连包 CallRun.seat 同一个函数)
|
||||
msg.data.nextseat = o_paiju.method.get_callgrade_seat();
|
||||
msg.data.countdown = o_desk.method.get_countdown_jiaofen();
|
||||
o_room.method.sendpack_toother(msg, -1);
|
||||
@@ -152,6 +156,12 @@ youle_erqiwang.jiaofen = function(pack){
|
||||
msg.data.banker = o_paiju.banker;
|
||||
msg.data.grade = o_paiju.call;
|
||||
msg.data.multiple = cls_youle_erqiwang_arith.get_base_bycall(msg.data.grade, _cfg.climb);
|
||||
//阶段:叫分结束、进入选主/投降(step2)。与重连包 deskinfo.step 同源同值
|
||||
msg.data.step = o_paiju.step;
|
||||
//控制权:选主与投降都只由庄家做(mod.xuanzhu / mod.touxiang 的 SEAT 校验就以 banker 为准),
|
||||
//故这里的「轮到谁」= o_paiju.banker —— 权威值取自牌局对象本身,不是前端按 banker 自行推导。
|
||||
//本包的 data.seat 是【最后一个叫分者】、不是控制权,别混用(协议 §4)
|
||||
msg.data.nextseat = o_paiju.banker;
|
||||
msg.data.countdown = o_desk.method.get_countdown_xuanzhu();
|
||||
if (o_paiju.call != 70){
|
||||
msg.data.touxiang = 0;
|
||||
@@ -242,6 +252,11 @@ youle_erqiwang.xuanzhu = function(pack){
|
||||
msg.data.success = true;
|
||||
msg.data.banker = o_paiju.banker;
|
||||
msg.data.flower = flower;
|
||||
//阶段:选主完成即进入埋牌(step3)。与重连包 deskinfo.step 同源同值
|
||||
msg.data.step = o_paiju.step;
|
||||
//控制权:埋牌只由庄家做(mod.maipai 的 SEAT 校验同样以 banker 为准),
|
||||
//权威值取自牌局对象;前端不再自己套「埋牌 = 庄家」这条规则
|
||||
msg.data.nextseat = o_paiju.banker;
|
||||
msg.data.countdown = o_desk.method.get_countdown_maipai();
|
||||
for (var i = 0; i < o_room.seatlist.length; i++) {
|
||||
msg.conmode = o_room.seatlist[i].conmode;
|
||||
@@ -320,8 +335,33 @@ youle_erqiwang.maipai = function(pack){
|
||||
msg.rpc = "maipai";
|
||||
msg.data = {};
|
||||
msg.data.success = true;
|
||||
//阶段:埋牌完成即进入出牌(step5,无独立投降阶段)。与重连包 deskinfo.step 同源同值
|
||||
msg.data.step = o_paiju.step;
|
||||
//控制权:本包的 seat 就是「轮到谁」——即将首出的庄家,取自权威的 playproc.currseat
|
||||
//(do_burycard 里 new_playround 刚写好),与重连包 PushCards.playproc.currseat 同源
|
||||
msg.data.seat = o_paiju.playproc.currseat;
|
||||
msg.data.countdown = o_desk.method.get_countdown_chupai();
|
||||
if (!_cfg.nocheck){
|
||||
//三家座位牌况(缺门/无对 + 报副统计),仅可查牌下发——与 chupai1/2/3、重连包
|
||||
//PushCards.seatlist 同一份数据、同一道门控。埋牌完成时它是刚初始化的空表
|
||||
//(每家 [[0,0],[0,0],[0,0],[0,0],[-1,-1]]),本身不含任何信息;但「埋牌完成 →
|
||||
//庄家首出」这段窗口内前端也要有表可画,漏发会让增量路径此刻为空、重连路径却有表。
|
||||
//可见性:表内只有「某家缺某花色/某花色无对」(由公开的跟牌行为推出)与报无主后
|
||||
//全场公示的余主数量(design §9 明确要求向全体三人展示),故三家整表下发不构成泄露。
|
||||
//【深拷贝】seatlist 是全程就地改写的活表,不把活对象挂进下发包(同 playproc/pushlist)
|
||||
msg.data.seatlist = cls_youle_erqiwang_paiju.get_seatlist(o_paiju);
|
||||
}
|
||||
//本轮进行态(design §5.1):do_burycard 内部已调用 new_playround 就地初始化好了 round-1
|
||||
//的进行态,与重连包 PushCards.playproc 同一个快照函数、同一结构(三家无差别)。
|
||||
//没有它,「埋牌完成 → 庄家首出」这段窗口内增量路径的 table.playproc 是 null,
|
||||
//重连路径却已有 round-1 初值,两条路径不一致。可见性:round/start/currseat 等全部
|
||||
//由桌面公开信息推出,cards 此刻全空、shuai_demand 为 null,不含任何私密信息,
|
||||
//与 chupai1/2/3.playproc 一样三家整体下发、不逐座位裁剪。
|
||||
msg.data.playproc = cls_youle_erqiwang_paiju.get_playproc(o_paiju);
|
||||
//埋牌底牌(本次埋下的 8 张):取服务端权威快照,【不回显客户端提交的 cards 原序】。
|
||||
//get_burycard 与重连包 PushCards.burycards 是同一个函数、同一套按本局主牌花色的排序,
|
||||
//两条路径因此顺序一致(SSOT);直接回显 cards 会把客户端的选牌顺序当成下发顺序。
|
||||
var _burycards = cls_youle_erqiwang_paiju.get_burycard(o_paiju);
|
||||
for (var i = 0; i < o_room.seatlist.length; i++) {
|
||||
msg.conmode = o_room.seatlist[i].conmode;
|
||||
msg.fromid = o_room.seatlist[i].fromid;
|
||||
@@ -329,7 +369,7 @@ youle_erqiwang.maipai = function(pack){
|
||||
msg.data.cards = o_paiju.method.get_seat_cards(i);
|
||||
//埋牌底牌(本次埋下的 8 张)。与 shangzhuang.bottomcards(底牌,发牌留桌 8 张)
|
||||
//是两批不同的牌,字段名刻意区分,勿混用
|
||||
msg.data.burycards = cards;
|
||||
msg.data.burycards = _burycards;
|
||||
delete msg.data.liangpai;
|
||||
} else {
|
||||
delete msg.data.cards;
|
||||
@@ -461,19 +501,35 @@ youle_erqiwang.chupai = function(pack){
|
||||
msg.data = {};
|
||||
msg.data.success = true;
|
||||
msg.data.seat = seat;
|
||||
msg.data.cards = re.cards ? re.cards : cards; //实际打出的牌(甩错时为单张)
|
||||
//实际打出的牌(甩错时为单张),顺序由 do_playcard 的 order_playcards 统一给出——
|
||||
//与 playproc.cards、重连包 PushCards.pushlist 同一个口径(SSOT)。
|
||||
//【不回落到入参 cards】那是客户端的点击顺序,服务端不采信、更不回显(server 04 §8);
|
||||
//do_playcard 成功时三个分支都会给出 re.cards,缺了就是它的缺陷,显式暴露、不兜底。
|
||||
msg.data.cards = re.cards;
|
||||
if (!_cfg.nocheck){
|
||||
//三家座位牌况(缺门/无对 + 报副统计),仅可查牌下发。
|
||||
//必须整表下发、不能只带出牌者一家:design §9 要求「一旦有玩家报无主,就为全体三人
|
||||
//显示另外两家各自的主牌数量与对子数」,服务端已在 do_playcard 里同时刷新三个座位
|
||||
//(class.paiju.js),只带一家会让另两家的统计滞后到它们各自出牌时才补上。
|
||||
//字段名与重连包 PushCards.seatlist 一致,前端两条路径复用同一份解析
|
||||
msg.data.seatlist = o_paiju.seatlist;
|
||||
//字段名与重连包 PushCards.seatlist 一致,前端两条路径复用同一份解析。
|
||||
//【深拷贝】seatlist 是全程就地改写的活表(本函数下方 do_playcard 每手都在改它),
|
||||
//不把活对象挂进下发包(同 playproc/pushlist 的理由)
|
||||
msg.data.seatlist = cls_youle_erqiwang_paiju.get_seatlist(o_paiju);
|
||||
}
|
||||
msg.data.nextseat = o_paiju.playproc.currseat;
|
||||
msg.data.countdown = o_desk.method.get_countdown_chupai();
|
||||
//当前抓分倍数(design §7.2.0):随本包一起下发、不另开推送;三家同值,grade 本就公开
|
||||
msg.data.curmultiple = o_paiju.method.get_curmultiple();
|
||||
//本轮进行态(design §5.1):与重连包 PushCards.playproc 同一个快照函数、同一结构。
|
||||
//没有它,前端在非重连时就拿不到「本轮谁先手/谁最大/三家各出了什么/第几轮」,只能自己
|
||||
//累积推导——那就是把对局推进逻辑搬到前端(前端红线「数据驱动、前端无对局状态机」)。
|
||||
//注意 re.idx==3 时 do_playcard 已在内部调用 new_playround 就地开了新一轮,
|
||||
//故 chupai3 带的是【新一轮】的进行态(与 nextseat 同步),这与此刻重连拿到的完全一致。
|
||||
//可见性:round/start/currseat/startcount/startflower/starttype/maxseat/maxcard 都是
|
||||
//由桌面公开信息直接推出的;cards 就是本轮已摊在桌上的牌;shuai_demand 是首家甩牌的
|
||||
//分量构成,而甩出的牌本身已公开、chupai1 也已把等价的 shuai 下发给三家。全部对三家可见,
|
||||
//无需逐座位裁剪(重连包 PushCards.playproc 本就对三家无差别下发,两条路径口径一致)
|
||||
msg.data.playproc = cls_youle_erqiwang_paiju.get_playproc(o_paiju);
|
||||
if (re.shuaicuo){
|
||||
msg.data.shuaicuo = 1; //甩错:本次甩牌被收回,只打出了最小一张(§5.4.5)
|
||||
}
|
||||
@@ -550,6 +606,8 @@ youle_erqiwang.chupai = function(pack){
|
||||
delete msg.data.nextseat;
|
||||
delete msg.data.countdown;
|
||||
delete msg.data.baozhu;
|
||||
//本轮进行态只对「还要继续出牌」有意义;本包已转为 jiesuan,不带
|
||||
delete msg.data.playproc;
|
||||
delete msg.data.cardsinhand;
|
||||
delete msg.data.maxseat;
|
||||
delete msg.data.grade;
|
||||
|
||||
@@ -18,4 +18,29 @@ t.eq('轮庄 庄2赢连庄', nextFirstseat(2, 0), 2);
|
||||
t.eq('轮庄 庄2闲赢下家(回0)', nextFirstseat(2, 1), 0);
|
||||
t.eq('轮庄 庄0闲赢下家1', nextFirstseat(0, 1), 1);
|
||||
|
||||
// ============ I-3 发牌包必须自带阶段 step 与控制权 seat ============
|
||||
// 红线「状态机唯一在服务端」:阶段与「轮到谁」必须由服务端在包里显式给出。
|
||||
// 曾经 fapai 只有 seat 没有 step,前端只能按 rpc 名硬编码 step=1——那就是把阶段推导
|
||||
// 搬到了前端。反向验证:把 class.desk.js 里的 msg.data.step 删掉,本组第一条必转红。
|
||||
function firstFapai() {
|
||||
const sent = [];
|
||||
const o_room = {
|
||||
seatlist: [{ conmode: 0, fromid: 0 }, { conmode: 0, fromid: 1 }, { conmode: 0, fromid: 2 }],
|
||||
asetcount: 6, method: { sendpack_toother: () => { } }
|
||||
};
|
||||
const prevApp = global.youle_erqiwang.app;
|
||||
global.youle_erqiwang.app = { SendPack: m => sent.push(JSON.parse(JSON.stringify(m))) };
|
||||
const desk = D.new(o_room);
|
||||
D.do_new_paiju(desk, 1); // 首个叫分者 = 1 号位(刻意不用 0,避免"恒等于 0"的假绿)
|
||||
global.youle_erqiwang.app = prevApp;
|
||||
return { desk, pj: desk.paiju_list[desk.paiju_list.length - 1], fapai: sent.filter(m => m.rpc === 'fapai') };
|
||||
}
|
||||
const fp = firstFapai();
|
||||
t.eq('发牌 fapai 带 step,且 = 牌局的真实阶段(1 叫分)', fp.fapai.map(m => m.data.step), [1, 1, 1]);
|
||||
t.eq('发牌 fapai.step 与 paiju.step 同源(不是写死的常量)', fp.fapai[0].data.step, fp.pj.step);
|
||||
t.eq('发牌 fapai 控制权 seat = 当前等待叫分者(firstseat=1)',
|
||||
fp.fapai.map(m => m.data.seat), [1, 1, 1]);
|
||||
t.eq('发牌 fapai.seat 与 get_callgrade_seat() 同源',
|
||||
fp.fapai[0].data.seat, fp.pj.method.get_callgrade_seat());
|
||||
|
||||
process.exit(t.done('desk') ? 0 : 1);
|
||||
|
||||
@@ -122,7 +122,7 @@ function mkFollow() {
|
||||
const pj = {
|
||||
idx: 1, step: 5, banker: 0, call: 65, flower: 1, callproc: [],
|
||||
seatlist: [[[0, 0], [0, 0], [0, 0], [0, 0], [-1, -1]], [[0, 0], [0, 0], [0, 0], [0, 0], [-1, -1]], [[0, 0], [0, 0], [0, 0], [0, 0], [-1, -1]]],
|
||||
playproc: {}, cards: make108()
|
||||
playproc: {}, playhistory: [], cards: make108()
|
||||
};
|
||||
for (let i = 0; i < 108; i++) pj.cards[i].dealowner = -1;
|
||||
const give = (cid, s) => { pj.cards[cid].dealowner = s + 1; pj.cards[cid].playround = -1; };
|
||||
@@ -141,7 +141,8 @@ function mkFollow() {
|
||||
get_liangpai: () => null,
|
||||
have_baofu: () => false,
|
||||
get_curmultiple: () => P.get_curmultiple(pj),
|
||||
get_mustcard: s => P.get_mustcard(pj, s)
|
||||
get_mustcard: s => P.get_mustcard(pj, s),
|
||||
get_pushlist: () => P.get_pushlist(pj)
|
||||
};
|
||||
P.new_playround(pj, 1, 0);
|
||||
return pj;
|
||||
|
||||
@@ -10,7 +10,7 @@ function newPaiju(step, banker, owner) {
|
||||
const paiju = {
|
||||
cards: make108(), step, banker, call: 65, flower: 1,
|
||||
seatlist: [[[0, 0], [0, 0], [0, 0], [0, 0], [-1, -1]], [[0, 0], [0, 0], [0, 0], [0, 0], [-1, -1]], [[0, 0], [0, 0], [0, 0], [0, 0], [-1, -1]]],
|
||||
playproc: {}, callproc: [], idx: 1
|
||||
playproc: {}, playhistory: [], callproc: [], idx: 1
|
||||
};
|
||||
for (let i = 0; i < 108; i++) paiju.cards[i].dealowner = owner + 1;
|
||||
paiju.method = {
|
||||
|
||||
@@ -1,9 +1,17 @@
|
||||
// 下发面泄露审计(server 红线「发全 ≠ 发多,按可见性下发」+ design §4/§9/§11)
|
||||
// 跑完整局,对每一个「服务器 → 某座位」的包做深度扫描,取出其中出现的所有牌 id,
|
||||
// 逐个判定「此刻该座位是否有权知道这张牌」。依据 design §4(底牌只有庄家可见 / 70分亮3秒)、
|
||||
// §9(查牌模式)、§11(结束亮埋牌底牌)与 server 红线「发全 ≠ 发多,按可见性下发」。
|
||||
// 跑完整局,对每一个「服务器 → 某座位」的包做两层扫描:
|
||||
// ① 牌 id 层:取出包里出现的所有牌 id,逐个判定「此刻该座位是否有权知道这张牌」。
|
||||
// 依据 design §4(底牌只有庄家可见 / 70分亮3秒)、§9(查牌模式)、§11(结束亮埋牌底牌)。
|
||||
// ② 统计量层:牌 id 之外,包里还有【由手牌算出来的数】——seatlist 的缺门/无对表、
|
||||
// playproc 的 maxseat/maxcard、curmultiple、捡分。它们不含牌 id,①层一个都扫不到,
|
||||
// 却同样能泄露("某家某花色还剩几张"这种统计,功能正常、玩家看不出、只有抓包的人知道)。
|
||||
// 故本层用【公开账本】独立重算这些数:只喂三家都看得见的信息(已广播的 chupai*.cards、
|
||||
// chupai1 的 count/flower/cardtype、庄家/叫分/主花色),算出来必须与包内值逐项相等。
|
||||
// 能被公开信息算出来的数 = 不含私密信息;算不出来 = 它一定读了别人的手牌。
|
||||
// 同时钉死这些结构的【字段集/项数】,防止有人往里加一项新统计而门禁照样全绿。
|
||||
const R = require('./_rpc.js'); const mod = R.mod;
|
||||
const P = global.cls_youle_erqiwang_paiju, A = global.cls_youle_erqiwang_arith;
|
||||
const CFG = global.cls_youle_erqiwang_config;
|
||||
const D = require('../class.desk.js'), EX = require('../class.export.js');
|
||||
const t = require('./_assert')();
|
||||
let seed = 0xBEEF01;
|
||||
@@ -11,16 +19,33 @@ const rnd = n => { seed ^= seed << 13; seed ^= seed >>> 17; seed ^= seed << 5; s
|
||||
global.min_ontimeout = fn => fn();
|
||||
global.min_random = (a, b) => a + rnd(b - a + 1);
|
||||
const pk = (s, d) => ({ conmode: 0, fromid: s, data: Object.assign({ agentid: 1, playerid: s, gameid: 1, roomcode: 1, seat: s }, d || {}) });
|
||||
const JS = x => JSON.stringify(x);
|
||||
|
||||
// 深度收集一个对象里所有「看起来是牌 id」的整数(0~107)。
|
||||
// ==========================================================================
|
||||
// ①层:牌 id 的可见性
|
||||
// ==========================================================================
|
||||
|
||||
// 深度收集一个对象里所有「看起来是牌 id」的整数(0~107),并记下它【出现在哪个字段路径上】。
|
||||
// 为避免把 seat/count/grade 之类误当牌 id,只扫描已知承载牌 id 的字段名。
|
||||
// 【为什么要记路径】豁免必须按「哪一个字段」给,而不是按「哪一个 rpc」给:
|
||||
// 同一个包里既有该公开的分组(结算面板的抠底明细/算奖明细),也有绝不该公开的分组,
|
||||
// 按 rpc 整包放行会把后者一并放掉。路径只由对象键组成、忽略数组下标(如
|
||||
// aset.seatlist[1].cards → "aset.seatlist.cards")。
|
||||
const CARD_FIELDS = ['cards', 'bottomcards', 'burycards', 'cardsinhand', 'zhucards', 'gradecards', 'pushlist', 'MyCards', 'mustcard'];
|
||||
function collectCards(node, key, out) {
|
||||
function collectCards(node, path, key, out) {
|
||||
if (node === null || node === undefined) return;
|
||||
if (Array.isArray(node)) { node.forEach(x => collectCards(x, key, out)); return; }
|
||||
if (typeof node === 'number') { if (CARD_FIELDS.indexOf(key) >= 0 && node >= 0 && node <= 107) out.add(node); return; }
|
||||
if (typeof node === 'object') { for (const k in node) collectCards(node[k], CARD_FIELDS.indexOf(k) >= 0 ? k : key, out); }
|
||||
if (Array.isArray(node)) { node.forEach(x => collectCards(x, path, key, out)); return; }
|
||||
if (typeof node === 'number') { if (CARD_FIELDS.indexOf(key) >= 0 && node >= 0 && node <= 107) out.add(node + '@' + path); return; }
|
||||
if (typeof node === 'object') {
|
||||
for (const k in node) {
|
||||
collectCards(node[k], path ? path + '.' + k : k, CARD_FIELDS.indexOf(k) >= 0 ? k : key, out);
|
||||
}
|
||||
}
|
||||
}
|
||||
const cidOf = e => parseInt(e.slice(0, e.indexOf('@')), 10);
|
||||
const pathOf = e => e.slice(e.indexOf('@') + 1);
|
||||
// 路径尾匹配:deskinfo 会多一层分组前缀(Balance.aset.seatlist.cards vs aset.seatlist.cards)
|
||||
const pathIs = (path, suffix) => path === suffix || path.endsWith('.' + suffix);
|
||||
|
||||
// design §8.2 亮牌:庄家埋牌后手中【固定主牌】达门槛时,这些牌的牌面对两个闲家公开(仅可查牌模式)。
|
||||
// 【独立于 get_liangpai 重算一遍】——若实现误把非固定主牌(主花色普通牌、副牌)塞进 liangpai,
|
||||
@@ -43,26 +68,294 @@ let curLiangpai = new Set(); // 本局亮牌应公开的牌(埋牌后确定
|
||||
let curNoCheck = false; // 本局是否「不查牌」
|
||||
let liangpaiRounds = 0; // 可查牌 + 庄家达标的局数(亮牌真的下发过)
|
||||
let liangpaiRoundsNoCheck = 0; // 不查牌 + 庄家达标的局数(此时一张都不该发)
|
||||
let surrenderRuns = 0; // 走过投降结算的局数
|
||||
let disbandRuns = 0; // 走过中途解散结算的局数
|
||||
let awardExempt = 0; // 「算奖明细」这条显式豁免被真正用到的次数
|
||||
|
||||
const leaks = [];
|
||||
let pkts = 0, scanned = 0;
|
||||
let ctxRef = null;
|
||||
// 判定:seat 此刻是否有权知道 cid
|
||||
function mayKnow(pj, seat, cid, rpc) {
|
||||
// 判定:seat 此刻是否有权知道 cid(path 见 collectCards 的说明)
|
||||
function mayKnow(pj, seat, cid, rpc, path) {
|
||||
const c = pj.cards[cid];
|
||||
if (c.dealowner === -1) return true; // 规则去除的 3/4,不可能出现
|
||||
if (c.playround > 0) return true; // 已打出,全场可见
|
||||
if (c.dealowner === seat + 1) return true; // 自己的牌
|
||||
if (seat === pj.banker && (c.dealowner === 0 || c.dealowner === seat + 1)) return true; // 庄家可见底牌/埋牌底牌
|
||||
if (rpc === 'shangzhuang' && pj.call === 70 && c.dealowner === 0) return true; // §4 70分亮3秒
|
||||
if (rpc === 'jiesuan' && c.playround === 0) return true; // §11 结束亮埋牌底牌
|
||||
if (seat === pj.banker && c.dealowner === 0) return true; // 庄家可见底牌(含摸起后又埋下的)
|
||||
// §4 70分坐庄:上庄推送把 8 张底牌向三家亮 3 秒——只放行 shangzhuang 的 bottomcards 这一处
|
||||
if (rpc === 'shangzhuang' && pj.call === 70 && c.dealowner === 0 && pathIs(path, 'bottomcards')) return true;
|
||||
|
||||
// §11 结束亮埋牌底牌:本局【正常结算】后,埋牌底牌随抠底明细对全场公开。
|
||||
// jiesuan 广播(bottom.cards,sendpack_toother(msg,-1) 三家都发)与结算面板还开着时的
|
||||
// 重连包(Balance.bottom.cards)是【同一份数据的两条投递路径】,可见性必须同判。
|
||||
// 门控写成「step===6 且 result!==3」而不是「rpc 是 jiesuan/deskinfo」:
|
||||
// · 只认字段路径 bottom.cards,不整包放行(同一个包里的其他分组仍严格判定);
|
||||
// · result 3 = 中途解散(class.paiju.js get_paiju_account 的 case 2,o_paiju.result = 3)。解散是【没打完】,
|
||||
// 埋牌底牌不该亮。当前解散路径的 tmp_jiesuan_bottom 恒为 null、包里本就没有 bottom,
|
||||
// 所以这一条现在不改变任何判定;它挡的是【将来】有人给解散路径也带上 bottom——
|
||||
// 那时门禁必须转红,而不是因为「step 也是 6」被放行。
|
||||
// 【措辞更正】上一轮把「给重连包补上这条豁免」记成了"收紧"——其实那一步的净效果是
|
||||
// 【放宽】(在原豁免上加了一个 OR 分支,旧放行面 ⊂ 新放行面),只是放行面被限定在
|
||||
// step===6 的同一份公开数据上。真正的收紧是这一轮:从「按 rpc 整包放行」改成
|
||||
// 「按字段路径 bottom.cards + step6 + result!==3 放行」。
|
||||
if (c.playround === 0 && pathIs(path, 'bottom.cards') && pj.step === 6 && pj.result !== 3) return true;
|
||||
|
||||
// 【显式豁免 · 结算算奖明细】aset.seatlist[i].cards(get_paiju_account 的 _cglist[i].cards)
|
||||
// 是 design §8.1 冲关 / §8.3 傍王的算奖依据牌,三种结算来源都带、且三家都收到。
|
||||
// · 正常打完:这些牌早已打出(playround>0),本来就公开,本豁免对它无影响。
|
||||
// · 投降(step2 直接结算)与中途解散:牌【一张没打】(playround===-1),于是他家的
|
||||
// 王/冲关牌会随算奖明细发给三家。这是既有行为——算奖要向三家自证,牌局此刻已终止、
|
||||
// 不再有后续出牌,公开这几张牌不影响任何未决的对局信息。
|
||||
// 写成一条【带路径限定的显式豁免】而不是让它落在扫不到的盲区里:
|
||||
// 若哪天有人把「未打出的手牌」塞到别的字段上,它不在这条路径下,照样报泄露。
|
||||
if (pathIs(path, 'aset.seatlist.cards') && pj.step === 6) { awardExempt++; return true; }
|
||||
|
||||
if (rpc === 'mingpai') return true; // §9 明牌:单独在下面按内容校验
|
||||
// §8.2 亮牌:可查牌模式下,庄家的固定主牌牌面对闲家公开(只在 maipai / 重连包里下发)
|
||||
if (!curNoCheck && (rpc === 'maipai' || rpc === 'deskinfo') && curLiangpai.has(cid)) return true;
|
||||
// §8.2 亮牌:可查牌模式下,庄家的固定主牌牌面对闲家公开(只在 maipai / 重连包的 liangpai 分组里下发)
|
||||
if (!curNoCheck && (rpc === 'maipai' || rpc === 'deskinfo') && pathIs(path, 'liangpai.cards') && curLiangpai.has(cid)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
function run(roomtype, call) {
|
||||
// ==========================================================================
|
||||
// ②层:统计量必须能由「公开账本」独立重算出来
|
||||
// ==========================================================================
|
||||
// 账本里只有【三家都看得见】的东西:
|
||||
// 庄家 / 庄家叫分(shangzhuang 广播)、主花色(xuanzhu 广播)、
|
||||
// 每一手已摊在桌上的牌(chupai1/2/3 的 data.cards,三家同收)、
|
||||
// 首家这一手的 张数/花色/牌型(chupai1 的 count/flower/cardtype —— 它就是那几张明牌的描述)。
|
||||
// 账本【没有】任何人的手牌。凡是能从账本算出来的数,就不可能夹带手牌信息。
|
||||
let LG = null;
|
||||
let statChecks = 0;
|
||||
const statBad = [];
|
||||
|
||||
const flowerOf = (fl, id) => { const c = A.id_to_code(fl, id); return c > 1000 ? fl : Math.floor(c / 100); };
|
||||
// 牌面分值:5→5、10/K→10,其余 0。只由牌 id 推出(牌已摊在桌上,牌面是公开的)
|
||||
const scoreOf = id => { const n = A.id_to_number(id); return n === 5 ? 5 : ((n === 10 || n === 13) ? 10 : 0); };
|
||||
|
||||
// 首家这一手的牌型值 = 这一手【最小一张】的牌编码。
|
||||
// 【独立推导,不复用 can_playcard】:那段逐张状态机无论走 单张/对子/拖拉机/甩牌 哪个分支,
|
||||
// cardvalue 最终都停在「最后处理到的那张牌」上;牌已按编码降序排列,最后一张即最小一张。
|
||||
// 换个式子算出同一个数,才谈得上"独立重算"。
|
||||
function leadValue(fl, cards) {
|
||||
const s = A.order_cards(fl, cards.concat());
|
||||
return A.id_to_code(fl, s[s.length - 1]);
|
||||
}
|
||||
|
||||
// 跟家这一手的牌型值:照 design 的规则从【摊在桌上的牌】重算(can_followcard 里这段同样
|
||||
// 只吃 _sortfollow / startflower / startcardtype,不碰手牌——本函数是它的独立复述)。
|
||||
function followValue(fl, cards, startflower, starttype) {
|
||||
const s = A.order_cards(fl, cards.concat());
|
||||
const top = A.id_to_code(fl, s[0]);
|
||||
const flTop = top > 1000 ? fl : Math.floor(top / 100);
|
||||
const sameLine = (flTop === startflower || flTop === fl);
|
||||
// 副7 / 副2 同级归一(四门的 7 一样大、四门的 2 一样大)
|
||||
const norm = c => (c > 7000 && c < 8000) ? 7000 : ((c > 2000 && c < 3000) ? 2000 : c);
|
||||
if (starttype === 101) { return sameLine ? norm(top) : 0; }
|
||||
if (starttype > 101 && starttype < 200) { return 0; } // 首家甩单张:跟牌一律压不过
|
||||
if (starttype === 201) {
|
||||
if (A.get_pairlist(fl, s).length !== 1) return 0;
|
||||
return sameLine ? norm(top) : 0;
|
||||
}
|
||||
if (starttype > 201 && starttype < 300) { return 0; } // 首家甩多对:同上
|
||||
if (starttype > 300 && starttype < 400) {
|
||||
if (A.get_tuolaji_list(fl, A.get_pairlist(fl, s), starttype).length !== 1) return 0;
|
||||
return sameLine ? top : 0;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
// 跟家这一手暴露出的「缺门 / 无对」:完全由摊在桌上的牌与首家牌型推出(design §9 的牌况表)。
|
||||
// 最大或最小的一张不是首家花色 → 这门没牌了(顺带也没对子)
|
||||
// 首家出的是对子/拖拉机、跟牌没凑够同样多的对子 → 这门没对子了
|
||||
function followFlags(fl, cards, startflower, starttype) {
|
||||
const s = A.order_cards(fl, cards.concat());
|
||||
let noflower = false, nopair = false;
|
||||
if (flowerOf(fl, s[0]) !== startflower) { noflower = true; nopair = true; }
|
||||
if (flowerOf(fl, s[s.length - 1]) !== startflower) { noflower = true; nopair = true; }
|
||||
if (starttype > 200 && A.get_pairlist(fl, s).length !== starttype % 100) { nopair = true; }
|
||||
return { noflower, nopair };
|
||||
}
|
||||
|
||||
// 首家甩牌的分量需求:把桌面上那一手直接分解即可(decompose_trump 只吃牌 id),非甩牌为 null
|
||||
function expectShuaiDemand() {
|
||||
if (LG.start < 0 || !LG.cards[LG.start]) return null;
|
||||
const comps = A.decompose_trump(LG.flower, LG.cards[LG.start].concat());
|
||||
if (comps.length <= 1) return null;
|
||||
const re = { tractors: [], pairs: 0, singles: 0 };
|
||||
for (const c of comps) {
|
||||
if (c.type === 'tractor') { re.tractors.push(c.len); }
|
||||
else if (c.type === 'pair') { re.pairs++; }
|
||||
else { re.singles++; }
|
||||
}
|
||||
return re;
|
||||
}
|
||||
|
||||
function ledgerNewRound(round, start) {
|
||||
LG.round = round; LG.start = start; LG.currseat = start;
|
||||
LG.startcount = -1; LG.startflower = -1; LG.starttype = -1;
|
||||
LG.maxseat = -1; LG.maxcard = -1;
|
||||
LG.cards = [null, null, null];
|
||||
}
|
||||
function ledgerApplyFollow(seat, cards) {
|
||||
const fl = LG.flower;
|
||||
const s = A.order_cards(fl, cards.concat());
|
||||
LG.cards[seat] = s;
|
||||
const v = followValue(fl, s, LG.startflower, LG.starttype);
|
||||
if (v > LG.maxcard) { LG.maxseat = seat; LG.maxcard = v; }
|
||||
const fg = followFlags(fl, s, LG.startflower, LG.starttype);
|
||||
const cell = LG.table[seat][LG.startflower - 1];
|
||||
if (fg.nopair) { cell[1] = 1; }
|
||||
if (fg.noflower) { cell[0] = 1; cell[1] = 1; }
|
||||
}
|
||||
function ledgerEndRound() {
|
||||
// 本墩被闲家收走 → 墩里的分归闲家(庄家收走则不计入闲家捡分)
|
||||
let g = 0;
|
||||
if (LG.maxseat !== LG.banker) {
|
||||
for (let i = 0; i < 3; i++) { for (const id of (LG.cards[i] || [])) { g += scoreOf(id); } }
|
||||
}
|
||||
LG.grade += g;
|
||||
LG.lastRoundGrade = g;
|
||||
ledgerNewRound(LG.round + 1, LG.maxseat);
|
||||
}
|
||||
// 当前抓分倍数:算法本身只吃 (叫分, 闲家捡分, 级距) 三个数——叫分与捡分都在账本里
|
||||
function ledgerCurmultiple() {
|
||||
if (!LG.call || LG.call <= 0) return 0;
|
||||
return A.get_upgrade(LG.call, LG.grade, A.get_qvalue(LG.call, LG.climb));
|
||||
}
|
||||
|
||||
// 把一个【逻辑包】喂进公开账本(同一个包会逐座位发 3 份,只喂第一份)
|
||||
function ledgerFeed(rpc, d) {
|
||||
if (!LG) return;
|
||||
switch (rpc) {
|
||||
case 'shangzhuang': LG.banker = d.banker; LG.call = d.grade; break;
|
||||
case 'xuanzhu': LG.flower = d.flower; break;
|
||||
case 'maipai': ledgerNewRound(1, d.seat); break;
|
||||
case 'chupai1': {
|
||||
const s = A.order_cards(LG.flower, d.cards.concat());
|
||||
LG.startcount = s.length;
|
||||
LG.startflower = flowerOf(LG.flower, s[0]);
|
||||
LG.starttype = d.cardtype;
|
||||
LG.maxseat = d.seat;
|
||||
LG.maxcard = leadValue(LG.flower, s);
|
||||
LG.cards[d.seat] = s;
|
||||
LG.currseat = (LG.start + 1) % 3;
|
||||
// 首家自报的 张数/花色 必须与桌面上那几张牌一致(同一份公开数据两处口径)
|
||||
statChecks++;
|
||||
if (d.count !== s.length || d.flower !== LG.startflower) {
|
||||
statBad.push(`chupai1 自报 count/flower=(${d.count},${d.flower}) 与桌面牌重算=(${s.length},${LG.startflower}) 不一致`);
|
||||
}
|
||||
// 账本自检:首出者必须就是本轮首家(账本若跟丢了轮次,下面所有比对都不再可信)
|
||||
statChecks++;
|
||||
if (d.seat !== LG.start) { statBad.push(`chupai1 首出者=${d.seat} 公开账本认为本轮首家=${LG.start}`); }
|
||||
break;
|
||||
}
|
||||
case 'chupai2': ledgerApplyFollow(d.seat, d.cards); LG.currseat = (LG.start + 2) % 3; break;
|
||||
case 'chupai3': ledgerApplyFollow(d.seat, d.cards); ledgerEndRound(); break;
|
||||
case 'jiesuan':
|
||||
// 收尾墩不发 chupai3,整包换成 jiesuan、把这一手放进 data.chupai(协议 §13/§14)
|
||||
if (d.chupai && d.chupai.cards) { ledgerApplyFollow(d.chupai.seat, d.chupai.cards); ledgerEndRound(); }
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// playproc 的字段集是【契约】:多一个键就是多一份下发信息,必须显式暴露出来
|
||||
const PLAYPROC_KEYS = ['cards', 'currseat', 'maxcard', 'maxseat', 'round', 'shuai_demand', 'start', 'startcount', 'startflower', 'starttype'];
|
||||
|
||||
function checkPlayproc(tag, pp) {
|
||||
statChecks++;
|
||||
const keys = Object.keys(pp).sort();
|
||||
if (JS(keys) !== JS(PLAYPROC_KEYS)) { statBad.push(`${tag} playproc 字段集变了:${JS(keys)}`); }
|
||||
const exp = {
|
||||
round: LG.round, start: LG.start, currseat: LG.currseat,
|
||||
startcount: LG.startcount, startflower: LG.startflower, starttype: LG.starttype,
|
||||
maxseat: LG.maxseat, maxcard: LG.maxcard
|
||||
};
|
||||
for (const k in exp) {
|
||||
statChecks++;
|
||||
if (pp[k] !== exp[k]) { statBad.push(`${tag} playproc.${k}=${pp[k]} 公开账本重算=${exp[k]}`); }
|
||||
}
|
||||
for (let s = 0; s < 3; s++) {
|
||||
statChecks++;
|
||||
const got = (pp.cards[s] === undefined || pp.cards[s] === null) ? null : pp.cards[s];
|
||||
if (JS(got) !== JS(LG.cards[s])) { statBad.push(`${tag} playproc.cards[${s}]=${JS(got)} 公开账本重算=${JS(LG.cards[s])}`); }
|
||||
}
|
||||
statChecks++;
|
||||
if (JS(pp.shuai_demand) !== JS(expectShuaiDemand())) {
|
||||
statBad.push(`${tag} playproc.shuai_demand=${JS(pp.shuai_demand)} 桌面牌重算=${JS(expectShuaiDemand())}`);
|
||||
}
|
||||
}
|
||||
|
||||
function checkSeatlist(tag, sl) {
|
||||
statChecks++;
|
||||
if (!Array.isArray(sl) || sl.length !== 3) { statBad.push(`${tag} seatlist 外层不是定长 3:${JS(sl)}`); return; }
|
||||
// 报副格(第 5 项)是 design §9 的【有意公开】:一旦有人把主牌打空(报无主),
|
||||
// 系统就向全体三人公示三家各自的余主数量与余主对数。反过来说——【没人报无主之前
|
||||
// 一格都不许透露】,必须还是初值 [-1,-1]。这条门控若被放宽,等于提前泄露三家主牌结构。
|
||||
const anyEmpty = sl.some(one => Array.isArray(one[4]) && one[4][0] === 0);
|
||||
for (let s = 0; s < 3; s++) {
|
||||
const one = sl[s];
|
||||
statChecks++;
|
||||
if (!Array.isArray(one) || one.length !== 5) {
|
||||
// 约定恒 5 项:花色 1~4 的 [缺门, 无对] + 报副 [余主数, 余主对数]。
|
||||
// 多出来的一项必定是一份【新的统计】——而新统计几乎注定是从真实手牌算出来的,
|
||||
// 公开账本重算不出来。这里直接判死,不给它蒙混过关的机会。
|
||||
statBad.push(`${tag} seat${s} seatlist 项数不是 5(新增统计?):${JS(one)}`);
|
||||
continue;
|
||||
}
|
||||
for (let f = 0; f < 5; f++) {
|
||||
statChecks++;
|
||||
const cell = one[f];
|
||||
if (!Array.isArray(cell) || cell.length !== 2 || typeof cell[0] !== 'number' || typeof cell[1] !== 'number') {
|
||||
statBad.push(`${tag} seat${s}[${f}] 形状不是二元数值组:${JS(cell)}`);
|
||||
}
|
||||
}
|
||||
// 前 4 格:只能是公开账本推得出的缺门/无对
|
||||
for (let f = 0; f < 4; f++) {
|
||||
const exp = [LG.table[s][f][0], LG.table[s][f][1]];
|
||||
// 主花色那一格另有一条来源:报无主公示之后,「余主数=0」等价于主花色缺门+无对、
|
||||
// 「余主对数=0」等价于主花色无对(class.paiju.js do_playcard 的报副刷新)。
|
||||
// 它推导自【本包自己给出的】报副格,仍属公开信息,不是额外泄露。
|
||||
if (LG.flower > 0 && f === LG.flower - 1 && Array.isArray(one[4])) {
|
||||
if (one[4][0] === 0) { exp[0] = 1; exp[1] = 1; }
|
||||
else if (one[4][0] > 0 && one[4][1] === 0) { exp[1] = 1; }
|
||||
}
|
||||
statChecks++;
|
||||
if (one[f][0] !== exp[0] || one[f][1] !== exp[1]) {
|
||||
statBad.push(`${tag} seatlist[${s}][${f}]=${JS(one[f])} 公开账本重算=${JS(exp)}`);
|
||||
}
|
||||
}
|
||||
statChecks++;
|
||||
if (!anyEmpty) {
|
||||
if (JS(one[4]) !== JS([-1, -1])) { statBad.push(`${tag} 无人报无主却下发了 seat${s} 的余主统计:${JS(one[4])}`); }
|
||||
} else if (one[4][0] < 0 || one[4][1] < 0) {
|
||||
statBad.push(`${tag} 已报无主但 seat${s} 的余主统计仍是初值:${JS(one[4])}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 对一个「分组对象」(包的 data,或 deskinfo 的 ChooseMain / BuryCards / PushCards)做统计量审计
|
||||
function statAuditGroup(tag, g, opts) {
|
||||
if (!g || typeof g !== 'object' || !LG) return;
|
||||
opts = opts || {};
|
||||
if (g.hasOwnProperty('curmultiple')) {
|
||||
statChecks++;
|
||||
const exp = ledgerCurmultiple();
|
||||
if (g.curmultiple !== exp) { statBad.push(`${tag} curmultiple=${g.curmultiple} 公开账本重算=${exp}`); }
|
||||
}
|
||||
if (opts.cumulativeGrade && g.hasOwnProperty('grade')) {
|
||||
statChecks++;
|
||||
if (g.grade !== LG.grade) { statBad.push(`${tag} grade=${g.grade} 公开账本重算=${LG.grade}`); }
|
||||
}
|
||||
// 分组这一层的 seatlist 恒指 design §9 的【牌况表】(maipai / chupai1/2/3 / PushCards);
|
||||
// 结算包里的 aset.seatlist 是另一回事,嵌在 aset 下、不会走到这里。
|
||||
// 【不加形状守卫】形状变了就该由 checkSeatlist 判红,而不是被守卫悄悄跳过
|
||||
if (g.hasOwnProperty('seatlist')) { checkSeatlist(tag, g.seatlist); }
|
||||
if (g.playproc) { checkPlayproc(tag, g.playproc); }
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
|
||||
function run(roomtype, call, opts) {
|
||||
opts = opts || {};
|
||||
const sent = [];
|
||||
const o_room = {
|
||||
roomtype, asetcount: 6, roomcode: 1, createtime: 'T', makewartime: 'T',
|
||||
@@ -71,49 +364,100 @@ function run(roomtype, call) {
|
||||
};
|
||||
curNoCheck = (String(roomtype).charAt(4) === '1'); // roomtype 位4:1=不查牌
|
||||
curLiangpai = new Set();
|
||||
LG = {
|
||||
climb: CFG.parse(roomtype).climb, banker: -1, call: -1, flower: -1,
|
||||
round: 0, start: -1, currseat: -1, startcount: -1, startflower: -1, starttype: -1,
|
||||
maxseat: -1, maxcard: -1, cards: [null, null, null], grade: 0, lastRoundGrade: 0,
|
||||
table: [0, 1, 2].map(() => [[0, 0], [0, 0], [0, 0], [0, 0]])
|
||||
};
|
||||
const desk = D.new(o_room); o_room.o_desk = desk;
|
||||
global.youle_erqiwang.app = { SendPack: m => sent.push({ to: m.fromid, m: JSON.parse(JSON.stringify(m)) }) };
|
||||
global.youle_erqiwang.import = { check_player: () => o_room, deduct_roomcard: () => { }, save_grade: () => { } };
|
||||
mod.import = global.youle_erqiwang.import; mod.app = global.youle_erqiwang.app;
|
||||
D.do_new_paiju(desk, 0);
|
||||
const pj = desk.method.curr_paiju();
|
||||
ctxRef = { pj, sent };
|
||||
|
||||
const audit = () => {
|
||||
while (sent.length) {
|
||||
const { to, m } = sent.shift();
|
||||
pkts++;
|
||||
// 同一个逻辑包逐座位发 3 份(i=0,1,2),只用第一份推进公开账本;
|
||||
// 但【每一份都要审计】,这样三家收到的统计量若有差异也会被抓住
|
||||
if (to === 0 || to === 'ALL') { ledgerFeed(m.rpc, m.data); }
|
||||
const targets = to === 'ALL' ? [0, 1, 2] : [to];
|
||||
statAuditGroup(`rpc=${m.rpc}→seat${to}`, m.data);
|
||||
// chupai3 的 data.grade 是【这一墩】闲家收到的分(>0 才带),与账本的墩内分值同源
|
||||
if (m.rpc === 'chupai3') {
|
||||
statChecks++;
|
||||
const got = m.data.hasOwnProperty('grade') ? m.data.grade : 0;
|
||||
if (got !== LG.lastRoundGrade) { statBad.push(`chupai3 本墩 grade=${got} 公开账本重算=${LG.lastRoundGrade}`); }
|
||||
}
|
||||
// 结算包的闲家总捡分 = 账本累计捡分 + 抠底(扣底只在最后一墩产生)
|
||||
if (m.rpc === 'jiesuan' && m.data.aset) {
|
||||
statChecks++;
|
||||
const bg = (m.data.bottom && m.data.bottom.grade2) ? m.data.bottom.grade2 : 0;
|
||||
if (m.data.aset.grade !== LG.grade + bg) {
|
||||
statBad.push(`jiesuan aset.grade=${m.data.aset.grade} 公开账本重算=${LG.grade}+抠底${bg}`);
|
||||
}
|
||||
}
|
||||
const out = new Set();
|
||||
collectCards(m.data, null, out);
|
||||
for (const seat of targets) for (const cid of out) {
|
||||
collectCards(m.data, '', null, out);
|
||||
for (const seat of targets) for (const e of out) {
|
||||
scanned++;
|
||||
if (!mayKnow(pj, seat, cid, m.rpc)) {
|
||||
leaks.push(`rpc=${m.rpc} → seat${seat} 泄露牌 ${cid}(dealowner=${pj.cards[cid].dealowner} playround=${pj.cards[cid].playround})`);
|
||||
const cid = cidOf(e);
|
||||
if (!mayKnow(pj, seat, cid, m.rpc, pathOf(e))) {
|
||||
leaks.push(`rpc=${m.rpc} @${pathOf(e)} → seat${seat} 泄露牌 ${cid}(dealowner=${pj.cards[cid].dealowner} playround=${pj.cards[cid].playround})`);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
// 重连快照的审计(每个座位各取一次)。阶段取【实际的】pj.step:最后一手打完后 step 已转 6,
|
||||
// 这里拿到的是结算快照,写死 "step5" 会让报错信息指错阶段(曾据此误判过)
|
||||
const auditDeskinfo = () => {
|
||||
for (let s = 0; s < 3; s++) {
|
||||
const di = EX.new().get_deskinfo(o_room, s);
|
||||
pkts++;
|
||||
statAuditGroup(`deskinfo(step${pj.step})→seat${s}.ChooseMain`, di.ChooseMain);
|
||||
statAuditGroup(`deskinfo(step${pj.step})→seat${s}.BuryCards`, di.BuryCards);
|
||||
statAuditGroup(`deskinfo(step${pj.step})→seat${s}.PushCards`, di.PushCards, { cumulativeGrade: true });
|
||||
const out = new Set(); collectCards(di, '', null, out);
|
||||
for (const e of out) {
|
||||
scanned++;
|
||||
const cid = cidOf(e);
|
||||
if (!mayKnow(pj, s, cid, 'deskinfo', pathOf(e))) {
|
||||
leaks.push(`deskinfo(step${pj.step}) @${pathOf(e)} → seat${s} 泄露牌 ${cid}(dealowner=${pj.cards[cid].dealowner} playround=${pj.cards[cid].playround})`);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
audit();
|
||||
mod.jiaofen(pk(pj.method.get_callgrade_seat(), { call })); audit();
|
||||
if (pj.step === 1) { mod.jiaofen(pk(pj.method.get_callgrade_seat(), { call: 0 })); audit(); }
|
||||
if (pj.step === 1) { mod.jiaofen(pk(pj.method.get_callgrade_seat(), { call: 0 })); audit(); }
|
||||
const b = pj.banker;
|
||||
|
||||
// ---- 投降结算路径(design §4:70 分坐庄可在选主阶段直接投降)----
|
||||
// 这条路径此前从未被审计过:9 局全部「正常打完」,mod.touxiang 一次都没走到。
|
||||
if (opts.surrender) {
|
||||
if (pj.call !== 70) { leaks.push(`投降用例前置不成立:庄家叫分=${pj.call},非 70 分坐庄`); return pj; }
|
||||
mod.touxiang(pk(b)); audit();
|
||||
if (pj.step !== 6 || pj.result !== 2) { leaks.push(`投降用例未真正结算:step=${pj.step} result=${pj.result}`); return pj; }
|
||||
auditDeskinfo();
|
||||
surrenderRuns++;
|
||||
return pj;
|
||||
}
|
||||
|
||||
mod.xuanzhu(pk(b, { flower: 1 + rnd(4) })); audit();
|
||||
// 亮牌快照在埋牌后才固定;先算出期望集合,再审计这一包(maipai 正是亮牌的下发时机)
|
||||
mod.maipai(pk(b, { cards: P.get_seat_cards(pj, b).slice(-8) }));
|
||||
curLiangpai = bankerLiangpaiCards(pj);
|
||||
if (curLiangpai.size > 0) { if (curNoCheck) { liangpaiRoundsNoCheck++; } else { liangpaiRounds++; } }
|
||||
audit();
|
||||
// 重连快照也要审计(每个座位各取一次)
|
||||
for (let s = 0; s < 3; s++) {
|
||||
const di = EX.new().get_deskinfo(o_room, s);
|
||||
const out = new Set(); collectCards(di, null, out);
|
||||
pkts++;
|
||||
for (const cid of out) { scanned++; if (!mayKnow(pj, s, cid, 'deskinfo')) leaks.push(`deskinfo(step${pj.step}) → seat${s} 泄露牌 ${cid}(dealowner=${pj.cards[cid].dealowner} playround=${pj.cards[cid].playround})`); }
|
||||
}
|
||||
auditDeskinfo();
|
||||
let guard = 0;
|
||||
while (pj.step === 5 && ++guard < 400) {
|
||||
if (opts.disbandAfter && guard > opts.disbandAfter) { break; }
|
||||
const seat = pj.playproc.currseat, hand = P.get_seat_cards(pj, seat);
|
||||
let pick = null;
|
||||
if (seat === pj.playproc.start) pick = [hand[hand.length - 1]];
|
||||
@@ -121,11 +465,7 @@ function run(roomtype, call) {
|
||||
if (!pick) break;
|
||||
mod.chupai(pk(seat, { cards: pick })); audit();
|
||||
if (guard % 7 === 0) { // 中途穿插重连与明牌
|
||||
for (let s = 0; s < 3; s++) {
|
||||
const di = EX.new().get_deskinfo(o_room, s);
|
||||
const out = new Set(); collectCards(di, null, out); pkts++;
|
||||
for (const cid of out) { scanned++; if (!mayKnow(pj, s, cid, 'deskinfo')) leaks.push(`deskinfo(step5) → seat${s} 泄露牌 ${cid}(dealowner=${pj.cards[cid].dealowner} playround=${pj.cards[cid].playround})`); }
|
||||
}
|
||||
auditDeskinfo();
|
||||
if (pj.method.have_baofu()) {
|
||||
sent.length = 0;
|
||||
mod.mingpai(pk(0));
|
||||
@@ -148,6 +488,19 @@ function run(roomtype, call) {
|
||||
}
|
||||
}
|
||||
audit();
|
||||
|
||||
// ---- 中途解散结算路径(get_disbandRoom)----
|
||||
// 同样此前零覆盖。平台在解散时取这份 deskfree 下发给三家(前端 Game_Modify.Free),
|
||||
// 故按「广播给三家」审计;随后的 step6 重连快照(result===3)也一并过一遍。
|
||||
if (opts.disbandAfter) {
|
||||
const msg = EX.new().get_disbandRoom(o_room);
|
||||
if (!msg) { leaks.push('解散用例前置不成立:get_disbandRoom 返回 null'); return pj; }
|
||||
sent.push({ to: 'ALL', m: JSON.parse(JSON.stringify(msg)) });
|
||||
audit();
|
||||
if (pj.step !== 6 || pj.result !== 3) { leaks.push(`解散用例未真正结算:step=${pj.step} result=${pj.result}`); return pj; }
|
||||
auditDeskinfo();
|
||||
disbandRuns++;
|
||||
}
|
||||
return pj;
|
||||
}
|
||||
|
||||
@@ -160,13 +513,28 @@ for (const call of [65, 5]) run('00000', call);
|
||||
seed = 0x777001;
|
||||
run('00000', 65);
|
||||
run('00001', 65);
|
||||
// 投降与解散:两条结算路径此前从未进过本门禁(9 局全是「正常打完」)
|
||||
seed = 0x24680;
|
||||
run('00000', 70, { surrender: true });
|
||||
run('00001', 70, { surrender: true });
|
||||
run('00000', 65, { disbandAfter: 11 });
|
||||
run('00001', 60, { disbandAfter: 5 });
|
||||
|
||||
t.eq('下发面无越权泄露', [...new Set(leaks)].slice(0, 5), []);
|
||||
t.eq('统计量均可由公开账本重算', [...new Set(statBad)].slice(0, 5), []);
|
||||
// 覆盖下限:若收集器失效(扫不到牌),上面的断言会假绿,这里钉住实际扫描量
|
||||
t.eq('审计覆盖 下发面 ≥ 1000 个', pkts >= 1000, true);
|
||||
t.eq('审计覆盖 可见性判定 ≥ 10000 次', scanned >= 10000, true);
|
||||
t.eq('审计覆盖 统计量重算 ≥ 10000 次', statChecks >= 10000, true);
|
||||
// 覆盖下限(续):随机手牌下庄家未必达到亮牌门槛。若一局都没触发,
|
||||
// 上面「无越权泄露」对亮牌这条路径就是【碰巧没走到】而不是【验证过安全】——必须钉住。
|
||||
t.eq('审计覆盖 亮牌路径(可查牌) 至少 1 局', liangpaiRounds >= 1, true);
|
||||
t.eq('审计覆盖 亮牌路径(不查牌) 至少 1 局', liangpaiRoundsNoCheck >= 1, true);
|
||||
console.log(` [统计] 扫描 ${pkts} 个下发面 / ${scanned} 次「座位×牌」可见性判定 / 亮牌达标 可查牌${liangpaiRounds}局·不查牌${liangpaiRoundsNoCheck}局`);
|
||||
t.eq('审计覆盖 投降结算路径 至少 1 局', surrenderRuns >= 1, true);
|
||||
t.eq('审计覆盖 中途解散结算路径 至少 1 局', disbandRuns >= 1, true);
|
||||
// 「算奖明细」那条显式豁免必须真的被用到,否则它是一条无人走过的死规则,
|
||||
// 早晚会在无人察觉时开始放行别的东西
|
||||
t.eq('审计覆盖 算奖明细豁免确实被走到', awardExempt >= 1, true);
|
||||
console.log(` [统计] 扫描 ${pkts} 个下发面 / ${scanned} 次「座位×牌」可见性判定 / ${statChecks} 次统计量重算比对`);
|
||||
console.log(` [统计] 亮牌达标 可查牌${liangpaiRounds}局·不查牌${liangpaiRoundsNoCheck}局 / 投降${surrenderRuns}局 / 解散${disbandRuns}局 / 算奖明细豁免命中 ${awardExempt} 次`);
|
||||
process.exit(t.done('leak') ? 0 : 1);
|
||||
|
||||
@@ -128,7 +128,7 @@ function mkBottomPaiju(maxseat, winCards, bottomScoreCards, flower) {
|
||||
for (let i = 0; i < 108; i++) cards[i] = { id: i, playround: -1, score: 0, dealowner: 1, playowner: -1 };
|
||||
for (const [cid, sc] of bottomScoreCards) { cards[cid].playround = 0; cards[cid].score = sc; }
|
||||
const pc = [[], [], []]; pc[maxseat] = winCards;
|
||||
const o = { banker: 0, flower, cards, playproc: { maxseat, cards: pc } };
|
||||
const o = { banker: 0, flower, cards, playproc: { maxseat, cards: pc }, playhistory: [] };
|
||||
o.method = { get_burycard: () => P.get_burycard(o), get_grade_incard: c => P.get_grade_incard(o, c) };
|
||||
return o;
|
||||
}
|
||||
@@ -154,7 +154,7 @@ function mkFirstPlay(flower, dealMap) {
|
||||
for (let i = 0; i < 108; i++) cards[i] = { id: i, dealowner: 99, playround: -1, playowner: -1, score: 0, flower: A.id_to_flower(i), number: A.id_to_number(i) };
|
||||
for (const [cid, owner] of dealMap) cards[cid].dealowner = owner;
|
||||
const sl = () => [[0, 0], [0, 0], [0, 0], [0, 0], [-1, -1]];
|
||||
const o = { step: 5, banker: 0, flower, cards, seatlist: [sl(), sl(), sl()], playproc: {} };
|
||||
const o = { step: 5, banker: 0, flower, cards, seatlist: [sl(), sl(), sl()], playproc: {}, playhistory: [] };
|
||||
o.method = { have_baofu: () => o.seatlist.some(s => s[4][0] == 0) };
|
||||
P.new_playround(o, 1, 0);
|
||||
return o;
|
||||
@@ -193,7 +193,7 @@ function mkTrick(deal, scores) {
|
||||
for (const [cid, seat] of deal) cards[cid].dealowner = seat + 1;
|
||||
for (const [cid, s] of scores) cards[cid].score = s;
|
||||
const sl = () => [[0, 0], [0, 0], [0, 0], [0, 0], [-1, -1]];
|
||||
const o = { step: 5, banker: 0, flower: 1, cards, seatlist: [sl(), sl(), sl()], playproc: {} };
|
||||
const o = { step: 5, banker: 0, flower: 1, cards, seatlist: [sl(), sl(), sl()], playproc: {}, playhistory: [] };
|
||||
o.method = { have_baofu: () => o.seatlist.some(s => s[4][0] == 0) };
|
||||
P.new_playround(o, 1, 0); // 第 1 轮由庄家(0)首出
|
||||
return o;
|
||||
|
||||
@@ -165,13 +165,173 @@ const cmZhuang = E.get_deskinfo(e.o_room, 0);
|
||||
t.eq('重连 ChooseMain/BuryCards 侧仍用bottomcards命名未被误改',
|
||||
cmZhuang.PushCards.burycards !== undefined && cmZhuang.PushCards.bottomcards === undefined, true);
|
||||
|
||||
// ===================== 下发面一致性:增量路径(实时推送) 必须与 全量路径(重连 deskinfo) 给出同一份数据 =====================
|
||||
// 背景:前端一致性测试(同一局真包,逐包增量回放 vs 一次性 deskinfo 重建)暴露出 4 处服务端下发面缺陷。
|
||||
// 这些都不是"算错了",而是"同一份数据两条路径给的不一样 / 有一条根本没给",前端只能自己补——
|
||||
// 而自己补就等于把对局推进逻辑搬到前端(前端红线「数据驱动、前端无对局状态机」)。以下逐条钉死。
|
||||
const JS = x => JSON.stringify(x);
|
||||
const AR = require('../class.arith.js'); // 本文件下方另有同模块的 A,此处在其声明之前,故另起名
|
||||
|
||||
// ---- ① bottomcards:上庄推送 / ChooseMain(step2) / BuryCards(step3) 三处必须【同序】----
|
||||
// 曾经的缺陷:get_bottomcards 每次按"当时的 paiju.flower"重排,而底牌是在【选主之前】翻给庄家看的,
|
||||
// 于是 step2(flower=-1) 与 step3(flower 已定) 给出两种顺序——主花色的那张牌被顶到队首。
|
||||
// 现按发牌时冻结的快照下发。反面守卫:主牌花色刻意选成底牌里真实存在的花色,一旦退回"按当时 flower 排"必红。
|
||||
function driveBottom(flowerPick) {
|
||||
const pj = P.new({ paiju_list: [] }, 0);
|
||||
const c = setup("00000", pj);
|
||||
mod.jiaofen(pack(0, { call: 65 })); mod.jiaofen(pack(1, { call: 0 })); mod.jiaofen(pack(2, { call: 0 }));
|
||||
const sz = c.sent.filter(m => m.rpc === 'shangzhuang').find(m => m.data.cards !== undefined).data; // 庄家包
|
||||
const step2 = E.get_deskinfo(c.o_room, 0).ChooseMain;
|
||||
mod.xuanzhu(pack(0, { flower: flowerPick(pj) }));
|
||||
const step3 = E.get_deskinfo(c.o_room, 0).BuryCards;
|
||||
return { pj, c, sz, step2, step3 };
|
||||
}
|
||||
// 主牌花色必须挑一个【会真的改变底牌排序】的:否则本组用例是空转的——
|
||||
// 退回"按当时 flower 重排"也照样绿(实测:随手挑底牌里第一张的花色就是空转,它本来就排在队首)。
|
||||
let btReorders = false;
|
||||
const bt = driveBottom(pj => {
|
||||
const snap = P.get_bottomcards(pj);
|
||||
for (let f = 1; f <= 4; f++) {
|
||||
if (JS(AR.order_cards(f, snap.concat())) !== JS(snap)) { btReorders = true; return f; }
|
||||
}
|
||||
return 1;
|
||||
});
|
||||
// 先钉住前提:所选花色确实会让"按主牌花色排"与"冻结快照"不同序,下面三条才是有效守卫
|
||||
t.eq('底牌 用例前提:所选主牌花色会改变排序(否则本组用例空转)', btReorders, true);
|
||||
t.eq('底牌 上庄推送 8 张', bt.sz.bottomcards.length, 8);
|
||||
t.eq('底牌 上庄推送 == 重连 ChooseMain(step2) 同序', JS(bt.step2.bottomcards), JS(bt.sz.bottomcards));
|
||||
t.eq('底牌 上庄推送 == 重连 BuryCards(step3) 同序(选主后不得重排)', JS(bt.step3.bottomcards), JS(bt.sz.bottomcards));
|
||||
// 快照必须是【副本】:order_cards 是原地排序,调用方就地重排不得污染冻结的快照
|
||||
const bcCopy = P.get_bottomcards(bt.pj); bcCopy.reverse();
|
||||
t.eq('底牌 get_bottomcards 返回副本(调用方重排不污染快照)', JS(P.get_bottomcards(bt.pj)), JS(bt.sz.bottomcards));
|
||||
|
||||
// ---- ② curmultiple:ChooseMain(step2) / BuryCards(step3) 必须与 shangzhuang 同源同值 ----
|
||||
// 此刻一张牌未出、捡分恒为 0 → 必为 +3(大光)。曾经 deskinfo 不带此字段,重连后前端角标掉回 0。
|
||||
t.eq('抓分倍数 上庄推送 curmultiple=3(捡分恒0→大光)', bt.sz.curmultiple, 3);
|
||||
t.eq('抓分倍数 重连 ChooseMain.curmultiple 与上庄同值', bt.step2.curmultiple, bt.sz.curmultiple);
|
||||
t.eq('抓分倍数 重连 BuryCards.curmultiple 与上庄同值', bt.step3.curmultiple, bt.sz.curmultiple);
|
||||
|
||||
// ---- ③ maipai.seatlist:与 PushCards.seatlist 同表同门控 ----
|
||||
// 曾经的缺陷:maipai 不带 seatlist,「埋牌完成 → 庄家首出」这段窗口增量路径为空、重连路径却有表。
|
||||
const mpZhuang3 = mpAll.find(m => m.data.cards !== undefined);
|
||||
const mpXian3 = mpAll.find(m => m.data.cards === undefined);
|
||||
t.eq('埋牌 可查牌 maipai 带 seatlist(庄闲同表)',
|
||||
JS(mpZhuang3.data.seatlist), JS(E.get_deskinfo(e.o_room, 0).PushCards.seatlist));
|
||||
t.eq('埋牌 可查牌 maipai 闲家同一张表(seatlist 三家公开、不裁剪)',
|
||||
JS(mpXian3.data.seatlist), JS(mpZhuang3.data.seatlist));
|
||||
t.eq('埋牌 seatlist 埋牌完成时为初始化空表', JS(mpZhuang3.data.seatlist),
|
||||
JS([0, 1, 2].map(() => [[0, 0], [0, 0], [0, 0], [0, 0], [-1, -1]])));
|
||||
// 反面:不查牌模式一律不下发(与 chupai / PushCards 同一道门控,design §9)
|
||||
t.eq('埋牌 不查牌 maipai 无 seatlist',
|
||||
en.sent.filter(m => m.rpc === 'maipai').every(m => m.data.seatlist === undefined), true);
|
||||
|
||||
// ---- ③b maipai.playproc:与 PushCards.playproc 同一个快照函数、同结构,且是深拷贝 ----
|
||||
// 曾经的缺陷:mod.js 组 maipai 包时没有放 playproc(do_burycard 内部已 new_playround 就地初始化好
|
||||
// round-1 的进行态),「埋牌完成 → 庄家首出」这段窗口增量路径的 table.playproc 是 null,
|
||||
// 重连路径却已有初值——与 seatlist 是同一个窗口、同一条理由,被漏在了同一处。
|
||||
t.eq('埋牌 maipai 带 playproc(庄闲同值)',
|
||||
JS(mpZhuang3.data.playproc), JS(E.get_deskinfo(e.o_room, 0).PushCards.playproc));
|
||||
t.eq('埋牌 maipai 闲家同一份 playproc(三家公开、不裁剪)',
|
||||
JS(mpXian3.data.playproc), JS(mpZhuang3.data.playproc));
|
||||
t.eq('埋牌 playproc 埋牌完成时为 round-1 初值(庄家首出、桌面全空)', mpZhuang3.data.playproc, {
|
||||
round: 1, start: 0, currseat: 0, startcount: -1, startflower: -1, starttype: -1,
|
||||
maxseat: -1, maxcard: -1, cards: [null, null, null], shuai_demand: null
|
||||
});
|
||||
// 反面:playproc 不受查牌门控——不像 seatlist,它只是桌面公开信息,不查牌模式下也恒有
|
||||
t.eq('埋牌 不查牌 maipai 仍带 playproc',
|
||||
en.sent.filter(m => m.rpc === 'maipai').every(m => m.data.playproc !== undefined), true);
|
||||
// 深拷贝守卫:playproc 是全程复用的活对象,new_playround 就地重置同一个对象。
|
||||
// 若下发挂的是活引用,埋牌后紧跟着打一手牌,已发出的 maipai.playproc 会被回改成下一状态的样子。
|
||||
const dcPj = P.new({ paiju_list: [] }, 0);
|
||||
const dcC = setup("00000", dcPj);
|
||||
mod.jiaofen(pack(0, { call: 65 })); mod.jiaofen(pack(1, { call: 0 })); mod.jiaofen(pack(2, { call: 0 }));
|
||||
mod.xuanzhu(pack(0, { flower: 1 }));
|
||||
const dcInner = mod.app.SendPack;
|
||||
let dcLive = null;
|
||||
mod.app.SendPack = m => { if (m.rpc === 'maipai' && m.data.cards !== undefined) dcLive = m.data.playproc; dcInner(m); };
|
||||
mod.maipai(pack(0, { cards: P.get_seat_cards(dcPj, 0).slice(0, 8) }));
|
||||
mod.app.SendPack = dcInner;
|
||||
const dcSnapAtSend = JS(dcLive);
|
||||
const dcHand = P.get_seat_cards(dcPj, dcPj.playproc.currseat);
|
||||
mod.chupai(pack(dcPj.playproc.currseat, { cards: [dcHand[dcHand.length - 1]] })); // 推进出一手,制造"若为活引用会被回改"的条件
|
||||
t.eq('埋牌 playproc 下发的是深拷贝快照(已发出的包不被后续出牌回改)', JS(dcLive), dcSnapAtSend);
|
||||
|
||||
// ---- ④ burycards:maipai 必须发服务端权威快照,不得回显客户端提交的 cards 原序 ----
|
||||
// 曾经的缺陷:msg.data.burycards = cards(请求包原样回显),而 PushCards.burycards 走 get_burycard()
|
||||
// 按主牌花色排序 —— 同一副埋牌底牌两条路径两种顺序(SSOT)。
|
||||
const bu = (() => {
|
||||
const pj = P.new({ paiju_list: [] }, 0);
|
||||
const c = setup("00000", pj);
|
||||
mod.jiaofen(pack(0, { call: 65 })); mod.jiaofen(pack(1, { call: 0 })); mod.jiaofen(pack(2, { call: 0 }));
|
||||
mod.xuanzhu(pack(0, { flower: 1 }));
|
||||
// 刻意打乱提交顺序:手牌是从大到小的,取 8 张后【反转】成从小到大提交
|
||||
const raw = P.get_seat_cards(pj, 0).slice(0, 8).reverse();
|
||||
mod.maipai(pack(0, { cards: raw }));
|
||||
const mp = c.sent.filter(m => m.rpc === 'maipai').find(m => m.data.burycards !== undefined);
|
||||
return { pj, c, raw, mp };
|
||||
})();
|
||||
t.eq('埋牌底牌 maipai == 重连 PushCards 同序(SSOT)',
|
||||
JS(bu.mp.data.burycards), JS(E.get_deskinfo(bu.c.o_room, 0).PushCards.burycards));
|
||||
t.eq('埋牌底牌 maipai 不回显客户端提交的原序', JS(bu.mp.data.burycards) === JS(bu.raw), false);
|
||||
t.eq('埋牌底牌 maipai 与提交的是同一批牌(只是顺序不同)',
|
||||
JS(bu.mp.data.burycards.concat().sort((x, y) => x - y)), JS(bu.raw.concat().sort((x, y) => x - y)));
|
||||
|
||||
// ---- ⑤ chupai1/2/3.playproc:结构与取值必须与同一时刻的 PushCards.playproc 完全一致 ----
|
||||
// 没有它,前端在非重连时拿不到「本轮谁先手/谁最大/三家各出了什么/第几轮」,只能自己累积推导。
|
||||
// 用真实牌局打满 28 轮,每出一手就拿三家的下发包与三家的重连快照逐一比对。
|
||||
const pp = (() => {
|
||||
const pj = P.new({ paiju_list: [] }, 0);
|
||||
const c = setup("00000", pj);
|
||||
mod.jiaofen(pack(0, { call: 65 })); mod.jiaofen(pack(1, { call: 0 })); mod.jiaofen(pack(2, { call: 0 }));
|
||||
mod.xuanzhu(pack(0, { flower: 1 }));
|
||||
mod.maipai(pack(0, { cards: P.get_seat_cards(pj, 0).slice(-8) }));
|
||||
// 深拷贝守卫需要【活引用】:setup 的 SendPack 会 JSON 深拷贝每个包,用 c.sent 验不出活引用问题。
|
||||
// 故在其之外再挂一层,原样留住服务端交出来的那个 playproc 对象。
|
||||
const live = [];
|
||||
const innerSend = mod.app.SendPack;
|
||||
mod.app.SendPack = m => { if (/^chupai[123]$/.test(m.rpc)) live.push(m.data.playproc); innerSend(m); };
|
||||
let cmp = 0, mism = 0, missing = 0, rounds = 0, guard = 0, jiesuanHasPlayproc = null, firstSnap = null;
|
||||
while (pj.step === 5 && ++guard < 400) {
|
||||
const seat = pj.playproc.currseat, hand = P.get_seat_cards(pj, seat);
|
||||
let pick = null;
|
||||
if (seat === pj.playproc.start) pick = [hand[hand.length - 1]];
|
||||
else for (const x of hand) if (AR.can_followcard(pj.flower, hand, [x], pj.playproc.startcount, pj.playproc.startflower, pj.playproc.starttype).result) { pick = [x]; break; }
|
||||
if (!pick) break;
|
||||
c.sent.length = 0;
|
||||
mod.chupai(pack(seat, { cards: pick }));
|
||||
const cps = c.sent.filter(m => /^chupai[123]$/.test(m.rpc));
|
||||
const js = c.sent.filter(m => m.rpc === 'jiesuan');
|
||||
if (js.length) jiesuanHasPlayproc = js.some(m => m.data.playproc !== undefined);
|
||||
if (!cps.length) continue;
|
||||
rounds++;
|
||||
if (cps.some(m => m.data.playproc === undefined)) missing++;
|
||||
if (firstSnap === null) firstSnap = JS(live[0]); // 第 1 包发出【当时】的样子
|
||||
if (pj.step !== 5) continue; // 已进结算,无"当前进行态"可比
|
||||
for (let s = 0; s < 3; s++) {
|
||||
const rec = E.get_deskinfo(c.o_room, s).PushCards.playproc;
|
||||
cmp++;
|
||||
if (JS(cps[s].data.playproc) !== JS(rec)) mism++;
|
||||
}
|
||||
}
|
||||
mod.app.SendPack = innerSend;
|
||||
return { pj, cmp, mism, missing, rounds, jiesuanHasPlayproc, firstSnap, firstRef: live[0] };
|
||||
})();
|
||||
t.eq('本轮进行态 28轮打完(用例真的走到底)', pp.pj.playproc.round, 28);
|
||||
t.eq('本轮进行态 chupai1/2/3 恒带 playproc', [pp.rounds > 80, pp.missing], [true, 0]);
|
||||
t.eq('本轮进行态 增量下发 == 同时刻重连快照(逐包逐座位)', [pp.cmp > 200, pp.mism], [true, 0]);
|
||||
t.eq('本轮进行态 chupai3 转 jiesuan 时不带 playproc', pp.jiesuanHasPlayproc, false);
|
||||
// 深拷贝守卫:playproc 是全程复用的活对象(new_playround 就地重置同一个对象)。
|
||||
// 若下发挂的是活引用,打完整局后"第一包里的第 1 轮进行态"会变成最后一轮的样子。
|
||||
t.eq('本轮进行态 下发的是深拷贝快照(已发出的包不被后续出牌回改)', JS(pp.firstRef), pp.firstSnap);
|
||||
t.eq('本轮进行态 第1包就是第1轮、cards 定长3', [pp.firstRef.round, pp.firstRef.cards.length], [1, 3]);
|
||||
|
||||
// ===================== §5.4 甩牌:chupai1 需下发分量构成 shuai(cardtype 表达不了甩牌)=====================
|
||||
// 主花色=♦(1)。庄家(0) 甩「主♦K对 + 主♦5单张」;两闲家手上一张主牌都没有 → 甩牌合法
|
||||
function mkShuai(extraToSeat1) {
|
||||
const pj = {
|
||||
idx: 1, step: 5, banker: 0, call: 65, flower: 1, callproc: [],
|
||||
seatlist: [[[0, 0], [0, 0], [0, 0], [0, 0], [-1, -1]], [[0, 0], [0, 0], [0, 0], [0, 0], [-1, -1]], [[0, 0], [0, 0], [0, 0], [0, 0], [-1, -1]]],
|
||||
playproc: {}, cards: make108()
|
||||
playproc: {}, playhistory: [], cards: make108()
|
||||
};
|
||||
for (let i = 0; i < 108; i++) pj.cards[i].dealowner = -1; // 先全部清空
|
||||
const give = (cid, s) => { pj.cards[cid].dealowner = s + 1; pj.cards[cid].playround = -1; };
|
||||
@@ -212,57 +372,129 @@ t.eq('甩错 shuaicuo=1', shPk && shPk.data.shuaicuo, 1);
|
||||
t.eq('甩错 只打出最小一张(主♦5)', shPk && shPk.data.cards, [id(1, 1, 5)]);
|
||||
t.eq('甩错 无 shuai 分量', shPk && shPk.data.shuai, undefined);
|
||||
|
||||
// ===================== 重连 PushCards.pushlist:结构恒 3 座位 + 按本局主牌花色排序 =====================
|
||||
// 造 5 轮已出牌的牌局(每轮每家 1 张),外加 seat0 在第 1 轮同时出「主5 + 副♥A」用于验证排序
|
||||
function mkPushlist() {
|
||||
const pj = {
|
||||
idx: 1, step: 5, banker: 0, call: 65, flower: 1, callproc: [],
|
||||
seatlist: [[[0, 0], [0, 0], [0, 0], [0, 0], [-1, -1]], [[0, 0], [0, 0], [0, 0], [0, 0], [-1, -1]], [[0, 0], [0, 0], [0, 0], [0, 0], [-1, -1]]],
|
||||
playproc: { round: 5, start: 0, currseat: 0, cards: [[], [], []] },
|
||||
cards: make108()
|
||||
};
|
||||
for (let i = 0; i < 108; i++) pj.cards[i].dealowner = (i % 3) + 1;
|
||||
// 每轮每家一张
|
||||
let next = 0;
|
||||
for (let r = 1; r <= 5; r++) for (let s = 0; s < 3; s++) {
|
||||
for (let i = next; i < 108; i++) {
|
||||
if (pj.cards[i].dealowner === s + 1 && pj.cards[i].playround === -1) { pj.cards[i].playround = r; break; }
|
||||
// ===================== 重连 PushCards.pushlist:与 chupai 下发的 data.cards 同一份数据 =====================
|
||||
// 【契约】「这一手打出的牌」只有一个顺序口径:do_playcard 用 order_playcards 归一一次,
|
||||
// 之后 chupai1/2/3 的 data.cards、playproc.cards、playhistory→PushCards.pushlist 全都读它。
|
||||
// 因此重连包里的每一手,必须与当时那个 chupai 包的 cards【逐元素完全相等】。
|
||||
//
|
||||
// 旧实现在这里分岔:pushlist 是从 cards[i].playround/dealowner 反查后自己再排一次序,
|
||||
// 与 chupai 回显的「客户端提交原序」是两个写入处——一手多张(甩牌/对子)时必然不同,
|
||||
// 同编码的一对牌更是排序都区分不了。故本用例必须【一手多张 + 乱序提交】才抓得住。
|
||||
//
|
||||
// 【乱序提交】asc() 把选好的一手反转成升序再发,模拟真实客户端的点击顺序;
|
||||
// 服务端不得回显它,一律给权威降序。
|
||||
function ascSubmit(pj, cards) { return AR.order_cards(pj.flower, cards.concat()).reverse(); }
|
||||
|
||||
function drivePushlist(roomtype, hands) {
|
||||
const c = driveToPlay(roomtype);
|
||||
const pj = c.pj;
|
||||
const played = []; // [{round, seat, cards(下发的)}...]
|
||||
for (let h = 0; h < hands; h++) {
|
||||
if (pj.step !== 5) break;
|
||||
const seat = pj.playproc.currseat;
|
||||
const round = pj.playproc.round;
|
||||
const hand = P.get_seat_cards(pj, seat);
|
||||
let pick = null;
|
||||
if (seat === pj.playproc.start) {
|
||||
// 首家优先领一对(制造"一手多张";对子两张同编码,排序无法区分,正是分岔点)
|
||||
const pairs = AR.get_pairlist(pj.flower, hand.concat());
|
||||
for (const pr of pairs) {
|
||||
if (AR.can_playcard(pj.flower, pr.concat(), seat, pj.seatlist,
|
||||
[0, 1, 2].filter(s => s !== seat).map(s => P.get_seat_zhucards(pj, s))).result) {
|
||||
pick = pr.concat(); break;
|
||||
}
|
||||
}
|
||||
// seat0 第1轮补出:主5(flower1) 与 副♥A(flower3),主牌必须排在副牌前
|
||||
const zhu5 = id(1, 1, 5), fuA = id(1, 3, 1);
|
||||
for (const c of [zhu5, fuA]) { pj.cards[c].dealowner = 1; pj.cards[c].playround = 1; }
|
||||
// 当前轮(第5轮)桌面上的牌:两种查牌模式下都必须能恢复(design §9 边界说明)
|
||||
const cur = id(2, 1, 10);
|
||||
pj.cards[cur].dealowner = 1; pj.cards[cur].playround = 5;
|
||||
pj.playproc.cards[0] = [cur];
|
||||
pj.method = {
|
||||
get_seat_cards: s => P.get_seat_cards(pj, s),
|
||||
get_jian_grade: () => P.get_jian_grade(pj),
|
||||
get_burycard: () => P.get_burycard(pj),
|
||||
get_liangpai: () => null,
|
||||
get_curmultiple: () => P.get_curmultiple(pj),
|
||||
get_mustcard: s => P.get_mustcard(pj, s)
|
||||
};
|
||||
return { pj, zhu5, fuA, cur };
|
||||
if (!pick) { pick = [hand[hand.length - 1]]; }
|
||||
} else {
|
||||
const n = pj.playproc.startcount;
|
||||
const get = AR.get_followcard(pj.flower, hand.concat(), n, pj.playproc.startflower, pj.playproc.starttype);
|
||||
const base = (get && get.mustcard) ? get.mustcard.concat() : [];
|
||||
if (base.length === n) { pick = base; }
|
||||
else {
|
||||
const pool = (get && get.cancard && get.cancard.length ? get.cancard : hand).filter(x => base.indexOf(x) < 0);
|
||||
const need = n - base.length;
|
||||
const combo = [];
|
||||
(function walk(st) {
|
||||
if (pick) return;
|
||||
if (combo.length === need) {
|
||||
const cand = base.concat(combo);
|
||||
if (AR.can_followcard(pj.flower, hand, cand, n, pj.playproc.startflower, pj.playproc.starttype).result
|
||||
&& (!pj.playproc.shuai_demand || AR.flush_follow_ok(pj.flower, hand, cand, pj.playproc.shuai_demand))) {
|
||||
pick = cand;
|
||||
}
|
||||
const pl = mkPushlist();
|
||||
const plRoom = setup("00000", pl.pj).o_room;
|
||||
const di = E.get_deskinfo(plRoom, 1).PushCards.pushlist;
|
||||
t.eq('重连 pushlist 轮数=5', di.length, 5);
|
||||
t.eq('重连 pushlist 每轮恒3个座位', di.map(r => r.length), [3, 3, 3, 3, 3]);
|
||||
return;
|
||||
}
|
||||
for (let i = st; i < pool.length && !pick; i++) { combo.push(pool[i]); walk(i + 1); combo.pop(); }
|
||||
})(0);
|
||||
}
|
||||
}
|
||||
if (!pick) { break; }
|
||||
const before = c.sent.length;
|
||||
mod.chupai(pack(seat, { cards: ascSubmit(pj, pick) }));
|
||||
const cp = c.sent.slice(before).filter(m => /^chupai[123]$/.test(m.rpc))[0];
|
||||
if (cp) { played.push({ round, seat, cards: cp.data.cards, submitted: ascSubmit(pj, pick) }); }
|
||||
}
|
||||
return { c, pj, played };
|
||||
}
|
||||
|
||||
const plv = drivePushlist("00000", 12);
|
||||
t.eq('pushlist 用例真的出过一手多张', plv.played.some(x => x.cards.length > 1), true);
|
||||
const di = E.get_deskinfo(plv.c.o_room, 1).PushCards.pushlist;
|
||||
t.eq('重连 pushlist 每轮恒3个座位', di.every(r => r.length === 3), true);
|
||||
t.eq('重连 pushlist 不含 undefined', di.some(r => r.some(x => x === undefined)), false);
|
||||
// 第1轮 seat0 的牌:主5 必须排在副♥A 之前(按 paiju.flower=1 排序,而非泄漏的大王花色5)
|
||||
t.eq('重连 pushlist 按本局主牌花色排序(主5在副A前)',
|
||||
di[0][0].indexOf(pl.zhu5) < di[0][0].indexOf(pl.fuA), true);
|
||||
// 核心:逐手比对「重连归档」与「当时的 chupai 包」——两条路径同一份数据,必须逐元素相等
|
||||
let plMismatch = [];
|
||||
for (const h of plv.played) {
|
||||
const arc = di[h.round - 1] && di[h.round - 1][h.seat];
|
||||
if (JS(arc) !== JS(h.cards)) { plMismatch.push({ round: h.round, seat: h.seat, arc, pkt: h.cards }); }
|
||||
}
|
||||
t.eq('重连 pushlist 每一手都与当时的 chupai.cards 逐元素相等', plMismatch, []);
|
||||
// 反面:服务端不得回显客户端的提交顺序(提交是升序,下发必须是权威降序)
|
||||
const plMulti = plv.played.filter(x => x.cards.length > 1);
|
||||
t.eq('chupai.cards 不回显客户端提交原序(一手多张时)',
|
||||
plMulti.some(x => JS(x.cards) === JS(x.submitted) && JS(x.cards) !== JS(AR.order_cards(plv.pj.flower, x.cards.concat()))), false);
|
||||
t.eq('chupai.cards 是本局主牌花色的权威降序',
|
||||
plMulti.every(x => JS(x.cards) === JS(AR.order_cards(plv.pj.flower, x.cards.concat()))), true);
|
||||
// 客户端提交的数组不被就地改写(can_playcard 是原地排序,do_playcard 必须先拷贝)
|
||||
const inplace = driveToPlay("00000");
|
||||
const inHand = P.get_seat_cards(inplace.pj, inplace.pj.playproc.currseat);
|
||||
const inArg = [inHand[inHand.length - 1], inHand[inHand.length - 2]];
|
||||
const inArgCopy = inArg.concat();
|
||||
mod.chupai(pack(inplace.pj.playproc.currseat, { cards: inArg }));
|
||||
t.eq('chupai 不就地改写 pack.data.cards', inArg, inArgCopy);
|
||||
|
||||
// §9 不查牌模式:出牌历史属于「查牌」,一律不下发;但当前轮桌面上的牌必须照常恢复
|
||||
const pl2 = mkPushlist();
|
||||
const pc2 = E.get_deskinfo(setup("00001", pl2.pj).o_room, 1).PushCards;
|
||||
const plv2 = drivePushlist("00001", 2);
|
||||
const pc2 = E.get_deskinfo(plv2.c.o_room, 1).PushCards;
|
||||
t.eq('重连 不查牌 无出牌历史 pushlist', pc2.pushlist, undefined);
|
||||
t.eq('重连 不查牌 当前轮桌面牌仍恢复(playproc.cards)', pc2.playproc.cards[0], [pl2.cur]);
|
||||
const pc1 = E.get_deskinfo(plRoom, 1).PushCards;
|
||||
t.eq('重连 不查牌 当前轮桌面牌仍恢复(playproc.cards)',
|
||||
JS(pc2.playproc.cards[plv2.played[0].seat]), JS(plv2.played[0].cards));
|
||||
const pc1 = E.get_deskinfo(plv.c.o_room, 1).PushCards;
|
||||
t.eq('重连 可查牌 有出牌历史 pushlist', Array.isArray(pc1.pushlist), true);
|
||||
t.eq('重连 可查牌 当前轮桌面牌也在', pc1.playproc.cards[0], [pl.cur]);
|
||||
|
||||
// ===================== 重连 PushCards.baozhu:与 chupai 的 baozhu 同源同门控 =====================
|
||||
// baozhu 是「余主公示 + 明牌按钮」的开关(design §9)。它只在 chupai 包里给、重连包不给的话,
|
||||
// 重连后按钮凭空消失——状态变更没有包承载(server 红线:发全下发面)。
|
||||
function mkBaozhuRoom(roomtype) {
|
||||
const o_desk_stub = { paiju_list: [] };
|
||||
const pj = P.new(o_desk_stub, 0);
|
||||
pj.banker = 0; pj.call = 65; pj.flower = 1; pj.step = 5;
|
||||
P.new_playround(pj, 1, 0);
|
||||
const lead = id(1, 3, 9);
|
||||
pj.cards[lead].dealowner = 1; pj.cards[lead].playround = -1;
|
||||
for (const cc of P.get_seat_zhucards(pj, 1)) { pj.cards[cc].playround = 1; }
|
||||
pj.seatlist[1][4][0] = 0; // seat1 报无主 → have_baofu()=true
|
||||
const c = setup(roomtype, pj);
|
||||
mod.chupai(pack(0, { cards: [lead] }));
|
||||
return c;
|
||||
}
|
||||
const bzOn = mkBaozhuRoom("00000");
|
||||
t.eq('重连 可查牌 PushCards 带 baozhu,且与 chupai 同值',
|
||||
E.get_deskinfo(bzOn.o_room, 1).PushCards.baozhu,
|
||||
bzOn.sent.filter(m => /^chupai[123]$/.test(m.rpc))[0].data.baozhu);
|
||||
t.eq('重连 可查牌 已报无主 → baozhu=1', E.get_deskinfo(bzOn.o_room, 1).PushCards.baozhu, 1);
|
||||
const bzOff = mkBaozhuRoom("00001");
|
||||
t.eq('重连 不查牌 baozhu 恒 0', E.get_deskinfo(bzOff.o_room, 1).PushCards.baozhu, 0);
|
||||
|
||||
// ===================== §7.3 下发面 multiple 必须随「爬坡」开关取值(与结算 aset.multiple 同源)=====================
|
||||
// 叫 45 分:常规算子基础子数 6(design §7.1「50 及以下统一 6」),爬坡 7(design §7.3.1)
|
||||
@@ -346,7 +578,7 @@ t.eq('叫分 反 已不叫者再叫 未产生庄家', bd.banker, -1);
|
||||
// 桩要"完整到能走完成功路径":否则一旦校验被改松,这里会**抛异常打断整个文件**,
|
||||
// 后面的断言全不执行——看到的是崩溃而不是某条断言转红,定位与信号都变差。
|
||||
const mkBury = (step, banker) => ({
|
||||
step, banker, cards: make108(), playproc: { currseat: banker },
|
||||
step, banker, cards: make108(), playproc: { currseat: banker }, playhistory: [],
|
||||
method: {
|
||||
check_cards_valid: c => P.check_cards_valid({ cards: make108() }, c),
|
||||
check_cards_inhand: () => true,
|
||||
@@ -382,4 +614,175 @@ mod.chupai(pack(0, { cards: [P.get_seat_cards(gd.pj, 0)[0]] }));
|
||||
global.min_ontimeout = realTO;
|
||||
t.eq('§11 叫分→选主→埋牌→出牌 全程未注册任何定时器', timerCount, 0);
|
||||
|
||||
// ===================== I-3 阶段 step 与控制权 必须由服务端在包里显式给出 =====================
|
||||
// server 红线:「阶段、控制权(轮到谁)… 必须由服务端唯一维护并在相关下发包里显式给出;
|
||||
// 任何状态变更都要有包承载」。曾经的缺陷:增量推送里【一个 step 都没有】,选主/埋牌阶段
|
||||
// 也没有「轮到谁」,前端只能按 rpc 名硬编码 step、并自己套「选主/埋牌 = 庄家」这条规则——
|
||||
// 那就是前端持有了一小段对局状态机(服务端将来插一个中间阶段,前端不会红、只会静默画错)。
|
||||
//
|
||||
// 本组逐包钉死两件事:①每个包的 step == 同时刻 paiju.step(不是常量,是权威值);
|
||||
// ②每个包的「轮到谁」== 服务端各 RPC 校验所用的同一个权威来源。
|
||||
// 反向验证:删掉 mod.js/class.desk.js 里任意一处 msg.data.step / nextseat,本组必转红。
|
||||
const sp = (() => {
|
||||
const pj = P.new({ paiju_list: [] }, 0);
|
||||
const c = setup("00000", pj);
|
||||
const grab = rpc => c.sent.filter(m => m.rpc === rpc);
|
||||
const out = {};
|
||||
mod.jiaofen(pack(0, { call: 65 }));
|
||||
out.jiaofen1 = grab('jiaofen')[0]; out.after_jf1 = { step: pj.step, next: pj.method.get_callgrade_seat() };
|
||||
mod.jiaofen(pack(1, { call: 0 }));
|
||||
out.jiaofen2 = grab('jiaofen')[1]; out.after_jf2 = { step: pj.step, next: pj.method.get_callgrade_seat() };
|
||||
out.deskinfo1 = E.get_deskinfo(c.o_room, 1);
|
||||
mod.jiaofen(pack(2, { call: 0 })); // → 0 号位上庄
|
||||
out.shangzhuang = grab('shangzhuang')[0]; out.after_sz = { step: pj.step, banker: pj.banker };
|
||||
out.deskinfo2 = E.get_deskinfo(c.o_room, 1); // 闲家视角(控制权与视角无关)
|
||||
mod.xuanzhu(pack(0, { flower: 1 }));
|
||||
out.xuanzhu = grab('xuanzhu')[0]; out.after_xz = { step: pj.step, banker: pj.banker };
|
||||
out.deskinfo3 = E.get_deskinfo(c.o_room, 2);
|
||||
mod.maipai(pack(0, { cards: P.get_seat_cards(pj, 0).slice(-8) }));
|
||||
out.maipai = grab('maipai')[0]; out.after_mp = { step: pj.step, curr: pj.playproc.currseat };
|
||||
out.deskinfo5 = E.get_deskinfo(c.o_room, 1);
|
||||
return { pj, c, out };
|
||||
})();
|
||||
const O = sp.out;
|
||||
// ① 阶段 step:每个包都有,且等于同时刻牌局的真实阶段
|
||||
t.eq('阶段 jiaofen 带 step,且 == 当时的 paiju.step(仍在叫分=1)', [O.jiaofen1.data.step, O.after_jf1.step], [1, 1]);
|
||||
t.eq('阶段 jiaofen(第二包) step 仍为 1', O.jiaofen2.data.step, O.after_jf2.step);
|
||||
t.eq('阶段 shangzhuang 带 step,且 == paiju.step(选主/投降=2)', [O.shangzhuang.data.step, O.after_sz.step], [2, 2]);
|
||||
t.eq('阶段 xuanzhu 带 step,且 == paiju.step(埋牌=3)', [O.xuanzhu.data.step, O.after_xz.step], [3, 3]);
|
||||
t.eq('阶段 maipai 带 step,且 == paiju.step(出牌=5)', [O.maipai.data.step, O.after_mp.step], [5, 5]);
|
||||
// 与重连包 deskinfo.step 同值:增量与全量两条路径必须给同一个阶段
|
||||
t.eq('阶段 jiaofen.step == 同时刻 deskinfo.step', O.jiaofen2.data.step, O.deskinfo1.step);
|
||||
t.eq('阶段 shangzhuang.step == 同时刻 deskinfo.step', O.shangzhuang.data.step, O.deskinfo2.step);
|
||||
t.eq('阶段 xuanzhu.step == 同时刻 deskinfo.step', O.xuanzhu.data.step, O.deskinfo3.step);
|
||||
t.eq('阶段 maipai.step == 同时刻 deskinfo.step', O.maipai.data.step, O.deskinfo5.step);
|
||||
// ② 控制权:每个包的「轮到谁」== 服务端自己校验时用的权威来源
|
||||
t.eq('控制权 jiaofen.nextseat == get_callgrade_seat()(下一个叫分者)', O.jiaofen1.data.nextseat, O.after_jf1.next);
|
||||
t.eq('控制权 jiaofen 不叫后跳过该家', O.jiaofen2.data.nextseat, O.after_jf2.next);
|
||||
t.eq('控制权 shangzhuang.nextseat == banker(选主/投降只由庄家做)', O.shangzhuang.data.nextseat, O.after_sz.banker);
|
||||
t.eq('控制权 xuanzhu.nextseat == banker(埋牌只由庄家做)', O.xuanzhu.data.nextseat, O.after_xz.banker);
|
||||
t.eq('控制权 maipai.seat == playproc.currseat(即将首出者)', O.maipai.data.seat, O.after_mp.curr);
|
||||
// 反面:shangzhuang 的 data.seat 是【最后一个叫分者】,不是控制权——两者必须能区分开,
|
||||
// 否则"直接读 seat 当控制权"的写法也能蒙混过关(本局:最后叫分者=2、庄家=0)
|
||||
t.eq('控制权 shangzhuang.seat(最后叫分者) 与 nextseat(庄家) 确实不同 → 用例非空转',
|
||||
O.shangzhuang.data.seat !== O.shangzhuang.data.nextseat, true);
|
||||
// 重连包:ChooseMain / BuryCards 也必须显式给控制权,且与增量推送同值
|
||||
t.eq('控制权 重连 ChooseMain.seat == shangzhuang.nextseat', O.deskinfo2.ChooseMain.seat, O.shangzhuang.data.nextseat);
|
||||
t.eq('控制权 重连 BuryCards.seat == xuanzhu.nextseat', O.deskinfo3.BuryCards.seat, O.xuanzhu.data.nextseat);
|
||||
t.eq('控制权 重连 PushCards 由 playproc.currseat 给出 == maipai.seat',
|
||||
O.deskinfo5.PushCards.playproc.currseat, O.maipai.data.seat);
|
||||
|
||||
// ===================== I-2 结算阶段(step6)的重连包必须与 jiesuan 推送给同一份结算数据 =====================
|
||||
// 曾经的缺陷:deskinfo 的 Balance 只给 readystate + aset,而 jiesuan 推送给的是
|
||||
// chupai + bottom + aset(+ 末局 account)——结算面板还开着时断线重连/硬刷新,
|
||||
// 抠底明细与末局大结算全部空白(同一份数据两条路径给的不一样)。
|
||||
function driveToSettle(roomtype, hookDesk) {
|
||||
const pj = P.new({ paiju_list: [] }, 0);
|
||||
const c = setup(roomtype, pj);
|
||||
if (hookDesk) { hookDesk(c.o_desk, pj); }
|
||||
mod.jiaofen(pack(0, { call: 65 })); mod.jiaofen(pack(1, { call: 0 })); mod.jiaofen(pack(2, { call: 0 }));
|
||||
mod.xuanzhu(pack(0, { flower: 1 }));
|
||||
mod.maipai(pack(0, { cards: P.get_seat_cards(pj, 0).slice(-8) }));
|
||||
let guard = 0;
|
||||
while (pj.step === 5 && ++guard < 400) {
|
||||
const seat = pj.playproc.currseat, hand = P.get_seat_cards(pj, seat);
|
||||
let pick = null;
|
||||
if (seat === pj.playproc.start) { pick = [hand[hand.length - 1]]; }
|
||||
else { for (const x of hand) { if (AR.can_followcard(pj.flower, hand, [x], pj.playproc.startcount, pj.playproc.startflower, pj.playproc.starttype).result) { pick = [x]; break; } } }
|
||||
if (!pick) { break; }
|
||||
mod.chupai(pack(seat, { cards: pick }));
|
||||
}
|
||||
return { pj, c, jiesuan: c.sent.filter(m => m.rpc === 'jiesuan')[0] };
|
||||
}
|
||||
const sv = driveToSettle("00000");
|
||||
t.eq('结算 用例真的打到 step6', sv.pj.step, 6);
|
||||
t.eq('结算 jiesuan 带 step=6(阶段同样由包承载)', sv.jiesuan.data.step, 6);
|
||||
const bal = [0, 1, 2].map(s => E.get_deskinfo(sv.c.o_room, s).Balance);
|
||||
t.eq('结算 重连 Balance.aset == jiesuan.aset(三家)',
|
||||
bal.map(b => JS(b.aset)), [0, 1, 2].map(() => JS(sv.jiesuan.data.aset)));
|
||||
t.eq('结算 重连 Balance.bottom == jiesuan.bottom(抠底明细,三家)',
|
||||
bal.map(b => JS(b.bottom)), [0, 1, 2].map(() => JS(sv.jiesuan.data.bottom)));
|
||||
// 用例非空转:抠底分组必须真的有内容(cards 恒 8 张),否则上面两边"同为 undefined"也会绿
|
||||
t.eq('结算 抠底分组真的有内容(8张埋牌底牌) → 用例非空转', bal[0].bottom.cards.length, 8);
|
||||
t.eq('结算 重连 deskinfo.step == 6', E.get_deskinfo(sv.c.o_room, 0).step, 6);
|
||||
// 已按下准备的座位不再重发结算数据(原有门控不变)
|
||||
sv.c.o_desk.prepare[1] = 1;
|
||||
const balReady = E.get_deskinfo(sv.c.o_room, 1).Balance;
|
||||
t.eq('结算 已准备的座位不带 aset/bottom/account(门控不变)',
|
||||
[balReady.aset, balReady.bottom, balReady.account], [undefined, undefined, undefined]);
|
||||
// 末局:account 也必须能从重连包拿到。用桩 get_desk_account 造出 account(真实实现要写战绩,
|
||||
// 与本用例无关),验证的是「get_paiju_account 冻结了它 + get_deskinfo 原样给出」这条链路
|
||||
const svLast = driveToSettle("00000", (o_desk) => {
|
||||
o_desk.method.get_desk_account = m => { m.data.account = [{ score: 1 }, { score: 2 }, { score: 3 }]; return m; };
|
||||
});
|
||||
svLast.pj.idx = 99; // 末局判定:idx >= asetcount(6)
|
||||
// idx 改在结算之前才有效,故这里重新走一遍结算路径:直接再调一次 get_paiju_account
|
||||
const lastMsg = P.get_paiju_account(svLast.pj, 0, { data: {} });
|
||||
t.eq('结算 末局 jiesuan 带 account', Array.isArray(lastMsg.data.account), true);
|
||||
t.eq('结算 末局 重连 Balance.account == jiesuan.account',
|
||||
JS(E.get_deskinfo(svLast.c.o_room, 0).Balance.account), JS(lastMsg.data.account));
|
||||
// 反面:非末局不带 account(与 jiesuan 推送的取舍一致,不凭空造分组)
|
||||
t.eq('结算 非末局 jiesuan 无 account', sv.jiesuan.data.account, undefined);
|
||||
t.eq('结算 非末局 重连 Balance 无 account', bal[0].account, undefined);
|
||||
|
||||
// ===================== M-2 seatlist 必须以深拷贝下发(不把活对象挂进下发包)=====================
|
||||
// paiju.seatlist 是一张全程就地改写的活表(do_playcard 每手都在改缺门/无对/报副)。
|
||||
// 同一份数组曾同时挂在 maipai 的三个逐座位包、chupai1/2/3 与重连包 PushCards 上——
|
||||
// 平台真实链路立刻序列化看不出来,但这是隐患,与 playproc / pushlist / bottomcards 同一条标准。
|
||||
const EMPTY_TABLE = JS([0, 1, 2].map(() => [[0, 0], [0, 0], [0, 0], [0, 0], [-1, -1]]));
|
||||
const sl = (() => {
|
||||
const pj = P.new({ paiju_list: [] }, 0);
|
||||
const c = setup("00000", pj);
|
||||
mod.jiaofen(pack(0, { call: 65 })); mod.jiaofen(pack(1, { call: 0 })); mod.jiaofen(pack(2, { call: 0 }));
|
||||
mod.xuanzhu(pack(0, { flower: 1 }));
|
||||
// 深拷贝守卫需要【活引用】:setup 的 SendPack 会 JSON 深拷贝每个包,用 c.sent 验不出问题
|
||||
const inner = mod.app.SendPack;
|
||||
let mpLive = null, cpLive = null;
|
||||
mod.app.SendPack = m => {
|
||||
if (m.rpc === 'maipai' && mpLive === null) { mpLive = m.data.seatlist; }
|
||||
if (/^chupai[123]$/.test(m.rpc) && cpLive === null) { cpLive = m.data.seatlist; }
|
||||
inner(m);
|
||||
};
|
||||
mod.maipai(pack(0, { cards: P.get_seat_cards(pj, 0).slice(-8) }));
|
||||
// 重连快照也取一份活引用(此刻还是初始空表)
|
||||
const diLive = E.get_deskinfo(c.o_room, 1).PushCards.seatlist;
|
||||
let guard = 0;
|
||||
while (pj.step === 5 && ++guard < 400) {
|
||||
const seat = pj.playproc.currseat, hand = P.get_seat_cards(pj, seat);
|
||||
let pick = null;
|
||||
if (seat === pj.playproc.start) { pick = [hand[hand.length - 1]]; }
|
||||
else { for (const x of hand) { if (AR.can_followcard(pj.flower, hand, [x], pj.playproc.startcount, pj.playproc.startflower, pj.playproc.starttype).result) { pick = [x]; break; } } }
|
||||
if (!pick) { break; }
|
||||
mod.chupai(pack(seat, { cards: pick }));
|
||||
}
|
||||
mod.app.SendPack = inner;
|
||||
return { pj, c, mpLive, cpLive, diLive };
|
||||
})();
|
||||
// 用例非空转:整局打完后这张活表必须真的变过,否则"没被回改"是碰巧
|
||||
t.eq('座位牌况 整局打完后 paiju.seatlist 确实变过 → 用例非空转', JS(sl.pj.seatlist) !== EMPTY_TABLE, true);
|
||||
t.eq('座位牌况 maipai 下发的是深拷贝(已发出的包不被后续出牌回改)', JS(sl.mpLive), EMPTY_TABLE);
|
||||
t.eq('座位牌况 chupai1 下发的是深拷贝(打完整局后仍是发出当时的样子)',
|
||||
JS(sl.cpLive) !== JS(sl.pj.seatlist), true);
|
||||
t.eq('座位牌况 重连 PushCards 下发的是深拷贝', JS(sl.diLive), EMPTY_TABLE);
|
||||
// 正面:深拷贝的内容必须与当时的活表逐格相等(拷贝不能拷错/拷浅)
|
||||
const slNow = P.get_seatlist(sl.pj);
|
||||
t.eq('座位牌况 get_seatlist 内容与活表逐格相等', JS(slNow), JS(sl.pj.seatlist));
|
||||
slNow[0][0][0] = 9;
|
||||
t.eq('座位牌况 get_seatlist 是深拷贝(改副本不影响本体)', sl.pj.seatlist[0][0][0] !== 9, true);
|
||||
|
||||
// ===================== Balance.readystate 必须以深拷贝下发(不把活数组挂进下发包)=====================
|
||||
// o_desk.prepare 是一张全程就地改写的活数组(do_prepare 把某一格置 1、do_new_paiju 就地重建)。
|
||||
// 它此前被整根挂进 deskinfo.Balance.readystate,与 playproc / seatlist / pushlist 同级的隐患:
|
||||
// 已发出的包会随后续准备操作被回改。标准与 get_playproc 一致——下发副本。
|
||||
const rs = driveToSettle("00000");
|
||||
const rsLive = E.get_deskinfo(rs.c.o_room, 0).Balance.readystate;
|
||||
t.eq('准备状态 内容与活数组逐格相等', JS(rsLive), JS(rs.c.o_desk.prepare));
|
||||
t.eq('准备状态 发包时刻三家皆未准备 → 用例起点明确', JS(rsLive), JS([0, 0, 0]));
|
||||
rs.c.o_desk.prepare[1] = 1; //模拟「包已发出之后,1 号位按了准备」(do_prepare 的就地写)
|
||||
t.eq('准备状态 活数组确实变过 → 用例非空转', JS(rs.c.o_desk.prepare), JS([0, 1, 0]));
|
||||
t.eq('准备状态 下发的是深拷贝(已发出的包不被后续准备操作回改)', JS(rsLive), JS([0, 0, 0]));
|
||||
// 反面:改副本不得影响本体
|
||||
rsLive[2] = 9;
|
||||
t.eq('准备状态 是深拷贝(改副本不影响本体)', rs.c.o_desk.prepare[2], 0);
|
||||
|
||||
process.exit(t.done('rpc') ? 0 : 1);
|
||||
|
||||
Reference in New Issue
Block a user