Files
joywayerandClaude Opus 5 f8a154ff7a 二七王:result.chupai 移出 GameState,本墩结果改走 EQW_TRICK_END 事件载荷
谁最大、这墩闲家得几分是【一次性表现】(收牌动画、得分飘字),任何 deskinfo 都不下发它,
写进 GameState 就多出一处「只活在前端、服务端不知道」的对局态——出牌中途断线重连即归零,
违反红线「丢弃 this.data、仅凭最近一次服务端快照重画,界面必须一致」。

三处一起改,缺一不可:
- PlayHandler chupai3:删 result.chupai 写入,改随 EQW_TRICK_END 下发 {seat,cards,maxseat?,grade?};
  aset.grade 的累加保留(它有服务端来源 PushCards.grade / chupai3.grade,是镜像量)
- ResultHandler:_apply 的 map 删掉 chupai。收尾墩不发 chupai3、整包换成 jiesuan,
  不改这里则删 EXCLUDE 后必转红。收尾墩改为与 chupai3 走同一对事件、同一份载荷字段
  (补 emit EQW_TRICK_END 并带上 grade),否则最后一墩的得分没有出口
- GameState:删掉 result.chupai 字段本身(声明 + reset),不留永远为 null 的死字段

test_consistency 的 EXCLUDE 随之删到只剩 table.ancard3s 一条,27 checks 仍全绿;
该条改注释标明「当前夹具(65 分坐庄、单局)无法触发,此条未经验证」,不假装它在守什么。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 22:06:41 +08:00

