二七王:前端 handler 六处下发面漏读/错读修复(增量路径对齐重连路径)
一致性测试(同一局真包:逐包增量回放 vs 一次性 deskinfo 重连)暴露的六处前端缺陷:
① PlayHandler:table.playproc 由「字段映射错误」改为原样拷贝
原实现把这一手的 seat/cards/count/flower/cardtype 灌进 playproc,而服务端 playproc
是本轮进行态(round/start/currseat/.../cards),两者几乎无交集、撞名的 cards 语义还相反。
服务端已在 chupai1/2/3 补发 playproc,前端零推导直接镜像,不自行累积 round/maxseat。
② 本手信息改走 EQW_CARD_PLAYED 事件载荷,不进 GameState
{order, seat, cards, count?, flower?, cardtype?, shuai?, shuaicuo?},带 ? 的缺字段不兜底。
落牌是一次性表现而非对局状态;新立 GameState 字段只会再制造一处增量/全量不一致。
③ PlayHandler:table.pushlist 逐手落盘、按轮归档(GameState.pushPlay)
chupai1 开新的一轮、2/3 落进当前轮,任意时刻都与 deskinfo 的全量重建相等(服务端
pushlist 含进行中的当前轮)。不读 chupai3.playproc.cards——那是下一轮的进行态(协议 §13)。
仅可查牌模式累积,与服务端同一道门控,否则不查牌房会凭空攒出重连没有的历史。
④ ResultHandler:归档收尾轮(最后一手走 jiesuan.chupai,不发 chupai3)
夹具实证 chupai1×28 / chupai2×28 / chupai3×27。jiesuan.chupai 只有 seat/cards/maxseat。
归档随即被结算收敛清空(deskinfo step6 不带 PushCards),但 emit 同步,落牌表现在清空前取值。
⑤ BuryHandler:接住 maipai 新增的 seatlist(此刻为全初值,界面无差别但重连侧有)
⑥ ResyncHandler 的 ChooseMain/BuryCards 补映射 curmultiple(原重连重建为 0,抓分角标掉档);
BuryHandler 埋牌完成后清空 my.bottomCards——底牌上庄时已并入手牌,埋牌后只剩 burycards
有意义,deskinfo 的 PushCards 不下发 bottomcards,重连重建恒为 [](语义已逐条核实)。
一致性测试 8 红 → 3 红。剩余 3 条同源:服务端 maipai 漏发 playproc(与本次服务端补发
seatlist 是同一个窗口、同一个理由),前端无法自补且不得伪造,详见报告 §7 待裁决。
另有 test_handlers_play.js:84 断言的正是 ① 修掉的错误映射,按纪律未改测试,见报告 §8。
未触碰 server/、client/tests/(含夹具与 EXCLUDE);服务端 29 checks 全绿。
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -3,10 +3,18 @@
|
|||||||
///////////////////////////////////////////////////////////////
|
///////////////////////////////////////////////////////////////
|
||||||
// 协议 §9:cards 埋牌后手牌(仅庄家) / burycards 埋牌底牌(仅庄家) /
|
// 协议 §9:cards 埋牌后手牌(仅庄家) / burycards 埋牌底牌(仅庄家) /
|
||||||
// seat 首出者(必为庄家) / countdown 出牌倒计时 /
|
// seat 首出者(必为庄家) / countdown 出牌倒计时 /
|
||||||
|
// seatlist 三家牌况(仅可查牌模式) /
|
||||||
// liangpai 亮牌(仅闲家 + 可查牌模式 + 庄家固定主牌达门槛)
|
// liangpai 亮牌(仅闲家 + 可查牌模式 + 庄家固定主牌达门槛)
|
||||||
//
|
//
|
||||||
// 注意 burycards(埋牌底牌,庄家埋下的 8 张)与 bottomcards(底牌,发牌留桌的 8 张)
|
// 注意 burycards(埋牌底牌,庄家埋下的 8 张)与 bottomcards(底牌,发牌留桌的 8 张)
|
||||||
// 是两批不同的牌,协议 §0.0 与验收清单专门警告过别写反。
|
// 是两批不同的牌,协议 §0.0 与验收清单专门警告过别写反。
|
||||||
|
//
|
||||||
|
// 【seatlist 此刻是全初值,但必须接】:埋牌完成时这张表刚初始化
|
||||||
|
// (每家 [[0,0],[0,0],[0,0],[0,0],[-1,-1]]),界面上看不出差别——但「埋牌完成 → 庄家首出」
|
||||||
|
// 这段窗口内重连拿到的 PushCards.seatlist 就是它,漏接会让增量路径此刻为空、重连路径却有表。
|
||||||
|
//
|
||||||
|
// 【bottomcards 到此失效,要清空】:底牌在上庄时已翻给玩家看并并入手牌,埋牌完成后
|
||||||
|
// 只剩「埋牌底牌 burycards」有意义;deskinfo 的 PushCards 不带 bottomcards,重连重建恒为 []。
|
||||||
var EQW_BuryHandler = EQW_BuryHandler || {
|
var EQW_BuryHandler = EQW_BuryHandler || {
|
||||||
|
|
||||||
handle: function (data) {
|
handle: function (data) {
|
||||||
@@ -20,8 +28,13 @@ var EQW_BuryHandler = EQW_BuryHandler || {
|
|||||||
cards: 'cards',
|
cards: 'cards',
|
||||||
buryCards: 'burycards'
|
buryCards: 'burycards'
|
||||||
});
|
});
|
||||||
//闲家才可能有的亮牌
|
//底牌已并入手牌、埋牌完成即失效(重连的 PushCards 不带它),显式清空保持两条路径一致
|
||||||
EQW_GameState._apply(EQW_GameState.table, data, { liangpai: 'liangpai' });
|
EQW_GameState.my.bottomCards = [];
|
||||||
|
//三家牌况(仅可查牌模式)与闲家才可能有的亮牌
|
||||||
|
EQW_GameState._apply(EQW_GameState.table, data, {
|
||||||
|
seatlist: 'seatlist',
|
||||||
|
liangpai: 'liangpai'
|
||||||
|
});
|
||||||
EQW_GameState._apply(EQW_GameState.turn, data, {
|
EQW_GameState._apply(EQW_GameState.turn, data, {
|
||||||
seat: 'seat',
|
seat: 'seat',
|
||||||
countdown: 'countdown'
|
countdown: 'countdown'
|
||||||
|
|||||||
@@ -14,6 +14,16 @@
|
|||||||
//
|
//
|
||||||
// 【mustcard 没有就要清空】:它是「本轮跟牌的必出牌」,只对当前这一轮有效。
|
// 【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。
|
||||||
var EQW_PlayHandler = EQW_PlayHandler || {
|
var EQW_PlayHandler = EQW_PlayHandler || {
|
||||||
|
|
||||||
//order: 1/2/3,对应 chupai1/2/3
|
//order: 1/2/3,对应 chupai1/2/3
|
||||||
@@ -23,19 +33,11 @@ var EQW_PlayHandler = EQW_PlayHandler || {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
//—— 本轮桌面:记下这一手(谁出的、出了什么、首家的牌型信息)——
|
//—— 本轮进行态:服务端权威快照,原样拷贝,前端零推导 ——
|
||||||
if (!EQW_GameState.table.playproc) { EQW_GameState.table.playproc = {}; }
|
EQW_GameState._apply(EQW_GameState.table, data, { playproc: 'playproc' });
|
||||||
var proc = EQW_GameState.table.playproc;
|
|
||||||
proc.order = order;
|
//—— 出牌历史:把这一手记进本轮(chupai1 = 一轮的第一手,开新的一轮)——
|
||||||
EQW_GameState._apply(proc, data, {
|
EQW_GameState.pushPlay(data.seat, data.cards, order === 1);
|
||||||
seat: 'seat',
|
|
||||||
cards: 'cards',
|
|
||||||
count: 'count',
|
|
||||||
flower: 'flower',
|
|
||||||
cardtype: 'cardtype',
|
|
||||||
shuai: 'shuai',
|
|
||||||
shuaicuo: 'shuaicuo'
|
|
||||||
});
|
|
||||||
|
|
||||||
//—— 自己的手牌:只有出牌者本人才收到 cardsinhand ——
|
//—— 自己的手牌:只有出牌者本人才收到 cardsinhand ——
|
||||||
EQW_GameState._apply(EQW_GameState.my, data, { cards: 'cardsinhand' });
|
EQW_GameState._apply(EQW_GameState.my, data, { cards: 'cardsinhand' });
|
||||||
@@ -60,7 +62,7 @@ var EQW_PlayHandler = EQW_PlayHandler || {
|
|||||||
countdown: 'countdown'
|
countdown: 'countdown'
|
||||||
});
|
});
|
||||||
|
|
||||||
EventBus.emit(EQW_Events.EQW_CARD_PLAYED);
|
EventBus.emit(EQW_Events.EQW_CARD_PLAYED, this._playPayload(data, order));
|
||||||
if (data.hasOwnProperty('cardsinhand')) { EventBus.emit(EQW_Events.EQW_HAND_CHANGED); }
|
if (data.hasOwnProperty('cardsinhand')) { EventBus.emit(EQW_Events.EQW_HAND_CHANGED); }
|
||||||
|
|
||||||
//—— 末家出完:本轮结束,记下谁最大、闲家得了多少分 ——
|
//—— 末家出完:本轮结束,记下谁最大、闲家得了多少分 ——
|
||||||
@@ -80,5 +82,17 @@ var EQW_PlayHandler = EQW_PlayHandler || {
|
|||||||
}
|
}
|
||||||
|
|
||||||
EventBus.emit(EQW_Events.EQW_TURN_CHANGED);
|
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;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -43,6 +43,20 @@ var EQW_ResultHandler = EQW_ResultHandler || {
|
|||||||
|
|
||||||
//三种来源共用的落地逻辑:有哪组就写哪组,缺的保持 null
|
//三种来源共用的落地逻辑:有哪组就写哪组,缺的保持 null
|
||||||
_applyResult: function (d) {
|
_applyResult: function (d) {
|
||||||
|
//—— 收尾轮的第三手:牌局在最后一手打完时【不发 chupai3】,整包换成 jiesuan(协议 §13/§14)——
|
||||||
|
//夹具实跑一局的包数就是证据:chupai1×28 / chupai2×28 / chupai3×27。
|
||||||
|
//只在 chupai3 归档会永远丢掉最后一轮,故在这里补齐——它必是本轮第三手,落进当前这一轮。
|
||||||
|
//jiesuan.chupai 的字段与 chupai3 不同:只有 seat / cards / maxseat,
|
||||||
|
//没有 order / playproc / cardsinhand / grade。
|
||||||
|
//归档随后会被下面的结算收敛清掉(deskinfo step6 不带 PushCards),但 emit 是同步的:
|
||||||
|
//落牌与收牌表现在清空之前就已取到完整的收尾轮。
|
||||||
|
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; }
|
||||||
|
EventBus.emit(EQW_Events.EQW_CARD_PLAYED, lastPlay);
|
||||||
|
}
|
||||||
|
|
||||||
//结算是"清空过程量、只留结算数据"的快照点:deskinfo step6 只带 Balance(=aset),
|
//结算是"清空过程量、只留结算数据"的快照点:deskinfo step6 只带 Balance(=aset),
|
||||||
//其余分组一律回到 reset() 默认值——jiesuan 也要收敛到同一状态,否则重连前后画面不一致
|
//其余分组一律回到 reset() 默认值——jiesuan 也要收敛到同一状态,否则重连前后画面不一致
|
||||||
//(清单以 ResyncHandler.handleDeskinfo 在 step6 时实际重建出的字段集为准,不多清不少清)。
|
//(清单以 ResyncHandler.handleDeskinfo 在 step6 时实际重建出的字段集为准,不多清不少清)。
|
||||||
|
|||||||
@@ -85,10 +85,11 @@ var EQW_ResyncHandler = EQW_ResyncHandler || {
|
|||||||
|
|
||||||
_applyChooseMain: function (g) {
|
_applyChooseMain: function (g) {
|
||||||
EQW_GameState._apply(EQW_GameState.aset, g, {
|
EQW_GameState._apply(EQW_GameState.aset, g, {
|
||||||
banker: 'banker',
|
banker: 'banker',
|
||||||
call: 'call',
|
call: 'call',
|
||||||
multiple: 'multiple',
|
multiple: 'multiple',
|
||||||
touxiang: 'touxiang'
|
touxiang: 'touxiang',
|
||||||
|
curmultiple: 'curmultiple' //与 shangzhuang 推送同源同值,漏读会让「抓分」角标掉档
|
||||||
});
|
});
|
||||||
EQW_GameState._apply(EQW_GameState.my, g, { bottomCards: 'bottomcards' });
|
EQW_GameState._apply(EQW_GameState.my, g, { bottomCards: 'bottomcards' });
|
||||||
EQW_GameState._apply(EQW_GameState.turn, g, { countdown: 'countdown' });
|
EQW_GameState._apply(EQW_GameState.turn, g, { countdown: 'countdown' });
|
||||||
@@ -97,10 +98,11 @@ var EQW_ResyncHandler = EQW_ResyncHandler || {
|
|||||||
|
|
||||||
_applyBuryCards: function (g) {
|
_applyBuryCards: function (g) {
|
||||||
EQW_GameState._apply(EQW_GameState.aset, g, {
|
EQW_GameState._apply(EQW_GameState.aset, g, {
|
||||||
banker: 'banker',
|
banker: 'banker',
|
||||||
call: 'call',
|
call: 'call',
|
||||||
multiple: 'multiple',
|
multiple: 'multiple',
|
||||||
flower: 'flower'
|
flower: 'flower',
|
||||||
|
curmultiple: 'curmultiple' //同上:一张牌未出时恒为 +3(大光),也必须重建
|
||||||
});
|
});
|
||||||
EQW_GameState._apply(EQW_GameState.my, g, { bottomCards: 'bottomcards' });
|
EQW_GameState._apply(EQW_GameState.my, g, { bottomCards: 'bottomcards' });
|
||||||
EQW_GameState._apply(EQW_GameState.turn, g, { countdown: 'countdown' });
|
EQW_GameState._apply(EQW_GameState.turn, g, { countdown: 'countdown' });
|
||||||
|
|||||||
@@ -3,10 +3,14 @@
|
|||||||
///////////////////////////////////////////////////////////////
|
///////////////////////////////////////////////////////////////
|
||||||
// 前端红线:框架保持游戏中立,玩法专属事件由子游戏追加到 EventBus.Events。
|
// 前端红线:框架保持游戏中立,玩法专属事件由子游戏追加到 EventBus.Events。
|
||||||
//
|
//
|
||||||
// 【事件只带「发生了什么」,不带数据】——订阅者从 EQW_GameState 读。
|
// 【对局状态不进事件载荷】——凡是 GameState 里有的,订阅者一律从 EQW_GameState 读,
|
||||||
// 这样同一份数据不会在事件载荷与状态里各存一份(SSOT)。
|
// 同一份数据不在事件载荷与状态里各存一份(SSOT)。
|
||||||
// 唯一例外是 EQW_RPC_FAILED,它带 {rpc, errcode}:那是一次性的错误信息,
|
// 例外只有「一次性、不属于对局状态、deskinfo 也不下发」的信息,它们不该进 GameState
|
||||||
// 不属于对局状态、不该进 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 || {
|
var EQW_Events = EQW_Events || {
|
||||||
|
|
||||||
EQW_RESET: 'eqw.reset', //新一局发牌,全部清场
|
EQW_RESET: 'eqw.reset', //新一局发牌,全部清场
|
||||||
|
|||||||
@@ -100,6 +100,28 @@ var EQW_GameState = EQW_GameState || {
|
|||||||
return out;
|
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 = { 目标键: 源键 }。
|
//把包里的字段写进目标分组。map = { 目标键: 源键 }。
|
||||||
//【缺字段不兜底】源键在包里不存在就不写,保持原值——不填默认值掩盖漏发。
|
//【缺字段不兜底】源键在包里不存在就不写,保持原值——不填默认值掩盖漏发。
|
||||||
_apply: function (target, src, map) {
|
_apply: function (target, src, map) {
|
||||||
|
|||||||
Reference in New Issue
Block a user