两处对着平台代码核实后修正:
发包必须用 RpcHelper.sendRpc('youle','erqiwang',...),不能用 sendGameRpc
——后者预设 route="room",包会被路由到平台房间模块、永远到不了子游戏 mod.js,且不报错。
解散结算根本进不了 _ReceiveData:平台按 route 分流,room 路由交给 Net[rpc],
只有子游戏路由才转 _ReceiveData。平台另有专门入口 Game_Modify.Free(Desk.deskfree),
取值路径因此比协议文档少一层(deskfree.data.aset),且参数可能为 null 需容忍。
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
19 KiB
二七王前端 · 子项目 B:网络与数据镜像 · 设计
日期:2026-08-27 状态:待实施 权威源:协议
server/games/erqiwang/docs/protocol/packet_protocol.md;玩法server/games/erqiwang/docs/design/design.md; 前端规范docs/client/development-guide/(尤其 04 网络对接、05 §6 数据驱动);界面规格docs_dev/二七王-UI资源与精灵清单.md。 前置:子项目 A(地基与逻辑层)已合入 master。
1. 背景与定位
子项目 A 交付了常量层、纯逻辑核心(CardCodec/CardOrder/CardMark/SeatMap/SpriteIndex)、布局求解器、前后端共享算法与单测框架。B 负责把前端接上服务端:发包、收包分发、把服务端快照镜像成前端状态、重连重画。
B 不含任何 UI 组件与渲染——那是 C–E。B 的全部产物都能脱离界面用单测覆盖,这既是范围约束,也是可测性的保证。
1.1 B 在六阶段中的位置
| # | 子项目 | 状态 |
|---|---|---|
| A | 地基与逻辑层 | ✅ 已完成(52 commit,合入 master) |
| B | 网络与数据镜像(本文) | 本次 |
| C | 牌桌主界面 | 待做 |
| D | 阶段操作界面 | 待做 |
| E | 弹窗与结算 | 待做 |
| F | 建房与接入收尾 | 待做 |
2. 架构
点击(C–E 阶段的组件)
└─ Rpc.sendXxx(意图) 语义化发包,只带「做什么 + 目标标识」
└─ RpcHelper.sendRpc('youle','erqiwang') 框架基座,自动注入平台字段
└─ 引擎发送
服务端推送
└─ Game_Modify._ReceiveData 平台入口(转发壳,不写业务)
└─ SubGameHooks._ReceiveData
└─ Dispatcher.dispatch(msg) 唯一收包入口,纯路由表
└─ handlers.handleXxx(data) 一 rpc 一处理器
├─ ① 判 data.success
├─ ② 把包内权威字段写进 GameState
└─ ③ emit 语义事件
└─ (C–E)组件订阅事件 → 从 GameState 读 → set/refresh
关键约束:handlers 不引用任何 UI 组件。这是 B 能脱离 C 独立完成与测试的前提,也避免了「网络层反向依赖表现层」。
2.1 目录
client/js/01_SubGame/codes/
net/
Rpc.js 语义化发包(一操作一方法)
Dispatcher.js 唯一收包入口 + 纯路由表
handlers/
DealHandler.js fapai
CallHandler.js jiaofen / shangzhuang
MainHandler.js xuanzhu
BuryHandler.js maipai
PlayHandler.js chupai1 / chupai2 / chupai3
QueryHandler.js mingpai / tishi
ResultHandler.js jiesuan / Free(解散结算,走平台 Game_Modify.Free 入口)
ReadyHandler.js zhunbei
ResyncHandler.js deskinfo(重连)+ StartWar(开局)
FailHandler.js 失败回包(与请求同名的 rpc)
state/
GameState.js 服务端快照的镜像,前端 SSOT
Events.js 事件常量(追加到 EventBus.Events)
单向依赖:handlers → GameState → Events;Rpc 独立于三者,只依赖 GameState.room.mySeat。
3. GameState:服务端快照的镜像
3.1 设计原则
- 只镜像、不派生:
GameState只存服务端下发过的字段,不存任何前端算出来的结论。需要派生的(手牌排序、标记、花色统计)由 A 阶段的core/纯函数在渲染时现算,不落地成状态。 - 字段名对齐协议:便于逐条核对「前端状态 == 服务端快照」。协议里叫
currcall就叫currcall,不改名成currentCall。 - 缺字段不兜底:包里没有的字段保持原值,不填默认值掩盖。必须存在却缺失的(如
chupai1缺seat)显式console.error并跳过该包——这是服务端漏发,修在服务端(前端 05 §6.1)。
3.2 结构
EQW_GameState = {
// —— 房间级(跨小局)——
room: {
asetCount: 0, // 总局数 ← fapai.asetcount / deskinfo.count
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 // 是否允许投降(仅 70 分坐庄为 1)
},
// —— 当前控制权与倒计时(任何阶段都读这里)——
turn: {
seat: -1, // 该谁操作 ← 各包的 seat / nextseat
countdown: 0 // 展示用秒数 ← 各包的 countdown
},
// —— 叫分过程 ——
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 // 大局结算(末局或解散才有)
}
}
3.3 读写接口
GameState.reset()——清空到初始值(新一局发牌时调)。room.mySeat与room.options不清(跨局不变)。GameState.applyXxx(data)——每类包一个写入方法,只写包里存在的字段(hasOwnProperty判定)。GameState.snapshot()——返回深拷贝,供测试做「增量 vs 全量」比对。
没有 getter 语义方法:组件直接读 GameState.aset.banker 这样的路径。多加一层读接口只是转发,不产生价值(YAGNI)。
4. 收包分发
4.1 分发表
Dispatcher 是纯路由表 + 分发器,不写业务:
var _handlers = {
'fapai': function (d) { DealHandler.handle(d); },
'jiaofen': function (d) { CallHandler.handleJiaofen(d); },
'shangzhuang': function (d) { CallHandler.handleShangzhuang(d); },
'xuanzhu': function (d) { MainHandler.handleXuanzhu(d); },
'maipai': function (d) { BuryHandler.handle(d); },
'chupai1': function (d) { PlayHandler.handle(d, 1); },
'chupai2': function (d) { PlayHandler.handle(d, 2); },
'chupai3': function (d) { PlayHandler.handle(d, 3); },
'mingpai': function (d) { QueryHandler.handleMingpai(d); },
'tishi': function (d) { QueryHandler.handleTishi(d); },
'jiesuan': function (d) { ResultHandler.handleJiesuan(d); },
'zhunbei': function (d) { ReadyHandler.handle(d); }
};
未知 rpc 只 console.warn、不抛错——平台日后新增推送不该让前端崩。
4.2 三个容易写错的地方
① 失败回包与成功推送同名。 协议 §0.2:任一校验不通过,服务端回一个 rpc 与请求同名的失败包(chupai 失败回 chupai,而不是 chupai1/2/3),只含 success:false + errcode,且只回给请求者。
于是 mingpai / tishi / jiaofen 这些 rpc 既可能是成功推送、也可能是失败回包;而 chupai / maipai / xuanzhu / touxiang 只会以失败回包的形式出现(成功走 chupai1/2/3、maipai、xuanzhu 推送)。
判定顺序:dispatch 先看 data.success——为 false 一律交给 FailHandler(emit RPC_FAILED,带 rpc 与 errcode),不进业务 handler。这样每个业务 handler 都可以假定自己拿到的是成功包。
② 解散结算走另一个平台入口。 它根本进不了 _ReceiveData——平台 12_Logic.js:258 按 _msg.route 分流,platform/agent/room 三个路由交给 Net[rpc],只有子游戏路由(erqiwang)才转给 Game_Modify._ReceiveData。而解散包的 route 是 room。
平台为此提供了专门入口:07_Desk.js:875 的 Game_Modify.Free(Desk.deskfree) → SubGameHooks.Free(deskfree)。
两个要点:
- 取值路径比协议文档少一层:协议 §14.1 描述的是原始下发包(
data.deskfree.data.aset),但平台已经把deskfree取出来作为参数传入,所以子游戏侧是deskfree.data.aset/deskfree.data.account。 - 参数可能是
null:Desk.deskfree初值为null,解散若发生在开战后、首局发牌前,服务端get_disbandRoom返回null、平台走「不带deskfree」分支——Free(null)必须容忍,只做房间收尾、不弹结算。
③ countdown 只是展示用的秒数。 协议 §0.3:服务端没有对应定时器,归零不会自动叫分/选主/埋牌/出牌,也不判负、不跳过。轮到谁而谁不操作,牌局就停在那一直等。
因此 GameState.turn.countdown 只是个数字,前端绝不能在归零时做任何界面推进(不跳阶段、不清控制权)。倒计时的插值显示是 D 阶段的事,B 只负责把服务端给的秒数存下来。
5. 发包
5.1 语义化方法
必须用 RpcHelper.sendRpc('youle', 'erqiwang', rpc, data),不能用 sendGameRpc——后者预设 route = "room"(平台房间模块),而二七王的 route 是 erqiwang。用错了包会被平台路由到房间模块、永远到不了子游戏的 mod.js,且不会有任何报错。
RpcHelper 已自动注入 agentid / gameid / playerid / roomcode 四个平台字段,Rpc.js 只需补 seat 与业务字段。
Rpc.js 一个操作一个方法,seat 从 GameState.room.mySeat 自动带上(协议要求包内 seat 供服务端做一致性校验,但身份由连接反查,前端填的 seat 不作身份依据):
Rpc.jiaofen(call) // call: 5–70,0 = 不叫
Rpc.touxiang() // 仅 70 分坐庄且在选主阶段可用
Rpc.xuanzhu(flower) // flower: 1方块 2梅花 3红心 4黑桃
Rpc.maipai(cards) // 8 张牌 id
Rpc.chupai(cards) // 牌 id 列表
Rpc.mingpai() // 查看他家主牌
Rpc.tishi(tip) // tip: 1踩 2没分 3有分
Rpc.zhunbei() // 准备
5.2 只带意图,不带结论
这是防作弊的根本(前端 04 §1):前端能自己推出来的东西,就是玩家能改的东西。
| 可以带 | 禁止带 |
|---|---|
| 操作类型、牌 id 列表、花色、提示类型、座位号 | 分数、番数、"我胡了"之类判定、结算结果、阶段推进指令、nextSeat、handCards |
A 阶段的 shared/ 算法只用于本地提示与预校验,结果不回传。
5.3 参数校验
发包前做本地形状校验(不是规则校验):牌 id 必须是 0–107 的整数、互不重复、数组非空。不合形状就 fail-fast 抛错,不发出残缺包——服务端会按 PARAM 拒绝,但让错误暴露在最近处更省事。
规则合法性不在前端判(能不能出这手牌、叫分够不够低),那是服务端的事。
6. 事件契约
事件常量追加到 EventBus.Events(框架游戏中立,玩法专属事件定义在子游戏侧):
| 事件 | 何时 emit | C–E 谁关心 |
|---|---|---|
EQW_RESET |
新一局发牌 | 全部(清场) |
EQW_HAND_CHANGED |
手牌变化(发牌/摸底/选主重排/埋牌/出牌后) | 手牌区 |
EQW_CALL_CHANGED |
叫分推进 | 叫分面板、玩家位状态 |
EQW_BANKER_SET |
上庄 | 顶部信息条、玩家位庄标、底牌区 |
EQW_MAIN_SET |
选主 | 顶部信息条、手牌区(重排) |
EQW_BURY_DONE |
埋牌完成 | 手牌区、亮牌面板 |
EQW_CARD_PLAYED |
有人出牌 | 出牌区、手牌区、牌况角标 |
EQW_TRICK_END |
一轮结束(chupai3 带 maxseat) |
出牌区(收牌)、捡分飘字 |
EQW_TURN_CHANGED |
控制权或倒计时变化 | 倒计时、操作条显隐 |
EQW_MINGPAI |
收到明牌数据 | 明牌面板 |
EQW_TIP |
收到对家提示 | 提示气泡 |
EQW_ASET_RESULT |
小局结算 | 小局结算面板 |
EQW_ACCOUNT_RESULT |
大局/解散结算 | 大局结算面板 |
EQW_RESYNC_ALL |
重连全量重画 | 全部 |
EQW_RPC_FAILED |
失败回包 | 提示条、按钮恢复 |
事件只带「发生了什么」,不带数据——订阅者从 GameState 读。这样避免同一份数据在事件载荷与状态里各存一份(SSOT)。唯一例外是 EQW_RPC_FAILED,它带 {rpc, errcode}——这是一次性的错误信息,不属于对局状态、不该进 GameState。
7. 重连与开局
7.1 两条入口,一条路径
| 平台入口 | 场景 | 数据 |
|---|---|---|
SubGameHooks.StartWar(_msg) |
开局 | makewar 差异化下发(sendtype:1 + seatlist[]),先按本座位取自己那份 |
SubGameHooks.Reconnect(_deskinfo) |
断线重连 / 硬刷新 | get_deskinfo 全量快照 |
两者都交给 ResyncHandler:填 GameState → emit EQW_RESYNC_ALL。差别只在「填多少」,后半段完全相同。
7.2 deskinfo 的分组
按 step 只带对应阶段的分组,ResyncHandler 逐组判在则填:
- 恒有:
count/idx/PlayerInfo/step/MyCards CallRun(step 1):seat/countdown/nowcall/multiple/call[]ChooseMain(step 2):banker/call/multiple/countdown/bottomcards(仅庄家)/touxiangBuryCards(step 3):同上 +flowerPushCards(step 5):banker/call/multiple/flower/countdown/burycards(仅庄家)/grade/gradecards/playproc/seatlist/liangpai/pushlist/curmultiple/mustcardBalance(step 6):readystate/aset
重连不重放一次性事件:协议明确 70 分坐庄的 3 秒开底是上庄时的一次性事件,deskinfo 不重放——前端不得在重连时补播。
8. 错误处理
| 情形 | 处理 |
|---|---|
data.success === false |
交 FailHandler,emit EQW_RPC_FAILED{rpc, errcode},不进业务 handler |
| 未知 rpc | console.warn,不抛错 |
| 必需字段缺失 | console.error 并跳过该包(服务端漏发,修在服务端) |
| 可选字段缺失 | 保持 GameState 原值,不填默认值 |
解散包缺 deskfree |
容忍(首局发牌前解散的正常情形),只做房间收尾 |
| 发包参数形状非法 | fail-fast 抛错,不发出残缺包 |
一律不做的:用 || 0 / || [] 兜底掩盖缺失、据本地推断补齐服务端未下发的状态、在 countdown 归零时推进界面。
9. 测试
B 没有界面,全部产物都用 Node 单测覆盖,沿用 A 阶段的 client/tests/ 框架。
| 测试 | 覆盖 |
|---|---|
test_gamestate.js |
各 applyXxx 的字段写入;reset 的清空范围(mySeat/options 不清);缺字段不兜底 |
test_dispatcher.js |
路由表完整性(协议里每个推送 rpc 都有 handler);success:false 优先走 FailHandler;未知 rpc 不抛错 |
test_handlers.js |
逐包喂入 → 断言 GameState 与 emit 的事件 |
test_rpc.js |
发包只带意图:桩掉 RpcHelper,断言字段集合不含结论字段;参数形状校验的正反例 |
test_resync.js |
deskinfo 各 step 分组的填充;StartWar 差异化下发按座位取值 |
test_consistency.js |
增量 vs 全量一致性(见下) |
9.1 增量 vs 全量一致性(核心验收)
把一局的推送包按序喂进去累积出的 GameState,与用同一时刻的 deskinfo 全量重建出的 GameState,必须完全相等。
这是前端红线「视图 = f(服务端快照)」的可执行形式:
判据:任意时刻丢弃
this.data、仅凭最近一次服务端快照重画,界面必须完全一致——做不到即"前端私存了状态"或"服务端漏发了字段",都要修。
不一致意味着两件事之一,都要修:
- 前端私存了服务端不知道的状态(增量路径写了
deskinfo里没有的东西); - 服务端漏发了字段(
deskinfo缺了增量推送给过的信息)——这时修在服务端。
9.2 测试数据用服务端真实下发的包
server/games/erqiwang/test/_rpc.js 是现成的 L3 RPC 脚手架:它装配 mod.js、把 SendPack/sendpack_toother 全指向同一个 sent 数组,逐个快照真实的下发包。
所以 B 的测试不手写假包——跑一局服务端流程,把 sent 里的真实包喂进前端 GameState。这样测的是真实契约,而不是我对协议文档的理解。协议文档写错或前端理解偏差,都会在这里暴露。
具体做法:写一个夹具把服务端 sent 数组导出成 JSON(按座位区分,因为下发是差异化的),前端测试读它回放。导出脚本放 client/tests/,不污染服务端测试目录。
10. 范围边界
做:发包封装、收包分发、GameState、事件契约、重连/开局路径、上述单测。
不做:
- 任何 UI 组件与渲染调用(C–E)
- 倒计时的插值显示(D,B 只存服务端给的秒数)
- 建房界面与
roomtype拼串(F) - 战绩(F,框架
RecordView+ 平台入口) - 平台聊天 / 语音 / 解散投票界面(平台全包,子游戏只处理解散结算包)
11. 遗留与依赖
| 项 | 状态 | 处理 |
|---|---|---|
| 结算得分数字资源第 16 帧后缀 | A 阶段遗留,+18 还是 +18分 未定 |
E 阶段对设计稿定,不影响 B |
| 明牌/历史面板 28 张牌每张仅露 14px | A 阶段遗留,视觉偏窄 | E 阶段复核,不影响 B |
| 出牌操作条提示按钮 55 vs 63 高度差 | A 阶段遗留 | D 阶段对设计稿定,不影响 B |
roomtype 解析 |
B 需要它来判「可查牌 / 爬坡 / 傍王」 | B 内实现解析(与服务端 class.config.js 的 parse() 同规则),F 阶段的建房拼串复用同一份 |