115 lines
5.8 KiB
JavaScript
Raw Permalink 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.
// 【核心验收】增量累积的 GameState 必须与 deskinfo 全量重建的完全相等
const fs = require('fs');
const path = require('path');
const { ROOT, load } = require('./_load');
const t = require('./_assert')();
load('client/js/gameabc-framework/system/EventBus.js');
load('client/js/01_SubGame/codes/state/Events.js');
load('client/js/01_SubGame/codes/state/GameState.js');
load('client/js/01_SubGame/codes/state/RoomOptions.js');
load('client/js/01_SubGame/codes/net/handlers/DealHandler.js');
load('client/js/01_SubGame/codes/net/handlers/CallHandler.js');
load('client/js/01_SubGame/codes/net/handlers/MainHandler.js');
load('client/js/01_SubGame/codes/net/handlers/BuryHandler.js');
load('client/js/01_SubGame/codes/net/handlers/PlayHandler.js');
load('client/js/01_SubGame/codes/net/handlers/QueryHandler.js');
load('client/js/01_SubGame/codes/net/handlers/ReadyHandler.js');
load('client/js/01_SubGame/codes/net/handlers/ResultHandler.js');
load('client/js/01_SubGame/codes/net/handlers/ResyncHandler.js');
load('client/js/01_SubGame/codes/net/Dispatcher.js');
const fx = JSON.parse(fs.readFileSync(path.join(ROOT, 'client/tests/fixtures/packets.json'), 'utf8'));
// 这些字段是「一次性事件」或「重连不重放」的,不参与一致性比对。
// 每加一条都必须写清理由——这个白名单是本测试唯一的松口处,滥用它等于废掉这条守卫。
//
// 【清单历史】result.bottom / result.account 已随服务端 Balance 补发而删除;
// result.chupai 已随「本墩结果改走 EQW_TRICK_END 事件载荷、不进 GameState」而删除。
// 现在只剩下面这一条。
const EXCLUDE = [
// 70 分坐庄的 3 秒开底是上庄时的一次性事件,协议明确 deskinfo 不重放。
// ⚠️ 当前夹具无法触发(65 分坐庄、单局),此条未经验证——保留是因为删掉之后
// 将来真出现不一致会静默通过;但它此刻并没有在守住任何东西,不要当成已验证的豁免。
'table.ancard3s'
];
function pick(snap, exclude) {
const out = JSON.parse(JSON.stringify(snap));
exclude.forEach(p => {
const parts = p.split('.');
let o = out;
for (let i = 0; i < parts.length - 1; i++) { o = o && o[parts[i]]; }
if (o) { delete o[parts[parts.length - 1]]; }
});
return out;
}
// 每个对齐点独立起步,不共用进程内任何残留(下面两个 build 函数各自从
// EQW_GameState.reset() + 显式赋值房间级字段开始,互不依赖调用顺序/次数):
//
// 【曾经的假绿】EQW_GameState.reset() 按设计不清 room.playerScores(它是"跨局不清"的
// 房间级累积量,见 GameState.js 注释)。旧版路径 A 从不写 playerScores(fapai 包不带),
// 于是它的值全靠"上一次由谁最后写过"——如果上一次恰好是路径 B(deskinfo 带 PlayerInfo)
// 跑过,路径 A 就会【继承】那份残留,凑巧与路径 B 的期望值相等,测试假绿;换一个座位
// 顺序、或把它放到第一个跑,残留没了就立刻转红。
// 真实客户端不存在这个残留——它在 fapai 之前先收到 StartWar(deskwar,见夹具补捕),
// 由它给出 room.playerScores 的开局初值。所以路径 A 的真实起点也改成先喂 StartWar,
// 每次都显式重新赋值,不再依赖任何"恰好还没被冲掉的旧值"。
function buildIncremental(seat, packetIndex) {
EQW_GameState.reset();
EQW_GameState.room.mySeat = seat;
EQW_GameState.room.options = EQW_RoomOptions_Parse.parse(fx.roomtype);
EQW_ResyncHandler.handleStartWar({ data: fx.startwar[seat] });
fx.seats[seat].slice(0, packetIndex).forEach(p => {
EQW_Dispatcher.dispatch({ rpc: p.rpc, data: p.data });
});
return EQW_GameState.snapshot();
}
function buildFull(seat, info) {
EQW_GameState.reset();
EQW_GameState.room.mySeat = seat;
EQW_GameState.room.options = EQW_RoomOptions_Parse.parse(fx.roomtype);
EQW_ResyncHandler.handleDeskinfo(info);
return EQW_GameState.snapshot();
}
// 对每个座位、每个 deskinfo 快照点做比对
let compared = 0;
fx.deskinfo.forEach(snapPoint => {
const seat = snapPoint.seat;
const incremental = buildIncremental(seat, snapPoint.packetIndex);
const full = buildFull(seat, snapPoint.info);
t.eq('座位' + seat + ' step' + snapPoint.step + ' 增量 == 全量',
pick(incremental, EXCLUDE), pick(full, EXCLUDE));
compared++;
});
t.eq('比对点数量 > 0', compared > 0, true);
// ================= Ruling 3:step3(埋牌)deskinfo 直读断言 =================
// 一致性比对无法单独覆盖 ResyncHandler._applyBuryCards 的字段映射正确性——
// 若增量路径压根不写某字段,两边可能「同为初始值」而比对照样绿。
// 这里独立取 step3 快照,逐字段直读 GameState 并核对具体值。
const step3 = fx.deskinfo.filter(d => d.step === 3)[0];
if (!step3) {
console.warn('[test_consistency] 夹具里没有 step3 快照,Ruling 3 的直读断言无法执行');
} else {
EQW_GameState.reset();
EQW_ResyncHandler.handleDeskinfo(step3.info);
const bc = step3.info.BuryCards;
t.eq('step3 直读 aset.banker', EQW_GameState.aset.banker, bc.banker);
t.eq('step3 直读 aset.call', EQW_GameState.aset.call, bc.call);
t.eq('step3 直读 aset.multiple', EQW_GameState.aset.multiple, bc.multiple);
t.eq('step3 直读 aset.flower', EQW_GameState.aset.flower, bc.flower);
t.eq('step3 直读 my.bottomCards', EQW_GameState.my.bottomCards, bc.bottomcards);
t.eq('step3 直读 turn.countdown', EQW_GameState.turn.countdown, bc.countdown);
t.eq('step3 直读 turn.seat(埋牌由庄家做)', EQW_GameState.turn.seat, bc.banker);
t.eq('step3 直读 aset.step', EQW_GameState.aset.step, step3.info.step);
}
process.exit(t.done('consistency') ? 0 : 1);