二七王:C-1 修复 room.mySeat 永远为 -1

平台 12_Logic.js 在第 389 行调 Game_Modify.appStart(),而 C_Player = new Player(-1)
要到同函数第 480 行才执行,此刻 typeof C_Player === 'undefined',appStart 整体空转;
之后玩家登录 SetSeat 拿到真座位,appStart 再也不会被调用。于是 mySeat 永远停在 -1:
发包带 seat:-1 被服务端 check_player 全部拒绝、StartWar 差异化下发认领不到本座位而
整包丢弃、SeatMap.toDisplay(-1) 抛错——且全程无一处报错。

- 新增 SubGameHooks._syncMySeat():room.mySeat 的唯一写入处(SSOT),幂等、
  座位非法或 C_Player 缺席时保持原值不倒退
- 在 setRoomDes / StartWar / Reconnect / ReconnectNoMakewar / appStart 各调用一次,
  不赌「所有路径都经过某一个入口」
- 新增 SubGameHooks.changeSeat 并同步座位:Desk.change_seat 是唯一「座位中途变化
  且不重走 setRoomDes」的路径,漏掉会让 mySeat 静默过期
- Rpc._seat() 改 fail-fast:座位非 0/1/2 即抛错,不再无声透传 -1
- 新增 client/tests/test_appstart_timing.js:复刻平台真实初始化时序,
  全程不直接赋值 mySeat;已反向验证(退回 6 个调用点后核心断言转红)
- test_rpc_send 补座位非法抛错断言;test_consistency 删除空转的 my.mustCard EXCLUDE

client 23 脚本全绿,server 17 脚本 / 680 checks 全绿。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-27 21:05:46 +08:00
co-authored by Claude Opus 5
parent 22e43cf2e6
commit af779f473a
5 changed files with 224 additions and 7 deletions
+41 -3
View File
@@ -249,6 +249,31 @@ SubGameHooks.gamemydraw = function (gameid, spid, times, timelong, no2, no3, no4
//—— 网络与对局(B 阶段接线)——
//平台入口只解包转交,业务全在 net/ 的 handler 里。
// ---------------------------------------------------------------------------
// 【room.mySeat 的唯一写入处】(SSOT)
//
// 为什么不能只在 appStart 里取一次:
// 平台 12_Logic.js:389 调 Game_Modify.appStart() 时,C_Player 还只是一个
// `var C_Player;`——`C_Player = new Player(-1)` 要到【同一个函数】的第 480 行才执行。
// 此刻 typeof C_Player === 'undefined',守卫为假、整个 appStart 空转;
// 之后玩家登录、07_Desk.js 里 C_Player.SetSeat() 拿到真座位,但 appStart 再也不会被调用,
// 于是 mySeat 永远停在 -1:发包带 seat:-1(服务端 check_player 必然拒绝)、
// StartWar 差异化下发找不到本座位而整包丢弃、SeatMap.toDisplay(-1) 抛错。
// ——而且【全程无报错】,这正是它能潜伏下来的原因。
//
// 因此改为:单一写入处 + 多个调用点。凡是平台已经确定座位的入口都同步一次,
// 不赌「所有路径都会经过某一个入口」。
//
// 幂等:可重复调用;C_Player 不可用或座位非法时【保持原值不写】——
// 绝不用 -1 覆盖已同步好的正确座位。
// ---------------------------------------------------------------------------
SubGameHooks._syncMySeat = function () {
if (typeof C_Player === 'undefined' || !C_Player) { return; }
var seat = C_Player.seat;
if (seat !== 0 && seat !== 1 && seat !== 2) { return; }
EQW_GameState.room.mySeat = seat;
};
// 对局推送。_msg 是完整包 {app, route, rpc, data}
// (平台 12_Logic.js 按 route 分流,只有子游戏路由 erqiwang 才到这里)
SubGameHooks._ReceiveData = function (_msg) {
@@ -257,17 +282,22 @@ SubGameHooks._ReceiveData = function (_msg) {
};
// 开局。可能差异化下发(sendtype:1 + seatlist[])
// 【必须先同步座位】handleStartWar 要按 mySeat 在 seatlist[] 里认领自己那份
SubGameHooks.StartWar = function (_msg) {
SubGameHooks._syncMySeat();
EQW_ResyncHandler.handleStartWar(_msg);
};
// 断线重连:拿到 get_deskinfo 的全量快照
// (handleDeskinfo 内的 GameState.reset() 不清 room.mySeat,此处同步的值不会被冲掉)
SubGameHooks.Reconnect = function (_deskinfo) {
SubGameHooks._syncMySeat();
EQW_ResyncHandler.handleDeskinfo(_deskinfo);
};
// 重连但未开战:没有牌局数据,无需重画对局
SubGameHooks.ReconnectNoMakewar = function () {
SubGameHooks._syncMySeat();
console.log('[EQW] 重连(未开战)');
};
@@ -277,15 +307,23 @@ SubGameHooks.Free = function (_msg) {
};
// 房间信息(房号 / 总局数 / roomtype 位串)
// 平台三条进桌路径(07_Desk.js:358 登录 / 510 建房 / 631 进房)都在 C_Player.SetSeat() 之后、
// Reconnect / StartWar / DeskInfo 之前调它——是覆盖面最广的一个同步点
SubGameHooks.setRoomDes = function (roomcode, asetcount, roomtype) {
SubGameHooks._syncMySeat();
EQW_ResyncHandler.handleSetRoomDes(roomcode, asetcount, roomtype);
};
// 换座(平台 07_Desk.js:186-191 已先把 C_Player.seat 改成新座位再回调这里)。
// 这是唯一「座位会中途变化、且不重走 setRoomDes」的路径,漏掉这里 mySeat 会静默过期
SubGameHooks.changeSeat = function (seat1, seat2) {
SubGameHooks._syncMySeat();
};
// 启动编排。B 阶段只记下自己的座位;UI 初始化留给 C 阶段
// 注意:此刻 C_Player 通常还没创建(见 _syncMySeat 上方注释),座位靠后续入口补齐
SubGameHooks.appStart = function () {
if (typeof C_Player !== 'undefined' && typeof C_Player.seat === 'number') {
EQW_GameState.room.mySeat = C_Player.seat;
}
SubGameHooks._syncMySeat();
if (typeof Desk !== 'undefined' && Desk.roomtype) {
EQW_GameState.room.options = EQW_RoomOptions_Parse.parse(Desk.roomtype);
}
+11 -2
View File
@@ -21,9 +21,18 @@ var EQW_Rpc = EQW_Rpc || {
APP: 'youle',
ROUTE: 'erqiwang',
//—— 内部:取自己的座位 ——
//—— 内部:取自己的座位(fail-fast)——
//room.mySeat 由 SubGameHooks._syncMySeat 从平台 C_Player.seat 同步,初始值是 -1。
//【为什么不能透传】seat:-1 发出去,服务端 check_player 必然返回 falsy → 每个操作都回 ERR.PLAYER,
//叫分/选主/埋牌/出牌全部发不出去,而前端一声不吭。无声透传正是这类缺陷能长期潜伏的原因,
//故与本文件其余形状校验一样在最近处显式失败。
_seat: function () {
return EQW_GameState.room.mySeat;
var seat = EQW_GameState.room.mySeat;
if (seat !== 0 && seat !== 1 && seat !== 2) {
throw new Error('[EQW_Rpc] 座位未同步: ' + seat +
'(须为 0/1/2;检查 SubGameHooks._syncMySeat 是否已由平台入口调用)');
}
return seat;
},
//—— 内部:发送 ——