二七王:前端 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:
2026-08-27 19:41:48 +08:00
co-authored by Claude Opus 5
parent f7b87f32e8
commit 729128aa9d
6 changed files with 97 additions and 28 deletions
@@ -14,6 +14,16 @@
//
// 【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 || {
//order: 1/2/3,对应 chupai1/2/3
@@ -23,19 +33,11 @@ var EQW_PlayHandler = EQW_PlayHandler || {
return;
}
//—— 本轮桌面:记下这一手(谁出的、出了什么、首家的牌型信息)——
if (!EQW_GameState.table.playproc) { EQW_GameState.table.playproc = {}; }
var proc = EQW_GameState.table.playproc;
proc.order = order;
EQW_GameState._apply(proc, data, {
seat: 'seat',
cards: 'cards',
count: 'count',
flower: 'flower',
cardtype: 'cardtype',
shuai: 'shuai',
shuaicuo: 'shuaicuo'
});
//—— 本轮进行态:服务端权威快照,原样拷贝,前端零推导 ——
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' });
@@ -60,7 +62,7 @@ var EQW_PlayHandler = EQW_PlayHandler || {
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); }
//—— 末家出完:本轮结束,记下谁最大、闲家得了多少分 ——
@@ -80,5 +82,17 @@ var EQW_PlayHandler = EQW_PlayHandler || {
}
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;
}
};