Files
erqiwang_youle/client/js/01_SubGame/codes/net/handlers/PlayHandler.js
T
joywayerandClaude Opus 5 729128aa9d 二七王:前端 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>
2026-08-27 19:41:48 +08:00

99 lines
5.1 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
///////////////////////////////////////////////////////////////
////////// 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。
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); }
//—— 末家出完:本轮结束,记下谁最大、闲家得了多少分 ——
if (order === 3) {
if (!EQW_GameState.result.chupai) { EQW_GameState.result.chupai = {}; }
EQW_GameState._apply(EQW_GameState.result.chupai, data, {
seat: 'seat',
cards: 'cards',
maxseat: 'maxseat',
grade: 'grade'
});
//本轮闲家得分累加进小局捡分;没得分时不带 grade,累计值保持不变
if (data.hasOwnProperty('grade')) {
EQW_GameState.aset.grade = EQW_GameState.aset.grade + data.grade;
}
EventBus.emit(EQW_Events.EQW_TRICK_END);
}
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;
}
};