From 4e79f135ce92a283698ad77d3960badf5f8bec29 Mon Sep 17 00:00:00 2001 From: Joywayer Date: Thu, 27 Aug 2026 17:19:43 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BA=8C=E4=B8=83=E7=8E=8B=EF=BC=9A=E5=89=8D?= =?UTF-8?q?=E7=AB=AF=E4=BA=8B=E4=BB=B6=E5=B8=B8=E9=87=8F=E4=B8=8E=20GameSt?= =?UTF-8?q?ate=20=E9=AA=A8=E6=9E=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GameState 是服务端快照的镜像、前端 SSOT:只镜像不派生、字段名对齐协议、 缺字段不兜底。reset 清对局态但保留 mySeat 与 options(跨局不变)。 事件只带「发生了什么」,数据由订阅者从 GameState 读,避免同一份数据两处存。 Co-Authored-By: Claude Opus 5 (1M context) --- client/js/01_SubGame/codes/state/Events.js | 37 +++++++ client/js/01_SubGame/codes/state/GameState.js | 102 ++++++++++++++++++ client/tests/test_gamestate.js | 56 ++++++++++ 3 files changed, 195 insertions(+) create mode 100644 client/js/01_SubGame/codes/state/Events.js create mode 100644 client/js/01_SubGame/codes/state/GameState.js create mode 100644 client/tests/test_gamestate.js diff --git a/client/js/01_SubGame/codes/state/Events.js b/client/js/01_SubGame/codes/state/Events.js new file mode 100644 index 0000000..652ccbc --- /dev/null +++ b/client/js/01_SubGame/codes/state/Events.js @@ -0,0 +1,37 @@ +/////////////////////////////////////////////////////////////// +////////// EQW_Events: 玩法事件常量 /////////////////////////// +/////////////////////////////////////////////////////////////// +// 前端红线:框架保持游戏中立,玩法专属事件由子游戏追加到 EventBus.Events。 +// +// 【事件只带「发生了什么」,不带数据】——订阅者从 EQW_GameState 读。 +// 这样同一份数据不会在事件载荷与状态里各存一份(SSOT)。 +// 唯一例外是 EQW_RPC_FAILED,它带 {rpc, errcode}:那是一次性的错误信息, +// 不属于对局状态、不该进 GameState。 +var EQW_Events = EQW_Events || { + + EQW_RESET: 'eqw.reset', //新一局发牌,全部清场 + EQW_HAND_CHANGED: 'eqw.hand_changed', //自己手牌变化 + EQW_CALL_CHANGED: 'eqw.call_changed', //叫分推进 + EQW_BANKER_SET: 'eqw.banker_set', //上庄 + EQW_MAIN_SET: 'eqw.main_set', //选主 + EQW_BURY_DONE: 'eqw.bury_done', //埋牌完成 + EQW_CARD_PLAYED: 'eqw.card_played', //有人出牌 + EQW_TRICK_END: 'eqw.trick_end', //一轮结束(chupai3 带 maxseat) + EQW_TURN_CHANGED: 'eqw.turn_changed', //控制权或倒计时变化 + EQW_MINGPAI: 'eqw.mingpai', //收到明牌数据 + EQW_TIP: 'eqw.tip', //收到对家提示 + EQW_ASET_RESULT: 'eqw.aset_result', //小局结算 + EQW_ACCOUNT_RESULT: 'eqw.account_result', //大局/解散结算 + EQW_READY_CHANGED: 'eqw.ready_changed', //有人准备 + EQW_RESYNC_ALL: 'eqw.resync_all', //重连/开局全量重画 + EQW_RPC_FAILED: 'eqw.rpc_failed' //失败回包 {rpc, errcode} +}; + +//追加到框架事件总线的事件表(框架不认识玩法事件,由子游戏注册) +if (typeof EventBus !== 'undefined' && EventBus.Events) { + for (var _eqwEvtKey in EQW_Events) { + if (EQW_Events.hasOwnProperty(_eqwEvtKey)) { + EventBus.Events[_eqwEvtKey] = EQW_Events[_eqwEvtKey]; + } + } +} diff --git a/client/js/01_SubGame/codes/state/GameState.js b/client/js/01_SubGame/codes/state/GameState.js new file mode 100644 index 0000000..cd1b747 --- /dev/null +++ b/client/js/01_SubGame/codes/state/GameState.js @@ -0,0 +1,102 @@ +/////////////////////////////////////////////////////////////// +////////// 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 + 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 跨局不变,不清 + reset: function () { + this.room.asetIdx = 0; + this.room.playerScores = []; + + 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; + } +}; diff --git a/client/tests/test_gamestate.js b/client/tests/test_gamestate.js new file mode 100644 index 0000000..d8e3a35 --- /dev/null +++ b/client/tests/test_gamestate.js @@ -0,0 +1,56 @@ +// GameState 骨架:初始值、reset 的清空范围、snapshot 深拷贝 +const { 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'); + +// ---- 事件常量已追加进 EventBus.Events ---- +t.eq('事件常量非空', Object.keys(EQW_Events).length > 0, true); +t.eq('RESET 已入 EventBus.Events', EventBus.Events.EQW_RESET, EQW_Events.EQW_RESET); +t.eq('事件名互不重复', + Object.keys(EQW_Events).length, + Object.keys(EQW_Events).map(k => EQW_Events[k]).filter((v, i, a) => a.indexOf(v) === i).length); + +// ---- 初始值 ---- +t.eq('初始 mySeat', EQW_GameState.room.mySeat, -1); +t.eq('初始 banker', EQW_GameState.aset.banker, -1); +t.eq('初始 flower', EQW_GameState.aset.flower, 0); +t.eq('初始手牌', EQW_GameState.my.cards, []); +t.eq('初始三家叫分', EQW_GameState.call.calls, [null, null, null]); + +// ---- snapshot 是深拷贝、且不含方法 ---- +EQW_GameState.my.cards = [1, 2, 3]; +const snap = EQW_GameState.snapshot(); +t.eq('snapshot 取到值', snap.my.cards, [1, 2, 3]); +EQW_GameState.my.cards.push(4); +t.eq('snapshot 是深拷贝(不随后续修改)', snap.my.cards, [1, 2, 3]); +t.eq('snapshot 不含方法', typeof snap.reset, 'undefined'); +t.eq('snapshot 含全部分组', + Object.keys(snap).sort(), + ['aset', 'call', 'my', 'result', 'room', 'table', 'turn']); + +// ---- reset:清对局态,但 mySeat / options 不清(跨局不变)---- +EQW_GameState.room.mySeat = 2; +EQW_GameState.room.options = { climb: 1 }; +EQW_GameState.aset.banker = 1; +EQW_GameState.aset.flower = 3; +EQW_GameState.table.pushlist = [[1], [2]]; +EQW_GameState.result.aset = { grade: 5 }; +EQW_GameState.reset(); + +t.eq('reset 后 mySeat 保留', EQW_GameState.room.mySeat, 2); +t.eq('reset 后 options 保留', EQW_GameState.room.options, { climb: 1 }); +t.eq('reset 后 banker 清空', EQW_GameState.aset.banker, -1); +t.eq('reset 后 flower 清空', EQW_GameState.aset.flower, 0); +t.eq('reset 后 pushlist 清空', EQW_GameState.table.pushlist, []); +t.eq('reset 后 result 清空', EQW_GameState.result.aset, null); +t.eq('reset 后手牌清空', EQW_GameState.my.cards, []); + +// ---- reset 两次结果一致(幂等)---- +const afterFirst = EQW_GameState.snapshot(); +EQW_GameState.reset(); +t.eq('reset 幂等', EQW_GameState.snapshot(), afterFirst); + +process.exit(t.done('gamestate') ? 0 : 1);