diff --git a/server/games/erqiwang/class.desk.js b/server/games/erqiwang/class.desk.js index 4173bfa..b8f4c42 100644 --- a/server/games/erqiwang/class.desk.js +++ b/server/games/erqiwang/class.desk.js @@ -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++) { diff --git a/server/games/erqiwang/class.export.js b/server/games/erqiwang/class.export.js index a6b5efb..c5c98c2 100644 --- a/server/games/erqiwang/class.export.js +++ b/server/games/erqiwang/class.export.js @@ -95,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; //庄家叫分 @@ -120,6 +124,9 @@ var cls_youle_erqiwang_export = cls_youle_erqiwang_export || { break; case 3: //埋牌 deskinfo.BuryCards = {}; + //控制权:埋牌只由庄家做(mod.maipai 的 SEAT 校验以 banker 为准)。 + //与 xuanzhu 推送的 nextseat 同源同值 + deskinfo.BuryCards.seat = paiju.banker; //庄家 deskinfo.BuryCards.banker = paiju.banker; //庄家叫分 @@ -183,7 +190,9 @@ var cls_youle_erqiwang_export = cls_youle_erqiwang_export || { 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){ @@ -208,8 +217,21 @@ var cls_youle_erqiwang_export = cls_youle_erqiwang_export || { //玩家的准备状态 deskinfo.Balance.readystate = o_room.o_desk.prepare; 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; } diff --git a/server/games/erqiwang/class.paiju.js b/server/games/erqiwang/class.paiju.js index 95e43bb..b575199 100644 --- a/server/games/erqiwang/class.paiju.js +++ b/server/games/erqiwang/class.paiju.js @@ -180,8 +180,15 @@ var cls_youle_erqiwang_paiju = cls_youle_erqiwang_paiju || { paiju.playhistory = []; //输赢结果 0:庄赢 1:闲赢 2:庄投降 3:中途解散 paiju.result = null; - //结算包,供断线重连时使用,新开一局时清除 - paiju.tmp_jiesuan_aset = 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(); @@ -295,6 +302,11 @@ var cls_youle_erqiwang_paiju = cls_youle_erqiwang_paiju || { 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); @@ -510,6 +522,24 @@ var cls_youle_erqiwang_paiju = cls_youle_erqiwang_paiju || { 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){ @@ -1028,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); @@ -1167,8 +1204,14 @@ var cls_youle_erqiwang_paiju = cls_youle_erqiwang_paiju || { msg = o_paiju.o_desk.method.get_desk_account(msg); } - //保存结算包,供断线重连时使用 - o_paiju.tmp_jiesuan_aset = msg.data.aset; + //保存结算包,供断线重连时使用(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; } diff --git a/server/games/erqiwang/docs/protocol/packet_protocol.md b/server/games/erqiwang/docs/protocol/packet_protocol.md index d754c3b..07a05ee 100644 --- a/server/games/erqiwang/docs/protocol/packet_protocol.md +++ b/server/games/erqiwang/docs/protocol/packet_protocol.md @@ -158,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 | 整数 | 叫分倒计时 | --- @@ -188,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 | 整数 | 叫分倒计时 | --- @@ -203,6 +205,8 @@ | 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` 为准)。
⚠️ **别和本包的 `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)。
**顺序在发牌结束时即冻结**(服务端 `paiju.bottomcards`,按「还没有主牌」的口径排一次):底牌是在**选主之前**翻给庄家看的,那时主牌花色尚不存在,故不按主牌花色排。本包与重连包 `ChooseMain.bottomcards` / `BuryCards.bottomcards` 三处**同序**,客户端存一次即可全程复用(含「查底牌」回看)| | ancard3s | 整数 | **开底**标志。仅 70 分坐庄时出现且为 `1`:表示庄家**摸底**之前,需将 `bottomcards` 这 8 张**底牌**向所有玩家翻开 3 秒(design §4/§7.1);非 70 分无此属性 | @@ -250,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列表 | @@ -280,7 +286,8 @@ | burycards | 数组 | **埋牌底牌**(庄家埋下的 8 张),庄家才有此属性,闲家没有该属性。注意与 `shangzhuang.bottomcards`(**底牌**,发牌留桌 8 张)是两批不同的牌,见 §0.0。
**取服务端权威快照 `get_burycard()`,不是客户端请求包里 `cards` 的原序**:按本局主牌花色从大到小排好,与重连包 `PushCards.burycards` **同源同序** | | seatlist | 数组 | 三家座位牌况,**仅可查牌模式下发**(不查牌无此属性,design §9),结构与门控同 `chupai1/2/3.seatlist` 与重连包 `PushCards.seatlist`。
埋牌完成时它是刚初始化的**空表**(每家 `[[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`。
**可见性**:全部字段由桌面公开信息推出(此刻尚无一张牌打出),三家整体下发、不逐座位裁剪 | -| seat | 整数 | 出牌者的位置序号(即将首出的庄家)| +| 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...] }`——**庄家手中全部固定主牌的具体牌面**,按本局主牌序从大到小排好。
**固定主牌 = 双王 + 全部花色的 2 + 全部花色的 7**,**不含**主花色的普通牌 A/K/Q/J/10/9/8/6/5。
**门槛**(任一满足即下发):固定主牌总数 ≥10 / 王 ≥3 / 7 ≥6 / 2 ≥6。**不限叫分**。
亮出的是**固定的一份**,不随被哪条门槛触发而增减;也**不给数量统计**——数量前端自己数 `cards.length` 即可。
口径是庄家**埋牌后的静态快照**(排除已埋的 8 张,但包含之后已打出的牌),全局固定不随出牌缩水,故重连包里取值一致 | @@ -451,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(出牌包,仅正常出牌结算存在)** | 参数名 | 类型 | 说明 | @@ -590,6 +604,7 @@ | 参数名 | 类型 | 说明 | | --- | --- | --- | +| 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)| @@ -604,6 +619,7 @@ | 参数名 | 类型 | 说明 | | --- | --- | --- | +| 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)| @@ -640,6 +656,8 @@ | --- | --- | --- | | readystate | 数组 | 所有玩家的准备状态 | | aset | json | 单局结算包,同上面结算包中的 `aset`,只有当自己的准备状态为0时才有此属性 | +| bottom | json | **抠底包**,结构同 §14 的 `bottom`,与该局 `jiesuan` 推送的 `data.bottom` **同源同值**(服务端在 `get_paiju_account` 末尾冻结的同一份快照)。**仅正常出牌结算才有**(投降/解散没有抠底这一步);同样受「自己的准备状态为 0」门控。
**为什么必须有**:结算面板还开着时断线重连/硬刷新,只恢复 `aset` 会让抠底明细整块空白——同一份数据两条路径给的不一样(server 红线「发全下发面」)。
**可见性**:埋牌底牌在本局结算时已随 `jiesuan` 广播给三家(design §11 结束亮底),此处不构成额外泄露 | +| account | json | **大局结算包**,结构同 §14 的 `account`,与该局 `jiesuan` 推送的 `data.account` **同源同值**。**仅末局或中途解散才有**,与 `jiesuan` 的取舍完全一致(有就带、没有就不带)| --- diff --git a/server/games/erqiwang/mod.js b/server/games/erqiwang/mod.js index ff2d7f9..d469199 100644 --- a/server/games/erqiwang/mod.js +++ b/server/games/erqiwang/mod.js @@ -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,6 +335,10 @@ 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){ @@ -328,8 +347,9 @@ youle_erqiwang.maipai = function(pack){ //(每家 [[0,0],[0,0],[0,0],[0,0],[-1,-1]]),本身不含任何信息;但「埋牌完成 → //庄家首出」这段窗口内前端也要有表可画,漏发会让增量路径此刻为空、重连路径却有表。 //可见性:表内只有「某家缺某花色/某花色无对」(由公开的跟牌行为推出)与报无主后 - //全场公示的余主数量(design §9 明确要求向全体三人展示),故三家整表下发不构成泄露 - msg.data.seatlist = o_paiju.seatlist; + //全场公示的余主数量(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 同一个快照函数、同一结构(三家无差别)。 @@ -491,8 +511,10 @@ youle_erqiwang.chupai = function(pack){ //必须整表下发、不能只带出牌者一家: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(); diff --git a/server/games/erqiwang/test/test_desk.js b/server/games/erqiwang/test/test_desk.js index 2583365..fa8fa72 100644 --- a/server/games/erqiwang/test/test_desk.js +++ b/server/games/erqiwang/test/test_desk.js @@ -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); diff --git a/server/games/erqiwang/test/test_leak.js b/server/games/erqiwang/test/test_leak.js index d35be68..8738dfa 100644 --- a/server/games/erqiwang/test/test_leak.js +++ b/server/games/erqiwang/test/test_leak.js @@ -55,7 +55,11 @@ function mayKnow(pj, seat, cid, rpc) { 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 结束亮埋牌底牌 + // §11 结束亮埋牌底牌:本局一旦结算(step6),埋牌底牌就对全场公开——jiesuan 广播本就 + // 三家都发(sendpack_toother(msg,-1) 的 bottom.cards)。重连包在 step6 给出的 Balance.bottom + // 是【同一份数据的另一条投递路径】,可见性必须同判,否则会把「重连要看到与广播一样的结算面板」 + // 误判成泄露。注意这里限定 step===6,不是无条件放行 playround===0 + if (c.playround === 0 && (rpc === 'jiesuan' || (rpc === 'deskinfo' && pj.step === 6))) return true; if (rpc === 'mingpai') return true; // §9 明牌:单独在下面按内容校验 // §8.2 亮牌:可查牌模式下,庄家的固定主牌牌面对闲家公开(只在 maipai / 重连包里下发) if (!curNoCheck && (rpc === 'maipai' || rpc === 'deskinfo') && curLiangpai.has(cid)) return true; @@ -124,7 +128,9 @@ function run(roomtype, call) { 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})`); } + // 阶段取【实际的】pj.step:最后一手打完后 step 已转 6,这里拿到的是结算快照, + // 写死 "step5" 会让报错信息指错阶段(曾据此误判过) + 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})`); } } if (pj.method.have_baofu()) { sent.length = 0; diff --git a/server/games/erqiwang/test/test_rpc.js b/server/games/erqiwang/test/test_rpc.js index 9e90c0d..0287038 100644 --- a/server/games/erqiwang/test/test_rpc.js +++ b/server/games/erqiwang/test/test_rpc.js @@ -614,4 +614,160 @@ 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); + process.exit(t.done('rpc') ? 0 : 1);