Files
erqiwang_youle/docs/superpowers/specs/2026-08-27-二七王前端B-网络与数据镜像-design.md
T
joywayerandClaude Opus 5 d4de34c951 二七王:B 设计修正发包路由与解散入口
两处对着平台代码核实后修正:

发包必须用 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>
2026-08-27 17:05:11 +08:00

19 KiB
Raw Blame History

二七王前端 · 子项目 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(仅庄家)/ touxiang
  • BuryCards(step 3):同上 + flower
  • PushCards(step 5):banker / call / multiple / flower / countdown / burycards(仅庄家)/ grade / gradecards / playproc / seatlist / liangpai / pushlist / curmultiple / mustcard
  • Balance(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 阶段的建房拼串复用同一份