Files
erqiwang_youle/client/js/01_SubGame/codes/state/GameState.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

135 lines
6.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_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
},
//—— 结算 ——
result: {
chupai: null,
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 = { chupai: null, 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]; }
}
}
};