Compare commits

...
127 Commits
Author SHA1 Message Date
joywayerandClaude Opus 5 637fa8fca7 二七王:补全群组 242 的注释——出牌历史/上一轮/亮牌一组三用
单看原注释「出牌历史」会以为 242 只有一个用途,
而 Sprites_Result.js 与 Layout_Result.js 里它是三个面板共用一套 HIST_* 精灵。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 22:37:08 +08:00
joywayerandClaude Opus 5 52ad71f8b2 二七王:合入前端子项目 B(网络与数据镜像)
建立前端网络层与状态镜像层:state/(GameState 状态镜像、Events、RoomOptions)、
net/(Rpc 发包、Dispatcher 收包分发、10 个 handler)、SubGameHooks 平台接线。

核心是「增量回放 vs 全量重连」一致性测试——用服务端实跑一局导出的真包,
把逐包增量与断线重连两条路径逼到同一结果。据此在界面开发前修掉 12 个缺陷:
前端私存对局态 6 处、服务端漏发下发面 5 处,以及 mySeat 恒为 -1
(平台调 appStart 时 C_Player 尚未 new,致所有请求包带 seat:-1、开局快照整包丢弃)。

服务端 649 → 729 checks;泄露门禁从「只扫牌 id」扩到统计量重算与投降/解散路径。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 22:32:20 +08:00
joywayerandClaude Opus 5 08c387e609 二七王:test_leak 门禁强化——统计量重算、豁免按字段路径收窄、覆盖投降/解散
泄露缺陷功能正常、玩家看不出、只有抓包的人知道,所以门禁的【盲区】比一次具体泄露更值得投入。
本轮补掉四处盲区:

① 统计量层(新增):原来只扫牌 id,seatlist 的计数、playproc.maxcard 的牌编码、curmultiple
   一个都扫不到。新增「公开账本独立重算」——只喂三家都看得见的信息(已广播的 chupai*.cards、
   chupai1 的 count/flower/cardtype、庄家/叫分/主花色,账本里没有任何人的手牌),
   算出的 maxseat/maxcard/缺门无对表/curmultiple/捡分 必须与包内值逐项相等;
   再钉死 seatlist 恒 5 项、playproc 恒 10 个键,防止有人新加一项统计而门禁照样全绿。
   报副格(design §9 的有意公开)单独做门控断言:没人报无主之前必须还是 [-1,-1]。
   反向验证 5 组(塞第 6 项统计 / 提前填报副格 / 按真实手牌点亮已有缺门格 /
   maxcard 混入余主数 / curmultiple 混入余主数)全部转红。

② 埋牌底牌豁免:从「按 rpc 整包放行」收窄为「按字段路径 bottom.cards + step===6 + result!==3」。
   为此 collectCards 改为同时记录牌 id 的字段路径。对照实证:构造「给解散路径也带上 bottom」,
   新规则转红、旧规则静默放行。

③ 投降 / 解散两条结算路径此前零覆盖(9 局全是正常打完),各补 2 局并加覆盖下限断言。
   审计未发现新泄露;唯一需放行的是「算奖明细 aset.seatlist[].cards」——投降/解散时牌一张没打,
   他家的王/冲关牌会随算奖明细发给三家。这是既有行为(算奖需向三家自证、局已终止),
   写成带理由、带命中计数(awardExempt>=1)的显式豁免,不让它落在扫不到的盲区里。

④ 措辞更正:上一轮把「给重连包补上豁免」记成"收紧",实际净效果是放宽(旧放行面 ⊂ 新放行面),
   注释已改成准确说法。

覆盖量:3141 个下发面 / 38619 次可见性判定 / 103175 次统计量重算比对。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 22:07:15 +08:00
joywayerandClaude Opus 5 8e0c8fdc90 二七王:接上 SubGameHooks.DeskInfo(中途进桌),复用重连的重画路径
平台 07_Desk.js 在「牌局已开始 + 走加入房间而非重连」时调 Game_Modify.DeskInfo(_msg.data.deskinfo),
该 hook 此前只有注释占位,整份 deskinfo 被 02 转发壳静默丢弃、界面全空且不报错。

裁剪核实(读平台源码):两条路径的 deskinfo 都出自 server_room/class.return.js——
进房走 self_join_room 的 get_deskinfo(o_room, o_player.gameinfo.seat),
登录走 self_login 的 get_deskinfo(o_player.gameinfo.o_room, o_player.gameinfo.seat),
同一个函数、同一个「本人座位」入参。底牌/埋牌底牌只给庄家、亮牌只给闲家、mustcard 只给轮到那家
等可见性裁剪对中途进桌一样生效,不存在「拿到别人视角」的问题,接线安全。

test_hooks 新增 4 条断言,含「同一份 deskinfo 分别喂 DeskInfo 与 Reconnect,snapshot 逐字段相同」。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 22:06:53 +08:00
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
joywayerandClaude Opus 5 b9a423071b 二七王:Balance.readystate 改为深拷贝下发,扩展深拷贝守卫测试
o_desk.prepare 是 do_prepare 就地置 1、do_new_paiju 就地重建的活数组,
整根挂进 deskinfo.Balance.readystate 会让「已发出的包」随后续准备操作被回改。
与 get_playproc / get_seatlist / get_pushlist / get_bottomcards 同一条标准,改为下发副本。

test_rpc 扩展既有的深拷贝守卫:正面(内容逐格相等)+ 非空转(活数组确实变过)
+ 反面(已发出的包不被回改、改副本不影响本体)。反向验证:去掉 .concat() 后两条断言转红。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 22:06:26 +08:00
joywayerandClaude Opus 5 8b2cfdaf27 二七王:前端删掉 9 处阶段/控制权推导,一律读服务端包字段
I-3 前端不再持有对局状态机(配合服务端上一提交的下发面补齐):
- 删掉 5 处按 rpc 名硬编码的 aset.step(Deal=1 / Call=2 / Main=3 / Bury=5 / Result=6),
  一律改 _apply 读包里的 step
- 删掉 4 处 turn.seat = aset.banker(CallHandler / MainHandler / ResyncHandler×2),
  增量路径读 nextseat、重连路径读分组里的 seat;同一条规则不再有 4 个写入处

I-2 ResyncHandler._applyBalance 由只接 aset 扩为接 aset/bottom/account,
结算面板开着时重连/硬刷新不再丢抠底明细与末局大结算。
一致性测试 EXCLUDE 随之从 4 条收缩为 2 条(去掉 result.bottom / result.account,
只留一次性事件 table.ancard3s 与归下一轮的 result.chupai),未新增任何豁免条目。

夹具按新包结构重跑(种子不变,牌局不变):diff 纯为 67 行新增字段、0 行删除;
test_fixture 补上 step/nextseat/Balance 同源断言(68→82 checks)。
export_packets.js 里关于 playproc/seatlist 活引用的注释已过时,更正为仍存在的
Balance.readystate 与三份结算快照。

各 handler 单测补齐新字段并新增守卫用例:刻意构造 nextseat != banker、
step 为非预期值的包,使「退回硬编码/banker 反推」的写法必然转红(11/11 已验证)。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 21:31:20 +08:00
joywayerandClaude Opus 5 5d981a722e 二七王:I-3/I-2/M-2 服务端补齐阶段·控制权·结算重连下发面
I-3 阶段与控制权改由服务端唯一维护并逐包下发:
- fapai / jiaofen / shangzhuang / xuanzhu / maipai / jiesuan 各补 step(取自 o_paiju.step)
- shangzhuang / xuanzhu 补 nextseat(控制权 = o_paiju.banker,选主/投降/埋牌均只由庄家做)
- deskinfo 的 ChooseMain / BuryCards 各补 seat(控制权,与增量推送 nextseat 同源同值)
- 各下发点的真实值先用探针跑真实一局逐个核实;顺带发现 shangzhuang.data.seat 是
  最后一个叫分者而非控制权(本局实测 2 vs 庄家 0),故控制权另起名 nextseat
- get_paiju_account 统一把 step 置 6:解散结算此前无人推进 step,包里给不出诚实的阶段值

I-2 结算阶段的重连包补发抠底明细与大局结算:
- get_paiju_account 末尾一并冻结 tmp_jiesuan_bottom / tmp_jiesuan_account
- get_deskinfo case 6 的 Balance 据此补 bottom / account,与 jiesuan 推送同源同值、
  门控一致(投降/解散无 bottom、非末局无 account);新局开始时三份快照一并清除
- 修复「结算面板开着时断线重连/硬刷新 → 抠底明细与末局大结算空白」

M-2 seatlist 不再以活引用挂进下发包:
- 新增 get_seatlist() 深拷贝快照,maipai / chupai / deskinfo.PushCards 三处改用它

测试:test_rpc 新增 I-3/I-2/M-2 三节(131→166 checks)、test_desk 新增 fapai
阶段与控制权用例(6→10);全部新增守卫已逐条做「退回修复必转红」反向验证(15/15)。
test_leak 的可见性规则同步收紧到「本局结算(step6)后埋牌底牌才全场公开」,
并修掉一处把阶段写死成 step5 的报错文案;整局泄露审计仍全绿。
协议文档同步补充上述字段说明。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 21:30:56 +08:00
joywayerandClaude Opus 5 cfa0409d94 二七王:CLAUDE.md 补记断言反向验证与 CRLF 脚本改写注意事项
两条都是本轮开发中真实踩到的:
- 新增守卫断言若不做「故意破坏→看是否变红」,可能加的是空转断言;
- 仓库为 CRLF 行尾,脚本化改写用 `\n` 匹配会一行不改却静默成功,
  曾据此差点得出「测试抓不住原缺陷」的错误结论。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 21:07:15 +08:00
joywayerandClaude Opus 5 af779f473a 二七王: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>
2026-08-27 21:05:46 +08:00
joywayerandClaude Opus 5 22e43cf2e6 二七王:平台接线与加载段
SubGameHooks 只解包转交、不写业务:_ReceiveData 收到的是完整包,
按 rpc 分发;Free 的参数是平台已取出的 deskfree、可能为 null;
appStart 只记座位与房间选项,UI 初始化留给 C 阶段。

index.html 里 handler 排在 Dispatcher 之前——后者注册时要引用全部 handler。

另加 test_index_load_order.js:按 index.html 实际 script 顺序自动加载
codes/ 全部文件 + 3 个框架依赖,验证顺序无遗漏、无环,并断言
EQW_Dispatcher 路由表确已注册,作为长期守卫。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 20:16:45 +08:00
joywayerandClaude Opus 5 dac4a94b38 二七王:夹具补甩牌样本,重连恢复 baozhu
导出脚本原来全出单张,单张一手无所谓顺序,服务端两个顺序口径的分岔
在夹具里永远测不出来。改出牌策略(种子不变):首家优先合法甩牌 → 领
最大的一对 → 最小单张;跟家凑合法跟牌;提交时刻意反转成升序,模拟真实
客户端的点击顺序,把「下发顺序是否被请求顺序污染」暴露出来。
中段 step5 快照锚点从 1 个加到 5 个——开局那三张快照的 pushlist 还是空的,
只有打到一半的快照才真的比对到出牌历史。

重导结果:51 手出牌,一手多张 30 手、合法甩牌 4 手,deskinfo 快照 18 张,
仍覆盖 step 1/2/3/5/6;连跑两次逐字节相同。
该样本在修复前使 test_consistency 5 个比对点转红,修复后全绿。

前端 ResyncHandler 补上 PushCards.baozhu 的映射(服务端已补发),
test_resync 加正反两条断言。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 20:12:24 +08:00
joywayerandClaude Opus 5 4508fe005f 二七王:出牌顺序收敛为唯一口径,重连包补发 baozhu
同一份数据「这一手打出的牌」此前有两个顺序口径:chupai*/playproc.cards
回显客户端请求原序,重连包 pushlist 则从 playround 反查后自己再排一次序。
一手多张(甩牌/对子/拖拉机)时两者必然分岔;同编码的一对牌更是排序都
区分不了,靠"两处各自排序"永远收敛不了。

改为单一写入处:
- 新增 order_playcards(),do_playcard 入口归一化一次(顺带隔离
  can_playcard 的原地排序,不再就地改写 pack.data.cards)
- 新增 paiju.playhistory,出牌当场归档;新增 get_pushlist() 只做深拷贝
- class.export.js 删掉整段反查+重排,改为直读归档
- do_playcard 三个分支都给 re.cards,mod.js 去掉回落到入参的兜底

顺带修复重连包漏发 baozhu:它是余主公示与明牌按钮的开关,此前只在
chupai 包里给,报无主后重连按钮会凭空消失。现与 chupai 同源同门控下发。

协议文档新增 §0.6「一手出牌」的顺序口径,并补 PushCards.baozhu 字段。
测试:pushlist 一节改为真实链路驱动(一手多张 + 乱序提交),断言升级为
「重连每一手与当时的 chupai.cards 逐元素相等」;三处修复均已退回验证转红。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 20:12:03 +08:00
joywayerandClaude Opus 5 5689ceab6f 二七王:重新生成夹具、BuryHandler 接住 maipai.playproc,修正甩错断言的错误映射
配合上一个 commit 服务端补发的 maipai.playproc,重新生成真包夹具(diff 只新增
三家 maipai 包各一份 round-1 初值 playproc,无其他变化)后跑一致性测试,暴露出
前端还有一处对应缺口:BuryHandler.js 组 table.playproc 时没有接 data.playproc
(PlayHandler.js 早已对 chupai1/2/3 做了同样的原样拷贝),于是「埋牌完成→庄家
首出」窗口增量路径的 table.playproc 停在上一阶段的 null。现补上,并在
test_handlers_bury.js 新增一条用例守住、用「退回修复必转红」反向验证。

test_handlers_play.js:84 的 `S.table.playproc.shuaicuo` 断言的正是此前 Fix Round 2
裁决②(本手信息收进事件载荷、不进 GameState)判定为缺陷的旧映射,playproc 改为
原样拷贝服务端结构后必然读 null 硬崩。按 task-12d-report.md §8 的裁决与建议替换:
断言改为守事件载荷(EQW_CARD_PLAYED 的 shuaicuo/order/seat/cards/count/flower/
cardtype)+ playproc 原样拷贝,强度不降——原断言只守住"甩错标志被处理"这一件事,
新断言额外多守了"本手信息不得混入 playproc"一条。

client/tests/test_consistency.js 全绿(23 checks,此前 20 绿/3 红);
client/tests/run.js 全绿(20 脚本);server/games/erqiwang/test/run.js 全绿
(17 脚本/675 checks,见上一个 commit)。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 19:48:54 +08:00
joywayerandClaude Opus 5 cdf2bc730e 二七王:maipai 补发 playproc(增量路径与重连路径对齐)
前端一致性测试剩余 3 红的根因:do_burycard() 内部已调 new_playround 初始化好
round-1 的进行态,但 mod.js 组 maipai 包时没有放 playproc(协议 §9 字段表也没有),
「埋牌完成 → 庄家首出」这段窗口增量路径 table.playproc 为 null,重连路径却已有初值——
与上一个 commit(8452535)给 maipai 补 seatlist 是同一个窗口、同一条理由,被漏在了同一处。

修法:复用既有的 get_playproc() 快照函数(与 chupai1/2/3、PushCards.playproc 同源,
本身就是逐字段深拷贝,不引入活引用风险)。可见性:此刻一张牌未出,round/start/currseat
等全部由桌面公开信息推出,cards 全空、shuai_demand 为 null,不含私密信息,故与
chupai1/2/3.playproc 一样三家整体下发、不受查牌模式门控。

协议文档 §9 同步补字段说明。test_rpc.js 新增 5 checks:庄闲同值、与重连快照比对、
round-1 初值断言、不查牌模式下仍恒有、深拷贝守卫;已用「退回修复必转红」反向验证
(3/5 转红)。既有 121 checks 未改一条断言,全绿(17 脚本 / 675 checks)。未涉及 shared/。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 19:46:32 +08:00
joywayerandClaude Opus 5 729128aa9d 二七王:前端 handler 六处下发面漏读/错读修复(增量路径对齐重连路径)
一致性测试(同一局真包:逐包增量回放 vs 一次性 deskinfo 重连)暴露的六处前端缺陷:

① PlayHandler:table.playproc 由「字段映射错误」改为原样拷贝
   原实现把这一手的 seat/cards/count/flower/cardtype 灌进 playproc,而服务端 playproc
   是本轮进行态(round/start/currseat/.../cards),两者几乎无交集、撞名的 cards 语义还相反。
   服务端已在 chupai1/2/3 补发 playproc,前端零推导直接镜像,不自行累积 round/maxseat。

② 本手信息改走 EQW_CARD_PLAYED 事件载荷,不进 GameState
   {order, seat, cards, count?, flower?, cardtype?, shuai?, shuaicuo?},带 ? 的缺字段不兜底。
   落牌是一次性表现而非对局状态;新立 GameState 字段只会再制造一处增量/全量不一致。

③ PlayHandler:table.pushlist 逐手落盘、按轮归档(GameState.pushPlay)
   chupai1 开新的一轮、2/3 落进当前轮,任意时刻都与 deskinfo 的全量重建相等(服务端
   pushlist 含进行中的当前轮)。不读 chupai3.playproc.cards——那是下一轮的进行态(协议 §13)。
   仅可查牌模式累积,与服务端同一道门控,否则不查牌房会凭空攒出重连没有的历史。

④ ResultHandler:归档收尾轮(最后一手走 jiesuan.chupai,不发 chupai3)
   夹具实证 chupai1×28 / chupai2×28 / chupai3×27。jiesuan.chupai 只有 seat/cards/maxseat。
   归档随即被结算收敛清空(deskinfo step6 不带 PushCards),但 emit 同步,落牌表现在清空前取值。

⑤ BuryHandler:接住 maipai 新增的 seatlist(此刻为全初值,界面无差别但重连侧有)

⑥ ResyncHandler 的 ChooseMain/BuryCards 补映射 curmultiple(原重连重建为 0,抓分角标掉档);
   BuryHandler 埋牌完成后清空 my.bottomCards——底牌上庄时已并入手牌,埋牌后只剩 burycards
   有意义,deskinfo 的 PushCards 不下发 bottomcards,重连重建恒为 [](语义已逐条核实)。

一致性测试 8 红 → 3 红。剩余 3 条同源:服务端 maipai 漏发 playproc(与本次服务端补发
seatlist 是同一个窗口、同一个理由),前端无法自补且不得伪造,详见报告 §7 待裁决。
另有 test_handlers_play.js:84 断言的正是 ① 修掉的错误映射,按纪律未改测试,见报告 §8。

未触碰 server/、client/tests/(含夹具与 EXCLUDE);服务端 29 checks 全绿。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 19:41:48 +08:00
joywayerandClaude Opus 5 f7b87f32e8 二七王:重新生成真包夹具、补捕 StartWar、修复一致性测试用例间污染
服务端 8452535 改了下发面(chupai1/2/3 新增 playproc、deskinfo step2/3 新增
curmultiple、maipai 新增 seatlist、bottomcards/burycards 顺序统一),前端真包
夹具 packets.json 已过期,重新导出并逐项核对新字段存在与三处/两处顺序一致。

夹具新增顶层 startwar[](每座位一份,结构=deskwar=deskinfo,取自 do_new_paiju
之后、jiaofen 之前的同一时刻),独立存放、不混入 seats[],避免 packetIndex 对
齐锚点错位;test_fixture.js 补相应断言。

修复 test_consistency.js 的用例间污染:EQW_GameState.reset() 按设计不清
room.playerScores(跨局累积量),旧版增量路径从不写它,实际靠"沿用同进程里
上一次全量路径写入的残留"凑出假绿,换座位顺序或调整执行顺序就会转红。改为
每个对齐点独立起步(buildIncremental/buildFull 各自 reset),且增量路径先喂
StartWar 再喂增量包,与真实客户端时序一致,不再依赖任何隐式残留。已验证正
序/倒序跑出的 PASS/FAIL 集合逐条相同。

修完后 23 checks:15 绿、8 红。红项均已归因:3 处为任务已知的前端未接线缺口
(table.playproc 映射、table.pushlist 累积、maipai.seatlist 消费),另发现两
类同类缺口(ResyncHandler 未映射 deskinfo 新增的 curmultiple 字段、BuryHandler
埋牌后未清空 my.bottomCards),均未改动 client/js 正式代码,留给下一个前端任务。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 19:28:13 +08:00
joywayerandClaude Opus 5 84525359fc 二七王:服务端下发面四处缺陷修复(增量路径与重连路径对齐)
前端一致性测试(同一局真包:逐包增量回放 vs 一次性 deskinfo 重连)暴露的四处
服务端缺陷,均属「同一份数据两条路径给的不一样 / 有一条根本没给」:

① chupai1/2/3 补发 playproc(本轮进行态)
   与 deskinfo.PushCards 共用新增的 get_playproc() 快照函数,结构取值完全一致。
   playproc 是全程复用的活对象(new_playround 就地重置),故快照做逐字段深拷贝,
   避免已发出的包被后续出牌回改。可见性:全部字段由桌面公开信息推出,
   与既有 PushCards.playproc 一样三家无差别下发。

② deskinfo ChooseMain(step2)/BuryCards(step3) 补发 curmultiple
   与 shangzhuang 推送同源同值;此刻一张牌未出、捡分恒 0,必为 +3(大光)。
   原先重连重建为 0,顶部「抓分」角标掉档。

③ maipai 补发 seatlist
   与 chupai/PushCards 同一张表、同一道查牌模式门控;埋牌完成时为初始化空表。

④ bottomcards / burycards 收敛到单一权威源(SSOT)
   - burycards:maipai 原样回显了客户端请求包里 cards 的顺序,改取 get_burycard()
     权威快照,与 PushCards.burycards 同一函数。请求包只承载「意图」,其顺序不可信。
   - bottomcards:get_bottomcards 按「调用时的 flower」排序,导致上庄推送(flower=-1)
     与重连 BuryCards(flower 已定) 两种顺序。底牌是选主【之前】就翻给玩家看的、
     发牌结束即固定的快照,故改为发牌时算一次并冻结,get_bottomcards 返回其副本
     (order_cards 是原地排序,交出本体会被调用方就地重排)。三个调用点无需改动。

协议文档同步 7 处(新增 playproc/seatlist/curmultiple 字段说明与两处排序口径)。
新增 test_rpc.js「下发面一致性」用例组 21 checks,含 28 轮真实牌局逐包逐座位比对
(249 次、0 不一致)与深拷贝守卫;每条均已用「退回修复必转红」反向验证。
既有 649 checks 未改一条断言,全绿(17 脚本 / 670 checks)。未涉及 shared/。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 19:17:09 +08:00
joywayerandClaude Opus 5 c4c061b0e1 二七王:修复三处前端漏读(Task 12 一致性测试 Fix Round 1 · 类别 A)
一致性测试发现的 8 处不一致中,分诊出 3 处确属"deskinfo 里有、增量 handler
没读"的前端缺陷,本次修复:

- room.playerScores:reset() 不再清空,改为跨局保留字段;ResultHandler 从
  jiesuan 的 aset.seatlist[i].score(协议 §14「累积得分」)回填,避免一局
  中段(fapai 后至 jiesuan 前)总积分持续为空。
- call.currcall/call.calls:CallHandler.handleShangzhuang(step1→2 转场)
  清回默认值——deskinfo 从 ChooseMain 起就不再带 CallRun,这两个叫分过程量
  该阶段起无意义。
- ResultHandler._applyResult(jiesuan/解散共用)补齐结算清场:把 aset.*/
  turn.*/call.*/table.*/my.bottomCards/buryCards/cards 清回 reset() 默认值,
  与 deskinfo step6 只应用 Balance 分组时的重建结果对齐;顺带清掉出完最后一张
  牌的座位因 jiesuan.chupai 不带 cardsinhand 留下的幽灵手牌。

发现 3(curmultiple)、发现 6(seatlist 窗口期)判给服务端,发现 4/5
(table.playproc/pushlist)待裁决,均未改动。test_consistency.js 由 9
PASS/14 FAIL 转为 14 PASS/9 FAIL,EXCLUDE 白名单未改一字。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 18:57:54 +08:00
joywayerandClaude Opus 5 33c18a2c17 二七王:增量 vs 全量一致性测试(B 阶段核心验收)
把前端红线「视图 = f(服务端快照)」变成可执行断言:按序喂推送包累积出的
GameState,必须与同一时点 deskinfo 全量重建的完全相等。

不一致只有两种可能,都要修:前端私存了服务端不知道的状态,或服务端漏发了字段。
EXCLUDE 白名单只收「一次性事件」与「重连不重放」两类,每条都写明理由。

本测试当前保持失败:比对发现了 7 处前端 handler 缺陷(含 table.playproc 字段
结构与协议不符等高优先级问题)与 1 处疑似服务端下发顺序不一致,均未在本次
改动中修复,详见 task-12-report.md,留待裁决。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 18:48:08 +08:00
joywayerandClaude Opus 5 e2d470d37f 二七王:修复夹具导出的活引用污染并引入固定种子
审查发现 C-1(阻塞):export_packets.js 的 snap() 直接存了
class.export.js get_deskinfo() 返回的 PushCards.playproc/seatlist——这两个
字段在服务端是无拷贝的活引用赋值,脚本却在整局跑完后才统一 JSON.stringify
写盘,导致中途出牌不断原地改写这两个对象,已存的 step5 快照被事后篡改成终
局状态。修法是在 snap() 里对 get_deskinfo 的返回值套一层已有的 clone()。这
是导出脚本"延后序列化"这个用法本身的缺陷,不动 server/。

同时按裁决引入固定种子:min_random 只在 class.paiju.js 的发牌洗牌里用到,
原始实现走 Math.random();改为在导出脚本进程内 monkey-patch 一个 xorshift32
确定性 PRNG(写法与服务端既有测试 test_flow.js 等一致),种子值 DEAL_SEED 写
成具名常量。连跑两次导出脚本,packets.json 逐字节无 diff。

test_fixture.js 补强三条断言:底牌"只发给部分座位"改为精确断言恰好1个座位;
新增 packetIndex/roomtype/step 自洽性检查;step5 快照的 playproc 回归锁直接
捕获本次的活引用污染类缺陷;deskinfo 阶段覆盖从"≥3个"改为显式钉死
[1,2,3,5,6]。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 18:35:02 +08:00
joywayerandClaude Opus 5 e61725da40 二七王:前端测试的服务端真包夹具
用服务端 test/_rpc.js + class.desk.js 脚手架跑一局(发牌→叫分→上庄→选主→
埋牌→28轮出牌→结算),把真实下发包按座位导出成 JSON 供前端回放。不手写假
包——测的是真实契约,协议文档写错或前端理解偏差都会在这里暴露。

_rpc.js 的 setup() 只接受已构造好的 o_paiju,不走 class.desk.js,产不出
fapai;改用 test_flow.js/test_endgame.js 的 mkRoom() 模式(D.new + 全局
youle_erqiwang.app/import 与 mod.app/mod.import 同指一个 sent 数组),两条
既有用法缺一不可。

夹具自检守住质量:覆盖 fapai/jiaofen/shangzhuang/xuanzhu/maipai/
chupai1/2/3/jiesuan 全部阶段、保留座位差异(底牌只发给部分座位)、每个下发
包都带 success。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 18:22:51 +08:00
joywayerandClaude Opus 5 a5ca3bc5d9 二七王:重连与开局 handler,路由表注册齐全
重连即重画:断线重连与硬刷新复用同一条路径(填 GameState → emit RESYNC_ALL),
不为重连单写一套渲染。deskinfo 按 step 分组填充,重连不重放 70 分开底那类
一次性事件。StartWar 支持差异化下发,按本座位取自己那份。

路由表 12 条注册齐全,test_dispatcher 的完整性断言随之转绿——
漏接一个包就会红。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 18:15:04 +08:00
joywayerandClaude Opus 5 e8fc3a9bfa 二七王:结算与解散 handler
三种结算来源组成不同(正常出牌 / 投降 / 解散),共用一套落地逻辑:
有哪组写哪组,缺的保持 null。

解散走平台的 Game_Modify.Free 入口而非收包分发表——它的 route 是 room,
被平台分流走了。取值路径因此比协议文档少一层,且参数可能为 null
(首局发牌前解散),已覆盖。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 18:09:06 +08:00
joywayerandClaude Opus 5 070ff37db1 二七王:明牌、提示、准备 handler
明牌数据进 GameState(面板可反复开关查看);提示不进——它是一次性通知、
不是对局状态,重连也不重放,存下来只会变成幽灵数据,故只随事件带出。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 18:04:33 +08:00
joywayerandClaude Opus 5 47c8a27d0d 二七王:补全出牌 handler 测试里 chupai3 各用例的 turn.seat 断言
三条 chupai3 用例(不带 grade 那条、跨包累加的两条)之前都带了 nextseat
字段却没验证它,读起来像遗漏,也让"chupai3 同样会推进控制权"这件事只被
验证了一次。补齐后四条 chupai3 用例均验证 turn.seat;跨包累加的两条从
turn.seat 由 0 变 1,顺带证明了第二个包确实被处理,不只是看 grade 一个数字。

只改测试文件,PlayHandler.js 未改动。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 18:01:56 +08:00
joywayerandClaude Opus 5 5a77e4b132 二七王:修正出牌 handler 注释并补强 grade 累加与 nextseat 测试
审查发现简报本身两处缺陷:
1. grade 累加测试断言力不足,无法区分"累加"与"仅在字段存在时赋值"两种
   实现——补一条跨包连续收两个 grade 的用例(10 再 15,断言最终 25)钉住
   累加语义。
2. chupai3 实际也带 nextseat(服务端在 switch 前统一设置三个包),简报
   注释误写成"chupai1/2 独有";修正文件头注释,并给 chupai3 测试用例补
   上 nextseat 与对应的 turn.seat 断言,使包结构与真实下发一致。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 17:58:42 +08:00
joywayerandClaude Opus 5 5637d63c3b 二七王:出牌 handler
三个包字段不同:chupai1 带牌型信息、chupai1/2 带 nextseat 与 mustcard,
chupai3 带 maxseat 与本轮得分。

两个易错点已覆盖:cardsinhand 只有出牌者自己有,别人出牌时缺该字段不能抹掉
自己的手牌;mustcard 只对当前轮有效,没给就要清空,否则上一轮的建议会残留。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 17:51:36 +08:00
joywayerandClaude Opus 5 daf2708af9 二七王:选主与埋牌 handler
选主后主牌集合变化、手牌需重排,但重排是渲染时由 core/CardOrder 现算,
不在状态里落地——GameState 只镜像不派生。

埋牌区分 burycards(埋牌底牌)与 bottomcards(底牌),两批不同的牌;
闲家无 cards/burycards 但可能有 liangpai,缺字段一律不抹已有值。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 17:46:01 +08:00
joywayerandClaude Opus 5 081130b020 二七王:发牌与叫分 handler
fapai 是一局起点,先 reset 清上局残留(保留 mySeat 与 options)。
叫分里 0(不叫)与 null(还没叫)严格区分。上庄的 bottomcards/cards 只有庄家有,
缺字段时保持原值、不抹掉闲家已有手牌——GameState 的「缺字段不兜底」在此体现。

新增 GameState._apply 助手:按 {目标键:源键} 映射写入,源键不存在就不写。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 17:41:09 +08:00
joywayerandClaude Opus 5 01eb6c60b5 二七王:收包分发器与失败回包处理
分发器只分发不写业务。判定顺序上先看 data.success,失败包一律先分流给
FailHandler——协议规定失败回包的 rpc 与请求同名(chupai 失败回 chupai,
成功走 chupai1/2/3),不先分流则每个业务 handler 都要自己判一遍。

未知 rpc 只警告不抛错,畸形入参不崩。路由表完整性断言留待全部 handler 就位。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 17:36:15 +08:00
joywayerandClaude Opus 5 65674542b3 二七王:补全 FORBIDDEN 列表覆盖完整协议字段
FORBIDDEN 列表扩充到覆盖协议中服务端→客户端的全部字段名,
作为回归网防止未来在发包方法中意外泄漏服务端计算结果。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 17:32:44 +08:00
joywayerandClaude Opus 5 ff46d874c0 二七王:前端语义化发包
route 显式写 erqiwang——RpcHelper.sendGameRpc 预设 route="room",
用它包会被路由到平台房间模块、到不了子游戏 mod.js 且不报错。

只做形状校验(牌 id 范围/去重/张数),规则合法性由服务端裁定;
不合形状即抛错、不发残缺包。测试断言发包字段集合不含任何结论字段。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 17:28:11 +08:00
joywayerandClaude Opus 5 a82969a23b 二七王:前端 roomtype 位串解析
与服务端 class.config.js 的 parse() 同规则;缺失/非字符串/过短一律按 '0',
这是协议 §0.5 明文规定的行为。已逐位对照服务端确认。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 17:21:39 +08:00
joywayerandClaude Opus 5 4e79f135ce 二七王:前端事件常量与 GameState 骨架
GameState 是服务端快照的镜像、前端 SSOT:只镜像不派生、字段名对齐协议、
缺字段不兜底。reset 清对局态但保留 mySeat 与 options(跨局不变)。
事件只带「发生了什么」,数据由订阅者从 GameState 读,避免同一份数据两处存。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 17:19:43 +08:00
joywayerandClaude Opus 5 2df7179266 二七王:前端子项目 B 实施计划
13 个任务,每个自带 TDD 循环与提交:GameState 骨架与事件契约、roomtype 解析、
语义化发包、分发器与失败回包、五组 handler、重连与开局、服务端真包夹具、
增量 vs 全量一致性、平台接线。

自检修掉两处:Task 8 一条拿自己跟自己比的恒真断言;Task 12 引用的
fx.roomtype / snapPoint.packetIndex 在 Task 11 的夹具结构里没定义
——packetIndex 是两条路径的对齐锚点,缺了一致性测试无从比对。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 17:16:11 +08:00
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
joywayerandClaude Opus 5 f63de40229 二七王:前端子项目 B(网络与数据镜像)设计定案
架构:点击 → 语义化发包 → RpcHelper;推送 → 唯一分发入口 → 一 rpc 一 handler
→ 写 GameState → emit 语义事件。handler 不引用任何 UI 组件,故 B 可脱离 C 独立完成与测试。

GameState 只镜像不派生、字段名对齐协议、缺字段不兜底。事件只带「发生了什么」,
数据由订阅者从 GameState 读,避免同一份数据两处存。

两条核心验收把红线变成断言:增量累积与 deskinfo 全量重建必须完全相等
(即「视图 = f(服务端快照)」);发包字段集合不含任何结论字段。
测试数据用服务端 _rpc.js 脚手架捕获的真实下发包,不手写假包。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 17:02:40 +08:00
joywayerandClaude Opus 5 8dba099717 二七王:HIST_FAN/MING_FAN maxWidth 由 490 改判定为 480,让 spacingMin 真正生效
上一版把 maxWidth 收窄到 490 只是让 RIGHT 座位恰好贴住画布边界(零余量),且
spacingMin:-96 在 28 张时永远不触底、成了死参数。裁定改为 480:
(480-110)/27-110≈-96.30 超过 spacingMin,spacing 被截断为 -96,总宽变为
28×110+27×(-96)=488,与清单原注释「每张露14px、总宽488」精确对齐——500 是笔误,
488 才是当初想要的值。

求解器验证(count=28):LEFT 16→504、RIGHT 791→1279、SELF 396→884,三座位均落在
0-1280 内且有余量。同步订正 Layout_Result.js 注释与清单 §6.9c 文字/数值。

client/tests/run.js 与 server/games/erqiwang/test/run.js 均全绿,精灵总数(380)/
布局节点数(83) 未变。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 09:08:48 +08:00
joywayerandClaude Opus 5 2b1dd0d947 二七王:修复出牌操作条/亮牌历史布局越界,补主牌统计条改名遗漏
- I1:Layers.js 群组 206 改名遗漏一处注释(亮牌信息条 → 主牌统计条),全仓复查确认无第二处
- I2:出牌操作条补「提示」项后 anchor:center 导致已有两项挤偏,改用 anchor:left 并反推
  anchorX=446,令「出牌」按钮回到参考图实测 x≈556(求解器验证:446/556/748)
- I3:HIST_FAN/MING_FAN 的 maxWidth 反推算错(旧注释「488」有误,实际总宽恒等于
  maxWidth=500),导致 RIGHT 座位整排超出画布 5px;收窄为 490 使三座位求解结果落在
  0-1280 内,订正注释与清单 §6.9c 对应文字
- M3:同步修正计划文档里遗留的旧名「亮牌信息条/亮牌条」
- M6:test_constants.js 补 items[].key 的三命名空间解析守卫(此前完全无守卫)
- M5/M7:订正 ACC_ROW_TEXT_GRID 与扣底按钮宽度的估值依据说明
- M1/M2:清单 §6.9 子节重新排序为 6.9a→6.9b→6.9c 并统一标题层级,修正脚注星号误渲染

client/tests/run.js 与 server/games/erqiwang/test/run.js 均全绿,精灵总数(380)/
布局节点数(83) 两个钉死断言未变。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 09:06:01 +08:00
joywayerandClaude Opus 5 8f36b61620 二七王:补齐三处配置缺口(扣底按钮 / 出牌提示按钮槽位 / 三个 View 的布局节点)
这三处都是「精灵或按钮已在清单里定案,但配置层缺东西」,不补的话 C/D/E 阶段
写渲染代码时会发现没有布局节点,极易就地硬编码坐标(违反零裸值红线)。

1) 底栏「扣底」按钮没有精灵(清单 §2.6 列了 5 个功能按钮,只登记了 4 个)
   - 新增 BTN_BURY_CARDS(1056) / BTN_BURY_CARDS_TEXT(1057),群组 205 号段内空闲号,
     资源沿用 BTN_BOTTOM_FUNC,与其余四个按钮同构。
   - FOOTER_FUNC_BUTTONS 的 items 由 4 项扩到 5 项,扣底紧邻底牌(照 §2.6 表序)。
     扣底宽 78 是【估值】:清单未给,取同为两字按钮的「底牌」实测值,已在配置与清单里注明。
   - 扣底看【埋牌底牌】(burycards)、底牌看【底牌】(bottomcards),两批不同的牌,
     JSDoc 里补了防写反的提示(对应验收清单第 13 条)。

2) 出牌阶段「提示」按钮有精灵、无布局槽位
   - PLAY_OPERATION_BAR.items 补上 PLAY_BTN_TIP,顺序照清单 §1.7 行文:倒计时 / 出牌 / 提示。
   - 宽 180 是【估值】:清单 §6.8 自身漏了该项;与埋牌条的提示按钮同资源 BTN_TIP
     (§3.3 实测 180×63),埋牌条 items 首项亦取 180,故沿用。
   - 顺带记下一处待裁定项:本条 itemHeight 为 55(对齐 BTN_PLAY 157×55),
     而 BTN_TIP 原生高 63,高度差待设计稿定。

3) AccountView / HistoryView / MingPaiView 一个布局节点都没有
   - AccountView(§6.9a 新增):外壳 6 个节点 + 玩家栏「容器 ACC_ROW_CONTAINER +
     行节点 ACC_ROW(itemHeight 即行高,行数由 ctx.count 给出)+ 行内模板贴附到行矩形」
     + 面板外 4 按钮,共 14 个节点。行内节点求出的是相对容器的坐标,可直接喂
     SpriteCopyUtils.create(与 §5.6a 冲关牌同一用法)。
   - HistoryView / MingPaiView(§6.9c 新增):遮罩 + 容器 + 每家一排牌(fan) + 座位标签,
     各 4 个节点。照 CHONGGUAN_FAN 同构、锚点沿用 §6.7 冲关牌的三家落点;
     差别只在张数上限——出牌历史摊平后可达 28 张以上,故 maxWidth 放宽到 500、
     spacingMin 收到 -96【估值】。MING_* 只有 LEFT/RIGHT 两个 bySeat 变体(明牌不看自己)。
   - 坐标来源:大局结算.png / 冲关牌型显示.png 目视实测,w/h 取资源尺寸;
     配置与清单里均已注明「目视实测估值,待设计稿复核」。
   - 运行时才确定的字段(动态文字宽高、随行浮动的 anchorY、数字精灵宽)一律
     runtime 显式声明。

钉死断言按实际重新数并更新:精灵总数 378 → 380,布局节点总数 61 → 83。
另把 ACC_TPL_SCORE_NUM 加进 NUM_STYLE 同源校验表(与 RESULT_SCORE_NUM 同一套资源)。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 08:40:48 +08:00
joywayerandClaude Opus 5 f982460c9c 二七王:群组 206 改名为主牌统计条,与 design §8.2 的「亮牌」消歧义
群组 206 原名「亮牌条 / LIANGPAI_BAR」,但它的实际用途与坐标 (35,400) 对应的是
清单 §2.5「自己的主牌统计条」——手牌上方显示 x对 x主、前端本地统计、有手牌时常显。
而 design §8.2 真正的「亮牌」是庄家埋牌后向两个闲家亮出自己全部固定主牌的具体牌面,
走遮罩面板(群组 242,复用 HIST_* 精灵)。清单 §0.0 专门用一节区分「亮牌 / 余主公示 /
明牌 / 开底」四类公开信息,名字撞车极易把渲染写到错误的界面上。

统一改名(ID 一律不变,只改名字):
- 群组   EQW_Groups.LIANGPAI_BAR   → ZHU_STAT_BAR (206)
- View   EQW_Sprites.LiangPaiView  → ZhuStatView,容器 LiangPaiBar → ZhuStatBar
- 精灵   LIANGPAI_BG/_TEXT (1030/1031) → ZHU_STAT_BG / ZHU_STAT_TEXT
- 布局   EQW_Layout.LIANGPAI_BG    → ZHU_STAT_BG
- 资源   EQW_Images.BAR_LIANGPAI   → BAR_ZHU_STAT (605)

同步清单 §0.0 / §0.3 群组表与号段表 / §1.6 / §2.5 / §3.5 / §3.6 / §5.1 / §5.7b /
§6.5 / §7.2 T-9 / §7.3 第 5 条,并在 §0.3 群组表下加修订说明(2026-08-27 + 缘由)。

一并订正清单 §1.6 的一处笔误:收到 liangpai 时应弹出亮牌遮罩面板(群组 242),
原文写的是「渲染 §2.5 亮牌信息条」——正是同名异实造成的串位。
EQW_Anim.LIANGPAI_AUTO_CLOSE 属真正的亮牌面板,保持原名,仅补注释说明归属。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 08:40:09 +08:00
joywayerandClaude Opus 5 dc08513886 gitignore 忽略 .superpowers 工作区
多智能体执行过程的临时产物(任务简报、审查包、报告),不应入库。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 08:23:56 +08:00
joywayerandClaude Opus 5 92a8caa53f 恢复 githooks 机械红线校验
2026-08-11「先关闭 githooks」注释掉了唯一的 exec 行,提交闸此后一直空转。
恢复前已空跑验证:暂存一个含现代语法的测试文件与一个 config 文件,
退出码 0——测试豁免生效、存量代码不被误拦。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 08:23:44 +08:00
joywayerandClaude Opus 5 b11a3f17a9 二七王:Sprites_CreateRoom.js 补 CreateRoomView 的布局说明注释
分区标题标出 Layer(平台 27)与 Group 250,正文含 ASCII 版式图:类别竖排 ×
选项槽横排的两层嵌套,标出三个类别标题、8 组「选择框 + 文字」与房卡附注各自
对应的精灵常量与 attach 目标;另附帧号公式、roomtype 五位映射、房卡联动表、
资源说明与 @see 出处,并注明参考图是别的游戏、只借版式不借内容。

纯注释改动,数据部分一字未动(精灵总数仍 378、布局节点仍 61)。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 01:52:54 +08:00
joywayerandClaude Opus 5 6c339060bf 二七王:Sprites_Result.js 补 5 个 View 的布局说明注释
为 AsetResultView / ChongGuanOverlayView / AccountView / HistoryView /
MingPaiView 各补一块 JSDoc:分区标题标出 Layer 与 Group,正文含 ASCII 版式图
(小局结算三家光晕与底牌算式区、冲关叠加层三家牌位与遮罩、大局结算面板的
标题栏/玩家栏/规则栏/面板外按钮条、出牌历史与明牌的三排/两排版式),
另附元素说明、关闭方式对照、资源说明、精灵 ID 分配与 @see 出处。

纯注释改动,数据部分一字未动(精灵总数仍 378、布局节点仍 61)。
如实记下三处待补:判定结果动画 D-8 无设计稿;AccountView / HistoryView /
MingPaiView 尚无 EQW_Layout 布局节点,图中坐标为参考图目视实测。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 01:51:39 +08:00
joywayerandClaude Opus 5 1644b3ebb2 二七王:Sprites_Action.js 补 5 个 View 的布局说明注释
为 CallPanelView / ChooseMainView / OperationBarView / CountdownView /
OverlayView 各补一块 JSDoc:分区标题标出 Layer 与 Group,正文含 ASCII 版式图
(叫分面板 7×2 档位阵与单档按钮三精灵拆解、选主面板 4 花色+投降一排、
埋牌与出牌两条操作条及其中的 CD_SELF 槽位、倒计时三处落点、浮层四类元素的
屏幕位置),另附元素说明、时序说明、资源说明、精灵 ID 分配与 @see 出处。

纯注释改动,数据部分一字未动(精灵总数仍 378、布局节点仍 61)。
如实记下一处缺口:出牌阶段「提示」按钮已有精灵但无布局槽位,待设计稿。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 01:48:23 +08:00
joywayerandClaude Opus 5 ccc41ce092 二七王:修正 Sprites_Table/Cards 注释里 ASCII 版式图的对齐
上两个提交的三处版式图在等宽字体下右边框错位(手牌区示意框、结算底牌区
文案行、已出牌区的牌角标小图)。按「CJK 双宽、其余单宽」重排到等宽,
不改任何数据。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 01:48:14 +08:00
joywayerandClaude Opus 5 a4b12a1ebb 二七王:Sprites_Cards.js 补 4 个 View 的布局说明注释
为 HandView / BottomCardsView / BuryCardsView / PlayAreaView 各补一块 JSDoc:
分区标题标出 Layer 与 Group,正文含 ASCII 版式图(手牌单排/双排两种形态、
底牌背面与翻正面、埋牌底牌在结算面板内的位置、三家已出牌区的镜像关系与
牌型标签/牌角标贴附方式)、元素说明、资源说明、精灵 ID 分配与 @see 出处。

纯注释改动,数据部分一字未动(精灵总数仍 378、布局节点仍 61)。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 01:39:49 +08:00
joywayerandClaude Opus 5 19f93bb96f 二七王:Sprites_Table.js 补 4 个 View 的布局说明注释
按参考图与 UI 清单,为 TopInfoView / PlayerMarkView / LiangPaiView / FooterView
各补一块 JSDoc:分区标题标出 Layer 与 Group,正文含 ASCII 版式图(标注各区域
对应的精灵常量与实测坐标)、元素说明、资源说明、精灵 ID 分配与 @see 出处。

纯注释改动,数据部分一字未动(精灵总数仍 378、布局节点仍 61)。
顺带如实记下两处文档缺口:群组 206 的命名(亮牌条 vs 主牌统计条)与
底栏「扣底」按钮尚无精灵。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 01:37:44 +08:00
joywayerandClaude Opus 5 0c58ae67db 二七王:倒计时与结算得分改用数字图精灵,收尾几处命名与文档订正
- CD_LEFT/CD_RIGHT/CD_SELF(1150-1152)由文字精灵改为多帧图数字精灵:
  资源 NUM_COUNTDOWN,纯秒数无后缀;对应布局节点 P_COUNTDOWN 的 h 改取
  NUM_STYLE.COUNTDOWN.charHeight,w 留到运行时注入。
- 结算得分 RESULT_SCORE_LEFT/RIGHT/SELF(1805-1807)同理由文字精灵改为
  数字图精灵(NUM_RESULT_WIN/NUM_RESULT_LOSE),与大局结算已有的
  ACC_TPL_SCORE_NUM 保持一致;布局节点随之调整,移除不再使用的
  TEXT_STYLE.SCORE_BIG。
- NUM_CALL_SCORE 第16帧由"待美术与规则确认"改为明确的"无后缀"(叫分档位
  面板只显示纯数字,"N子"角标是独立文字精灵);NUM_MAIN_SUIT_COUNT /
  NUM_RESULT_WIN / NUM_RESULT_LOSE 第16帧仍待确认,不改。
- Layout_Action.js 的 CALL_BTN_SCORE_TEXT、Layout_Result.js 的
  RESULT_SCORE_TEXT 改名为 CALL_BTN_SCORE_NUM / RESULT_SCORE_NUM:
  两者驱动的都是数字图精灵,_TEXT 后缀误导;同步测试引用。
- docs/client/development-guide/02:订正"SpriteManager 第1154行"这一失效
  行号引用(回退后该行号落在 setTextWithWidth 体内),改为不写死行号。
- docs_dev 清单同步:§2.4/§5.4/§5.6/§6.6/§6.9 倒计时与结算得分的精灵类型
  描述、§6.6/§6.9 对应布局参数。
- test_constants.js 的 NUM_STYLE 同源守卫表新增 COUNTDOWN、RESULT_WIN、
  RESULT_LOSE 三对(连同改名后的 CALL_BTN_SCORE_NUM),共 6 条新断言。

精灵总数、布局节点总数均未变(378 / 61),只改类型与命名,不增删。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 00:54:42 +08:00
joywayerandClaude Opus 5 857bc955bb 框架:订正 setNumberImage 帧序注释的自相矛盾(1-based)
方法头「帧序号:0-9对应数字,10='.'...」与方法体内「字母'b'-'g'→帧10-16」
互相矛盾,且均与代码实际不符:'0' 的 charCode 48 减 47 得帧1,'.'→'b' 的
charCode 98 减 87 得帧11,故正确读法是 1-based:帧1-10=数字0-9、
帧11='.'、帧12='+'、帧13='-'、帧14='x'、帧15='/'、帧16=可变后缀。
补充说明编码表故意跳过 'a'(97-87=10 会与 '9'→帧10 撞帧)的原因。
只改注释,不动一行代码。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 00:47:00 +08:00
joywayerandClaude Opus 5 717e405292 二七王:SubGameHooks 注释去掉已作废的 NumberRenderer 提法
事件链路本身不变,只把注释里对已回退方案的引用换成实际存在的例子
(248 号协议勾选的绘制回调)。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 00:07:55 +08:00
joywayerandClaude Opus 5 35ee85e1d6 二七王:回退自造的图集叠绘,改用平台既有 SpriteManager.setNumberImage
方案作废并纠正认知:多帧图片精灵【不是】只能显示一位数——SpriteManager 第 1154 行
早有 setNumberImage(spriteId, text, charWidth):把整串文本设给一个多帧图片精灵,
引擎按字符逐帧渲染,精灵宽度按「字符数 × charWidth」自动调整。bc75e40 造的
drawImageRegion + NumberRenderer 是重复轮子,本次整体回退(revert bc75e40)。

- 回退:删除 gameabc-framework/ui/NumberRenderer.js 与其单测,SpriteManager 与
  index.html 恢复到改动前(与 bc75e40^ 逐字节一致)。
- ImageResources.js:数字类资源改为 16 帧、固定帧序 0123456789.+-x/p,逐条写明
  各帧含义、第16帧后缀(选主张数=「张」;结算/叫分的后缀标注待美术与规则确认,未瞎填)、
  单字符宽高与整图尺寸(16 × 单字宽),并说明帧序不可重排、不可省略占位。
- LayoutConstants.js:NUM_STYLE 改为 setNumberImage 所需的 res/charWidth/charHeight/suffix。
- Layout_Action.js:两个数字节点 h 取 charHeight(SSOT),w 留运行时注入
  (宽度由 setNumberImage 按字符数自动改,随位数变)。
- Sprites_Action.js / Sprites_Result.js:注释改为 setNumberImage 用法;
  结算玩家栏 ACC_TPL_SCORE_NUM 是复制精灵也能用(该接口不依赖绘制回调)。
- test_constants.js:NUM_STYLE 守卫改为校验 charWidth/charHeight/suffix 与
  「h 同源、w 运行时注入」;suffix 限定为 setNumberImage 支持的 分/倍/张。
- 文档:清单 §1.3/§1.5/§3.6/§5.4/§6.8 与前端 02 的那一节全部按 setNumberImage 重写,
  三种机制改为「文字精灵 / setFrame 切帧 / setNumberImage」,并记下这次误判作为反例。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 00:07:35 +08:00
joywayerandClaude Opus 5 dbdd6607d7 二七王:文档同步图集叠绘数字(清单 §1.3/§1.5/§3.6/§5.4/§6.8 + 前端 02 新增一节)
- docs_dev 清单:§3.6 数字资源改为「等宽连续排列的一张图」并给出字符顺序/单字宽高/整图尺寸,
  §1.3 说明叫分档位分数为何必须改叠绘(14 档里 13 档两位数、每档一个精灵),
  §1.5/§5.4 选主张数由 8 个精灵改回 4 个(1678–1681 记跳号不复用),
  §6.8 布局节点合并 + 新增 NUM_STYLE 数字样式表;各处均带 2026-08-26 修订说明与缘由。
- 前端 02:新增「三种显示机制:文字精灵 / 多帧图片精灵 / 图集叠绘」一节,
  给出三问选用判据、多位数误用 setFrame 的坑与真实事故、叠绘的三条前提与出图规格;
  §5 补一条「转发必须接上且参数对齐,否则事件静默失效」的警示;DO/DON'T 各补一行。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 23:56:32 +08:00
joywayerandClaude Opus 5 2fe0f661b4 二七王:数字改回「一个数字一个精灵」,数字资源改为等宽连续图
有了图集叠绘能力,多位数不再需要每位一个精灵:

- Sprites_Action.js:选主花色张数从每花色两个(_TENS/_ONES)改回每花色一个
  (MAIN_SUIT_COUNT_1..4 = 1674–1677),空出的 1678–1681 按本文件既有写法记跳号、不复用;
  叫分档位 CALL_BTN_NUM_1..14 数量不变,注释改为图集叠绘(现在能正确显示两位数)。
- ImageResources.js:NUM_COUNTDOWN / NUM_RESULT_WIN / NUM_RESULT_LOSE / NUM_CALL_SCORE /
  NUM_MAIN_SUIT_COUNT 不再是编辑器配帧的多帧资源,改为等宽连续排列的一张图,
  逐条写明字符排列顺序、单字符宽高、整图尺寸(给美术的出图规格)。
- Layout_Action.js:张数的十位/个位两节点合并为一个;两个数字节点的 w/h 直接引用
  NUM_STYLE(SSOT),不再由调用方运行时注入。
- Sprites_Result.js:记下 ACC_TPL_SCORE_NUM 是复制精灵、无法用 NumberRenderer 的风险。
- 单测:精灵总数 382→378、布局节点 62→61(按实际更新,未放宽下界);
  新增 NUM_STYLE 资源键/字段合法性与「布局节点 w/h 与样式同源」守卫。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 23:53:16 +08:00
joywayerandClaude Opus 5 3789e0814e 二七王:接通平台精灵事件链路(SubGameHooks → SpriteEventController)
SubGameHooks 原是空骨架,Game_Modify.* → SubGameHooks.* → SpriteEventController
这条链是断的:不仅数字叠绘的绘制回调不会触发,转发壳里已注册的 13 号战绩按钮、
150 号帮助按钮、248 号协议勾选也收不到事件。

实现 utlmousedown / utlmousedown_nomove / mouseup / utlmousemove /
utlgamemydrawbegin / gamemydraw 六个 hook,只做转发、不写业务逻辑,
参数顺序逐个对齐 SpriteEventController.handleXxx 的签名(错位会让事件静默失效),
转发前判断依赖存在,防御风格与转发壳一致。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 23:53:03 +08:00
joywayerandClaude Opus 5 bc75e40acc 二七王:新增图集叠绘数字能力(SpriteManager 源矩形 API + NumberRenderer)
多帧图片精灵一次只显示一帧=只能显示一位数,两位数就得两个精灵,
14 个叫分档位(13 档两位数)每档只配一个精灵时根本渲染不出来。

- SpriteManager.drawImageRegion:把 GameABCUtils.Draw.drawImage 的源矩形
  能力暴露到业务层(drawImage 只透传了整图版),校验/日志/返回值风格照现有
  drawImage 一致;注明必须在精灵绘制回调中调用。
- gameabc-framework/ui/NumberRenderer.js:游戏中立,bind/setValue/clear/unbind,
  绘制回调里按字宽逐位裁源矩形画在同一个精灵上;layout 抽为纯函数可脱离引擎单测;
  字符映射不到显式抛错,不静默跳过。
- EQW_Layout.NUM_STYLE:5 套数字的字宽/字高/字间距/对齐/绘制区宽集中配置(零裸值),
  资源只存键名(与 CARD_SIZE.res 同一约定)。
- client/tests/test_numberrenderer.js:43 checks(多位/单位/正负号/三种对齐/负字间距/
  空值/自定义 charMap/非法字符与非法样式抛错/5 套真实样式画得下)。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 23:52:56 +08:00
joywayerandClaude Opus 5 b81e800f22 二七王:选主面板按美术实际出图方式(一张图5帧)重做资源与精灵定义
BTN_SUIT_CHOOSE 改为 5 帧(4 花色按钮各含底+图标+角标底、帧5=投降按钮含底+文字),
删除独立的 SUIT_ICON_L(512)/BTN_SURRENDER(534)/花色图标精灵/角标底精灵/投降文字精灵;
花色张数从单个文字精灵改为图片数字精灵(十位+个位),新增资源 NUM_MAIN_SUIT_COUNT(636)。
同步规格清单文档与两个测试文件里被钉死的精灵总数(387→382)。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 23:30:12 +08:00
joywayerandClaude Opus 5 be3e8d0837 二七王:清单 §5.2 手牌标记改为每张牌一个
原表「牌型分组标记 ×8」与 §1.6「三种标记互斥、每张牌至多一个」矛盾。
裁决按每张牌——§1.6 是行为规格更权威,且三种标记本就是逐张判定的属性;
实测一手 28 张常带 13 个以上标记,8 个远远不够。

号段 1236–1243 太窄且其后 1250 起已被底牌区占用,改为 2100–2135。
前端 Sprites_Cards.js 与 core/CardMark.js 已按此落地。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 22:32:10 +08:00
joywayerandClaude Opus 5 503ad4b374 二七王:SeatMap 座位号显式校验,去掉恒真断言
- toDisplay 此前不校验入参:toDisplay(seat, undefined) 会算出 NaN,NaN 既不等于 0
  也不等于 1,最后落到 return 'LEFT'——一个看起来很合理的错误答案。现与兄弟方法
  toSeat 一样对非 0/1/2 显式抛错;toSeat 的 mySeat 同样校验(否则算出 NaN 座位号)。
  补 8 条反面用例 + 1 条「合法组合不被误伤」的正面用例。
- test_cardcodec.js 删掉 t.eq('deck1/deck2 全部同帧', true, true) 这条恒真断言,
  改为收集不匹配项后断言为空——恒真断言在上面的循环被删掉后仍会 PASS。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 22:27:05 +08:00
joywayerandClaude Opus 5 d1b60275a4 二七王:建房选项配置补精灵键、bit 统一挂到 item、补 || {} 保护声明
Sprites_CreateRoom.js 的注释说「配置里每个选项声明自己用哪个精灵键」,但 EQW_RoomOptions
的 items 只有 label/value,渲染器只剩「按下标拼字符串」一条路——而那正是同文件明文禁止的;
何况也拼不对(组的 key 是 'rules',精灵却叫 ROOM_BOX_RULE_1)。

- 每个类别加 titleSprite,每个选项加 box/text 精灵键,房卡附注加 noteSprite。
- bit 统一挂到每个 item 上(单选组各项写同一个 bit),拼 roomtype 的循环对
  radio/checkbox 是同一个形状(item → 写 value 到 bit),不再按组/项两种层级分支;
  checkbox 项补上 value:'1'。
- 补上其他常量文件都有的 `|| {}` 保护性声明。
- 守卫新增:精灵键必须存在且互不重复、必须恰好覆盖建房 View 预置的全部选项精灵、
  bit 不得再挂在组上、单选组内 bit 一致、多选组 bit 互异、位号刚好铺满 0–4
  (对齐服务端 class.config.js 的 IDX_ASET..IDX_NOCHECK)。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 22:26:21 +08:00
joywayerandClaude Opus 5 ee65871694 二七王:手牌标记精灵扩到与手牌一一对应(36 个),并钉死精灵总数
清单 §5.2 表格写的是「牌型分组标记 ×8」,但 §1.6 明确规定三种标记互斥、【每张牌】
至多一个,core/CardMark.js 也是按每张牌返回标记的。一手 28 张牌通常带 13 个以上标记、
36 张埋牌手更多,8 个远远不够。§1.6 是行为规格、比表格措辞更权威,故按 §1.6 裁决:
HAND_MARK_1..36 与 HAND_CARD_1..36 一一对应。

- ID 另起 2100–2135 整段空号:1236 往后接不下 36 个连号(1250 起已是底牌/埋牌区),
  换空号段比给别的 View 重新编号更安全;仍落在子游戏段 1001–2999,无重复。
- 守卫补「手牌与标记 1..36 一一对应且同群组」检查。
- 精灵总数由「> 200」改为钉死 387(test_constants / test_spriteindex 两处同一个数),
  布局节点数同理已钉死——松下界删掉整个 View 都还能通过。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 22:25:19 +08:00
joywayerandClaude Opus 5 92edb6a3f5 二七王:布局求解器补齐显式失败与运行时注入契约,配置补 runtime/targetKind 声明
求解器(ui/LayoutSolver.js):
- 按方向校验 anchor:竖排只收 top/bottom/center、横排只收 left/right/center,
  未知 direction 一并拒绝。框架 AlignmentUtils 的 switch 对写错方向的 anchor 落到
  default(居中),笔误会被静默吞掉——ROOM_CATEGORY_COLUMN 就是这么把前两行摆到画布外的。
- grid 校验合并 ctx 后的 cols/rows 必须是数字:缺 rows 时 capacity=NaN 让超载守卫失效、
  循环一次不跑,静默返回空数组(一个矩形都不产出)。
- line/fan/grid 要求 anchorX/anchorY 必须是数字(框架的 anchorY||0 会把缺失变成 0);
  point 要求 x/y 必须是数字。
- apply() 精灵数与矩形数不等时抛错,不再静默截断:截断会让多余精灵停在上一手牌的旧坐标上。
- 统一运行时注入:ctx 里的 INJECT_KEYS(target/x/y/w/h/anchorX/anchorY/rows/
  itemWidth/itemHeight)覆盖配置同名字段,优先级 ctx > bySeat > base。
- line + items 竖排显式拒绝(只实现了水平)。

配置:
- ROOM_CATEGORY_COLUMN anchor 由 'left' 改为 'top'(竖排语义)。
- ROOM_OPTION_OVERFLOW_GRID 补 rows(runtime)、槽尺寸与 anchor 沿用 ROOM_OPTION_ROW,
  cols 直接引用 ROOM_OPTION_MAX_PER_ROW;ROOM_OPTION_ROW 补上注释里已声明的 itemHeight。
- 所有依赖运行时注入的节点加 runtime 声明,所有 attach 节点加 targetKind
  (sprite / layout / platform),显式区分「忘了写」与「故意延后到运行时」。

守卫(tests/test_constants.js):
- 求解冒烟改为只对 runtime 声明过的键注入假值,没声明却缺字段的照常抛错变红。
- 补矩形数量断言(line/fan/grid 按项数、point/attach 恒 1)——此前只查数组与坐标类型,
  空数组照样通过,「少画了几张牌」对守卫完全不可见。
- attach.target 改为只在 targetKind 指定的那一个命名空间里校验存在。
- 补 runtime 声明自身的合法性检查与布局节点总数(62)钉死。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 22:24:16 +08:00
joywayerandClaude Opus 5 b81be36f6e 二七王:拖拉机极大连续段扫描与牌编码区间常量收敛进 shared
前后端此前各写了一遍「分对子 → is_continuous 扫极大连续段 → ≥2 对算拖拉机」:
服务端 class.arith.js decompose_trump 与前端 core/CardMark.js。规则内容(什么算连续、
几对起算拖拉机)散在两处,正是 shared/ 要消除的重复。

- shared/cards.js 新增 group_tractor_runs(mainflower, pairlist):返回极大连续段数组,
  孤立对子作为长度 1 的段返回,拼接后恰好还原入参;阈值导出为 TRACTOR_MIN_PAIRS。
- shared/cards.js 导出牌编码区间边界具名常量(CODE_ZHU_MIN / CODE_ZHENG2_* /
  CODE_FU2_* / CODE_ZHENG7_* / CODE_FU7_* / CODE_XIAOWANG / CODE_DAWANG /
  CODE_A_IN_ZHU),并在 id_to_code / is_continuous / trump_rank 内部改用它们。
- decompose_trump 改调共享扫描,对外行为完全不变(分量类型、顺序、cards 归属不变)。
- CardMark 删掉本地扫描改调共享版本,区间判定与标记值改引用常量,去掉 1000/3000/
  4000/8000/9000 裸字面量。
- 前端副本经 sync_shared.cmd 同步;服务端单测 624→645 checks 全绿,前端 8 个测试全绿。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 22:17:09 +08:00
joywayerandClaude Opus 5 b22558d0b3 二七王:前端接入骨架与 index.html 加载段
SubGameHooks 从模板复制、本阶段保持空骨架(无 hook 时转发壳走平台默认)。
index.html 按 client 06 §3 的顺序加入 codes 段:config → shared → core → ui
→ SubGameHooks → 三文件转发壳,三个转发壳一字未动。
Node vm 依序加载验证无异常,全部 EQW_* 与 SubGameHooks 就绪;两端测试全绿。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 21:51:53 +08:00
joywayerandClaude Opus 5 a05cc7b7fa 二七王:常量层机械守卫测试补 ctx.w/ctx.h 求解冒烟兜底
求解冒烟第一次跑真实跑出 9 个节点(叫分档位分数文字、花色数量文字、牌角标、
结算文案等)因配置有意不写 w/h(真实宽高等文案渲染出来才知道)而求解失败;
裁决方向是给 LayoutSolver 补 ctx.w/ctx.h 运行时注入通道(已由 6390ce7 落地,
与既有 ctx.target 对称)。本次让冒烟测试的合成 ctx 对称跟进:仅在配置(含
bySeat 覆盖后)确实缺失 w/h 时才注入假尺寸,不覆盖配置自带的值,保证两条
路径都被真实覆盖到。60 个布局节点、84 次求解全部通过。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 21:46:41 +08:00
joywayerandClaude Opus 5 6390ce74c2 二七王:LayoutSolver attach 加 ctx.w/ctx.h 运行时尺寸注入通道
求解冒烟守卫拿真实布局配置跑出 9 个失败节点:叫分档位分数文字、花色数量
文字、牌角标、结算文案等动态文字标签配置里有意不写 w/h(真实宽高要等
文案渲染出来才知道),此前的按对齐模式校验因此在 hAlign=center/right、
corner=topRight/bottomRight 时正确地抛错,但没给调用方注入真实尺寸的通道。

补 ctx.w/ctx.h,语义与已有的 ctx.target 对称:优先于配置里的 n.w/n.h,
校验与求解都用合并后的值,返回的 width/height 也用合并后的值(不再是
可能为 undefined 的配置原值);两处都缺且用得到该维度时仍抛错,错误信息
点明"配置未写 X,且 ctx.X 未给出"两条路径。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 21:43:33 +08:00
joywayerandClaude Opus 5 ec954ea4fe 二七王:补建房折行 grid 的 cols 字段,补庄标 kind 覆盖注释
按复审意见修 2 处 Minor:ROOM_OPTION_OVERFLOW_GRID 补上清单原文给出的
cols:maxPerRow;P_BANKER_MARK.bySeat.SELF 补注释说明有意将 kind 由
attach 覆盖为 point(底栏庄标是定点,不贴附头像框,与左右上家不同)。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 21:35:44 +08:00
joywayerandClaude Opus 5 949702dc90 二七王:Task 13 守卫加入「求解冒烟」
拿真实布局配置逐节点实际调用求解器,断言不抛错且坐标非 NaN。
字段名写错、缺 w/h、kind 不认识、bySeat 缺座位都会当场炸出来,
而不是等到界面上静默摆歪——这是配置与求解器之间契约的最强守卫。

attach.target 的解析范围同时改为三者并集(子游戏精灵 / 布局节点名 / 平台精灵)。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 21:32:48 +08:00
joywayerandClaude Opus 5 91f0eb116c 二七王:LayoutSolver attach 支持运行时 target 注入,按对齐模式校验 w/h
配置落地后暴露两个缺口:叫分档位/花色图标/牌角标等 attach 目标要运行时
才确定,配置写不出固定 target,需要 ctx.target 覆盖/补全(优先于 n.target,
两者皆无则抛错);纯文字标签没有固定 w/h,此前缺失时会静默算出 NaN 坐标。
改为按本次实际走的对齐分支(hAlign/vAlign/corner)逐一核对 AlignmentUtils
源码确认是否用到 sprite.width/height,只在用得到时才要求对应字段是数字,
不用得到时(如 left/top/bottom、topLeft)不强制,避免逼纯文字配置编造假值。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 21:29:31 +08:00
joywayerandClaude Opus 5 6c92920df8 二七王:前端布局与动画常量
照清单 §6 各表逐条录入,字段名与 LayoutSolver 的 node 一一对应、可原样喂入。
布局文件保持纯数据:无函数、无副作用;分行策略等写成数据标记而非函数。
建房选项配置自带 bit 与 value,roomtype 拼串遍历配置即可,不按下标硬拼。
动画参数含 D-8 判定动画的待补说明。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 21:26:16 +08:00
joywayerandClaude Opus 5 1976edf9bd 二七王:前端 SpriteIndex,精灵键名扁平索引
布局配置按键名引用精灵,本模块把嵌套的精灵常量展平供查。
重复键与未知键一律抛错——静默覆盖会让界面指向别处的精灵且极难排查。
用例含真实常量的全量建索引校验。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 21:06:15 +08:00
joywayerandClaude Opus 5 4d05457590 二七王:拆分 HistoryMingPaiView,补充注释细节
拆分理由:Layer1/Layer2 的写法让 History/MingPai 两个 group 容器各自
属于哪个图层只靠书写顺序隐含,无结构绑定。拆成 HistoryView(Layer 304)
与 MingPaiView(Layer 305) 两个独立 View 后,「一个 View 恰好一个 Layer」
成为不变式,精灵键名/ID/内容不变。

顺带补两处注释 Minor:PlayAreaView 的 PlayLeft/PlayRight 块首补资源键
说明;AccountView 的 4 个功能按钮改为逐条写资源键,不再靠顺序隐式对应。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 21:02:20 +08:00
joywayerandClaude Opus 5 69cb04ecfa 二七王:确立「一个 View 恰好一个图层」不变式
原本允许 Layer1/Layer2 声明多图层,但「哪个 group 属于哪个图层」只能靠
注释隐含、无法机械判定。改为跨图层的界面拆成两个 View,各带自己的 Layer——
一个 View 将来对应一个 BaseComponent 组件,本就该分开。

SpriteIndex 加校验守住该不变式:View 缺 Layer、或写成 Layer1/Layer2 均报错。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 21:01:04 +08:00
joywayerandClaude Opus 5 488f36b545 二七王:前端精灵结构常量
照清单 §5.1–§5.8 逐条录入,键名与 ID 原样照抄;省略写法(如「结构同 202」)
全部展开写全。每个精灵注明类型/用途/资源键/帧说明,后期照此在编辑器里创建。
按协调方中途下达的结构变更,Group 与其名下精灵合并为 group 容器
(groupName: { id: 群组常量, 精灵键: 精灵ID, ... })。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 20:53:35 +08:00
joywayerandClaude Opus 5 8b579896b2 二七王:精灵常量再优化为 View → group 容器 → 精灵
group 与其名下精灵合并为 { id: 群组常量, 精灵键: id, ... },
所属关系由结构表达而非注释;拿到 group 对象即可 showGroup 并遍历其精灵。

SpriteIndex 随之支持 groupIdOf:精灵与群组 ID 被结构绑定,
显隐走群组时可机械查出。校验增至 5 处显式失败(重复键、精灵值非数字、
View 下混入标量键、group 缺 id、查不到的键)。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 20:51:18 +08:00
joywayerandClaude Opus 5 5ac445d08d 二七王:精灵常量改为以 UI View 为组织单位
Layer/Group 扁平声明在 View 定义下,精灵只写 id、所属关系写在注释里;
一个 View 对应一个 UI 组件,拿到 View 即拿到它的全部精灵与图层群组。

SpriteIndex 的展平逻辑随之调整:保留键 Layer*/Group* 跳过,其余键即精灵;
新增「值非数字即报错」——两者结构上同级、只靠键名区分,写错前缀会静默变成假精灵。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 20:44:17 +08:00
joywayerandClaude Opus 5 6546f75965 二七王:前端图层/群组/图片/声音常量
照清单 §0.3 与 §3 逐条录入,图片资源每条注明用途/帧数/帧义/尺寸——
后期即照这些注释在编辑器里建资源。声音因 T-20 未决只建骨架、不臆造条目。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 20:22:24 +08:00
joywayerandClaude Opus 5 8d6d272f76 二七王:LayoutSolver grid 补显式失败校验,attach 四角补精确坐标测试
审查发现 _solveGrid 在 count > cols*rows 时静默截断多出的项,违反显式失败
红线;补校验直接抛错并报出 count 与容量。fillOrder 字段之前从未被读取、
形同虚设,改为显式拒绝除 'row' 外的值(不实现列优先,YAGNI)。
测试补 grid 超载/恰好满格/fillOrder 三态,以及 attach 四角(topLeft/
bottomLeft/bottomRight)的精确坐标核对(此前只有 topRight 有精确值)。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 20:15:57 +08:00
joywayerandClaude Opus 5 2d0469e7f3 二七王:前端 LayoutSolver,五型布局求解器
solve 是纯函数(attach 靠 ctx.rects 查目标矩形而非反查引擎),
fan 的 clamp 间距算法只此一处实现;line 带 items 时自行按累计宽度推进,
不走假设等宽的 distribute。未知 kind/缺参/target 缺失一律抛错,不兜底。
apply 是唯一触碰 SpriteManager 的三行封装。

测试里 attach 外侧右上角一条按 AlignmentUtils.alignCornerTopRight 的真实实现
(角对角重合,非贴外侧)修正了期望值,未在求解器里做偏移补偿迁就猜测值。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 20:09:26 +08:00
joywayerandClaude Opus 5 3a5da5b975 二七王:前端 CardMark,手牌标记推导与花色统计
三种标记互斥、优先级 tractor > zheng > zhu,据主花色本地推导。
拖拉机识别与服务端 decompose_trump 同思路:分对子后扫极大连续段,
主牌一组、副牌按花色分组。反面用例覆盖不连续、单对、跨花色三种不成拖的情形。
countByFlower 供选主面板的张数与对数,协议明确服务端不下发。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 20:02:44 +08:00
joywayerandClaude Opus 5 39a920bed5 二七王:前端 CardOrder,手牌主牌序排序
直接委托 shared/cards.js 的 order_cards(与服务端同一份文件),
前端只负责 concat 防改入参、把未选主表达为 mainflower=0。
单测拿服务端 class.arith 的输出做逐一对照,五种主花色全覆盖。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 20:01:51 +08:00
joywayerandClaude Opus 5 e9333303d5 二七王:前端 SeatMap,服务端座位与显示位互换
清单 §0.1:下家 (seat+1)%3 显示在右上,与服务端 get_nextseat 同源。
3×3 全组合 + 互逆 + 非法键抛错都已覆盖。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 19:57:02 +08:00
joywayerandClaude Opus 5 65a73c5c2a 二七王:前端 CardCodec,cardId 转牌面帧号
清单 §0.4 的唯一权威转换:服务端花色段序与美术帧顺序相反,
转换把花色段反过来数。用清单给的 10 条样例逐条锁死,
另加两副牌同帧、值域、单射三组断言。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 19:56:32 +08:00
joywayerandClaude Opus 5 3b9828c8ca 二七王:修正文档里服务端测试脚本数量
实际是 17 个 test_*.js(624 checks),原文写的 20 把 _assert/_rpc/_shim 也数进去了。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 19:52:31 +08:00
joywayerandClaude Opus 5 656066b1a5 二七王:前端单测框架与 shared 同步守卫
测试侧用 vm.runInThisContext 加载浏览器全局脚本,正式代码不为测试加任何导出。
test_shared_sync 断言前后端 shared 逐字节一致,已做反向验证(改一个空格即红)。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 19:51:31 +08:00
joywayerandClaude Opus 5 147a28805e 二七王:sync_shared.cmd 补上 chcp 65001
对齐 build_spine_data.cmd 既有范式,修复交互式控制台下中文提示的乱码问题;
计划原文漏抄了这一行。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 19:47:16 +08:00
joywayerandClaude Opus 5 d287ce5c8c 二七王:新增根目录 shared 同步脚本与前端只读副本
同步是跨端动作(源在 server、目标在 client),脚本放仓库根目录。
只提供服务端→前端一个方向;前端副本只读,手改会被覆盖。
CLAUDE.md 常用命令一节同步收录。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 19:44:00 +08:00
joywayerandClaude Opus 5 9f32af7d72 二七王:抽出前后端共享算法 shared/cards.js
把牌值编码与牌型分组的 8 个纯函数(id_to_flower/id_to_number/id_to_code/
order_cards/is_continuous/get_pairlist/get_tuolaji_list/trump_rank)移入
shared/,arith 改为挂引用——对外方法名与签名不变,既有调用点零改动。

前端排序与手牌标记将同步这一份,避免两处各写一份导致主牌序漂移。
现有 20 个测试脚本原样全绿,即本次纯搬运的验收依据。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 19:37:46 +08:00
joywayerandClaude Opus 5 e326481f2f 二七王:前端子项目 A 实施计划
14 个任务,每个自带 TDD 循环与提交:shared 抽取与同步(1-2)、
单测框架与漂移守卫(3)、core 四个纯逻辑模块(4-7)、布局求解器(8)、
常量层三批(9/10/12)、精灵索引(11)、常量机械守卫(13)、接入骨架(14)。

顺带修订设计文档两处:shared 抽取函数数量 7→8(原文列了 8 个名字),
CardMark.markOf 签名与计划对齐(拖拉机判定需整手牌作上下文)。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 18:55:26 +08:00
joywayerandClaude Opus 5 0f29c2e08c 二七王:shared 同步脚本改放仓库根目录
同步是跨端动作(源在 server、目标在 client),不属于任何一端,
故 sync_shared.cmd 放根目录而非 client/scripts/。

同时写明单向流水线纪律:只改服务端 shared,改完跑脚本同步;
前端副本只读,本地改动会被覆盖,由字节一致守卫测试兜底。
验收补一条:CLAUDE.md「常用命令」需同步收录该脚本。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 18:42:52 +08:00
joywayerandClaude Opus 5 849de06194 二七王:前端子项目 A(地基与逻辑层)设计定案
前端拆为 A→F 六个子项目,本文定案 A:目录结构、常量层全量、
core 纯逻辑、codes/ui 布局求解器、shared 抽取与同步、单测框架、接入骨架。

两处取舍已定:求解器归子游戏 codes/ui;排序算法走服务端 shared/ 同源
(抽 7 个牌值编码与牌型分组函数,arith 改挂引用,现有 20 个测试为安全网)。

资源与精灵不阻塞编码——常量文件先行、即建资源的规格书,编辑器后期照建。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 18:36:55 +08:00
joywayerandClaude Opus 5 f04bff4e9f 二七王:S-8 curmultiple 改为带符号
问题:S-1 实现时取了绝对值,而判定的正负号恰恰是「谁赢」的区分——
3 大光 vs -3 升3级、2 小光 vs -2 升2级、1 过庄 vs -1 升1级,取绝对值后
三对完全撞在一起,D-8 的过程中判定动画无从分辨该播哪个。

改法:get_curmultiple 直接返回 get_upgrade 原值,与结算包 aset.upgrade
完全同口径(3/2/1 庄家大光/小光/过庄,-N 闲家升N级,0 叫分未定)。
顶部「抓分」角标只关心大小,取 Math.abs 即可;判定动画靠符号分辨,
前端一套映射通吃「过程中」与「结算前」两条路径。

协议 4 处 curmultiple 说明同步(shangzhuang / chupai1 / chupai2·3 /
PushCards)。

测试(hint 33 → 46 checks):
- 钉住三对「绝对值相同、判定相反」的组合可区分,以及符号语义
  (>0 庄赢 / <0 闲赢 / ==0 叫分未定)。
- 新增一组【直接调 get_curmultiple 本身】覆盖负数分支,含爬坡 Q 分段。

补上一个测试盲点:第一次反向验证(把实现退回取绝对值)竟然全绿——因为
原有断言里算法层的 cm() 走的是 A.get_upgrade、绕过了 get_curmultiple,
而端到端那局恰好是大光(正数),取不取绝对值都一样。补齐直接调用的用例后
再验,4 条断言立刻失败。

至此 §7.5 服务端待补 8 条全部完成,服务端侧无遗留;D-8 只差动画设计稿
与触发细节。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 12:42:29 +08:00
joywayerandClaude Opus 5 fd31272bd1 二七王:D-8 判定结果改为动画呈现,并记录 S-8
小局结算面板【不做】大光/小光/过庄/升N级 的静态文字,改由动画在两个时机
呈现:对局过程中(判定档位跳变时)与结算前(最终判定)。精灵 1814
RESULT_JUDGE_TEXT 与资源 634 的静态文字方案作废,634 改为帧动画资源
ANI_JUDGE,播放参数进 AnimationConfigs。

动画遵守「数据优先、表现延后」:先写数据再播动画,动画回调只刷界面不写
核心数据;动画缺失或卡住时结算面板与数据仍正确。

新增 S-8(阻塞 D-8 的过程中动画):S-1 实现 curmultiple 时取了绝对值,
而判定的正负号恰恰是「谁赢」的区分——取绝对值后
  3 大光 vs -3 升3级、2 小光 vs -2 升2级、1 过庄 vs -1 升1级
三对完全撞在一起,过程中动画无从判断该播哪个。建议改为带符号、与结算包
aset.upgrade 同口径,前端一套映射通吃两条路径;顶部抓分角标取 Math.abs
即可,不受影响。

当初取绝对值是因为 S-1 只服务顶部角标一个用途、只关心大小;现在多了动画
这个消费方,符号成了必需信息。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 12:12:58 +08:00
joywayerandClaude Opus 5 e51cde02b8 二七王:去掉开底提示文案,状态提示条收敛为 3 条
闲家看到底牌由背面翻成正面本身就是开底最直白的表达,再加一行「底牌公示中」
是多余的。移除后提示条只剩 3 条:等待庄家选主 / 等待庄家埋牌 / 强甩失败。

「不做」清单相应增至五处:叫分等待、出牌等待、准备等待、掉线重连、开底提示,
各自记明理由(倒计时已指明、平台标识已有、画面本身已表达)。

同时移除随该文案而设的「底牌公示是文案不是术语」那段注记——文案没了,
注记也不再需要;术语侧「开底」的定义在 §0.0 未变。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 08:20:48 +08:00
joywayerandClaude Opus 5 32cbe2fe91 二七王:T-33 提示条文案定案,亮牌改为纯自动关闭
T-33 定案,状态提示条全部 4 条:
  底牌公示中   全场   70 分开底那 3 秒
  等待庄家选主 闲家   shangzhuang → xuanzhu
  等待庄家埋牌 闲家   xuanzhu → maipai
  强甩失败     全场   收到 shuaicuo==1,自动淡出

明确不做四处并记下理由:叫分等待与出牌等待(倒计时就显示在当前操作者
位置旁,已指明在等谁;出牌一局约 84 次切换,弹条只会吵)、准备等待
(平台自带已准备/未准备标识)、掉线重连提示(平台 Layer 615 已覆盖)。

特别标注一处易绕回去的地方:「底牌公示中」是【界面文案】可以用,但
「底牌公示」作为【术语】已弃用(会与「亮牌=公示牌力」混淆),规则术语
一律说「开底」。文案配合 8 张牌摊开的画面不会误读,故沿用。

T-34 补充:亮牌面板改为【仅自动时长关闭】,遮罩不注册点击。并列出三个
遮罩面板关闭方式的对照表——亮牌仅自动、冲关牌型点遮罩、明牌/出牌历史
点遮罩+按钮切换,三者不同,实现时最容易写混。

至此待确认 34 项只剩 T-20 音效(已明确后补)。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 08:14:51 +08:00
joywayerandClaude Opus 5 6c78b37e89 二七王:T-34 亮牌展示时机定案,整理 T-33 等待提示条文案候选
T-34 已定:亮牌在【埋牌后、出牌前】一次性弹出——收 maipai 包(其中带
liangpai)时展示,出牌开始前收起,与 design §8.2 的规则时点一致。
点遮罩关闭 + 自动关闭时长兜底(liangpaiAutoClose 进配置);只有闲家会
收到并弹出;面板开着不阻塞出牌推送,收到出牌包直接收起即可。

T-33:按流程梳理出等待类文案候选 W1–W6 写进 §2.8,连同即时反馈类
(目前只有「强甩失败」)。同时标出三处需要拍板的:
- W4 出牌等待要不要做——一局约 84 次切换,每次弹条会很吵,且已有倒计时
  与已出牌区两重指示,倾向不做。
- W6 开底 3 秒显示什么——参考图上写的是「等待庄家选主」,但那张图叠画了
  开底与选主两个状态(§0.5),不能直接采信。
- 文案是否带昵称——带则提示条需随文案变宽。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 08:07:35 +08:00
joywayerandClaude Opus 5 796503153d 二七王:S-7 明牌条件按手册定案,服务端无需改动
结论:按 design §9.3——任一玩家报无主后,三家都显示「明牌」按钮,
且整个出牌阶段随时可查、不限次数。判定依据是「场上有人报无主」而非
「自己报无主」,报无主那位和另外两位一样能看。

此前我把前端条件收紧成「自己是无主玩家」,比手册严格,反而与服务端
have_baofu() 的受理条件对不上。现改回:

- 前端清单 §1.7 D-6、§2.6 底栏按钮、§0.0 对照表、baozhu 字段说明统一为
  「可查牌 + 出牌阶段 + baozhu==1 → 三家都显示」。
- design §9.3 第 3 条补一句消歧:明确「为这三个人都出现」、判定依据是
  场上有人报无主、且不限次数随时可查。原文靠「同时」承接上一条的
  「为全体三人」,容易读漏。
- protocol 的术语表与 mingpai 受理条件说明同步加注。

服务端与协议本来就是手册口径,无代码改动。此前记的「有主牌玩家伪造请求
也能拿到明牌数据」这一泄露担忧不成立——按手册他本就有权查看,服务端受理
是正确行为。

验收清单增至 18 条,新增一条专门钉住「三家都显示,不是只给自己无主那家」。
至此 §7.5 服务端待补项 7 条全部处理完毕,服务端侧无遗留。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 08:03:08 +08:00
joywayerandClaude Opus 5 9ea6bfa14d 二七王:立「怎么读参考图」原则,清掉由 mock 数值引出的伪待确认项
参考图是界面设计示意,只看版式/位置/样式,不看数值与状态组合。§0.5 新增
一张对照表写清哪些可信、哪些不可信:

  可信:位置、尺寸、层级、配色、字体、元素形态、牌面排布方式
  不可信:具体数值(一律填充假数据);同时出现的互斥元素(如多个玩家位
          同时挂庄标——那是在标「庄标在每个位置分别摆哪儿」);跨阶段并置
          的状态;底栏功能按钮的显隐

据此关闭两个本来就不成立的待确认项:
- T-31 大局结算「基础分」与「总得分」:图上两者数值相同且与右侧大字对不上,
  是填充数据所致。取 grade_jf_total 与 score,S-6 已实现。
- T-32 等待庄家选主.png 里底牌仍摊着:属叠画示意。实际先后仍按 design §4:
  开底 3 秒 → 摸底入手 → 选主。

其余三处已按同一原则处理过的表述(叫分角标 1子/2子/4子、埋牌双排 17/19)
统一改为指向 §0.5,口径一致。

顺带整理 §7.2:T-9 已定却仍留在「仍未决」表里,T-30~T-34 又不在表内。
现重整为「仍未决 3 项(T-20 音效 / T-33 等待文案 / T-34 亮牌展示时机)」+
「已定论 31 项」,并核对 T-1~T-34 连续无缺号。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 07:46:13 +08:00
joywayerandClaude Opus 5 76e82405e1 二七王:S-6 大局结算按 基础分/冲关分/傍王分 三项分解
问题:大局结算面板要分项显示,而 account 只有「累积得分 + 每局得分数组」。
傍王分尤其拆不出来——N = 冲关奖数 + 傍王王数,两个来源【求和之后】才代入
grade_aw = X×(2Ni−Nj−Nk),事后无法反推各自占比。

解法:在算钱时就拆。把 N 分成 N_冲关(只计庄家)与 N_傍王(勾选后庄闲都算),
分别代入同一公式各算一次。公式对 N 线性,故恒有
  grade_cg + grade_bw == grade_aw
这条恒等式正好当回归断言用。

服务端:
- class.paiju.js 抽出 award_pay(n) 复用同一公式,新增 grade_cg / grade_bw;
  desk.seatlist 追加三个累计位(基础/冲关/傍王)。
- class.desk.js seatlist 初始化为 5 元素;account 下发改为【对象数组】
  { score, grades, grade_jf_total, grade_cg_total, grade_bw_total }。
  原二元数组下标语义不清,且前端未开工,此时改代价最小(同 S-4 的判断)。

协议:aset.seatlist 增补 grade_cg / grade_bw;account 结构与恒等式
  grade_jf_total + grade_cg_total + grade_bw_total == score 写明;
  并记录这次结构变更的时间与理由。

测试(endgame 26 → 39 checks):
- 核心不变量:两分量之和 == grade_aw;两分量各自零和。
- 未勾傍王局:grade_bw 恒 0、grade_cg == grade_aw。
- 【新增一整局开傍王的端到端】:不开傍王时 grade_bw 恒 0,只验到平凡情形,
  必须有傍王局才算真验证了拆分。该局逐项复核两个分量的公式、naward 口径,
  并钉住「王数不全相等时 grade_bw 必有非零」防止又退化成平凡。
- 大局累计恒等式:基础+冲关+傍王 == 累积总分。

反向验证:把 _awbw 置零 → 4 条断言失败;把冲关分量误算到闲家头上 →
2 条断言失败。均已还原、全量 17 文件全绿。

另修四处测试桩:_rpc.js / test_paiju / test_rpc / test_success 里手工构造的
o_desk.seatlist 仍是 2 元素,未跟上 class.desk.js 的结构变更(脚本缺陷,
非业务缺陷)。

顺带解决小局结算「冲关分」标签的不严谨:改取 grade_cg 而非 grade_aw,
开傍王时不再把傍王贡献算进来。

遗留 T-31:参考图「基础分」与「总得分」两行数值相同且与右侧大字对不上
(30/30 vs +135),mock 不自洽。当前按 基础分=grade_jf_total、总分=score
提供。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 00:34:46 +08:00
joywayerandClaude Opus 5 50e6fd3ca4 二七王:庄家算奖统一口径为「冲关」
design §8.1 的「常规算奖」全面改称「冲关」,与服务端既有字段 chongguan、
界面「冲关分」「冲关牌型」对齐。理清三层关系并写进三份文档的术语表:

  冲关 = §8.1,只计庄家(旧称常规算奖)
  傍王 = §8.3,可选,庄闲都算
  算奖 = 上位概念 = 冲关 + 傍王 → naward(总奖数)、grade_aw(算奖得分)

「算奖」作为上位概念保留——naward / grade_aw / §8.4 的结算模型都建立在
合并概念之上,去掉它这些字段无从命名。故未做无差别替换,而是逐处甄别:
指 §8.1 的改冲关,指合计的留算奖。

design:17 处「常规算奖」→「冲关」,另修正 §7.0 第 4 层一处笔误
(「8.2 傍王」应为「8.3 傍王」,§8.2 是亮牌);§8.1 开头加改名说明。
protocol:新增 §0.0c 算奖层级表;chongguan / naward / grade_aw 注释同步。
前端清单:界面文案与精灵键名统一(算奖牌型→冲关牌型、BTN_AWARD_CARDS→
BTN_CG_CARDS、AWARD_*→CG_* 等),§0.0 补层级表。

据更新后的 小局结算.png:「算奖分」→「冲关分」、「算奖牌型」→「冲关牌型」。
参考图 算奖牌型显示.png 已随之更名为 冲关牌型显示.png,文档 6 处引用同步;
已逐一核对 16 张图的文件名在文档中都能对上。

顺带记录一处标签不严谨:小局结算的「冲关分」取的是 grade_aw,而后者是按
合计后的 N 算出来的。没开傍王时两者相等;开了傍王时该数含傍王贡献,叫
「冲关分」偏窄。根因与 S-6 同源(两分量算钱前已合并、事后拆不开),待
S-6 落地后此标签才名副其实。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 00:25:11 +08:00
joywayerandClaude Opus 5 b98f58effc 二七王:亮牌改为下发庄家全部固定主牌的具体牌面
规则变更(design §8.2):原先「只亮数量/结构,不亮具体是哪几张牌」,现改为
向两个闲家亮出【庄家手中全部固定主牌的具体牌面】。

- 固定主牌 = 双王 + 全部花色的 2 + 全部花色的 7,【不含】主花色普通牌
  A/K/Q/J/10/9/8/6/5。注意这与旧实现收集的「所有主牌」范围不同。
- 门槛不变(固定主牌总数 ≥10 / 王 ≥3 / 7 ≥6 / 2 ≥6,任一满足)。
- 不限叫分;仅可查牌模式下发;亮出的是固定的一份,不随被哪条门槛触发而增减。
- 协议 liangpai 由 {zhu,zhupair,zhutuo,wang,qi,er} 数量结构改为 {cards:[牌id...]},
  数量交由前端从 cards 自行计算。

服务端 get_liangpai() 重写(class.paiju.js);design §8.2 整节重写,术语表、
易混对照表、§9 相应更新;协议 §0.0b 与 maipai / PushCards 两处字段说明同步。

测试:
- test_paiju 亮牌用例按新规则重写(54 checks)。新增两条针对性用例:
  「只含固定主牌·主花色普通牌未混入」与「同时满足多档仍只亮同一份」——
  前者正是这次范围变更最容易写错的地方。
- test_leak 增加亮牌可见性规则。关键点:期望集合【独立于 get_liangpai 重算】,
  而不是复用被测实现——否则实现若误塞非固定主牌,审计会跟着放行、抓不住。
  反向验证证实了这一点:故意让 get_liangpai 混入主花色普通牌后,审计确实报警。

发现并修复一处假绿:原 6 局随机牌序下庄家一次都没达到亮牌门槛,即
「下发面无越权泄露」对亮牌这条路径根本没走到。现追加换牌序的 4 局(保留
原 6 局覆盖不动),并加两条覆盖断言钉住「可查牌达标 ≥1 局」「不查牌达标
≥1 局」,防止以后再退化成碰巧没触发。反向验证:去掉不查牌门控后审计立即
报出 5 张越权牌。

待定:T-34 亮牌的展示时机(出牌前一次性弹出 / 随时可查)。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-25 23:46:06 +08:00
joywayerandClaude Opus 5 dc9123da92 二七王:确立「摸底 / 开底 / 查底牌」术语,弃用有歧义的「底牌公示」
弃用原因:中文「底牌」既指发牌留桌那 8 张牌,也指"底细/实力"。我上一轮
造的「底牌公示」会被读成"公示庄家牌力",而那其实是 design §8.2 的「亮牌」
——两边撞车。改用「底」字族,与已有的「扣底」成套:

| 术语 | 含义 | 谁看得到 |
| 摸底 | 庄家坐定后把 8 张底牌摸起查看并入手牌 | 仅庄家 |
| 开底 | 70 分坐庄,摸底【之前】向全场翻开 3 秒 | 全场 |
| 查底牌 | 对局中随时回看这 8 张(前端底栏按钮) | 庄家全程;闲家仅开底过的局 |
| 扣底 | 末轮闲家用主牌赢下,翻开埋牌底牌计分(原有) | —— |

三份文档同步:design 术语表新增三条并更新易混对照、§4/§12.1/§12.3 正文
改用新词;protocol §0.0b 与 ancard3s 字段说明同步;前端清单 §0.0 补「底」
字族对照表,§1.4、§2.6 相应改写。

口诀相应更新为:「亮牌」「余主公示」是统计类,「明牌」「开底」是牌面类。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-25 23:34:18 +08:00
joywayerandClaude Opus 5 6ec6bdbbc2 二七王:确立「余主公示」「底牌公示」术语,核查「亮牌」冲突
术语决策:
- A 庄家手牌达门槛后公开数量/结构 → 仍叫「亮牌」(保持不变)
- B 有人报无主后展示各家剩余主牌数与对数 → 新术语「余主公示」
- C 70 分坐庄摸底牌前向全场亮 8 张底牌 3 秒 → 新术语「底牌公示」
- 「亮主」确认为选主界面的标题文案、不是规则术语,三份文档均注明不得
  用它指代任何规则

「亮牌」冲突核查结论:无同名不同义,但有三处字面相近而语义方向相反的
读错风险——亮牌 vs 明牌(一个给数量、一个给牌面,方向还相反)、亮牌 vs
亮主(只差一字且同在庄家阶段)、亮牌 vs「亮出底牌」。故在 design 术语表、
协议 §0.0b、前端清单 §0.0 各加一张四类「公开信息」对照表,一句话口诀:
「亮牌」「余主公示」只给数字,「明牌」「底牌公示」才给牌面。

design.md:术语表新增 亮牌 / 余主公示 / 明牌 / 底牌公示 四条定义 +
易混对照表;§4、§9、§12.1 正文启用新术语。
packet_protocol.md:新增 §0.0b 术语小节;seatlist 第 5 位、ancard3s、
baozhu 三处字段说明冠以对应术语。
前端清单:§0.0 补对照表,§2.5 改名为「自己的主牌统计 + 余主公示」。

另据新图 等待庄家选主.png 抽出一个通用部件「状态提示条」(§2.8):
深色半透条 + 居中白字,等待类(等待庄家选主…)与即时反馈类(强甩失败)
共用同一套精灵,只切位置预设与文案。原单列的 BAR_SHUAICUO 并入该部件。

新增 T-32(该图底牌仍摊着与 design §4 的先后顺序对不上,需确认)、
T-33(等待类文案清单待定)。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-25 23:04:03 +08:00
joywayerandClaude Opus 5 be06d3bf58 二七王:D-1..D-9 定稿落地,新增 S-6/S-7 两个服务端缺口
据新参考图(大局结算.png、强甩失败提示.png)与本轮确认,设计稿只剩 D-8:
- D-1 建房:子游戏只做「类别标签 + 选项」内容区,弹窗背景/标题/关闭/确认
  由平台提供,§5.8 相应不再列弹窗外壳。
- D-2 底牌 3 秒展示:复用庄家翻开底牌那套 UI,不要倒计时;非 70 分只有
  庄家可见、70 分全场可见——同一套界面只差可见范围,故砍掉原先规划的
  独立公示面板(资源 655 与精灵 1757/1758 作废)。
- D-3 甩错:桌面中央深色半透条「强甩失败」,不做收回动画。
- D-4 报无主:不做全场提示(资源 652、精灵 1753 作废),只让他家
  主N/对N 角标出现。
- D-6 明牌 / D-7 出牌历史:版式都照算奖牌型显示.png(遮罩 + 每家一排牌),
  与算奖叠加层共用同一套渲染。出牌历史【不按轮次】,按玩家聚合并用主牌序
  排序——前端摊平 pushlist 即可,无需改服务端。
- D-9 大局结算:玩家栏用精灵复制(新增 §5.7a)。

同时更正了一处我此前的理解错误:左侧那条「主牌对子: 1对」是【自己的主牌
统计、有手牌时常显】,不是 design §8.2 的庄家亮牌 liangpai。§2.5 已重写为
「自己的主牌统计 vs 他家的报无主角标」两套并列,并指出 liangpai 目前没有
界面位置(T-30)。

新增两个服务端缺口:
- S-6(阻塞 D-9):大局结算要显示「基础分/算奖分/傍王分」,而 account 只有
  累积得分与每局得分。傍王分尤其拆不出来——naward = 常规算奖 + 傍王王数,
  两者求和后才代入 grade_aw 公式,事后无法反推各自占比。建议把 N 拆成两个
  分量分别代入同一公式,相加应等于现 grade_aw(可作回归断言)。
- S-7(有泄露口子):明牌按钮条件收紧为「自己是无主玩家」,与 design §9.3
  的「全体三人」及服务端 have_baofu() 校验都不一致。前端先按严格规则显隐,
  但服务端校验未改前,有主牌的玩家伪造请求仍能拿到明牌数据。

另:修复文末附录丢失——三次提交前的一个切片脚本把它挤掉了,现已按当前
状态恢复并补充 SpriteCopyUtils 一行。新增 T-30/T-31、验收清单增至 17 条。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-25 21:32:38 +08:00
joywayerandClaude Opus 5 4aab71e6a3 二七王:实现 S-1 curmultiple 与 S-2 mustcard
S-1 当前抓分倍数(design §7.2.0):
- class.paiju.js 新增 get_curmultiple(),复用 get_qvalue + get_upgrade,
  与结算 aset.upgrade 同源同算法,差别仅在不含扣底(末轮才产生)。
- 随现有包下发、不另开推送:shangzhuang / chupai1-3 / deskinfo.PushCards。
- 三家同值,整表下发不涉及泄露(捡分本就公开);叫分未定时为 0。

S-2 跟牌必出牌(design §5.2):
- class.paiju.js 新增 get_mustcard(seat),内部复用与出牌校验完全相同的
  get_followcard,故建议与校验天然一致。
- 【只发给 nextseat 一家】:它是该玩家自己手牌的子集,整表下发会泄露
  他家手牌结构(server 红线:发全 ≠ 发多)。
- 四种情形不下发:非出牌阶段、该座位是本轮首家、首家甩牌、必出牌为空。
  甩牌一条尤其重要——甩牌跟牌走 flush_follow_ok 的逐分量匹配,与
  get_followcard 不是同一条路径,给建议会给错。

测试(新增 test/test_hint.js,33 checks):
- curmultiple:算法层逐档对齐 design §7.2.0 判定表(含爬坡 Q 分段)、
  三家同值、重连与出牌包一致、叫分未定为 0、与 get_upgrade 绝对值一致。
- mustcard:只有 nextseat 收到、另两家没有、内容确属收包者自己的牌、
  首家无、缺门有富余时无、手牌数恰等于首家张数时整手必出、甩牌不发、
  非出牌阶段不发、重连按 currseat 门控。

过程中修正的两个测试自身问题(均为脚本缺陷,非业务缺陷,业务判定经证据核实正确):
- 「缺门方无必出」原用例给闲2 只发 2 张牌,恰等于首家张数,get_followcard
  正确判定为「整手必出」;改为发 4 张才留出选择余地,并补一条对照用例
  锁住「恰等于张数则整手必出」这个正确行为。
- 「甩牌不发」「非出牌阶段不发」两条原本是假通过——桩的 startcount 仍是 -1,
  前置守卫先返回了 null,根本没测到被测因素。改为先把桩推进到跟牌态、
  每个用例只变动一个因素,并加一条「自证」用例确认推进后确实能算出必出牌。

另:mustcard 补进 test_leak.js 的 CARD_FIELDS 审计白名单(与 burycards 同理,
不加则误发不会被泄露审计发现)。反向验证:改成整表下发后,审计立刻报出
5 张越权牌;把 curmultiple 写死为 1 后,两条断言失败。均已还原、全绿。

协议文档同步 shangzhuang / chupai1-3 / PushCards 的字段说明。
至此 §7.5 服务端待补 5 条全部完成,服务端侧无遗留。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-25 20:25:32 +08:00
joywayerandClaude Opus 5 ecec44e93a 二七王:S-4 拆分 bottomcards 字段名,S-5 术语同步收口
S-4(服务端改动):底牌与埋牌底牌不再共用一个字段名。
- bottomcards = 底牌(发牌留桌 8 张)—— shangzhuang / ChooseMain / BuryCards,保持不变
- burycards   = 埋牌底牌(庄家埋下 8 张)—— maipai(mod.js)、PushCards(class.export.js)
- 结算 bottom.cards 未改名(分组名已表明是抠底相关),文档注明其语义

测试:
- test_leak.js 的 CARD_FIELDS 白名单加入 burycards。这一步是必须的——不加的话
  新字段会静默脱离下发面审计,闲家误收也发现不了。
- test_rpc.js 新增 11 条正反用例:庄家有 burycards 且为 8 张、庄家不再有
  bottomcards(抓改名漏改)、闲家两个字段皆无、重连 PushCards 同上。

验证(不止「跑过了」):
- 改动前基线 16 个文件全绿;改动后仍全绿,新增后 rpc 从 89 → 100 checks。
- 反向验证一:把 mod.js 的 burycards 改回 bottomcards → test_rpc 新用例 FAIL,
  且 test_leak 报出真实泄露(赋值改了而 delete 没改时,闲家会收到庄家埋的牌)。
- 反向验证二:删掉闲家侧的 delete msg.data.burycards → test_leak 立刻抓出
  5 张越权牌,确认 burycards 确已纳入审计白名单。
两次验证后均已还原并复跑全绿。

S-5:术语同步收口。design.md / packet_protocol.md / 代码注释均已改用
「底牌 / 埋牌底牌」;compliance 三份历史核对记录有意不改(反映当时的 design
表述,下轮核对按新 design 重做)。

另:T-9 亮牌条文案暂定「x对 x主」(取 liangpai.zhupair 与 zhu,其余档位
先不渲染、协议不变)。至此 29 项待确认规格全部有结论。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-25 18:07:17 +08:00
joywayerandClaude Opus 5 82cfc98502 二七王:design 与协议文档统一「底牌 / 埋牌底牌」术语
原命名与前端界面倒挂——界面上「底牌」按钮查看的正是发牌留桌那 8 张,
旧术语却把它叫「暗牌」,极易写反。现统一为:

- 底牌     = 发牌时没有发给玩家、扣在桌面的 8 张(原「暗牌」)
- 埋牌底牌 = 庄家埋牌时从手里扣下的 8 张(原「底牌」)

design.md:全文 38 处表述替换(术语表、§4 坐庄流程、§6.3 扣底、§7.1
70 分说明、§8 算奖快照、§12.1 流程、§12.3 阶段速览),并在术语表后补
「术语变更记录」说明改名缘由与协议字段尚未拆分的现状。

packet_protocol.md:新增 §0.0 术语小节置于全文之首,逐条列出
bottomcards 在哪些包里指底牌、哪些包里指埋牌底牌;相关字段说明同步改用
新术语,并在 maipai / PushCards 两处加同名不同义的警示。

字段名本身暂未改动(底牌保持 bottomcards、埋牌底牌拟改 burycards),
拆分计划见前端清单 §7.5 S-4;在拆分落地前收发两侧按包判断语义。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-25 17:58:18 +08:00
joywayerandClaude Opus 5 cfef896fd6 二七王:UI 清单统一底牌术语,细化叫分按钮,确认 S-1 语义
术语修订(本文先行,design/protocol 待同步):
- 底牌 = 发牌时没发给玩家、留在桌面的 8 张(原「暗牌」)
- 埋牌底牌 = 庄家埋牌扣下的 8 张(原「底牌」)
改后底栏按钮名实相符(底牌钮看底牌、扣底钮看埋牌底牌),原「术语陷阱」
一节改写为「术语已统一」。新增 §0.0 术语节置于全文之首。
全文 35 处表述与精灵键名同步(DARK_CARD→BOTTOM_CARD、
BOTTOM_CARD→BURY_CARD、ANCARD_3S→BOTTOM_3S)。

S-3 定论:底牌可见性按 design 手册办——庄家全程可看,闲家仅 70 分坐庄局
可看,其余局按钮不显示。服务端无需改动,现有下发面正好支持。

新增两条服务端待补:
- S-4 拆分 bottomcards 字段名:底牌保持 bottomcards,埋牌底牌改 burycards
  (maipai / PushCards / 结算 bottom 分组)。前端未开工,现在改代价最小。
- S-5 同步 design.md 与 packet_protocol.md 的术语,并列出待改位置清单。
  三份文档术语不一致会直接违反 SSOT,属最危险的那类分歧。

S-1 语义确认:当前捡分对应判定倍率的实时预览(大光×3/小光×2/过庄×1/
升N级×N)。要求随 chupai1/2/3、PushCards、shangzhuang 一起下发
curmultiple,不新开推送包;get_upgrade 现成可用。

叫分按钮(据更新后的 叫分按钮面板.png):
- 配色按档位位置固定,只因不可叫变灰,不随子数变。
- 按钮底与右上角角标底画在同一张图上,4 帧。
- 叫分分数用数字精灵(新增资源 NUM_CALL_SCORE),角标子数用文字精灵。
- 每档由 4 个精灵改为 3 个。
数字口径相应从「一律图片多帧」改为分工:美术字用图片、纯数据小字用文字精灵。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-25 17:48:49 +08:00
joywayerandClaude Opus 5 5dda571250 二七王:UI 清单落实 A/B/C 组决策,新增服务端待补项
规格确认(T-4/6/7/8/10/11/12/15/16/18/19/22/23/26 全部落地):
- 叫分按钮配色按【档位位置】固定三档(浅黄/金黄/橙)+ 灰=已叫过,
  按钮底与角标底各 4 帧。特别标注:参考图角标 1子/2子/4子 是 mock,
  颜色不随子数变(反证:常规算子下 50~5 档同为 6 子却是两种颜色)。
- 选主大数字=该花色张数、角标=对数;手牌标记 3 帧(拖/正2正7/其他主牌)。
- 底栏功能按钮定为 5 个,新增「扣底」;并显著标注术语陷阱——底栏「底牌」
  按钮看的是【暗牌】、「扣底」按钮看的才是【底牌】,而协议里两批牌共用
  bottomcards 字段名,要求前端用 anCards/diCards 区分。
- 结算底牌文案的 x 后面是扣底倍数;牌型标签只做「毙」;准备标识用平台的;
  数字一律用图片多帧(参考图数字带描边渐变)。
- 发牌动画:从右往左铺开。算奖叠加层点遮罩关闭、并列显示奖数。
- 自己发的提示本地回显,作为悲观 UI 的受控例外并写明成立理由
  (提示不含对局状态、服务端不校验真实性)。
- T-5 手牌排序定为对齐服务端 order_cards 主牌序,选主后整体重排。

新增 §7.5 服务端待补项:
- S-1 抓分倍数字段:协议现无,出牌阶段拿不到任何倍数;服务端 get_upgrade
  现成可用,建议补 curmultiple,语义待最终确认。
- S-2 下发应选中的牌:get_followcard 已返回 mustcard,仅用于内部校验、
  未下发;建议只发给 nextseat 一家(整表下发会泄露他家手牌结构)。
- S-3 ⚠️ 底栏「底牌」按钮要求所有人可看暗牌,与 design §4「非 70 分时
  暗牌只有庄家可见」及协议下发面直接冲突;给出三个方案,确认前前端按
  最保守的 (a) 限定可见范围实现。

新增 D-13(扣底查看面板)、验收清单增至 15 条。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-25 13:25:33 +08:00
joywayerandClaude Opus 5 2c3464b6f0 二七王:UI 清单关闭 7 项待确认,补平台图层清单与验收检查表
从平台代码/编辑器数据查证后关闭:
- T-2 解散投票:平台全包(Layer 420 CheckFree_Layer + GameUI.openCheck
  + Desk.self_apply_free_room 一族),子游戏只处理解散结算包,D-10 缩到
  只剩结算面板。
- T-3 建房图层:Layer 27 CreateRoom_Layer;Layer 28 SeniorOptions_Layer
  是代开房/抽水,与玩法规则选项无关,不走它。

按参考图算定 / 技术自决:
- T-21 手牌间距:单排 spacingMin=-78(36 张露 32px,与实测 33px 吻合),
  双排 -65。
- T-27 埋牌分行:上排 floor(n/2)、下排 ceil(n/2);参考图 17/19 按 mock 处理。
- T-25 line 增加可选 items 支持逐项不等宽,底栏功能钮组与埋牌/出牌操作条
  改用它。
- T-17 倒计时归零停在 0 不隐藏(隐藏会被误读为已超时/已跳过)。
- T-28 EQW_Layout 为权威,配置区手工同步并列入验收。

另补:
- §0.2 增列平台已占用的 14 个图层(创建房间/解散/帮助/设置/加载/重连/
  战绩/聊天语音等),凡列出者子游戏不必重建。
- 新增 §7.3 验收检查清单 12 条,逐条对应一处已知易错点。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-25 09:01:52 +08:00
joywayerandClaude Opus 5 b85c286dde 二七王:精灵复制范围收回,仅算奖牌型一处使用
上一次提交把复制方案扩到了建房选项、并把已出牌区列为候选,超出了
实际要求。现纠正:

- 已出牌区维持预置(28 × 3),不改复制,撤销原 T-29 候选项。
- 建房规则选项改回预置 20 个(3 类别标题 + 8 选项 × 2 + 房卡附注),
  选项集固定、数量小,不需要复制;渲染仍由 §1.1 的配置数据驱动。
- 选用判据表改为明确结论:全项目只有「算奖牌型」用复制,其余一律预置。
  精灵段有近 2000 空位,预置的 ID 开销不构成压力,不必给每处都背上
  显式清理负担。

原 T-30 顺延为 T-29。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-25 08:30:32 +08:00
joywayerandClaude Opus 5 766ef7eae3 二七王:UI 清单补冲关定义,算奖牌型与建房选项改精灵复制
- 补「冲关」的准确定义(class.arith.js:1277 get_chongguan):它就是
  design §8.1 常规算奖的服务端命名,不是独立规则。列出 count/wang/cards
  三个返回值的去向与计数规则,并点明两处易错:连对链必须先有 ≥3 王才
  计算;固定主牌 ≥10 张只触发亮牌、不算奖。前端不重算,一律取下发值。

- 算奖牌型叠加层改用 SpriteCopyUtils 精灵复制:cards 张数无小上限
  (四王 + 长连对链 + 八个 7 + 八个 2 可达 20+ 张),预置既可能不够又
  常年空占。编辑器只建 5 个精灵(遮罩 + 3 容器 + 1 模板),省约 19 个 ID,
  关闭 T-24。

- 建房界面按「类别标题(牌局/模式/规则)+ 选项组」重构:
  选项组分单选 / 多选 / 单选(可不选)三型,后者复用复选图;
  每个选项 = 选择框图片 + 文字精灵;选择框资源定为 2x2 四帧
  (单选未选/单选选中/复选未选/复选选中),帧号 =
  (useCheckbox?3:1)+(selected?1:0);类别标题一张图 3 帧。
  整个界面由「类别→选项组→选项」配置数据驱动渲染,渲染代码不认识
  具体玩法名,roomtype 靠每项自带的 bit 拼串。同样改精灵复制,
  编辑器只建 4 个精灵(原预置方案需 21 个)。

- 新增「预置 vs 精灵复制」的选用判据表,并据此标出下一个候选:
  已出牌区 84 个预置精灵(T-29,需先确认复制精灵满足 z 序与出牌动画)。

- 新增 T-29 / T-30。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-25 08:23:25 +08:00
joywayerandClaude Opus 5 ddb4f7d6a2 二七王:UI 清单增补布局配置规范,并据 4 张新/改参考图更新
新增/更新的参考图带来的确定项:
- 小局结算.png 更新:两个小字标签定为「牌局分」(grade_jf) /「算奖分」(grade_aw),
  关闭 T-13;新增「算奖牌型」「下一局」两个按钮。
- 算奖牌型显示.png:算奖明细走独立的半透遮罩叠加层(aset.seatlist[i].cards),
  关闭 T-14。
- 自己是闲家时的踩有分没分按钮.png:提示入口 = 底栏三按钮,占用庄标区域、
  与庄标互斥;气泡资源定为 3x2 六帧,按「头像在气泡哪一侧」选箭头朝向,
  关闭 D-5。
- 创建房间选项参考图:属他游戏内容,只取视觉范式;二七王实际为 4 行
  (局数/扣卡/玩法多选/查牌),无人数行,D-1 降级为「缺定稿」。
- 左上家出牌区确认为右上家的左右镜像,关闭 D-12。

§6 由「布局坐标表」重写为「布局配置规范与配置清单」:
- 所有布局参数一律外提为配置,业务代码零裸值(工程总则 §6 / client 02 §2)。
- 定义 5 种布局器 point / line / fan / grid / attach,参数名直接对齐框架
  AlignmentUtils 的入参,配置可原样喂入、零转换。
- fan 给出唯一的自适应间距算法;bySeat 表达三个显示位的差异;
  另有 textStyle 预设与 CARD_SIZE 尺寸档,改一处全局跟随。
- 补充配置文件组织与「与平台 PLAYER_INFO_LAYOUT 坐标一致性」的衔接要求。

待办变化:关闭 T-1/T-13/T-14 与 D-5/D-12,新增 T-22..T-28。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-25 08:14:30 +08:00
joywayerandClaude Opus 5 6fc9f15976 二七王:新增前端 UI 资源与精灵清单(docs_dev)+ 归档 9 张参考图
对照 design.md 全流程与 packet_protocol.md 下发字段,把二七王前端所需的
图片资源、声音、精灵、图层、群组、坐标整理成可执行规格书,供美术出图与
gameabc 编辑器建资源使用。

要点:
- 核实号段占用:精灵 1001-3000 段内仅 3000 被占(Spirit3000/Layer602),
  实际可用 1001-2999;图层 101-200/301-400、群组 201+、图片 501+ 全空闲。
- 牌面资源定为 10x6=60 帧通用排版(黑桃/红桃/梅花/方块 A-K + 双王 + 6 牌背),
  开大/中/小三套尺寸;给出唯一权威的 cardIdToFrame 转换与 10 条校验样例。
  服务端 flower 编号(1方块..4黑桃)与美术花色顺序相反,转换须按花色段倒序数。
- 标明平台已提供、子游戏不重建的部分(头像/昵称/分数、聊天语音气泡、
  RecordView、帮助、建房界面骨架与 25 号确认按钮)。
- 座位方向据 class.paiju.js:264 get_nextseat 定论:下家 =(seat+1)%3、显示在右上。
- 汇总 12 项待出设计稿(D-1..D-12)与 20 项待确认规格(T-2..T-21)。

ID 均为规划建议值,编辑器建成后须回填校准;坐标为参考图实测估值。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-24 22:40:44 +08:00
joywayerandClaude Opus 5 df4eefc9e2 二七王:design §4.6 修正笔误「最0号座位」→「0 号座位」,补第一局取固定座位的缘由
规则方确认轮庄规则本身无误(开局先叫分者为暂定庄家;第一局暂定庄家为座位 0;
之后庄赢原庄、庄输下家),与 §4.2/§4.6 及代码一致,无需改动逻辑。

仅修一处笔误并补上缘由:第一局此前没有打过任何牌局、不存在「上一局的庄家」,
所以取固定座位 0。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 15:13:39 +08:00
joywayerandClaude Opus 5 b924b6e864 二七王:第八轮核对(流程/牌型/玩法/算分)+ 新增阶段机迁移矩阵用例
规则方复述「开局庄家先叫分、不允许不叫分;第一局无庄家时默认座位0开始叫分」,
据此按四个维度重新取证,未发现不一致。

新增此前从未做过的取证:**阶段机迁移矩阵**。前几轮每个 handler 只测了 1~2 条
代表性失败路径,从没系统验证过「某个请求在别的阶段会不会被误放行、会不会静默
丢弃」。本轮把 8 个 RPC × 5 个阶段做成 40 格矩阵逐格驱动,全部符合:每个 RPC
只在应允阶段受理,其余一律回 STEP 失败包,无静默丢弃。

叫分起始者规则逐条取证,与规则方复述一致:
- 第一局 firstseat=0、待叫者=0
- 之后每局起始 = 暂定庄家:连打 6 局逐局比对「上局 banker + result」
- 庄赢连庄:随机对局几乎打不出 result=0,用「叫70分 + 首家出最大牌」专门构造
- 庄输/投降顺延下家
- 首家不允许「不叫」:第一局与其后每局(含连庄局)发 call=0 一律回 RULE,
  且叫分过程不变、仍轮到他自己

固化为 test/test_flow.js(7 项聚合断言,0.3s)。断言里带 cells===40,防止驱动
失败导致整行「跳过」而假绿。变异检验:去掉 chupai/maipai 的阶段校验、把 jiaofen
的阶段校验改成静默丢弃 —— 三条全部转红。

全套单测 540 → 547 项全绿。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 14:59:46 +08:00
joywayerandClaude Opus 5 31011e2eeb 二七王:改写 design §5.3(拖拉机相邻关系),消除自相矛盾表述
规则复核时提出「主牌链上位置相邻的对子即成拖拉机、不限花色」,据此发现 §5.3
首句「**同一花色内**点数相邻的两个对子构成拖拉机」与本节紧接着给出的跨花色
主牌链(正7-副7-正2-副2-主A…)自相矛盾。代码实现的一直是那条链(跨花色),
首句表述是错的。

§5.3 拆成两段重写:
- 副牌拖拉机:**必须同一花色**,链 A-K-Q-J-10-9-8-6-5(7、2 是固定主牌不入链,
  故 6 与 8 相连;3、4 不在牌堆,5 是最小档);跨花色不构成连对。
- 主牌拖拉机:只有一条完整序列,**位置相邻即成拖拉机、不限花色**——正7对+副7对、
  副7对+正2对、副2对+主A对、大王对+小王对都是合法两连对。
- 补充「同一档位的两个对子不相邻」:副7 在序列里只占一个位置,♥7对+♣7对 是两个
  平级对子而非拖拉机,副2 同理。

§1 术语表给「正2/正7」加上口语别名「主2/主7」,避免复核时来回换词。

代码无需改动(实测 14 段链全通、跨花色通、同档位不通,与改写后的表述一致)。

补 17 条用例把改写后的每条钉在**拖拉机层**——此前只测到 is_continuous(两张牌
相不相邻),没测过「这几个对子能不能真的组成拖拉机」。变异检验:主牌链改成必须
同花色 / 同档位副7对算相邻 / 副牌 8-6 去掉同花色约束,分别转红 6、2、4 条。

另:复核给出的副牌链写作「…-6-5-3」,需要牌堆保留 3;但 92 = 28×3 + 8 只有在
3、4 都剔除时才成立(保留 3 则 92÷3 除不尽、三家发不平)。经确认为笔误,维持
design §2 现状,并就地补两条断言钉住「参与发牌的牌里 3 和 4 各 0 张、共 92 张」。

全套单测 523 → 540 项全绿。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 14:41:41 +08:00
joywayerandClaude Opus 5 b5d9eece5d 二七王:第七轮核对(反方向 代码→design)+ 新增下发面泄露审计
前六轮都是「design → 代码」方向。本轮反过来:从代码出发,把每个规则决策与
常量拉出来追问 design 有无明文依据。没有发现与 design 冲突的行为,但列出 6 项
「design 未明文规定、由代码自行决定」的项(倒计时秒数、§5.4.4 降级阶梯是否
最长优先、甩牌分解的最大化合并约定、同级副7对不成连对、call 入参宽松而 cards
严格、get_chongguan 的 <28 隐式兜底),已写进 compliance 待规则设计者拍板。

同时补上一块此前完全没有测试的红线:**下发面按可见性下发**。design §4 暗牌
只有庄家可见、§9 查牌模式、§11 结束亮底牌,以及 server 红线「发全 ≠ 发多」,
此前各处门控只有逐条手工核对,从未系统验证「有没有哪个包把不该看的牌送到了
某个座位」。

新增 test/test_leak.js:跑 6 局(可查牌/不查牌 × 叫 65/70/5),对每一个
「服务器 → 某座位」的下发面(各 RPC 逐座位包 + 各阶段重连快照 + 明牌应答)
深度扫描出所有牌 id,逐个判定该座位此刻是否有权知道。结果 1814 个下发面 /
18706 次可见性判定,0 泄露。

变异检验:非 70 分把暗牌发给闲家(第三轮修过的历史缺陷)、重连把底牌发给
闲家、出牌包把剩余手牌发给所有人 —— 三条全部被抓住。

全套单测 520 → 523 项全绿。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 14:29:12 +08:00
joywayerandClaude Opus 5 49ee068c5c 二七王:解散/重连空守卫用例改为 try/catch,避免回归时整个文件崩溃
复检本会话修复项时发现:`解散·牌桌尚未创建 → 返回 null` 这两条用例是直接调
get_disbandRoom 的。守卫一旦被去掉,被测函数当场抛异常,未捕获时 test_endgame
在此中断,后面的「出牌入参顺序无关性」等断言全不执行——看到的是崩溃而不是
某条断言转红,定位与信号都差。改用 noThrow 包一层,异常转成可读的断言失败。

顺带补 get_deskinfo 的同名守卫用例(平台在开战前也可能回调重连)。两个守卫
都做了变异检验:分别去掉后对应断言干净转红,且文件后续断言照常执行。

全套单测 519 → 520 项全绿。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 14:13:20 +08:00
joywayerandClaude Opus 5 9d0675fce8 二七王:第六轮核对(多局大局 + 整局带牌型模糊),未发现不一致
前五轮的端到端都只跑单局、且只出单张。本轮补两个从未被驱动过的维度:

一、多局大局(design §12.1 步骤10 / §12.2 / §4.6)
真实驱动 6 局与 12 局:跨局轮庄与「上局 banker+result」严格对应,另单独造出
result=0 的庄赢局验证连庄端到端(此前只有 do_prepare 的桩单测);每局零和、
累计分与逐局累加一致;account 只在末局出现,打满后再准备被拒;房卡只扣一次;
战绩载荷完整;中途解散按当前累计分结算、result=3。全部相符。

二、整局带牌型模糊(新增 test/test_fuzz.js)
test_endgame 的驱动器只会出单张,对子/拖拉机/甩牌/甩错在整局链路里从未跑过。
新测试用带牌型的对局补上,并对每一墩用独立参考实现重算「谁最大」与服务端
比对(不是抽查),另独立重算捡分、扣底倍数、算奖与捡分子数分配。探针阶段
跑了 160 局约 3800 墩(4 个种子)0 异常,入库版固定为 20 局约 480 墩、0.3s。

变异检验:牌的归属写给非胜者、单张毙牌不再压过副牌、算奖分配去掉 ×X、
跟牌牌面值改回未排序入参 —— 四条全部转红。

过程中修正了测试自身的三个问题(已写进 test/README 与 compliance):
- 参考实现的 rank 取了负数,而 0 是「不参与本墩」的哨兵,哨兵反而数值最大;
- 闲家捡分按 playowner 累加再与 aset.grade 比,是拿被测字段自证,playowner
  写错时两边一起错照样通过 —— 改成按独立算出的墩胜者累计;
- 扣底倍数再调 get_bottom_multiple 去比,同样是自证,倍数表改成恒返回 2 也
  通过 —— 改成按 design §6.3 独立重算。
另:该文件的覆盖下限是发牌相关的,随机发牌下扣底可能一次都不出现(实测 60
次里有 1 次),故把发牌也接到固定种子上;随机发牌的整局覆盖由 test_endgame
承担。

全套单测 511 → 519 项全绿,总耗时 < 1.7s。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 13:58:02 +08:00
joywayerandClaude Opus 5 a4361a802d 二七王:补 9 处 design 规则的正/反/边界用例(均通过变异检验)
对 design §1~§12 重做「每条规则是否三类用例齐全」的审计。此前的覆盖矩阵按
「被测函数」组织,容易漏掉跨函数才体现的规则,本轮补齐 9 处:

- G1/G2 §4.2 叫分:下限5与非首家不叫接受,负数/缺call/步进外/同分拒;
  「不叫即退出本局叫分」——反悔再叫被 SEAT 拒、callproc 不变、仍轮到下一家
- G3 §4.5 埋牌:非庄家 SEAT、step2/step5 STEP、7张/9张/空数组 PARAM
- G4 §5.1 每轮由上一轮牌面最大的一方先出(闲1赢/闲2赢两种)
- G5 §6.2 闲家赢计入台面全部分牌(含庄家自己打出的)、庄家赢作废、
  两个闲家谁赢结果一致
- G6 §8 算奖快照:庄家埋后28(已埋不在内)、闲家28、投降36、打出后不缩水
- G7 §3 对子只认同花色同点数两副:跨花色副7/副2、正副7、大小王均不成对
- G8 §6.3 赢末轮的牌不全是主牌就不扣底(副牌拖拉机/混合出牌/顺序颠倒)
- G9 §11 无超时托管守卫:对局阶段 min_ontimeout 调用次数必须为 0,
  将来有人加超时代打就会转红

每条都在副本上注入违反该规则的改动确认转红(7 组变异全部被抓住)。

顺带修一个测试自身的问题:mkBury 桩缺 do_burycard,导致「埋牌张数校验被
改松」这类变异表现为整个文件崩溃而非某条断言转红,后续断言全不执行。桩已
补完整到能走完成功路径。

全套单测 462 → 511 项全绿。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 13:36:09 +08:00
joywayerandClaude Opus 5 1b76683704 二七王:固化两组 design 差分测试(§7 全量对拍 + §5.2 穷举差分)
把第五轮复验用的取证探针固化入库。两者都不枚举「我想到的场景」,而是把
design 的规则整条转写成参考实现再与代码大面积对拍,专治手写用例覆盖不到
的欠约束分支:

- test_calc.js:§7 算子全量对拍。参考实现逐字转写自 §7.1/§7.2/§7.3 判定表,
  比对 2 种算子模式 × 14 个叫分档 × grade 0~260 共 7308 格(每格比
  base/Q/判定倍率/最终子数),另按 design 的 17 张表逐格抽查 141 个写死的
  最终子数。断言按档位分组,失败可直接定位。
- test_followdiff.js:§5.2 跟牌强制层级穷举差分。对每手牌枚举全部 C 张出牌
  组合,逐个比对参考裁定与 can_followcard,约 11 万组。

两组均通过变异检验:关掉 follow_tractor_cover_ok → followdiff 三种拖拉机
首出全红;大光倍率 3→4 / 常规55档 base 4→5 / 爬坡40·35档 Q 20→40 → calc
精确指出档位与 grade。

过程中的教训已写进 test/README 与 compliance:差分测试的强度取决于造牌器。
第一版纯随机抽牌跑 7.8 万组全绿,但关掉覆盖度校验依然全绿——「同花色三组
互不相邻的两连对」这种唯一能区分「只出零散对子」与「先凑最长拖拉机」的
12 张结构随机抽不出来;掺牌又一度把它撑过枚举上限被静默跳过。故随机源改用
固定种子 PRNG,关键结构用确定性 structHands() 钉死。

全套单测 401 → 462 项全绿,总耗时 < 1.5s。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 12:56:30 +08:00
joywayerandClaude Opus 5 e8a7b245cc 二七王:记录第五轮·复验(design 维度的穷举/差分取证,未发现不一致)
不再逐条读代码,改为按 design.md 原文另写参考实现再与代码穷举对拍:

- §7 算子:两种算子模式 × 14 个叫分档 × grade 0~260 全量对拍 14672 格
  失配 0,另抽查 design 表格里写死的最终子数 113 格失配 0
- §5.2 跟牌强制层级:按原文写参考裁定,4000 手牌枚举全部出牌组合,
  86474 组失配 0
- §5.4 甩牌最大性:按 §5.4.2 原文写参考判定,6000 例失配 0
- §2/§3/§5.3/§6/§8/§4/§9/§10/§11 逐条取证,全部符合

两次「疑似失配」经查都是探针写错(甩错最小张那一档有两副牌、闲家赢轮的
台面分含庄家自己打出的分牌),代码是对的,已在台账注明避免后续重提。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 12:45:33 +08:00
joywayerandClaude Opus 5 2fd5829551 二七王:协议文档修正解散结算的真实投递方式,补 roomtype 位串表
以 packet_protocol.md 本身为核对对象(前四轮只在改代码时顺手同步、从未反向
验证「文档写的 = 客户端实际会收到的」),查出两处:

- §14 把解散结算写成 route=erqiwang / rpc=jiesuan 的独立包,实际平台是把
  get_disbandRoom 的返回值整个塞进房间路由的 route=room / rpc=free_room 包,
  即 data.deskfree = { rpc:"jiesuan", data:{...} },比文档多一层 data 包装,
  真实取值路径是 data.deskfree.data.aset;前端照原文档写会取空。新增 §14.1
  给出真实包结构、取值要点、两层 success 的归属与 deskfree 可能缺失的情形。
- 文档多处引用「roomtype 位2/位3」,却从未给出位串定义(只存在于 compliance
  文档里),前端无法据协议拼建房串。新增 §0.5 位表 + 缺省示例 + 兜底规则 +
  局数/扣卡 4 组合对照,并指明 class.config.js parse() 为唯一解析入口。

compliance 记第五轮核对:design 维度逐条复核未发现新的不一致;新发现 4 项
(F1 跟牌入参顺序、F2/F3 协议文档、F4 解散空守卫)已全部处置,并留痕本轮
确认无误、勿再重提的三点观察。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 12:18:18 +08:00
joywayerandClaude Opus 5 ce4c972b7b 二七王:修复跟牌判定依赖客户端入参顺序(可翻转本轮胜负与结算)
can_followcard 尾段计算 cardvalue/noflower/nopair 时用的是入参原始数组
followcards(客户端提交顺序),而 get_pairlist/get_tuolaji_list 都按降序
相邻取对。端到端实测:闲家用主拖拉机毙牌且为末轮,降序提交时闲家赢下本轮
(捡分 40、扣底 ×4),把同一手牌打乱成 [51,50,105,104] 后对子漏判、
cardvalue 归 0,变成庄家赢、闲家 0 分大光、不扣底——同一手合法牌仅靠数组
顺序就能翻转胜负、捡分归属与最终结算,同源问题还能抹掉 noflower 缺门标志
污染 §9 下发给全场的牌况表。

改为在 min_ary_deduct 削减 _followcards 之前另存完整排序快照 _sortfollow,
尾段 8 处全部改用它(_followcards 会被削减、不可复用)。

顺带给 get_disbandRoom 加空守卫:平台在 makewar 后立刻置 battlestate=1,
而首局是延迟 1 秒创建的,这段窗口内解散会在 curr_paiju() 的 undefined 上
解引用抛异常、打断平台解散链路;现返回 null 走平台既有的「不带 deskfree」分支。

补 8 条回归(入参顺序无关性 5 条 + 端到端 1 条 + 解散空守卫 2 条),
已用「回退修复 → 用例转红」反验有效性;全套单测 401 项全绿。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 12:18:04 +08:00
122 changed files with 40327 additions and 535 deletions
+1 -1
View File
@@ -2,4 +2,4 @@
# 机械红线校验:对本次暂存的文件做「可编辑范围 + 严格 ES5」硬检查,命中即阻断提交。
# 工具无关——无论 Claude Code、别的编辑器还是人手改,提交都会过这道闸。
# 启用(每个克隆一次性):git config core.hooksPath .githooks
# exec node "$(git rev-parse --show-toplevel)/.claude/hooks/check-redlines.js" --staged
exec node "$(git rev-parse --show-toplevel)/.claude/hooks/check-redlines.js" --staged
+3
View File
@@ -24,3 +24,6 @@ yarn-error.log*
.claude/settings.local.json
# 会话级哨兵:记录本会话已注入过哪些规范目录,SessionStart 会清空,勿提交
.claude/.spec-injected/
# 多智能体工作区(工具产物,不入库)
.superpowers/
+10 -1
View File
@@ -12,13 +12,20 @@
## 常用命令
没有配置任何构建/lint/测试工具。仓库里唯一的脚本:
没有配置任何构建/lint/测试工具。仓库里的脚本:
```bash
# 根据 client/assets/spine/*.json 重新生成 client/generated/spine_assets.js 与 spine_data.js
client/scripts/build_spine_data.cmd # 内部调用 build_spine_data.ps1
```
```bash
# 把服务端共享算法同步到前端只读副本(改动一律落在服务端 shared/,改完跑一次)
sync_shared.cmd
```
`server/games/erqiwang/shared/` 是前后端共享算法的**唯一可修改处**;`client/js/01_SubGame/codes/shared/` 是它的只读副本,手改会被下次同步覆盖,且 `client/tests/test_shared_sync.js` 会因字节不一致而失败。
在 `client/assets/spine/` 增删 Spine 导出文件后运行;不要手改这两个生成文件。
查看客户端效果需用 HTTP 方式(而非 `file://`)打开 `client/index.html`,例如 `npx http-server client -p 8080`。
@@ -67,4 +74,6 @@ git config core.hooksPath .githooks
## 测试与 Git
- **测试纪律**:细则见 client 05 §10 / server 04 §10。要点——测试代码可用现代语法(只跑 Node,不上线);**正式代码禁止为测试而加逻辑/放宽校验**,禁止为过测而改正式代码或降低测试标准(skip、软化断言、吞异常等);失败先用证据裁定「业务缺陷 vs 脚本缺陷」;正面/反面/边界用例都要覆盖。
- **新增断言要反向验证**:写完一条守卫断言,**故意破坏它守的那处逻辑,确认它真会变红**——否则你加的可能是一条空转断言(「全绿但什么都没验」比红更有害)。
- ⚠️ **本仓库文件是 CRLF 行尾**:任何基于行尾的脚本化改写(`sed`/正则批量删改)必须按 `\r?\n` 处理。写 `\n` 会**一行都匹配不上却静默成功**——曾据此得出「测试抓不住缺陷」的错误结论。脚本改完先 `git diff --stat` 确认文件真的变了,再据其结果下判断。
- **Git 提交**(细则见 server 04 §11):完成一个可独立成立的逻辑改动即可**自动提交、无需逐次确认**(`push` 按需);提交信息用中文、聚焦一件事、结尾保留 `Co-Authored-By` 署名;不用 `git add -A`/`git add .`、不跳过 hooks、不做 `reset --hard`/`push --force`。
+48
View File
@@ -208,6 +208,54 @@
<!-- 阶段5: 二七王子游戏实现层 -->
<!-- 5.1 配置常量(纯数据,无依赖,最先加载) -->
<script type="text/javascript" src="js/01_SubGame/codes/config/Layers.js"></script>
<script type="text/javascript" src="js/01_SubGame/codes/config/Groups.js"></script>
<script type="text/javascript" src="js/01_SubGame/codes/config/ImageResources.js"></script>
<script type="text/javascript" src="js/01_SubGame/codes/config/SoundResources.js"></script>
<script type="text/javascript" src="js/01_SubGame/codes/config/Sprites_Table.js"></script>
<script type="text/javascript" src="js/01_SubGame/codes/config/Sprites_Cards.js"></script>
<script type="text/javascript" src="js/01_SubGame/codes/config/Sprites_Action.js"></script>
<script type="text/javascript" src="js/01_SubGame/codes/config/Sprites_Result.js"></script>
<script type="text/javascript" src="js/01_SubGame/codes/config/Sprites_CreateRoom.js"></script>
<script type="text/javascript" src="js/01_SubGame/codes/config/LayoutConstants.js"></script>
<script type="text/javascript" src="js/01_SubGame/codes/config/Layout_Table.js"></script>
<script type="text/javascript" src="js/01_SubGame/codes/config/Layout_Cards.js"></script>
<script type="text/javascript" src="js/01_SubGame/codes/config/Layout_Action.js"></script>
<script type="text/javascript" src="js/01_SubGame/codes/config/Layout_Result.js"></script>
<script type="text/javascript" src="js/01_SubGame/codes/config/Layout_CreateRoom.js"></script>
<script type="text/javascript" src="js/01_SubGame/codes/config/AnimConstants.js"></script>
<!-- 5.2 前后端共享算法(只读副本,改动落在服务端 shared/,跑根目录 sync_shared.cmd 同步) -->
<script type="text/javascript" src="js/01_SubGame/codes/shared/cards.js"></script>
<!-- 5.3 纯逻辑核心 -->
<script type="text/javascript" src="js/01_SubGame/codes/core/CardCodec.js"></script>
<script type="text/javascript" src="js/01_SubGame/codes/core/SeatMap.js"></script>
<script type="text/javascript" src="js/01_SubGame/codes/core/CardOrder.js"></script>
<script type="text/javascript" src="js/01_SubGame/codes/core/CardMark.js"></script>
<script type="text/javascript" src="js/01_SubGame/codes/core/SpriteIndex.js"></script>
<!-- 5.4 布局求解 -->
<script type="text/javascript" src="js/01_SubGame/codes/ui/LayoutSolver.js"></script>
<!-- 5.5 对局状态与事件 -->
<script type="text/javascript" src="js/01_SubGame/codes/state/Events.js"></script>
<script type="text/javascript" src="js/01_SubGame/codes/state/GameState.js"></script>
<script type="text/javascript" src="js/01_SubGame/codes/state/RoomOptions.js"></script>
<!-- 5.6 网络:handler 在前,Dispatcher 在后(它注册时要引用全部 handler) -->
<script type="text/javascript" src="js/01_SubGame/codes/net/handlers/FailHandler.js"></script>
<script type="text/javascript" src="js/01_SubGame/codes/net/handlers/DealHandler.js"></script>
<script type="text/javascript" src="js/01_SubGame/codes/net/handlers/CallHandler.js"></script>
<script type="text/javascript" src="js/01_SubGame/codes/net/handlers/MainHandler.js"></script>
<script type="text/javascript" src="js/01_SubGame/codes/net/handlers/BuryHandler.js"></script>
<script type="text/javascript" src="js/01_SubGame/codes/net/handlers/PlayHandler.js"></script>
<script type="text/javascript" src="js/01_SubGame/codes/net/handlers/QueryHandler.js"></script>
<script type="text/javascript" src="js/01_SubGame/codes/net/handlers/ReadyHandler.js"></script>
<script type="text/javascript" src="js/01_SubGame/codes/net/handlers/ResultHandler.js"></script>
<script type="text/javascript" src="js/01_SubGame/codes/net/handlers/ResyncHandler.js"></script>
<script type="text/javascript" src="js/01_SubGame/codes/net/Dispatcher.js"></script>
<script type="text/javascript" src="js/01_SubGame/codes/net/Rpc.js"></script>
<!-- 5.7 平台接入(须在依赖之后、三文件转发壳之前) -->
<script type="text/javascript" src="js/01_SubGame/codes/SubGameHooks.js"></script>
<!-- 子游戏 Hooks 实现(转发壳三文件转发到此;须在依赖的 controllers/handlers 之后、三文件之前)-->
<script type="text/javascript" src="js/01_SubGame/00_SubGame_Config.js"></script>
<script type="text/javascript" src="js/01_SubGame/01_SubGame_modify.js"></script>
+349
View File
@@ -0,0 +1,349 @@
// ============================================================================
// SubGameHooks.js —— 二七王子游戏接入入口(实现骨架,本阶段空函数,B/F 阶段逐个填充)
// 平台契约文件(00/01/02 转发壳)会把同名平台接口转发到这里。只需实现【本游戏需要的】hook,
// 未实现的接口由转发壳走空操作(A)/平台默认返回值(B)/模板默认 UI 渲染(D)。严格 ES5。
// 接口清单与默认值见 spec §9 / 文档 06。
// ============================================================================
var SubGameHooks = window.SubGameHooks || {};
// ===== gameHallImport.*(大厅,9) =====
// [A] hallAppStart():大厅应用启动(注:与 Game_Modify.appStart 同名冲突,故加 hall 前缀区分)。
// SubGameHooks.hallAppStart = function () { /* ... */ };
// [A] jumpMenuScene():大厅跳转菜单场景。
// SubGameHooks.jumpMenuScene = function () { /* ... */ };
// [A] gameStart():大厅游戏开始入口。
// SubGameHooks.gameStart = function () { /* ... */ };
// [A] setGameList():设置游戏列表。
// SubGameHooks.setGameList = function () { /* ... */ };
// [A] clearGameinfo():清除游戏信息。
// SubGameHooks.clearGameinfo = function () { /* ... */ };
// [A] getWebdata(data):获取网络数据回调。
// SubGameHooks.getWebdata = function (data) { /* ... */ };
// [B] isInstalled(data):检查是否已安装;无 hook 时默认:return 1。
// SubGameHooks.isInstalled = function (data) { /* ... */ };
// [A] up_imgurl(recid, photo):更新图片 URL。
// SubGameHooks.up_imgurl = function (recid, photo) { /* ... */ };
// [A] getphoto(recid, photo):获取玩家头像。
// SubGameHooks.getphoto = function (recid, photo) { /* ... */ };
// ===== Game_Modify.*(事件/交互,来自 01,9) =====
// ---------------------------------------------------------------------------
// 精灵交互 6 件套:【只做转发,不写任何业务逻辑】
//
// 平台事件到达框架的完整链路:
// 引擎 → Game_Modify.xxx(01 转发壳) → SubGameHooks.xxx(本文件) → SpriteEventController.handleXxx
// → 按精灵 ID 分发到各 UI 组件注册的 handler
//
// 这一段接不上,整条链就是断的:转发壳里已注册的 13 号战绩按钮、150 号帮助按钮、
// 248 号协议勾选(绘制回调里叠绘对钩)全都收不到事件,且不报错、只是静默失效。
// 参数顺序必须与 SpriteEventController.handleXxx 的签名逐个对齐——错位会让事件【静默失效】。
// 防御风格与转发壳一致:先判断依赖存在再转发。
// ---------------------------------------------------------------------------
// [A] utlmousedown(gameid, spid, downx, downy, no1, no2, no3, no4, no5, no6):精灵按下事件;子游戏转发到 SpriteEventController。
SubGameHooks.utlmousedown = function (gameid, spid, downx, downy, no1, no2, no3, no4, no5, no6) {
if (typeof SpriteEventController === 'undefined') { return; }
return SpriteEventController.handleMouseDown(gameid, spid, downx, downy, no1, no2, no3, no4, no5, no6);
};
// [A] utlmousedown_nomove(gameid, spid, downx, downy, timelong, no1, no2, no3, no4, no5):精灵按下(无移动)事件。
SubGameHooks.utlmousedown_nomove = function (gameid, spid, downx, downy, timelong, no1, no2, no3, no4, no5) {
if (typeof SpriteEventController === 'undefined') { return; }
return SpriteEventController.handleMouseDownNoMove(gameid, spid, downx, downy, timelong, no1, no2, no3, no4, no5);
};
// [A] mouseup(gameid, spid_down, downx, downy, spid_up, upx, upy, timelong, no1, no2):精灵抬起事件。
SubGameHooks.mouseup = function (gameid, spid_down, downx, downy, spid_up, upx, upy, timelong, no1, no2) {
if (typeof SpriteEventController === 'undefined') { return; }
return SpriteEventController.handleMouseUp(gameid, spid_down, downx, downy, spid_up, upx, upy, timelong, no1, no2);
};
// [A] utlmousemove(gameid, spid, downx, downy, movex, movey, timelong, offmovex, offmovey, no1):精灵移动事件。
SubGameHooks.utlmousemove = function (gameid, spid, downx, downy, movex, movey, timelong, offmovex, offmovey, no1) {
if (typeof SpriteEventController === 'undefined') { return; }
return SpriteEventController.handleMouseMove(gameid, spid, downx, downy, movex, movey, timelong, offmovex, offmovey, no1);
};
// [A] utlgamemydrawbegin(gameid, spid, times, timelong, no2, no3, no4, no5, no6, no7):精灵绘制开始事件。
SubGameHooks.utlgamemydrawbegin = function (gameid, spid, times, timelong, no2, no3, no4, no5, no6, no7) {
if (typeof SpriteEventController === 'undefined') { return; }
return SpriteEventController.handleDrawBegin(gameid, spid, times, timelong, no2, no3, no4, no5, no6, no7);
};
// [A] gamemydraw(gameid, spid, times, timelong, no2, no3, no4, no5, no6, no7):精灵绘制帧事件。
SubGameHooks.gamemydraw = function (gameid, spid, times, timelong, no2, no3, no4, no5, no6, no7) {
if (typeof SpriteEventController === 'undefined') { return; }
return SpriteEventController.handleDraw(gameid, spid, times, timelong, no2, no3, no4, no5, no6, no7);
};
// [A] OpenCreateRoom():打开创建房间界面。
// SubGameHooks.OpenCreateRoom = function () { /* ... */ };
// [A] CloseCreateRoom():关闭创建房间界面。
// SubGameHooks.CloseCreateRoom = function () { /* ... */ };
// [A] setRoomDes(roomcode, asetcount, roomtype):设置房间描述文案。
// SubGameHooks.setRoomDes = function (roomcode, asetcount, roomtype) { /* ... */ };
// ===== Game_Modify.*(生命周期/回调,来自 02,45) =====
// [A] appStart():游戏应用启动(与 gameHallImport.appStart 语义不同,对应 Game_Modify 侧启动)。
// SubGameHooks.appStart = function () { /* ... */ };
// [A] StartWar(_msg):开战(开局)回调,解包开局数据并初始化游戏状态。
// SubGameHooks.StartWar = function (_msg) { /* ... */ };
// [A] _ReceiveData(_msg):接收服务端主动推送的数据包,派发给对应业务处理器。
// SubGameHooks._ReceiveData = function (_msg) { /* ... */ };
// [A] createRoom(_roomtype, _infinite):前端发起创建房间请求。
// SubGameHooks.createRoom = function (_roomtype, _infinite) { /* ... */ };
// [A] myJoinRoom(_msg):自己成功进入房间回调。
// SubGameHooks.myJoinRoom = function (_msg) { /* ... */ };
// [A] Reconnect(_deskinfo):重连回调,用服务端返回的桌面信息恢复游戏界面。
// SubGameHooks.Reconnect = function (_deskinfo) { /* ... */ };
// [A] stopAllSounds():停止所有声音(切换到后台时触发)。
// SubGameHooks.stopAllSounds = function () { /* ... */ };
// [A] Free(_msg):投票解散确认回调。
// SubGameHooks.Free = function (_msg) { /* ... */ };
// [A] DeskInfo(_msg):牌局已开始时加入牌桌的数据同步(实现见文末「网络与对局」一段)。
// [A] breakRoom():开局后自己退出房间触发。
// SubGameHooks.breakRoom = function () { /* ... */ };
// [A] playerJoinRoom(seat):其他玩家加入房间通知。
// SubGameHooks.playerJoinRoom = function (seat) { /* ... */ };
// [A] playerLeaveRoom(seat):其他玩家离开房间通知。
// SubGameHooks.playerLeaveRoom = function (seat) { /* ... */ };
// [A] updateScene():更新游戏界面(分辨率变化等)。
// SubGameHooks.updateScene = function () { /* ... */ };
// [A] closeGameScene():关闭游戏界面。
// SubGameHooks.closeGameScene = function () { /* ... */ };
// [A] playerOffline(seat):玩家掉线通知。
// SubGameHooks.playerOffline = function (seat) { /* ... */ };
// [A] playerOnline(seat):玩家上线通知。
// SubGameHooks.playerOnline = function (seat) { /* ... */ };
// [A] playerphonestate(seat, type):玩家电话状态变化通知。
// SubGameHooks.playerphonestate = function (seat, type) { /* ... */ };
// [A] shakeEvent():设备摇一摇事件。
// SubGameHooks.shakeEvent = function () { /* ... */ };
// [A] calResult(inputArr):计算结算结果。
// SubGameHooks.calResult = function (inputArr) { /* ... */ };
// [A] onEnterMainScene(roomtype):进入游戏主场景。
// SubGameHooks.onEnterMainScene = function (roomtype) { /* ... */ };
// [A] onExitMainScene():退出游戏主场景。
// SubGameHooks.onExitMainScene = function () { /* ... */ };
// [A] onGameConfig(_gameConfig):获取游戏配置时触发。
// SubGameHooks.onGameConfig = function (_gameConfig) { /* ... */ };
// [A] onEnterVideo():进入牌局回放模式。
// SubGameHooks.onEnterVideo = function () { /* ... */ };
// [A] onSurrender(_msg):收到投降回包。
// SubGameHooks.onSurrender = function (_msg) { /* ... */ };
// [A] onReady(seat):玩家准备触发。
// SubGameHooks.onReady = function (seat) { /* ... */ };
// [B] getRoomInfo(roomtype, type, tea):获取房间描述文案;无 hook 时默认:"房间描述" + roomtype。
// SubGameHooks.getRoomInfo = function (roomtype, type, tea) { /* ... */ };
// [B] getStarLimit(roomtype):获取星星场下限分数;无 hook 时默认:return 2001000。
// SubGameHooks.getStarLimit = function (roomtype) { /* ... */ };
// [B] getMult(roomtype, type):获取星星场倍数;无 hook 时默认:return 10000。
// SubGameHooks.getMult = function (roomtype, type) { /* ... */ };
// [B] getFullRoomInfo(roomtype):获取房间完整信息描述;无 hook 时默认:"房间描述" + roomtype。
// SubGameHooks.getFullRoomInfo = function (roomtype) { /* ... */ };
// [A] onOpenHelp(spid):打开帮助页面。
// SubGameHooks.onOpenHelp = function (spid) { /* ... */ };
// [A] onMainMenuScene():显示大厅主菜单界面。
// SubGameHooks.onMainMenuScene = function () { /* ... */ };
// [A] onCheckInput(_result):数字输入框确认回调。
// SubGameHooks.onCheckInput = function (_result) { /* ... */ };
// [A] onCreateRoom(_data):创建房间成功后触发。
// SubGameHooks.onCreateRoom = function (_data) { /* ... */ };
// [B] getLeaveLimit(roomtype):获取离场限制局数;无 hook 时默认:return 10。
// SubGameHooks.getLeaveLimit = function (roomtype) { /* ... */ };
// [A] onLocationInfo(_locationInfo):成功获取定位信息回调。
// SubGameHooks.onLocationInfo = function (_locationInfo) { /* ... */ };
// [A] onCreateDesk(roomtype):进入游戏界面创建牌桌之前触发。
// SubGameHooks.onCreateDesk = function (roomtype) { /* ... */ };
// [B] getVideoByRoomType(roomtype):获取该房型是否开启视频功能;无 hook 时默认:return 1。
// SubGameHooks.getVideoByRoomType = function (roomtype) { /* ... */ };
// [B] getRoomTopDescAry(roomtype):获取房间顶部描述文案数组;无 hook 时默认:["" + roomtype]。
// SubGameHooks.getRoomTopDescAry = function (roomtype) { /* ... */ };
// [A] getShareRoom(_msg):星星场房间列表数据。
// SubGameHooks.getShareRoom = function (_msg) { /* ... */ };
// [A] myExitRoom(seat):玩家自己退出房间。
// SubGameHooks.myExitRoom = function (seat) { /* ... */ };
// [A] changeSeat(seat1, seat2):接收到换座包触发。
// SubGameHooks.changeSeat = function (seat1, seat2) { /* ... */ };
// [A] onCloseVip():关闭 VIP 选项时调用。
// SubGameHooks.onCloseVip = function () { /* ... */ };
// [B] getRoomMode(roomtype):判断房间模式(0=非金币场);无 hook 时默认:return 0。
// SubGameHooks.getRoomMode = function (roomtype) { /* ... */ };
// [B] getMaxPlayerCount(roomtype):获取房间最大玩家数量;无 hook 时默认:return Game_Config.Max.PlayerCnt || 4。
// SubGameHooks.getMaxPlayerCount = function (roomtype) { /* ... */ };
// [D] updatePlayerInfoUI(targetSeat):更新指定座位玩家信息 UI;无 hook 时模板提供【可变人数】默认渲染
// (头像/昵称/分数随人数 2/3/4 贴合各玩家,布局见 01 的 Game_Modify.PLAYER_INFO_LAYOUT)。子游戏可用 hook 完全接管。
// SubGameHooks.updatePlayerInfoUI = function (targetSeat) { /* ... */ };
// ===== Game_Modify.*(桌面聊天/语音气泡,3) =====
// 以下接口无 hook 时,模板提供【可变人数】默认渲染(气泡随人数贴合各玩家,布局见 01 的 Game_Modify.BUBBLE_LAYOUT)。
// [D] ShowChat(seat, text):显示桌面文字聊天气泡(seat 为绝对座位)。
// SubGameHooks.ShowChat = function (seat, text) { /* ... */ };
// [D] gameui_play_voice(uiIndex):播放桌面语音气泡(uiIndex 为相对显示位序)。
// SubGameHooks.gameui_play_voice = function (uiIndex) { /* ... */ };
// [D] gameui_stop_voice(uiIndex):停止桌面语音气泡。
// SubGameHooks.gameui_stop_voice = function (uiIndex) { /* ... */ };
//—— 网络与对局(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) {
if (!_msg) { return; }
EQW_Dispatcher.dispatch({ rpc: _msg.rpc, data: _msg.data });
};
// 开局。可能差异化下发(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);
};
// 中途进桌:牌局【已经开始】,但自己走的是「加入房间」而非「重连」这条路径。
// 平台 07_Desk.js 在 self_join_room 且 battlestate==1 时走 Game_Modify.DeskInfo(_msg.data.deskinfo),
// 与 self_login 的 Game_Modify.Reconnect(_msg.data.deskinfo) 是两条并列分支——
// 不接这个 hook,整份 deskinfo 会被 02 转发壳静默丢弃(无 hook 即 no-op),界面全空且不报错。
//
// 【入参与 Reconnect 完全同源、且已按【自己的】座位裁剪】:
// 两条路径的 deskinfo 都出自服务端 server/youle/server_room/class.return.js —— 进房走
// self_join_room 的 get_deskinfo(o_room, o_player.gameinfo.seat),登录走 self_login 的
// get_deskinfo(o_player.gameinfo.o_room, o_player.gameinfo.seat),同一个函数、同一个
// 「本人座位」入参。get_deskinfo 内部的可见性裁剪(底牌/埋牌底牌只给庄家、亮牌只给闲家、
// mustcard 只给轮到的那家)因此对中途进桌一样生效,不会把别人的视角发过来。
// 故直接复用同一条重画路径(前端 04 §3「重连 = 重画」),不为它单写一套渲染。
SubGameHooks.DeskInfo = function (_deskinfo) {
SubGameHooks._syncMySeat();
EQW_ResyncHandler.handleDeskinfo(_deskinfo);
};
// 重连但未开战:没有牌局数据,无需重画对局
SubGameHooks.ReconnectNoMakewar = function () {
SubGameHooks._syncMySeat();
console.log('[EQW] 重连(未开战)');
};
// 解散。_msg 即 Desk.deskfree,可能为 null(首局发牌前解散)
SubGameHooks.Free = function (_msg) {
EQW_ResultHandler.handleFree(_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 () {
SubGameHooks._syncMySeat();
if (typeof Desk !== 'undefined' && Desk.roomtype) {
EQW_GameState.room.options = EQW_RoomOptions_Parse.parse(Desk.roomtype);
}
};
window.SubGameHooks = SubGameHooks;
if (typeof module !== 'undefined' && module.exports) { module.exports = SubGameHooks; }
@@ -0,0 +1,34 @@
///////////////////////////////////////////////////////////////
////////// EQW_Anim: 动画与时长参数(纯数据)//////////////////
///////////////////////////////////////////////////////////////
// client 02 §3「数据优先、表现延后」:动画只是体验层,
// 其开始/结束/出错回调里只刷界面、绝不写核心数据。
// 动画缺失或卡住时,数据与界面仍须正确。
var EQW_Anim = EQW_Anim || {
//发牌铺开:从右往左(清单 §1.2 T-4)
DEAL: { totalDuration: 800, perCardDelay: 20, direction: 'rightToLeft' },
//手牌选中上浮(清单 §6.7)
HAND_SELECT_OFFSET_Y: -40,
//捡分飘字(清单 §6.8)
SCORE_FLOAT: { rise: -60, duration: 800 },
//即时反馈提示条自动消失(清单 §6.8)
TOAST_DURATION: 1500,
//亮牌【遮罩面板】自动关闭(清单 §1.6 T-34:只按时长关闭,不做点击关闭)——
//这里的「亮牌」是 design §8.2 那件事(群组 242,复用 HIST_* 精灵),
//与群组 206 的主牌统计条(ZHU_STAT_*,2026-08-27 前也叫「亮牌条」)无关。
//清单原文时长「默认值待定」,此处沿用与开底展示同数量级的 3 秒作为起始估值,设计定稿后回填
LIANGPAI_AUTO_CLOSE: 3000,
//开底:70 分坐庄时 8 张底牌向全场翻开 3 秒(design §4 / 清单 §1.4 D-2,不要倒计时)
ANCARD_REVEAL_DURATION: 3000
//【D-8 待补】判定结果动画(大光/小光/过庄/升N级/投降)——
//已定形态:不做结算面板上的静态文字,改为动画,在对局过程中与结算前播放;
//靠 curmultiple 的【符号】分辨该播哪个(3/2/1 庄家大光/小光/过庄,-N 闲家升N级)。
//缺动画设计稿与触发细节,定案后在此补参数。
};
@@ -0,0 +1,41 @@
///////////////////////////////////////////////////////////////
////////// EQW_Groups: 群组 ID(清单 §0.3)////////////////////
///////////////////////////////////////////////////////////////
// 子游戏可用群组段:≥201(框架保留 0–200)。平台已占用 0–79,与下列不冲突。
// 键名为已裁决的硬约定——下一任务的精灵常量按这些键名引用,不得改名。
var EQW_Groups = EQW_Groups || {
TOP_INFO: 201, // 顶部信息条(主 / 叫分 / 抓分)
P_LEFT_MARK: 202, // 左上家附加标记(庄印章、主N对N、状态文字)
P_RIGHT_MARK: 203, // 右上家附加标记(庄印章、主N对N、状态文字)
P_SELF_MARK: 204, // 自己附加标记(庄印章、主N对N、状态文字)
FOOTER: 205, // 底栏功能按钮组(明牌 / 已出牌 / 上一轮 / 扣底 / 底牌)
ZHU_STAT_BAR: 206, // 自己的主牌统计条(手牌上方「x对 x主」,本地统计,清单 §2.5)
// ⚠ 2026-08-27 由 LIANGPAI_BAR 改名:它【不是】design §8.2 的「亮牌」——
// 那是庄家向闲家亮出固定主牌的具体牌面,走遮罩面板(群组 242),两者同名异实
HAND: 210, // 自己手牌
BOTTOM_CARDS: 211, // 底牌区(发牌留桌 8 张)
PLAY_SELF: 212, // 自己的已出牌区
PLAY_LEFT: 213, // 左上家的已出牌区
PLAY_RIGHT: 214, // 右上家的已出牌区
BURY_CARDS: 215, // 埋牌底牌展示区(结算时 8 张)
CALL_PANEL: 220, // 叫分面板
CHOOSE_MAIN: 221, // 选主 / 投降面板
BURY_BAR: 222, // 埋牌操作条
PLAY_BAR: 223, // 出牌操作条
COUNTDOWN: 224, // 倒计时(三位置)
OVERLAY: 230, // 桌面浮层(气泡 / 飘字 / 报无主 / 甩错)
ASET_RESULT: 240, // 小局结算
ACCOUNT: 241, // 大局总结算
HISTORY: 242, // 出牌历史 / 上一轮 / 亮牌——【一组三用】,共用同一套 HIST_* 精灵
// 「亮牌」即 design §8.2(庄家亮固定主牌),只显示庄家那一排、其余容器隐藏,
// 且只按时长自动关闭(EQW_Anim.LIANGPAI_AUTO_CLOSE),详见 Sprites_Result.js
MINGPAI: 243, // 明牌面板
CHONGGUAN: 244, // 冲关牌型叠加层(清单 §5.6a,挂在图层 302,精灵复制)
CREATE_ROOM: 250 // 建房规则选项
};
@@ -0,0 +1,392 @@
///////////////////////////////////////////////////////////////
////////// EQW_Images: 图片资源 ID(清单 §3,帧排布见 §0.4)////
///////////////////////////////////////////////////////////////
// 子游戏图片资源段:≥501(框架保留 1–500)。平台占用 1–439,与下列不冲突。
// 每条注释为后期在编辑器里建资源的规格书:用途 / 帧数 / 帧说明 / 尺寸。
// 尺寸均为参考图实测估值,出图前须与最终设计稿核对(清单 §3 表头说明)。
// 按钮类资源一律只出 1 帧普通态,按下缩放/透明反馈由框架统一处理。
//
// 以下键号在源清单中被显式判定「不需要」,本文件不占用,留空号以便追溯:
// 512 SUIT_ICON_L —— 选主面板花色图标已并入 BTN_SUIT_CHOOSE 帧1–4(美术改一张图出,2026-08-26)
// 534 BTN_SURRENDER —— 投降按钮已并入 BTN_SUIT_CHOOSE 帧5(美术改一张图出,2026-08-26)
// 539 BTN_READY —— 准备标识用平台自带的(清单 §0.2)
// 652 TXT_BAOZHU —— 报无主不做全场提示(D-4),只让他家 主N/对N 角标出现
// 655 PANEL_BOTTOM_3S —— 底牌 3 秒展示复用庄家翻底牌 UI(D-2),仅可见范围不同
var EQW_Images = EQW_Images || {
//===================== §3.1 牌面(3 套,见清单 §0.4)=====================
//用途:自己手牌与底牌区的牌面
//帧数:60(10列×6行,行优先)
//帧说明:帧1–13=黑桃A–K 帧14–26=红桃A–K 帧27–39=梅花A–K 帧40–52=方块A–K
// 帧53=小王 帧54=大王 帧55–60=牌背1–6(二七王用帧55)
// 二七王不引用帧 3,4,16,17,29,30,42,43(design §2 去掉 3 和 4),但资源照 60 帧通用排版出图
//尺寸:单帧 110×190,整图 1100×1140
CARD_FACE_L: 501,
//用途:三家已出牌区的牌面
//帧数:60(10列×6行,行优先)
//帧说明:帧排布与 CARD_FACE_L 完全一致——帧1–13=黑桃A–K 帧14–26=红桃A–K
// 帧27–39=梅花A–K 帧40–52=方块A–K 帧53=小王 帧54=大王 帧55–60=牌背1–6(二七王用帧55)
//尺寸:单帧 90×155,整图 900×930
CARD_FACE_M: 502,
//用途:结算埋牌底牌区的牌面
//帧数:60(10列×6行,行优先)
//帧说明:帧排布与 CARD_FACE_L 完全一致——帧1–13=黑桃A–K 帧14–26=红桃A–K
// 帧27–39=梅花A–K 帧40–52=方块A–K 帧53=小王 帧54=大王 帧55–60=牌背1–6(二七王用帧55)
//尺寸:单帧 50×70,整图 500×420
CARD_FACE_S: 503,
//===================== §3.2 花色图标 =====================
//用途:顶部信息条「主」列的花色小图标
//帧数:4
//帧说明:帧1=方块♦ 帧2=梅花♣ 帧3=红心♥ 帧4=黑桃♠(按服务端 flower 编号 1–4 排序,
// 与牌面资源的花色顺序黑桃→红桃→梅花→方块不同,两套互不换算)
//尺寸:30×30
SUIT_ICON_S: 511,
//===================== §3.3 按钮 =====================
//用途:叫分档位按钮底 + 右上角角标底(同一张图,不含文字)
//帧数:4
//帧说明:帧1=浅黄底+蓝角标(对应档位 70/65/60/55) 帧2=金黄底+绿角标(50/45/40)
// 帧3=橙底+深橙角标(35…5) 帧4=灰(已叫过/不可选)。颜色按档位位置固定,只因不可叫变灰
//尺寸:74×56
BTN_CALL_SCORE: 531,
//用途:「不叫」按钮
//帧数:1
//帧说明:蓝色渐变
//尺寸:184×57
BTN_NO_CALL: 532,
//用途:选主 / 投降面板按钮(花色按钮 + 投降按钮,一张图 5 帧)
//帧数:5
//帧说明:帧1–4=四个花色按钮,每帧已包含「按钮底 + 花色图标 + 右上角蓝色角标底」,
// 花色序按服务端 flower 编号——帧1=方块♦ 帧2=梅花♣ 帧3=红心♥ 帧4=黑桃♠;
// 帧5=投降按钮,已包含「按钮底 + 投降二字」
//尺寸:128×55
BTN_SUIT_CHOOSE: 533,
//用途:「提示」按钮
//帧数:1
//帧说明:蓝色渐变
//尺寸:180×63
BTN_TIP: 535,
//用途:「埋牌 (N/8)」按钮
//帧数:2
//帧说明:帧1=可提交(金黄) 帧2=置灰(未满 8 张)
//尺寸:180×63
BTN_BURY: 536,
//用途:「出牌」按钮
//帧数:2
//帧说明:帧1=可出 帧2=置灰
//尺寸:157×55
BTN_PLAY: 537,
//用途:底栏功能钮底(明牌/已出牌/上一轮/底牌共用)
//帧数:1
//帧说明:深色圆角
//尺寸:88×30
BTN_BOTTOM_FUNC: 538,
//用途:底栏「踩」/「有分」/「没分」按钮底(三者共用)
//帧数:1
//帧说明:深色圆角,同 BTN_BOTTOM_FUNC 风格但更窄
//尺寸:52×30
BTN_TISHI: 540,
//用途:结算「下一局」按钮
//帧数:1
//帧说明:金黄渐变
//尺寸:180×65
BTN_NEXT_ASET: 541,
//用途:结算「冲关牌型」按钮
//帧数:1
//帧说明:蓝色渐变
//尺寸:183×63
BTN_CG_CARDS: 542,
//用途:大局结算「再来一局」按钮
//帧数:1
//帧说明:黄色渐变
//尺寸:190×60
BTN_ACCOUNT_AGAIN: 543,
//用途:大局结算「分享好友」按钮
//帧数:1
//帧说明:蓝色渐变
//尺寸:190×60
BTN_ACCOUNT_SHARE: 544,
//用途:大局结算「复制战绩」按钮
//帧数:1
//帧说明:蓝色渐变
//尺寸:190×60
BTN_ACCOUNT_COPY: 545,
//用途:大局结算「退出房间」按钮
//帧数:1
//帧说明:红色渐变
//尺寸:190×60
BTN_ACCOUNT_EXIT: 546,
//用途:大局结算标题栏右上角关闭按钮
//帧数:1
//帧说明:(无)
//尺寸:36×36
BTN_ACC_CLOSE: 547,
//===================== §3.4 标记类 =====================
//用途:「庄」红色印章
//帧数:1
//帧说明:(无)
//尺寸:40×40
MARK_BANKER: 571,
//用途:牌角「庄」橙色斜标
//帧数:1
//帧说明:(无)
//尺寸:28×28
MARK_BANKER_CORNER: 572,
//用途:大王「大」圆标
//帧数:2
//帧说明:帧1=大王 帧2=小王
//尺寸:34×34
MARK_JOKER_BIG: 573,
//用途:「主N」/「对N」角标底
//帧数:2
//帧说明:帧1=黄底(主) 帧2=白底(对)
//尺寸:44×20
BADGE_ZHU_PAIR: 574,
//用途:子数/倍数角标底
//帧数:2
//帧说明:帧1=蓝底 帧2=橙底
//尺寸:34×18
BADGE_MULTIPLE: 575,
//用途:牌型标签
//帧数:1
//帧说明:只做「毙」一种(前端只展示这一个标签)。数据层仍保留 毙/垫/混合出牌 三种标识以备扩展,
// 但当前不出图、不显示
//尺寸:76×24
LABEL_CARDTYPE: 576,
//用途:手牌牌型标记
//帧数:3
//帧说明:帧1=「拖」(拖拉机) 帧2=橙色五角星(正2/正7) 帧3=蓝色五角星(除正2正7外的其他主牌)
//尺寸:26×26
MARK_CARD_GROUP: 577,
//用途:手牌选中标记
//帧数:1
//帧说明:待定(也可只用上浮表现,不出图)
//尺寸:待定
MARK_SELECTED: 578,
//用途:大局结算名次徽章(花瓣形)
//帧数:3
//帧说明:帧1=第1名(金) 帧2=第2名(橙) 帧3=第3名(蓝)
//尺寸:44×44
BADGE_RANK: 579,
//===================== §3.5 面板底图 =====================
//用途:顶部三列信息条底
//帧数:1
//帧说明:(无)
//尺寸:276×68
PANEL_TOP_INFO: 601,
//用途:叫分面板底
//帧数:1
//帧说明:(无)
//尺寸:575×170
PANEL_CALL_SCORE: 602,
//用途:「70分坐庄才可投降」提示条底
//帧数:1
//帧说明:(无)
//尺寸:345×33
BAR_HINT_TOP: 603,
//用途:「亮主」斜角标题条
//帧数:1
//帧说明:(无)
//尺寸:425×50
BAR_CHOOSE_MAIN: 604,
//用途:自己的主牌统计条底(半透圆角,手牌上方「x对 x主」)
//帧数:1
//帧说明:(无)
//尺寸:210×35
//2026-08-27 由 BAR_LIANGPAI 改名:与 design §8.2 的「亮牌」同名异实(后者是遮罩面板),见清单 §0.0
BAR_ZHU_STAT: 605,
//用途:结算底牌区面板底
//帧数:1
//帧说明:(无)
//尺寸:420×148
PANEL_BOTTOM_CARDS: 606,
//用途:结算分数光晕背景
//帧数:2
//帧说明:帧1=赢(橙) 帧2=输(蓝)
//尺寸:225×100
GLOW_RESULT: 607,
//用途:底栏深色条
//帧数:1
//帧说明:(无)
//尺寸:1280×72
BAR_FOOTER: 608,
//用途:半透黑遮罩(冲关牌型/出牌历史/明牌 共用)
//帧数:1
//帧说明:纯色可拉伸,出 1 张小图即可
//尺寸:1280×720
MASK_DIM: 609,
//用途:大局总结算面板底(圆角浅灰绿)
//帧数:1
//帧说明:(无)
//尺寸:1100×540
PANEL_ACCOUNT: 610,
//用途:大局结算标题栏(青绿底,含圆角上沿)
//帧数:1
//帧说明:(无)
//尺寸:1100×56
BAR_ACCOUNT_TITLE: 611,
//用途:大局结算底部规则栏(浅绿底,含圆角下沿)
//帧数:1
//帧说明:(无)
//尺寸:1100×62
BAR_ACCOUNT_RULE: 612,
//用途:玩家栏之间的分隔线
//帧数:1
//帧说明:(无)
//尺寸:1060×2
LINE_DIVIDER: 613,
//===================== §3.6 数字与符号 =====================
//
//【数字类资源的统一规范:16 帧固定帧序】(2026-08-26 订正)
//以下 NUM_* 一律是【多帧图片资源,16 帧】,帧序固定为:0 1 2 3 4 5 6 7 8 9 . + - x / p
// 帧1–10 = 数字 0–9 帧11 = 「.」 帧12 = 「+」 帧13 = 「-」
// 帧14 = 「x」 帧15 = 「/」 帧16 = 可变后缀(该套资源自己的后缀字,如「分」「倍」「张」)
//
//用法前提:显示多位数字【不拆精灵、不用 setFrame 逐位切】,而是用平台既有接口
// SpriteManager.setNumberImage(spriteId, text, charWidth)
//把整串文本设给【一个】多帧图片精灵,引擎按字符逐帧渲染,精灵宽度由该接口按
//【字符数 × charWidth】自动调整(charWidth 见 EQW_Layout.NUM_STYLE.*,本处只记出图规格)。
//
//出图要求:帧号由字符直接换算('0'–'9' → 帧1–10,后缀 → 帧16),所以帧序【不可重排、不可省略占位】——
//即使某套资源用不到 . + - x / 这几帧,也要按顺序留出帧位,否则帧号对不上、画面串位。
//整图尺寸一律 = 16 × 单字符宽(横向 16 帧)。
//用途:倒计时数字(带描边/渐变美术字)
//帧数:16(固定帧序 0123456789.+-x/p)
//帧说明:帧1–10 = 0–9;帧11–15 = . + - x /(本套用不到,仅占位);
// 帧16 = 后缀【本套无后缀,出空白占位】
//单字符尺寸:44×60
//整图尺寸:704×60(16 帧 × 44)
NUM_COUNTDOWN: 631,
//用途:结算得分数字(赢,橙,带描边/渐变美术字)
//帧数:16(固定帧序 0123456789.+-x/p)
//帧说明:帧1–10 = 0–9;帧12 = 「+」、帧13 = 「-」(得分带正负号,必出);帧11/14/15 = . x /(占位);
// 帧16 = 后缀【待美术与规则确认:结算大字目前只显示数字,若日后要显示「+120分」则此帧出「分」】
//单字符尺寸:46×62
//整图尺寸:736×62(16 帧 × 46)
NUM_RESULT_WIN: 632,
//用途:结算得分数字(输,蓝,带描边/渐变美术字)
//帧数:16(固定帧序 0123456789.+-x/p)
//帧说明:与 NUM_RESULT_WIN 完全一致(含帧16 待确认项),仅配色不同
//单字符尺寸:46×62
//整图尺寸:736×62(16 帧 × 46)
NUM_RESULT_LOSE: 633,
//用途:判定结果动画(大光/小光/过庄/升N级/投降)——帧动画资源,播放参数进 AnimationConfigs【待设计 D-8】
//帧数:待定
//帧说明:待定
//尺寸:待定
ANI_JUDGE: 634,
//用途:叫分档位的分数数字(深棕描边美术字,带描边/渐变,文字精灵做不出此效果)
//帧数:16(固定帧序 0123456789.+-x/p)
//帧说明:帧1–10 = 0–9;帧11–15 = . + - x /(本套用不到,仅占位);
// 帧16 = 无后缀(第16帧留空/不使用)【已确认,2026-08-27】:档位面板只显示纯数字(70…5),
// 旁边的「N子」角标是独立的文字精灵,不走这里
//单字符尺寸:30×40
//整图尺寸:480×40(16 帧 × 30)
//说明:14 个档位中 13 个是两位数(70…10),整串交给 setNumberImage,一档【一个】精灵,
// 见 Sprites_Action.js 的 CALL_BTN_NUM_1..14;面板上的「N子」角标是文字精灵,不走本资源
NUM_CALL_SCORE: 635,
//用途:选主面板花色张数数字(美术单独出图,字号配色与以上几套不同;一个花色一个精灵,见 Sprites_Action.js)
//帧数:16(固定帧序 0123456789.+-x/p)
//帧说明:帧1–10 = 0–9;帧11–15 = . + - x /(本套用不到,仅占位);
// 帧16 = 后缀「张」(显示形如「26张」,后缀与数字等宽,同为单字符宽)
//单字符尺寸:24×32(估值,与选主按钮 128×55 协调估算,出图前须与最终设计稿核对)
//整图尺寸:384×32(16 帧 × 24)
NUM_MAIN_SUIT_COUNT: 636,
//===================== §3.7 浮层与气泡 =====================
//用途:「踩」/「没分」/「有分」提示气泡
//帧数:6(3×2)
//帧说明:帧1=踩·箭头向左 帧2=没分·箭头向左 帧3=有分·箭头向左
// 帧4=踩·箭头向右 帧5=没分·箭头向右 帧6=有分·箭头向右
// 帧序内层是 tip(1踩/2没分/3有分)、外层是箭头朝向 → frame = (arrowRight ? 3 : 0) + tip
// 箭头向左 = 头像在气泡左侧、箭头向右 = 头像在气泡右侧
//尺寸:待定
BUBBLE_TISHI: 651,
//用途:通用状态提示条底(深色半透圆角,白字居中)——「等待庄家选主」/「强甩失败」等共用(清单 §2.8)
//帧数:1
//帧说明:可九宫拉伸,按文案长度变宽
//尺寸:363×50
BAR_TOAST: 653,
//用途:捡分飘字底(「20分」金色)
//帧数:1
//帧说明:(无)
//尺寸:待定
TXT_GRADE_FLOAT: 654,
//===================== §3.8 建房选项 =====================
//用途:建房选择框(单选钮 + 复选框合一)
//帧数:4(2×2)
//帧说明:帧1=单选·未选中(灰空心环) 帧2=单选·已选中(橙实心环)
// 帧3=复选·未选中(浅绿空方块) 帧4=复选·已选中
// 帧号 = (useCheckbox ? 3 : 1) + (selected ? 1 : 0);「单选(可不选)」用复选帧 3/4
//尺寸:22×22
ROOM_OPT_BOX: 681,
//用途:建房类别标题(一张图多帧)
//帧数:3
//帧说明:帧1=牌局 帧2=模式 帧3=规则
//尺寸:60×24
ROOM_CAT_TITLE: 682,
//用途:选项行底
//帧数:1
//帧说明:参考图为无底透明,可选
//尺寸:待定
ROW_ROOM_OPT: 683
};
@@ -0,0 +1,23 @@
///////////////////////////////////////////////////////////////
////////// EQW_Layers: 图层 ID(清单 §0.3)////////////////////
///////////////////////////////////////////////////////////////
// 子游戏可用图层段:101–200 / 301–400 / 501–600 / 701+(client 02 §1)
// 平台已占用 27/28/50/202/409/410/416/418/420/608/609/612/613/614/615/616/619/620,
// 均落在框架保留段,与下列不冲突。
var EQW_Layers = EQW_Layers || {
TABLE_STATIC: 101, // 牌桌常驻:顶部信息条、玩家位附加标记、主牌统计条、底栏(局数 + 功能钮)
HAND: 102, // 自己手牌区(单排 / 埋牌双排)
TABLE_CARDS: 103, // 桌面牌:底牌区、三家已出牌区、埋牌底牌展示区
ACTION: 104, // 阶段操作区:叫分面板、选主/投降面板、埋牌/出牌操作条、倒计时
OVERLAY: 105, // 桌面浮层:提示气泡、报无主、甩错、捡分飘字、70 分底牌亮 3 秒
POPUP_ASET_RESULT: 302, // 小局结算(清单 §5.6a:冲关牌型叠加层群组 244 也挂在本图层)
POPUP_ACCOUNT: 303, // 大局总结算 / 解散结算
POPUP_HISTORY: 304, // 出牌历史面板
POPUP_MINGPAI: 305, // 明牌面板
//306 POPUP_DISBAND 解散投票面板——清单 T-2「是否由平台提供」未决,暂不占号,定案后再补
//建房规则选项区挂在平台创建房间界面 Layer 27 CreateRoom_Layer 上,不新开图层(清单 T-3)
PLATFORM_CREATE_ROOM: 27
};
@@ -0,0 +1,63 @@
///////////////////////////////////////////////////////////////
////////// EQW_Layout: 布局配置总则(清单 §6)/////////////////
///////////////////////////////////////////////////////////////
// 设计基准 1280×720,ScreenFitMode 1,30fps(清单 §0.1)。
// 【纯数据文件】无函数、无副作用、不引用其他模块——
// fan 的间距算法、bySeat 合并、attach 换算都在 codes/ui/LayoutSolver.js。
// 数值为参考图目视实测估值(±5px),设计稿到位后只改本层。
//
// 本文件须最先加载(其余 5 个 Layout_*.js 会引用本文件的 CARD_SIZE / TEXT_STYLE,
// 这是 EQW_Layout 内部的自引用,不是跨模块引用)。
var EQW_Layout = EQW_Layout || {};
//座位键(显示位,非服务端座位号;换算见 core/SeatMap.js)
EQW_Layout.SEAT = { SELF: 'SELF', LEFT: 'LEFT', RIGHT: 'RIGHT' };
//三套牌面尺寸档(清单 §6.4)。改牌面尺寸只动这一处,引用它的区域自动跟随
EQW_Layout.CARD_SIZE = {
L: { w: 110, h: 190, res: 'CARD_FACE_L' }, //手牌、底牌、冲关牌
M: { w: 90, h: 155, res: 'CARD_FACE_M' }, //已出牌区
S: { w: 50, h: 70, res: 'CARD_FACE_S' } //结算埋牌底牌
};
//文字样式预设(清单 §6.3,逐条对应 §6.5–§6.9b 各表出现的 textStyle{...})
EQW_Layout.TEXT_STYLE = {
STATUS_BIG: { fontSize: 46, color: '#FFFFFF', align: 'center' }, //玩家位「60分」「不叫」(§6.6)
CALL_BIG: { fontSize: 52, color: '#FFD24A', align: 'center' }, //自己叫分大字(§6.8)
//结算总分大字原用 SCORE_BIG(§6.9),2026-08-27 改为多帧图数字精灵(NUM_STYLE.RESULT_WIN/RESULT_LOSE),
//不再是文字精灵,SCORE_BIG 无消费者,移除(避免留一个没人用的样式常量)
ASET_COUNT: { fontSize: 20, color: '#FFFFFF', align: 'center' }, //底栏「1/4 局」(§6.5)
ROOM_OPT: { normal: { fontSize: 18, color: '#DDDDDD' }, //建房选项文字(§6.9b)
selected: { fontSize: 18, color: '#E8873A' } },
ROOM_CAT: { fontSize: 18, color: '#7FBFB0', bold: true, align: 'right', maxWidth: 60 },
ROOM_CARD_COST_NOTE: { fontSize: 16, color: '#999999' } //建房房卡消耗附注(§6.9b)
};
//多帧图数字样式(清单 §3.6 / §6.8,2026-08-26 新增)——配合平台既有的
//SpriteManager.setNumberImage(spriteId, text, charWidth):把整串数字设给【一个】多帧图片精灵,
//引擎按字符逐帧渲染,精灵宽度由该接口按【字符数 × charWidth】自动调整。
//故多位数不需要拆成十位/个位多个精灵,也不用 setFrame 逐位切。
//本表是【零裸值红线】的落点:charWidth/charHeight 只在此定义一处,业务代码只引用不内联。
//
//字段:res —— 图片资源【键名】(与 CARD_SIZE.res 同一约定:纯数据文件不直接引用 EQW_Images,
// 调用方用 EQW_Images[style.res] 取真实 ID)
// charWidth —— 单字符宽(传给 setNumberImage;精灵宽 = 字符数 × charWidth,运行时自动变)
// charHeight —— 单字符高(setNumberImage 不改高度,此值供布局节点定 h)
// suffix —— 该套资源第 16 帧承载的后缀字(无后缀为 '';见 ImageResources.js §3.6 的帧序说明)
EQW_Layout.NUM_STYLE = {
//倒计时(§2.4):纯数字无后缀,秒数最多两位
COUNTDOWN: { res: 'NUM_COUNTDOWN', charWidth: 44, charHeight: 60, suffix: '' },
//结算得分 · 赢(§1.8/§6.9):用到 '+'(帧12)与 '-'(帧13);后缀是否为「分」待定,见资源注释
RESULT_WIN: { res: 'NUM_RESULT_WIN', charWidth: 46, charHeight: 62, suffix: '' },
//结算得分 · 输(§1.8/§6.9):帧序与赢的一致,仅配色不同
RESULT_LOSE: { res: 'NUM_RESULT_LOSE', charWidth: 46, charHeight: 62, suffix: '' },
//叫分档位分数(§1.3/§6.8):14 档中 13 档是两位数,整串交给 setNumberImage,无后缀
//(面板上的「N子」角标是文字精灵,不走这套)
CALL_SCORE: { res: 'NUM_CALL_SCORE', charWidth: 30, charHeight: 40, suffix: '' },
//选主面板花色张数(§1.5/§6.8):显示「N张」,「张」= 第 16 帧;单花色最多 26 张
MAIN_SUIT_COUNT: { res: 'NUM_MAIN_SUIT_COUNT', charWidth: 24, charHeight: 32, suffix: '张' }
};
@@ -0,0 +1,126 @@
///////////////////////////////////////////////////////////////
////////// EQW_Layout: 阶段操作区配置(清单 §6.8)//////////////
///////////////////////////////////////////////////////////////
// 【纯数据文件】无函数、无副作用;依赖 LayoutConstants.js 先加载
// (引用其 EQW_Layout.TEXT_STYLE.*,属 EQW_Layout 内部自引用)。
var EQW_Layout = EQW_Layout || {};
//===== 叫分面板 =====
EQW_Layout.CALL_PANEL_BG = { kind: 'point', x: 355, y: 140, w: 575, h: 170 };
EQW_Layout.CALL_HINT_BG = { kind: 'point', x: 470, y: 100, w: 345, h: 33 };
//14 档位按钮:grid 布局(清单 §6.1 示例,7×2)
EQW_Layout.CALL_BTN_GRID = {
kind: 'grid', anchorX: 640, anchorY: 160, cols: 7, rows: 2,
itemWidth: 74, itemHeight: 56, spacingX: 7, spacingY: 24,
anchor: 'center', fillOrder: 'row'
};
//档位分数数字(多帧图数字精灵):贴对应档位按钮居中。CALL_BTN_1..14 是 14 个固定精灵,但本节点是共用模板,
//target 由调用方按第几档注入(不预置单一 target)。
//宽度随位数变化(SpriteManager.setNumberImage 会按【字符数 × charWidth】自动改精灵宽),
//故 w 留到运行时注入;高度恒为单字高,取 NUM_STYLE.CALL_SCORE.charHeight(SSOT,2026-08-26)
//节点名去掉 _TEXT 后缀改为 _NUM:驱动的是数字图精灵而非文字精灵(2026-08-27)
EQW_Layout.CALL_BTN_SCORE_NUM = {
kind: 'attach', targetKind: 'sprite', hAlign: 'center', vAlign: 'middle', offsetY: 4,
h: EQW_Layout.NUM_STYLE.CALL_SCORE.charHeight,
runtime: ['target', 'w']
};
//档位子数角标:贴对应档位按钮右上角,纯文字无底图,清单未给宽高(同上,调用方注入 target)
EQW_Layout.CALL_BTN_MULTIPLE_BADGE = {
kind: 'attach', targetKind: 'sprite', corner: 'topRight', offsetX: -30, offsetY: 2,
runtime: ['target', 'w', 'h']
};
EQW_Layout.CALL_BTN_NONE = { kind: 'point', x: 553, y: 345, w: 184, h: 57 };
//自己叫分大字
EQW_Layout.CALL_SELF_SCORE_TEXT = {
kind: 'point', x: 570, y: 320, w: 140, h: 70, textStyle: EQW_Layout.TEXT_STYLE.CALL_BIG
};
//===== 选主 / 投降面板 =====
EQW_Layout.MAIN_TITLE_BG = { kind: 'point', x: 330, y: 315, w: 425, h: 50 };
//4 花色 + 投降:投降不显示时只排前 4 项,anchor:left 保证前 4 个位置不变
EQW_Layout.MAIN_SUIT_LINE = {
kind: 'line', direction: 'horizontal', anchorX: 330, anchorY: 373,
itemWidth: 128, itemHeight: 55, spacing: 9, anchor: 'left'
};
//张数数字:贴对应花色按钮下方居中,一个花色【一个】精灵,整串「N张」由 setNumberImage 显示。
//2026-08-26:原十位/个位两节点合并为本节点;宽度随位数变化(2 或 3 个字符)留到运行时注入,
//高度恒为单字高,取 NUM_STYLE.MAIN_SUIT_COUNT.charHeight(SSOT)
EQW_Layout.MAIN_SUIT_COUNT = {
kind: 'attach', targetKind: 'sprite', hAlign: 'center', vAlign: 'bottom', offsetY: -6,
h: EQW_Layout.NUM_STYLE.MAIN_SUIT_COUNT.charHeight,
runtime: ['target', 'w']
};
//对数角标文字:贴对应花色按钮右上角(按钮帧内已含角标底,本节点只定位叠加的文字)。
//清单未给宽高(调用方注入 target)
EQW_Layout.MAIN_SUIT_PAIR_BADGE = {
kind: 'attach', targetKind: 'sprite', corner: 'topRight', offsetX: -36, offsetY: 2, w: 34, h: 18,
runtime: ['target']
};
//===== 埋牌 / 出牌操作条 =====
//埋牌操作条:提示 / 倒计时(CD_SELF) / 埋牌,逐项不等宽(清单 §6.1)
EQW_Layout.BURY_OPERATION_BAR = {
kind: 'line', direction: 'horizontal', anchorX: 640, anchorY: 165,
itemHeight: 63, spacing: 45, anchor: 'center',
items: [
{ key: 'BURY_BTN_TIP', width: 180 },
{ key: 'CD_SELF', width: 60 },
{ key: 'BURY_BTN_SUBMIT', width: 180 }
]
};
//出牌操作条:倒计时(CD_SELF) / 出牌 / 提示,逐项不等宽(清单 §6.1)。
//2026-08-27 补上「提示」项:清单 §1.7 明确「出牌操作条 = 倒计时 + 出牌 按钮 + 提示 按钮
//(出牌阶段必须有)」,但 §6.8 给的 items 只有 [75,157](倒计时/出牌)——清单自身漏了提示;
//排序照 §1.7 的行文(倒计时 → 出牌 → 提示)。
//【估值】提示 180:清单未给该项宽度;提示按钮与埋牌条的提示按钮同资源 BTN_TIP(§3.3 实测 180×63),
//埋牌条 items 首项(同一个提示按钮)也取 180,故沿用 180,待设计稿复核。
//⚠ 本条 itemHeight 为 55(对齐出牌按钮 BTN_PLAY 157×55),而 BTN_TIP 原生高 63——
// 求解结果里提示项的 height 会是 55,实际显示高度以精灵自身资源为准;此高度差同样待设计稿裁定
//
//2026-08-27 修订:补「提示」项后原先仍用 anchor:'center',导致整排重新居中、把已有的
//CD_SELF / PLAY_BTN_SUBMIT 两项相对原先(仅 2 项时)左移了 107.5px——「出牌」按钮的位置在
//参考图 `出牌.png` 是有实测依据的(556–711,宽约 156×57),不该因为插入第 3 项而漂移。
//改为 anchor:'left',把 anchorX 定成【首项 CD_SELF 的起点】,让先前已有的两项固定不动、只是
//在其后追加「提示」——与 FOOTER_FUNC_BUTTONS 用 anchor:'right' 固定住最右项「底牌」是同一思路
//(那边插入更早的项,这边追加更晚的项,对称地选左/右锚点)。
//反推 anchorX:_solveLineItems 在 anchor:'left' 下 start = anchorX,故
// PLAY_BTN_SUBMIT.x = anchorX + CD_SELF.width(75) + spacing(35) = anchorX + 110
//要 PLAY_BTN_SUBMIT.x ≈ 556(参考图实测),故 anchorX = 556 - 110 = 446。
//实际调用求解器验证(count=3,spacing=35):
// CD_SELF x=446 width=75 (参考图目测约 444,估值层面 2px 差可接受)
// PLAY_BTN_SUBMIT x=556 width=157 (与参考图 556–711 实测吻合)
// PLAY_BTN_TIP x=748 width=180
EQW_Layout.PLAY_OPERATION_BAR = {
kind: 'line', direction: 'horizontal', anchorX: 446, anchorY: 345,
itemHeight: 55, spacing: 35, anchor: 'left',
items: [
{ key: 'CD_SELF', width: 75 },
{ key: 'PLAY_BTN_SUBMIT', width: 157 },
{ key: 'PLAY_BTN_TIP', width: 180 }
]
};
//===== 浮层 =====
//捡分飘字:起始位置在此。动画参数(floatRise / floatDuration)见 EQW_Anim.SCORE_FLOAT,不在此重复(SSOT)
EQW_Layout.GRADE_FLOAT = { kind: 'point', x: 570, y: 180, w: 170, h: 55 };
//状态提示条 · 等待类(如『等待庄家选主』)。与即时反馈类共用同一套精灵 BAR_TOAST/TXT_TOAST,
//仅位置预设不同,调用方按提示类型二选一应用(清单 §2.8:收敛为 3 条)
EQW_Layout.TOAST_WAIT = { kind: 'point', x: 500, y: 373, w: 320, h: 47 };
//状态提示条 · 即时反馈类(如『强甩失败』)。自动消失时长见 EQW_Anim.TOAST_DURATION,不在此重复(SSOT)
EQW_Layout.TOAST_INSTANT = { kind: 'point', x: 462, y: 172, w: 363, h: 50 };
@@ -0,0 +1,100 @@
///////////////////////////////////////////////////////////////
////////// EQW_Layout: 牌区配置(清单 §6.7)////////////////////
///////////////////////////////////////////////////////////////
// 【纯数据文件】无函数、无副作用;依赖 LayoutConstants.js 先加载
// (引用其 EQW_Layout.CARD_SIZE,属 EQW_Layout 内部自引用)。
var EQW_Layout = EQW_Layout || {};
//===== 自己手牌区(清单 §6.7)=====
//自己手牌 · 单排:36 张时 spacing ≈ -78(露 32px),与参考图实测 33px 吻合
EQW_Layout.HAND_FAN_SINGLE = {
kind: 'fan', direction: 'horizontal',
anchorX: 640, anchorY: 455, maxWidth: 1215,
itemWidth: EQW_Layout.CARD_SIZE.L.w, itemHeight: EQW_Layout.CARD_SIZE.L.h,
spacingMax: 0, spacingMin: -78, anchor: 'center', overlapFrom: 'left'
};
//自己手牌 · 双排(埋牌)。分行策略是数据标记 splitStrategy,由调用方据此分行,本文件不写函数:
//floorTop = 上排 floor(n/2)、下排 ceil(n/2)(36 张 = 18/18;参考图 17/19 属填充画法,不作依据,见清单 §0.5)。
//spacingMax / anchor / overlapFrom 清单双排行未重复给出,沿用单排同参数(同一批牌、同一压缩规则)
EQW_Layout.HAND_TWO_ROW = {
splitStrategy: 'floorTop',
ROW_TOP: {
kind: 'fan', direction: 'horizontal',
anchorX: 644, anchorY: 285, maxWidth: 928,
itemWidth: EQW_Layout.CARD_SIZE.L.w, itemHeight: EQW_Layout.CARD_SIZE.L.h,
spacingMax: 0, spacingMin: -65, anchor: 'center', overlapFrom: 'left'
},
ROW_BOTTOM: {
kind: 'fan', direction: 'horizontal',
anchorX: 644, anchorY: 455, maxWidth: 928,
itemWidth: EQW_Layout.CARD_SIZE.L.w, itemHeight: EQW_Layout.CARD_SIZE.L.h,
spacingMax: 0, spacingMin: -65, anchor: 'center', overlapFrom: 'left'
}
};
//手牌 · 选中上浮偏移量定义在 EQW_Anim.HAND_SELECT_OFFSET_Y(AnimConstants.js),此处不重复(SSOT)
//底牌区(8 张,未揭示为牌背、揭示后翻正面,同一批精灵)
EQW_Layout.BOTTOM_CARDS = {
kind: 'line', direction: 'horizontal',
anchorX: 655, anchorY: 110,
itemWidth: EQW_Layout.CARD_SIZE.L.w, itemHeight: EQW_Layout.CARD_SIZE.L.h,
spacing: -36, anchor: 'center'
};
//埋牌底牌区(结算,8 张,尺寸档 S)
EQW_Layout.BURY_CARDS = {
kind: 'line', direction: 'horizontal',
anchorX: 640, anchorY: 163,
itemWidth: EQW_Layout.CARD_SIZE.S.w, itemHeight: EQW_Layout.CARD_SIZE.S.h,
spacing: 2, anchor: 'center'
};
//===== 已出牌区(清单 §6.2 示例 / §6.7)=====
//三家已出牌区:三家共用 base,LEFT 与 RIGHT 左右镜像(overlapFrom 相反)
EQW_Layout.PLAY_AREA = {
kind: 'fan', direction: 'horizontal',
itemWidth: EQW_Layout.CARD_SIZE.M.w, itemHeight: EQW_Layout.CARD_SIZE.M.h,
maxWidth: 170, spacingMax: -35, spacingMin: -62,
bySeat: {
SELF: { anchorX: 650, anchorY: 265, anchor: 'center', overlapFrom: 'left' },
RIGHT: { anchorX: 1022, anchorY: 120, anchor: 'center', overlapFrom: 'left' },
LEFT: { anchorX: 258, anchorY: 120, anchor: 'center', overlapFrom: 'right' }
}
};
//已出牌 · 牌型标签:贴该区第一张牌。首槽是固定精灵(PLAY_SELF_1 / PLAY_LEFT_1 / PLAY_RIGHT_1),非动态目标。
//资源 LABEL_CARDTYPE 76×24(清单 §3)
EQW_Layout.PLAY_TYPE_LABEL = {
kind: 'attach', targetKind: 'sprite', corner: 'bottomLeft', offsetX: 0, offsetY: -24, w: 76, h: 24,
bySeat: {
SELF: { target: 'PLAY_SELF_1' },
LEFT: { target: 'PLAY_LEFT_1' },
RIGHT: { target: 'PLAY_RIGHT_1' }
}
};
//已出牌 · 牌角『庄』/『大』标:贴的是本轮打出的哪张具体牌,运行时才能确定,不是固定精灵键。
//本节点只是模板(corner/offset),调用方在确定具体是哪张牌之后,以此为基础补上 target 再喂给
//EQW_LayoutSolver.solve——这是有意的动态目标模式,不在配置里预置 target(清单未给尺寸;
//两种资源分别是 MARK_BANKER_CORNER 28×28 / MARK_JOKER_BIG 34×34,按实际显示的是哪个徽标取值)
EQW_Layout.PLAY_CARD_CORNER_MARK = {
kind: 'attach', targetKind: 'sprite', corner: 'topRight', offsetX: -28, offsetY: 0,
runtime: ['target', 'w', 'h']
};
//冲关牌(叠加层):精灵复制场景(§5.6a),spacingMin 放宽到 -88 以容纳 20+ 张的极端情况。
//anchor / direction 清单未重复给出,沿用其余 fan 配置的通用约定(center / horizontal)
EQW_Layout.CHONGGUAN_FAN = {
kind: 'fan', direction: 'horizontal', anchor: 'center',
itemWidth: EQW_Layout.CARD_SIZE.L.w, itemHeight: EQW_Layout.CARD_SIZE.L.h,
maxWidth: 420, spacingMax: -40, spacingMin: -88,
bySeat: {
LEFT: { anchorX: 260, anchorY: 120 },
RIGHT: { anchorX: 1035, anchorY: 120 },
SELF: { anchorX: 640, anchorY: 400 }
}
};
@@ -0,0 +1,119 @@
///////////////////////////////////////////////////////////////
////////// EQW_Layout / EQW_RoomOptions: 建房规则选项(清单 §6.9b / §1.1)
///////////////////////////////////////////////////////////////
// 【纯数据文件】无函数、无副作用;依赖 LayoutConstants.js 先加载
// (引用其 EQW_Layout.TEXT_STYLE.*,属 EQW_Layout 内部自引用)。
var EQW_Layout = EQW_Layout || {};
//===== 建房规则选项区布局(清单 §6.9b)=====
//两层嵌套:类别竖排(ROOM_CATEGORY_COLUMN)+ 类别内选项槽横排(ROOM_OPTION_ROW)。
//本节数值按参考图比例估,实际以平台建房界面尺寸为准(清单 §6.9b 原文,不是本文件误差)。
//类别竖排。itemWidth 清单未给——类别行宽度等于平台内容区实际宽度,由调用方按实际面板尺寸注入。
//anchor 必须是竖排语义的 top/bottom/center:写成水平语义的 'left' 会被框架 switch 的 default
//当成居中(3 个类别时算出 y=[-56,-16,24],前两行跑到画布外),求解器现已按方向校验并抛错
EQW_Layout.ROOM_CATEGORY_COLUMN = {
kind: 'line', direction: 'vertical', anchorX: 0, anchorY: 0,
itemHeight: 32, spacing: 8, anchor: 'top',
runtime: ['itemWidth']
};
//类别标题:贴所在类别行。该行是 ROOM_CATEGORY_COLUMN 求解结果的第 N 项,不是固定精灵,
//target 由调用方按类别序号注入。资源 ROOM_CAT_TITLE 60×24(清单 §3)
EQW_Layout.ROOM_CATEGORY_TITLE = {
kind: 'attach', targetKind: 'layout', hAlign: 'left', vAlign: 'middle', offsetX: 0, w: 60, h: 24,
runtime: ['target']
};
//类别内选项槽横排。anchorY 清单未给(随所在类别行的 y 浮动,由调用方按该行位置注入);
//itemHeight 清单未给,沿用 ROOM_CATEGORY_COLUMN.itemHeight(同一行内选择框与文字纵向居中)
EQW_Layout.ROOM_OPTION_ROW = {
kind: 'line', direction: 'horizontal', anchorX: 70,
itemWidth: 150, itemHeight: EQW_Layout.ROOM_CATEGORY_COLUMN.itemHeight, spacing: 0, anchor: 'left',
runtime: ['anchorY']
};
//选项组之间的额外间隔(同类别下两个选项组之间比组内选项多留的间距)
EQW_Layout.ROOM_OPTION_GROUP_GAP = 40;
//选择框:贴所在选项槽左侧(资源 ROOM_OPT_BOX 22×22)。target 由调用方按选项槽注入
//(槽是 ROOM_OPTION_ROW 求解结果的第 N 项,故 targetKind 是 layout 而非精灵键)
EQW_Layout.ROOM_OPTION_BOX = {
kind: 'attach', targetKind: 'layout', hAlign: 'left', vAlign: 'middle', w: 22, h: 22,
runtime: ['target']
};
//选项文字:贴同槽选择框右侧(target 是该槽的 ROOM_BOX_* 精灵,由调用方按选项配置的 box 键注入),
//纯文字,清单未给宽高,由调用方按渲染出的文案尺寸注入。
//选中态变橙见 EQW_Layout.TEXT_STYLE.ROOM_OPT(normal / selected 两态,由调用方按选中状态二选一)
EQW_Layout.ROOM_OPTION_TEXT = {
kind: 'attach', targetKind: 'sprite', hAlign: 'right', vAlign: 'middle', offsetX: 8,
runtime: ['target', 'w', 'h']
};
//房卡附注:贴扣卡组最后一个选项(固定精灵 ROOM_BOX_CARD_2)右侧。纯文字,宽高随文案运行时注入
EQW_Layout.ROOM_CARD_COST_NOTE = {
kind: 'attach', targetKind: 'sprite', target: 'ROOM_BOX_CARD_2',
hAlign: 'right', vAlign: 'middle', offsetX: 20,
textStyle: EQW_Layout.TEXT_STYLE.ROOM_CARD_COST_NOTE,
runtime: ['w', 'h']
};
//折行:选项数超过 maxPerRow 时改用 grid。二七王每组最多 2 项,不会触发(清单 §6.9b 原文);
//清单原文是 grid{cols:maxPerRow, spacingX:0, spacingY:8, fillOrder:'row'},cols 直接取自 maxPerRow,
//故与 spacingX/spacingY/fillOrder 一并记为已定的四个字段。
//rows 由实际选项数决定(ceil(选项数 / cols)),anchorX/anchorY 随所在类别行浮动 —— 三者都是运行时值,
//在 runtime 里显式声明;缺 rows 时求解器会抛错,不再静默返回空数组(一个矩形都不产出)。
//itemWidth / itemHeight 清单同样未给:折行摆的还是同一批选项槽,沿用 ROOM_OPTION_ROW 的槽尺寸,
//不另编一套数值;anchor 同理沿用 ROOM_OPTION_ROW 的 'left'(折行前后左边缘对齐一致)
EQW_Layout.ROOM_OPTION_MAX_PER_ROW = 4;
EQW_Layout.ROOM_OPTION_OVERFLOW_GRID = {
kind: 'grid', cols: EQW_Layout.ROOM_OPTION_MAX_PER_ROW, spacingX: 0, spacingY: 8, fillOrder: 'row',
itemWidth: EQW_Layout.ROOM_OPTION_ROW.itemWidth, itemHeight: EQW_Layout.ROOM_OPTION_ROW.itemHeight,
anchor: 'left',
runtime: ['rows', 'anchorX', 'anchorY']
};
///////////////////////////////////////////////////////////////
////////// EQW_RoomOptions: 建房规则选项配置(清单 §1.1)///////
///////////////////////////////////////////////////////////////
//整个界面由本配置驱动渲染,加一个选项 = 配置里加一条,不改渲染逻辑(工程总则 §5 OCP / §6 配置优先)。
//type: 'radio' 单选 / 'checkbox' 多选 / 'radioOptional' 单选可不选
//选择框帧号 = (useCheckbox ? 3 : 1) + (selected ? 1 : 0),useCheckbox = (checkbox 或 radioOptional)
//
//【精灵键由配置给出】每个类别声明自己的标题精灵 titleSprite,每个选项声明自己的选择框精灵 box
//与文字精灵 text,附注声明 noteSprite——渲染器一律按键名取精灵,【不得按下标拼字符串】
//(组的 key 是 'rules'、精灵却叫 ROOM_BOX_RULE_1,本来就拼不出来)。
//
//【roomtype 拼串只在一处实现】拼串就是遍历本配置,不得在别处按下标硬拼
//(与服务端 class.config.js parse() 对称的 SSOT 要求)。bit/value 统一挂在【每个 item 上】:
// - radio:组内各 item 写同一个 bit,选中哪个就把该 item 的 value 写进该位;
// - checkbox:勾选写 item.value、不勾写 '0'。
//这样拼串循环对两种 type 是同一个形状(item → 写 value 到 bit),不必按组/项两种层级分支
var EQW_RoomOptions = EQW_RoomOptions || {
categories: [
{ titleFrame: 1, titleSprite: 'ROOM_CAT_TITLE_1', /* 牌局 */ groups: [
{ key: 'aset', type: 'radio',
items: [ { label: '6局', value: '0', bit: 0, box: 'ROOM_BOX_ASET_1', text: 'ROOM_TEXT_ASET_1' },
{ label: '12局', value: '1', bit: 0, box: 'ROOM_BOX_ASET_2', text: 'ROOM_TEXT_ASET_2' } ] },
{ key: 'card', type: 'radio', note: 'cardCost', noteSprite: 'ROOM_TEXT_CARD_COST',
items: [ { label: '房主扣卡', value: '0', bit: 1, box: 'ROOM_BOX_CARD_1', text: 'ROOM_TEXT_CARD_1' },
{ label: 'AA每人扣卡', value: '1', bit: 1, box: 'ROOM_BOX_CARD_2', text: 'ROOM_TEXT_CARD_2' } ] }
] },
{ titleFrame: 2, titleSprite: 'ROOM_CAT_TITLE_2', /* 模式 */ groups: [
{ key: 'peek', type: 'radio',
items: [ { label: '可查牌', value: '0', bit: 4, box: 'ROOM_BOX_PEEK_1', text: 'ROOM_TEXT_PEEK_1' },
{ label: '不查牌', value: '1', bit: 4, box: 'ROOM_BOX_PEEK_2', text: 'ROOM_TEXT_PEEK_2' } ] }
] },
{ titleFrame: 3, titleSprite: 'ROOM_CAT_TITLE_3', /* 规则 */ groups: [
{ key: 'rules', type: 'checkbox',
items: [ { label: '傍王', value: '1', bit: 2, box: 'ROOM_BOX_RULE_1', text: 'ROOM_TEXT_RULE_1' },
{ label: '爬坡', value: '1', bit: 3, box: 'ROOM_BOX_RULE_2', text: 'ROOM_TEXT_RULE_2' } ] }
] }
],
//房卡消耗联动(协议 §0.5):不硬编码在 UI 代码里
cardCost: {
'0': { '0': '房主2张', '1': '房主4张' }, //房主扣卡:6局 / 12局
'1': { '0': '每人1张', '1': '每人2张' } //AA 每人扣卡:6局 / 12局
}
};
@@ -0,0 +1,269 @@
///////////////////////////////////////////////////////////////
////////// EQW_Layout: 结算区配置(清单 §6.9 / §5.7a / §5.7b)///
///////////////////////////////////////////////////////////////
// 小局结算 + 冲关遮罩 + 大局总结算 + 出牌历史/亮牌 + 明牌,共五块。
// 【纯数据文件】无函数、无副作用;依赖 LayoutConstants.js 先加载
// (引用其 EQW_Layout.NUM_STYLE.* / CARD_SIZE.*,属 EQW_Layout 内部自引用)。
var EQW_Layout = EQW_Layout || {};
EQW_Layout.RESULT_BOTTOM_PANEL = { kind: 'point', x: 430, y: 140, w: 420, h: 148 };
//底牌说明文字:贴面板底部,纯文字,清单未给宽高
EQW_Layout.RESULT_BOTTOM_TEXT = {
kind: 'attach', targetKind: 'layout', target: 'RESULT_BOTTOM_PANEL',
hAlign: 'center', vAlign: 'bottom', offsetY: -14,
runtime: ['w', 'h']
};
//三家分数光晕
EQW_Layout.RESULT_GLOW = {
kind: 'point', w: 225, h: 100,
bySeat: {
LEFT: { x: 175, y: 150 },
RIGHT: { x: 865, y: 150 },
SELF: { x: 490, y: 340 }
}
};
//三家总分大字(多帧图数字精灵,非文字精灵,2026-08-27):贴对应光晕居中。
//target 直接指向光晕精灵键(固定,非动态);高度恒为单字高,取 NUM_STYLE.RESULT_WIN.charHeight
//(与 NUM_STYLE.RESULT_LOSE 相同,SSOT);宽度随位数变化,留到运行时注入
//节点名去掉 _TEXT 后缀改为 _NUM:与 CALL_BTN_SCORE_NUM 同一理由,_TEXT 会让人误以为是文字精灵
EQW_Layout.RESULT_SCORE_NUM = {
kind: 'attach', targetKind: 'sprite', hAlign: 'center', vAlign: 'middle',
h: EQW_Layout.NUM_STYLE.RESULT_WIN.charHeight, runtime: ['w'],
bySeat: {
LEFT: { target: 'RESULT_GLOW_LEFT' },
RIGHT: { target: 'RESULT_GLOW_RIGHT' },
SELF: { target: 'RESULT_GLOW_SELF' }
}
};
//三家『牌局分』:贴对应光晕左下
EQW_Layout.RESULT_JF_TEXT = {
kind: 'attach', targetKind: 'sprite', hAlign: 'left', vAlign: 'bottom', offsetX: -15, offsetY: 24,
runtime: ['w', 'h'],
bySeat: {
LEFT: { target: 'RESULT_GLOW_LEFT' },
RIGHT: { target: 'RESULT_GLOW_RIGHT' },
SELF: { target: 'RESULT_GLOW_SELF' }
}
};
//三家『冲关分』:贴对应光晕右下
EQW_Layout.RESULT_AW_TEXT = {
kind: 'attach', targetKind: 'sprite', hAlign: 'right', vAlign: 'bottom', offsetX: 15, offsetY: 24,
runtime: ['w', 'h'],
bySeat: {
LEFT: { target: 'RESULT_GLOW_LEFT' },
RIGHT: { target: 'RESULT_GLOW_RIGHT' },
SELF: { target: 'RESULT_GLOW_SELF' }
}
};
EQW_Layout.RESULT_BTN_CG = { kind: 'point', x: 535, y: 515, w: 183, h: 63 };
EQW_Layout.RESULT_BTN_NEXT = { kind: 'point', x: 835, y: 518, w: 180, h: 65 };
//冲关遮罩:透明度也是配置项
EQW_Layout.CG_MASK = { kind: 'point', x: 0, y: 0, w: 1280, h: 720, opacity: 0.72 };
//===== 大局总结算 / 解散结算(AccountView,群组 241)=====
//
// 【坐标来源 · 2026-08-27 补齐】清单 §6.9 没有本面板的配置表,以下数值全部是
// docs_dev/uiref/大局结算.png 的【目视实测估值】(±5px),面板/标题栏/规则栏/按钮的 w/h
// 取清单 §3.5 / §3.3 的资源尺寸(那是权威值)。设计稿到位后只改本节。
// 之所以现在就落成配置:不补的话 C/D/E 阶段写渲染代码时会就地硬编码坐标(零裸值红线)。
//
// 【玩家栏是精灵复制】三条栏结构相同、条数随人数变,故不逐行写死,而是给
//「容器 ACC_ROW_CONTAINER + 行节点 ACC_ROW(行高即 itemHeight)+ 行内各模板贴附到行矩形」。
// 约定:ACC_ROW 及贴附到它的 ACC_TPL_* / ACC_ROW_TEXT_GRID 求解出的坐标都是
//【相对 ACC_ROW_CONTAINER 的】——SpriteCopyUtils 复制出的精灵是容器的子精灵,
// x/y 本就相对容器,求解结果可直接喂给 SpriteCopyUtils.create(与 §5.6a 冲关牌同一用法)。
// 行数由数据给出(ctx.count),不进配置(清单 §6.1)。
//面板底(1100×540,资源 PANEL_ACCOUNT)
EQW_Layout.ACC_PANEL_BG = { kind: 'point', x: 80, y: 88, w: 1100, h: 540 };
//标题栏:封住面板上沿(1100×56,资源 BAR_ACCOUNT_TITLE)
EQW_Layout.ACC_TITLE_BAR = {
kind: 'attach', targetKind: 'layout', target: 'ACC_PANEL_BG',
corner: 'topLeft', offsetX: 0, offsetY: 0, w: 1100, h: 56
};
//标题文字「二七王-3人 房号… 共…局 …」:贴标题栏内部左侧居中。
//文案随房间信息变长变短,真实宽高要等文案渲染出来才知道 → 留到运行时注入
EQW_Layout.ACC_TITLE_TEXT = {
kind: 'attach', targetKind: 'layout', target: 'ACC_TITLE_BAR',
hAlign: 'left', vAlign: 'middle', offsetX: 30,
runtime: ['w', 'h']
};
//关闭按钮:贴标题栏右上角(36×36,资源 BTN_ACC_CLOSE)
EQW_Layout.ACC_BTN_CLOSE = {
kind: 'attach', targetKind: 'layout', target: 'ACC_TITLE_BAR',
corner: 'topRight', offsetX: -20, offsetY: 10, w: 36, h: 36
};
//规则栏:封住面板下沿(1100×62,资源 BAR_ACCOUNT_RULE)
EQW_Layout.ACC_RULE_BAR = {
kind: 'attach', targetKind: 'layout', target: 'ACC_PANEL_BG',
corner: 'bottomLeft', offsetX: 0, offsetY: 0, w: 1100, h: 62
};
//规则文字「规则:可查牌、傍王、爬坡算子」:贴规则栏内部左侧居中,文案据 roomtype 拼出、长度不定
EQW_Layout.ACC_RULE_TEXT = {
kind: 'attach', targetKind: 'layout', target: 'ACC_RULE_BAR',
hAlign: 'left', vAlign: 'middle', offsetX: 30,
runtime: ['w', 'h']
};
//玩家栏容器(ACC_ROW_CONTAINER 精灵的位置):标题栏下沿 144 到规则栏上沿 566 之间
EQW_Layout.ACC_ROW_CONTAINER = { kind: 'point', x: 80, y: 144, w: 1100, h: 422 };
//玩家栏(行):竖排等距,itemHeight 即【行高/行距】。坐标相对容器,行数由 ctx.count 给出
EQW_Layout.ACC_ROW = {
kind: 'line', direction: 'vertical', anchorX: 0, anchorY: 14,
itemWidth: 1100, itemHeight: 132, spacing: 0, anchor: 'top'
};
//行内 · 名次徽章:贴该行内部左侧居中(44×44,资源 BADGE_RANK,帧=名次1–3)
EQW_Layout.ACC_TPL_RANK = {
kind: 'attach', targetKind: 'layout', target: 'ACC_ROW',
hAlign: 'left', vAlign: 'middle', offsetX: 28, w: 44, h: 44
};
//行内 · 头像:贴该行内部左侧居中,名次徽章右侧
EQW_Layout.ACC_TPL_AVATAR = {
kind: 'attach', targetKind: 'layout', target: 'ACC_ROW',
hAlign: 'left', vAlign: 'middle', offsetX: 92, w: 80, h: 80
};
//行内 · 文字栅格(ACC_TPL_TEXT 复制出的 6 条文字的槽位):4 列 × 2 行,行优先——
// 列1: 昵称 / ID:{playerid} 列2: 基础分{n} / 总得分{n}
// 列3: 冲关分{n}(参考图小字写「算奖分」,清单 §1.9 定案字段为 grade_cg_total)
// 列4: 傍王分{n}
// 第 2 行只有前两列有文字,第 3/4 列留空(求解仍返回 8 个槽位,调用方按数据取前 6 个)。
// anchorX 相对容器(= 参考图列1 绝对 x272 − 容器 x80);anchorY 随所在行浮动 → 运行时注入
// 【估值精度说明】本节点用等距列模型(itemWidth:176 spacingX:0 → 相对容器 192/368/544/720),
// 而参考图「大局结算.png」目视实测的列 x 约为 272/455/634/800(列距 183/179/166,并不等距)——
// 与等距模型偏差约 10px,比本文件头部通用声明的「±5px」更大,待设计稿复核(本次未改数值,仅订正估值说明)
EQW_Layout.ACC_ROW_TEXT_GRID = {
kind: 'grid', anchorX: 192, cols: 4, rows: 2,
itemWidth: 176, itemHeight: 34, spacingX: 0, spacingY: 10,
anchor: 'left', fillOrder: 'row',
runtime: ['anchorY']
};
//行内 · 右侧总分大字(多帧图数字精灵):贴该行内部右侧居中。
//高度恒为单字高,取 NUM_STYLE.RESULT_WIN.charHeight(与 RESULT_LOSE 相同,SSOT);
//宽度随位数变化(setNumberImage 按【字符数 × charWidth】自动改精灵宽),留到运行时注入
EQW_Layout.ACC_TPL_SCORE_NUM = {
kind: 'attach', targetKind: 'layout', target: 'ACC_ROW',
hAlign: 'right', vAlign: 'middle', offsetX: -25,
h: EQW_Layout.NUM_STYLE.RESULT_WIN.charHeight, runtime: ['w']
};
//行内 · 行间分隔线:贴该行【下沿】(vAlign bottom 的语义是"目标底边之下",见 AlignmentUtils)。
//1060×2,资源 LINE_DIVIDER;水平居中于 1100 宽的行,左右各留 20
EQW_Layout.ACC_TPL_DIVIDER = {
kind: 'attach', targetKind: 'layout', target: 'ACC_ROW',
hAlign: 'center', vAlign: 'bottom', offsetX: 0, offsetY: 0, w: 1060, h: 2
};
//面板【外】底部 4 个功能按钮(再来一局/分享好友/复制战绩/退出房间,各 190×60):
//绝对坐标(不在玩家栏容器内)。参考图里这排不是居中于屏幕、而是整体偏右,故用 anchor:left 定起点
EQW_Layout.ACC_FUNC_BUTTONS = {
kind: 'line', direction: 'horizontal', anchorX: 345, anchorY: 640,
itemWidth: 190, itemHeight: 60, spacing: 25, anchor: 'left'
};
//===== 出牌历史 / 上一轮 / 亮牌(HistoryView,群组 242)=====
//
// 【坐标来源 · 2026-08-27 补齐】清单 §5.7b 说本 View 与 MingPaiView、ChongGuanOverlayView
// 三者【同一种版式】(半透黑遮罩 + 每家一排重叠大牌),参考图同为
// docs_dev/uiref/冲关牌型显示.png;故以下节点照 CHONGGUAN_FAN / CG_MASK 同构给出,
// 三家落点沿用清单 §6.7 的冲关牌锚点(LEFT 260,120 / RIGHT 1035,120 / SELF 640,400)。
// 与冲关牌的唯一差别是【张数上限】:出牌历史摊平后可达 28 张以上,420 的 maxWidth 装不下。
// 2026-08-27 订正:原估值 maxWidth:500 是算错的——_solveFan 的间距公式是
// spacing = max(spacingMin, min(spacingMax, (maxWidth-itemWidth)/(count-1) - itemWidth))
// 代入 maxWidth 500 / itemWidth 110(CARD_SIZE.L.w)/ count 28:
// (500-110)/27 - 110 = -95.56,没有触到 spacingMin:-96 的下限,故实际 spacing 是 -95.56,
// 而 fan 的总宽恒等于 maxWidth(AlignmentUtils.distributeHorizontally 的 totalWidth 公式代入后
// 可化简为 maxWidth,只要 spacing 没被下限截断)——不是旧注释算的「488」。
// RIGHT 锚点 anchorX:1035、anchor:center 时,总宽 500 使整排右边缘落在 1035+250=1285,
// 超出 1280 设计画布 5px(LEFT/SELF 因锚点更靠左,仍在画布内,故此前没暴露)。
// 2026-08-27 再订正(裁定 maxWidth:490 → 480):490 虽能让 RIGHT 恰好贴住 1280,但零余量太脆弱——
// 这些锚点本身是参考图目视实测(±5px 误差),且 490 时 spacingMin:-96 永远不触底、是个死参数,
// 与「每张最窄只露 14px」这条约束名存实亡;旧注释原本写的「每张露 14px、总宽 488」正是
// spacingMin 触底后的结果,说明 488 才是当初想要的值,maxWidth:500 是与注释不符的笔误。
// 故改为 maxWidth:480:代入公式 (480-110)/27-110 = -96.30,**触及** spacingMin:-96 下限,
// 实际 spacing 被截断为 -96(不再等于 raw),总宽 = 28×110 + 27×(-96) = 3080-2592 = 488(不是 480,
// 因为 spacing 被下限截断后总宽不再恒等于 maxWidth)。RIGHT 边缘 = 1035+488/2 = 1279,留 1px 余量;
// LEFT 边缘 = 260+244 = 504,SELF 边缘 = 640+244 = 884,均有余量。spacingMin:-96 就此真正生效
// (不再是死参数)。每张露 14px(itemWidth+spacing = 110-96 = 14,与旧注释「14px」精确一致)。
// maxWidth:480 与 spacingMin:-96 仍是【估值,待设计稿复核】——清单未给本 View 的排布参数。
// 【已知限制,留给后来者】每张仅露 14px、视觉偏窄,待设计稿复核是否改为分两排显示,
// 或改用 CARD_SIZE.M(更小的牌面)——这是本次估值阶段接受的限制,不是本次要解决的问题。
//
// 【牌用精灵复制】求解结果直接作为 SpriteCopyUtils.create 的 x/y(相对容器),
// 与 §5.6a 冲关牌同一用法:三个容器精灵摆在原点、尺寸铺满屏,故容器内坐标 = 屏幕坐标。
//出牌历史 · 遮罩(兼点击关闭热区;【亮牌】用途下不注册点击,见 Sprites_Result.js 说明)
EQW_Layout.HIST_MASK = { kind: 'point', x: 0, y: 0, w: 1280, h: 720, opacity: 0.72 };
//出牌历史 · 三个牌容器(HIST_BOX_LEFT/RIGHT/SELF):同摆在原点、铺满屏,
//使 HIST_FAN 求出的屏幕坐标可直接当作容器内坐标使用
EQW_Layout.HIST_BOX = { kind: 'point', x: 0, y: 0, w: 1280, h: 720 };
//出牌历史 · 每家一排牌(张数不定,重叠压缩)
EQW_Layout.HIST_FAN = {
kind: 'fan', direction: 'horizontal', anchor: 'center',
itemWidth: EQW_Layout.CARD_SIZE.L.w, itemHeight: EQW_Layout.CARD_SIZE.L.h,
maxWidth: 480, spacingMax: -40, spacingMin: -96,
bySeat: {
LEFT: { anchorX: 260, anchorY: 120 },
RIGHT: { anchorX: 1035, anchorY: 120 },
SELF: { anchorX: 640, anchorY: 400 }
}
};
//出牌历史 · 三家座位标签(昵称/张数):各排牌下方;文案长度不定,w/h 运行时注入
EQW_Layout.HIST_LABEL = {
kind: 'point',
runtime: ['w', 'h'],
bySeat: {
LEFT: { x: 200, y: 318 },
RIGHT: { x: 975, y: 318 },
SELF: { x: 580, y: 598 }
}
};
//===== 明牌面板(MingPaiView,群组 243)=====
//
// 版式与 HistoryView 完全相同,差别只在【家数】:明牌只看另两家,没有自己那排,
// 故 bySeat 只有 LEFT / RIGHT 两个变体(精灵键为 MING_BOX_A / _B,A 落 LEFT 位、B 落 RIGHT 位)。
// 坐标来源同上:冲关牌型显示.png 目视实测 + §6.7 冲关牌锚点,【估值,待设计稿复核】。
EQW_Layout.MING_MASK = { kind: 'point', x: 0, y: 0, w: 1280, h: 720, opacity: 0.72 };
EQW_Layout.MING_BOX = { kind: 'point', x: 0, y: 0, w: 1280, h: 720 };
EQW_Layout.MING_FAN = {
kind: 'fan', direction: 'horizontal', anchor: 'center',
itemWidth: EQW_Layout.CARD_SIZE.L.w, itemHeight: EQW_Layout.CARD_SIZE.L.h,
maxWidth: 480, spacingMax: -40, spacingMin: -96,
bySeat: {
LEFT: { anchorX: 260, anchorY: 120 },
RIGHT: { anchorX: 1035, anchorY: 120 }
}
};
EQW_Layout.MING_LABEL = {
kind: 'point',
runtime: ['w', 'h'],
bySeat: {
LEFT: { x: 200, y: 318 },
RIGHT: { x: 975, y: 318 }
}
};
@@ -0,0 +1,156 @@
///////////////////////////////////////////////////////////////
////////// EQW_Layout: 常驻区 + 玩家位(清单 §6.5 / §6.6)//////
///////////////////////////////////////////////////////////////
// 【纯数据文件】无函数、无副作用;依赖 LayoutConstants.js 先加载
// (引用其 EQW_Layout.TEXT_STYLE.*,属 EQW_Layout 内部自引用)。
var EQW_Layout = EQW_Layout || {};
//===== 常驻区(清单 §6.5)=====
//顶部信息条底
EQW_Layout.TOP_INFO_BG = { kind: 'point', x: 486, y: 4, w: 276, h: 68 };
//顶部 · 花色图标:贴信息条内部左下角(资源 SUIT_ICON_S 30×30,清单 §3)
EQW_Layout.TOP_SUIT_ICON = {
kind: 'attach', targetKind: 'layout', target: 'TOP_INFO_BG',
hAlign: 'left', vAlign: 'bottom', offsetX: 20, offsetY: -8, w: 30, h: 30
};
//顶部 · 叫分数值文字 / 抓分数值文字:【清单 §6.5 未给出位置】——那张表只列了两个角标底与角标
//文字,而角标底又必须贴在这两段数值文字身上。此处显式占位:坐标与宽高全部标为运行时注入,
//由调用方按信息条内的三列表格实际排布给出;设计稿补齐后把 x/y/w/h 回填成固定值即可。
//显式占位是为了避免 C 阶段在渲染代码里就地硬编码一组坐标——那样这两处位置就再也无从统一修改
EQW_Layout.TOP_CALL_TEXT = { kind: 'point', runtime: ['x', 'y', 'w', 'h'] };
EQW_Layout.TOP_GRADE_TEXT = { kind: 'point', runtime: ['x', 'y', 'w', 'h'] };
//顶部 · 叫分角标底:贴叫分数值文字右上角(资源 BADGE_MULTIPLE 帧1 34×18)。
//角标数值文字复用同一矩形,不单独配置(清单未给独立位置,下同)
EQW_Layout.TOP_CALL_BADGE_BG = {
kind: 'attach', targetKind: 'layout', target: 'TOP_CALL_TEXT',
corner: 'topRight', offsetX: 4, offsetY: 6, w: 34, h: 18
};
//顶部 · 抓分角标底:贴抓分数值文字右上角(资源 BADGE_MULTIPLE 帧2 34×18)
EQW_Layout.TOP_GRADE_BADGE_BG = {
kind: 'attach', targetKind: 'layout', target: 'TOP_GRADE_TEXT',
corner: 'topRight', offsetX: 4, offsetY: 6, w: 34, h: 18
};
//自己的主牌统计条(手牌上方「x对 x主」,群组 206)。
//2026-08-27 由 LIANGPAI_BG 改名:与 design §8.2 的「亮牌」同名异实,后者是遮罩面板(群组 242)
EQW_Layout.ZHU_STAT_BG = { kind: 'point', x: 35, y: 400, w: 210, h: 35 };
//底栏条
EQW_Layout.FOOTER_BG = { kind: 'point', x: 0, y: 648, w: 1280, h: 72 };
//底栏 · 局数文字「1/4 局」
EQW_Layout.FOOTER_ASET_TEXT = {
kind: 'point', x: 635, y: 672, w: 65, h: 26, textStyle: EQW_Layout.TEXT_STYLE.ASET_COUNT
};
//底栏 · 功能钮组(明牌/已出牌/上一轮/扣底/底牌):逐项不等宽(清单 §6.1 T-25)。
//2026-08-27 补上「扣底」项(清单 §2.6 列的是 5 个按钮,此处原只排了 4 个)——
//排序按 §2.6 表:扣底紧邻底牌(两者分别看埋牌底牌 / 底牌,成对但不同批,勿写反)。
//【估值】扣底 78:清单未给该项宽度,参考图(等待叫分.png)底栏也只画出「底牌」一项
//(当时还没有「扣底」按钮,§2.6 后补)。依据取自【底牌】按钮实测的 78——不能笼统地说
//「两字按钮 = 78」:同为两字的「明牌」实测只有 74,两者并不相同,不可一概而论。
//故本项沿用「底牌」实测值 78,待设计稿复核(其余四项 74/87/93/78 为清单 §6.5 实测值)
EQW_Layout.FOOTER_FUNC_BUTTONS = {
kind: 'line', direction: 'horizontal', anchorX: 1250, anchorY: 672,
itemHeight: 30, spacing: 15, anchor: 'right',
items: [
{ key: 'BTN_MINGPAI', width: 74 },
{ key: 'BTN_HISTORY', width: 87 },
{ key: 'BTN_LAST_ROUND', width: 93 },
{ key: 'BTN_BURY_CARDS', width: 78 },
{ key: 'BTN_BOTTOM_CARDS', width: 78 }
]
};
//底栏 · 提示按钮组(踩/有分/没分):count 由运行时同时可见的按钮数给出,不进配置
EQW_Layout.FOOTER_TISHI_BUTTONS = {
kind: 'line', direction: 'horizontal', anchorX: 335, anchorY: 672,
itemWidth: 52, itemHeight: 30, spacing: 26, anchor: 'left'
};
//右侧语音钮(平台):坐标仅登记供 01_SubGame_modify.js 配置区人工同步核对(清单 §6.11 同类处理)
EQW_Layout.VOICE_BTN_REF = { kind: 'point', x: 1210, y: 310, w: 56, h: 56 };
//右侧聊天钮(平台):同上
EQW_Layout.CHAT_BTN_REF = { kind: 'point', x: 1210, y: 385, w: 56, h: 56 };
//===== 玩家位(清单 §6.6)=====
//头像框(平台提供):仅登记坐标供 attach 参考与 Game_Modify.PLAYER_INFO_LAYOUT 人工同步(清单 §6.11)。
//SELF 尺寸与 LEFT/RIGHT 不同(84×53 vs 91×118),bySeat 内单独覆盖 w/h
EQW_Layout.PLAYER_AVATAR_FRAME_REF = {
kind: 'point', w: 91, h: 118,
bySeat: {
LEFT: { x: 22, y: 122 },
RIGHT: { x: 1160, y: 122 },
SELF: { x: 22, y: 645, w: 84, h: 53 }
}
};
//『庄』印章:三家各一个精灵(P_LEFT_BANKER / P_RIGHT_BANKER / P_SELF_BANKER),按 bySeat 求位置。
//LEFT/RIGHT 贴平台头像框外角;SELF 为底栏固定点(与清单 §6.5「底栏·庄标」同一元素,此处合并,避免重复定义)。
//注意:base 的 kind 是 attach,SELF 变体里【有意】把 kind 覆盖为 point——EQW_LayoutSolver._mergeBySeat
//会合并包括 kind 在内的所有字段,这里利用该机制表达「底栏庄标是固定坐标、不贴附任何目标」,
//与 LEFT/RIGHT 两个上家贴头像框(attach)是不同的定位方式,不是笔误
EQW_Layout.P_BANKER_MARK = {
kind: 'attach', targetKind: 'platform', w: 40, h: 40,
bySeat: {
LEFT: { target: 'P_LEFT_AVATAR', corner: 'topRight', offsetX: 7, offsetY: 8 },
RIGHT: { target: 'P_RIGHT_AVATAR', corner: 'topLeft', offsetX: -40, offsetY: 8 },
SELF: { kind: 'point', x: 338, y: 668, w: 32, h: 34 }
}
};
//『主N』角标底:仅左右两家(自己主/对数已在手牌区可见,无此角标)。w/h 为清单 §6.6 实测值
EQW_Layout.P_ZHU_BADGE = {
kind: 'attach', targetKind: 'platform', corner: 'bottomLeft', offsetX: 0, offsetY: 6, w: 40, h: 19,
bySeat: {
LEFT: { target: 'P_LEFT_AVATAR' },
RIGHT: { target: 'P_RIGHT_AVATAR' }
}
};
//『对N』角标底:贴主N角标右侧。w/h 清单未重复给出,沿用主N角标同尺寸
//(同资源 BADGE_ZHU_PAIR 的另一帧,帧1黄底=主N、帧2白底=对N,尺寸一致)
EQW_Layout.P_PAIR_BADGE = {
kind: 'attach', targetKind: 'layout', target: 'P_ZHU_BADGE', hAlign: 'right', vAlign: 'middle',
offsetX: 44, offsetY: 0, w: 40, h: 19
};
//状态文字「60分」/「不叫」:仅左右两家(自己此阶段无独立文字区,见清单 §6.6 表)
EQW_Layout.P_STATUS_TEXT = {
kind: 'point', w: 130, h: 70, textStyle: EQW_Layout.TEXT_STYLE.STATUS_BIG,
bySeat: {
LEFT: { x: 170, y: 165 },
RIGHT: { x: 980, y: 165 }
}
};
//倒计时:三家都有(多帧图数字精灵,纯秒数无后缀,见 NUM_STYLE.COUNTDOWN)。
//高度恒为单字高,取 NUM_STYLE.COUNTDOWN.charHeight(SSOT);宽度随位数变化
//(SpriteManager.setNumberImage 按【字符数 × charWidth】自动改精灵宽),故 w 留到运行时注入
EQW_Layout.P_COUNTDOWN = {
kind: 'point', h: EQW_Layout.NUM_STYLE.COUNTDOWN.charHeight,
runtime: ['w'],
bySeat: {
LEFT: { x: 195, y: 165 },
RIGHT: { x: 1010, y: 165 },
SELF: { x: 445, y: 345 }
}
};
//提示气泡(踩/没分/有分):朝向 arrowRight 是配置项,不按座位硬判(清单 §6.6)。
//w/h 清单 §3 标『待定』,此处用示意尺寸占位,资源定稿后回填(不影响 kind/字段结构)
EQW_Layout.TISHI_BUBBLE = {
kind: 'attach', targetKind: 'platform', w: 96, h: 44,
bySeat: {
LEFT: { target: 'P_LEFT_AVATAR', hAlign: 'right', vAlign: 'middle', offsetX: 10, arrowRight: false },
RIGHT: { target: 'P_RIGHT_AVATAR', hAlign: 'left', vAlign: 'middle', offsetX: -10, arrowRight: true },
SELF: { target: 'P_SELF_AVATAR', hAlign: 'right', vAlign: 'top', offsetX: 10, offsetY: -70, arrowRight: false }
}
};
@@ -0,0 +1,16 @@
///////////////////////////////////////////////////////////////
////////// EQW_Sounds: 声音资源 ID(清单 §4)//////////////////
///////////////////////////////////////////////////////////////
// 子游戏声音资源段:≥101(框架保留 1–100)。平台现占用 1–5。
//
// 【T-20 未决】整套音效尚未定案——清单 §4 整节标注「整节待确认 T-20」:
// design 与协议均未定义任何音效;清单里的候选表(发牌/叫分/不叫/上庄/选主/投降/
// 埋牌/出牌/甩牌/甩错/毙牌/赢下一轮/报无主/踩没分有分/扣底/结算赢/结算输/
// 倒计时/按钮点击)明确标注「未经确认,不得直接作为出音依据」。
// 待确认项:是否需要男/女声区分、是否需要方言语音包、SND_TISHI 是否用真人语音、
// 倒计时提示音从第几秒开始。
// 定案前【不臆造条目】。届时在此按 SoundResources.template.js 的格式补充,
// 每条注明:用途 / 时长 / 是否循环。
var EQW_Sounds = EQW_Sounds || {
//待 T-20 定案后补充
};
@@ -0,0 +1,532 @@
///////////////////////////////////////////////////////////////
////////// EQW_Sprites: 精灵结构(清单 §5.4/§5.5 阶段操作区与浮层)
///////////////////////////////////////////////////////////////
// 子游戏精灵段 1001–2999。结构与 Sprites_Table.js 一致:
// View 下 Layer 为图层声明(一个 View 恰好一个图层),其余键是 group 容器;
// 容器内 id 为群组 ID、其余键才是精灵 id。
// 连号精灵逐条写全,不用循环生成。
var EQW_Sprites = EQW_Sprites || {};
// ============================================================================
// 叫分面板 - Layer 104 (ACTION), Group 220 (CALL_PANEL)
// ============================================================================
/**
* 叫分面板 - CallPanelView
*
* 用途: 轮到【自己】叫分时在桌面中央弹出,14 个档位按钮(两行 × 7)+「不叫」按钮 +
* 上方一条静态提示条。收到 jiaofen 推送后收起。轮到他家叫分时本面板不出现。
*
* 布局说明(参考图 docs_dev/uiref/自己叫分.png 全景、docs_dev/uiref/叫分按钮面板.png 近景):
*
* ┌──────────────────────────────┐ y=100 x=470,345×33
* │ 70分坐庄才可投降 │ ← CALL_HINT_BG(1601)
* └──────────────────────────────┘ y=133 +CALL_HINT_TEXT(1602)
* ┌────────────────────────────────────────────────────────────┐ y=140
* │ ┌──┐ ┌──┐ ┌──┐ ┌──┐ ┌──┐ ┌──┐ ┌──┐ │ x=355
* │ │70│ │65│ │60│ │55│ │50│ │45│ │40│ ← 行1 = 档位 1–7 │ 575×170
* │ └──┘ └──┘ └──┘ └──┘ └──┘ └──┘ └──┘ │
* │ ┌──┐ ┌──┐ ┌──┐ ┌──┐ ┌──┐ ┌──┐ ┌──┐ │
* │ │35│ │30│ │25│ │20│ │15│ │10│ │5 │ ← 行2 = 档位 8–14 │
* │ └──┘ └──┘ └──┘ └──┘ └──┘ └──┘ └──┘ │
* └────────────────────────────────────────────────────────────┘ y=310
* ↑ CALL_PANEL_BG(1600) 面板底
* 档位排布 EQW_Layout.CALL_BTN_GRID: grid, anchor center, anchorX=640 anchorY=160,
* cols=7 rows=2, itemWidth=74 itemHeight=56, spacingX=7 spacingY=24, fillOrder='row'
*
* 12 ┌──────────────┐ y=345
* ↑ │ 不 叫 │ x=553, 184×57
* CD_SELF └──────────────┘ y=402
* (CountdownView, ↑ CALL_BTN_NONE(1645)+CALL_BTN_NONE_TEXT(1646)
* EQW_Layout.P_COUNTDOWN.SELF = 445,345)
*
* 单个档位按钮由【3 个精灵】叠成(第 n 档 n=1..14):
*
* ┌───────────────┐
* │ ┌────┐│ ← CALL_BTN_BADGE_n (1631+n-1) 文字精灵,随爬坡开关变
* │ │2子 ││ EQW_Layout.CALL_BTN_MULTIPLE_BADGE: attach 按钮 topRight(-30,+2)
* │ 70 └────┘│ ← CALL_BTN_NUM_n (1617+n-1) 多帧图数字精灵
* │ │ EQW_Layout.CALL_BTN_SCORE_NUM: attach 按钮 center/middle(+4)
* └───────────────┘
* ↑ CALL_BTN_n (1603+n-1) 按钮底(已含右上角角标底),74×56
*
* 元素说明:
* - 14 档固定为 70 65 60 55 50 45 40 / 35 30 25 20 15 10 5(行1 = 70…40,行2 = 35…5)。
* - 【按钮配色按档位位置写死,不随数据变】:
* 位置 1–4 (70/65/60/55) → 帧1 浅黄底 + 蓝角标底
* 位置 5–7 (50/45/40) → 帧2 金黄底 + 绿角标底
* 位置 8–14 (35…5) → 帧3 橙底 + 深橙角标底
* 不可叫(已被叫过 / 不高于 currcall)→ 帧4 全灰
* ⚠ 参考图里角标写的 1子/2子/4子 是填充假数据(§0.5),恰好与三组配色一一对应,
* 【不要据此以为颜色随子数变】——颜色按位置固定,角标数值才随爬坡开关变。
* 反证:常规算子下 50/45/40 与 35…5 的 multiple 同为 6 子,图里却是两种颜色。
* - 【两种数字,两种精灵】档位分数用多帧图数字精灵(带描边美术字,setNumberImage 整串一个
* 精灵,两位数不拆);角标子数用文字精灵(值域 2–15,随爬坡动态变,用文字最省)。
* - 可选性由服务端权威 currcall 决定,前端只据其置灰、不自行推导规则:
* 暂定庄家首叫 → 5–70 全可选,【无「不叫」】;其后 → 只有严格低于 currcall 的档位可选。
* - 【悲观 UI】点击后只发包(jiaofen {seat, call},不叫为 call:0),面板收起与状态文字
* 出现一律等推送包到达再做;唯一允许的乐观处理是按钮本身防连点禁用。
* - 上方提示条「70分坐庄才可投降」是静态文字,仅在面板展开时显示。
*
* 资源说明:
* - 面板底: EQW_Images.PANEL_CALL_SCORE (602), 1 帧, 575×170
* - 提示条底: EQW_Images.BAR_HINT_TOP (603), 1 帧, 345×33
* - 档位按钮底: EQW_Images.BTN_CALL_SCORE (531), 4 帧, 74×56(含角标底,见上)
* - 档位数字: EQW_Images.NUM_CALL_SCORE (635), 16 帧固定帧序 0123456789.+-x/p,
* 单字符 30×40, 整图 480×40;charWidth 取 EQW_Layout.NUM_STYLE.CALL_SCORE.charWidth
* - 不叫按钮: EQW_Images.BTN_NO_CALL (532), 1 帧, 184×57, 蓝色渐变
*
* 精灵ID分配: 1600–1646(群组 220 号段 1600–1649)
*
* @see docs_dev/二七王-UI资源与精灵清单.md §1.3 叫分坐庄 / §5.4 Layer 104 / §6.8
* @see docs_dev/uiref/自己叫分.png
* @see docs_dev/uiref/叫分按钮面板.png
* @see docs_dev/uiref/自己已经叫分轮到下家叫分.png
*/
//叫分面板:14 档位按钮 + 「不叫」按钮(清单 §5.4,Layer 104 群组 220)
EQW_Sprites.CallPanelView = {
Layer: EQW_Layers.ACTION, //104
CallPanel: {
id: EQW_Groups.CALL_PANEL, //220
CALL_PANEL_BG: 1600, //图片:面板底,资源 EQW_Images.PANEL_CALL_SCORE
CALL_HINT_BG: 1601, //图片:提示条底,资源 EQW_Images.BAR_HINT_TOP
CALL_HINT_TEXT: 1602, //文字:「70分坐庄才可投降」
//—— 14 档位按钮底(含角标底),资源 EQW_Images.BTN_CALL_SCORE,档位 70..5,
// 帧1=浅黄+蓝角标(70/65/60/55) 帧2=金黄+绿角标(50/45/40) 帧3=橙+深橙角标(35..5) 帧4=灰(不可选),
// 颜色按档位位置固定,仅因不可叫切换到灰帧4 ——
CALL_BTN_1: 1603,
CALL_BTN_2: 1604,
CALL_BTN_3: 1605,
CALL_BTN_4: 1606,
CALL_BTN_5: 1607,
CALL_BTN_6: 1608,
CALL_BTN_7: 1609,
CALL_BTN_8: 1610,
CALL_BTN_9: 1611,
CALL_BTN_10: 1612,
CALL_BTN_11: 1613,
CALL_BTN_12: 1614,
CALL_BTN_13: 1615,
CALL_BTN_14: 1616,
//—— 14 档位分数数字(多帧图数字精灵),资源 EQW_Images.NUM_CALL_SCORE(16 帧固定帧序),
// 字符宽见 EQW_Layout.NUM_STYLE.CALL_SCORE.charWidth。
// 显示用 SpriteManager.setNumberImage(spriteId, '70', charWidth):整串交给引擎按字符逐帧渲染、
// 精灵宽度自动按字符数调整,故 14 档(其中 13 档是两位数)各只需【一个】精灵,不拆十位/个位 ——
CALL_BTN_NUM_1: 1617,
CALL_BTN_NUM_2: 1618,
CALL_BTN_NUM_3: 1619,
CALL_BTN_NUM_4: 1620,
CALL_BTN_NUM_5: 1621,
CALL_BTN_NUM_6: 1622,
CALL_BTN_NUM_7: 1623,
CALL_BTN_NUM_8: 1624,
CALL_BTN_NUM_9: 1625,
CALL_BTN_NUM_10: 1626,
CALL_BTN_NUM_11: 1627,
CALL_BTN_NUM_12: 1628,
CALL_BTN_NUM_13: 1629,
CALL_BTN_NUM_14: 1630,
//—— 14 档位 multiple 子数角标文字(「2子」…「15子」),随爬坡规则开关变 ——
CALL_BTN_BADGE_1: 1631,
CALL_BTN_BADGE_2: 1632,
CALL_BTN_BADGE_3: 1633,
CALL_BTN_BADGE_4: 1634,
CALL_BTN_BADGE_5: 1635,
CALL_BTN_BADGE_6: 1636,
CALL_BTN_BADGE_7: 1637,
CALL_BTN_BADGE_8: 1638,
CALL_BTN_BADGE_9: 1639,
CALL_BTN_BADGE_10: 1640,
CALL_BTN_BADGE_11: 1641,
CALL_BTN_BADGE_12: 1642,
CALL_BTN_BADGE_13: 1643,
CALL_BTN_BADGE_14: 1644,
CALL_BTN_NONE: 1645, //图片:「不叫」按钮,资源 EQW_Images.BTN_NO_CALL
CALL_BTN_NONE_TEXT: 1646 //文字:按钮文案「不叫」
}
};
// ============================================================================
// 选主 / 投降面板 - Layer 104 (ACTION), Group 221 (CHOOSE_MAIN)
// ============================================================================
/**
* 选主 / 投降面板 - ChooseMainView
*
* 用途: 庄家上庄后弹出,让庄家在【4 个花色】与【投降】之间二选一(同一决策点、互斥)。
* 只有庄家看得到;两个闲家此时看到的是「等待庄家选主」提示条(OverlayView)。
* 收到 xuanzhu 推送后收起,进入埋牌。
*
* 布局说明(参考图 docs_dev/uiref/亮主.png):
*
* ┌─────────────────────────────────────────┐ y=315 x=330, 425×50
* │ 亮主 20 │ ← MAIN_TITLE_BG(1650) 斜角条
* └─────────────────────────────────────────┘ y=365 +MAIN_TITLE_TEXT(1651)「亮主」
* ↑ ↑
* 标题文字 倒计时 CD_SELF(属 CountdownView,不在本群组)
*
* ┌─────────┐┌─────────┐┌─────────┐┌─────────┐┌─────────┐ y=373 x=330 起
* │ ♠ 2对 ││ ♥ 2对 ││ ♣ 2对 ││ ♦ 2对 ││ 投 降 │ 128×55,间距 9
* │ 6张 ││ 8张 ││ 6张 ││ 7张 ││ │
* └─────────┘└─────────┘└─────────┘└─────────┘└─────────┘ y=428
* ↑ ↑ ↑ ↑
* │ │ └ MAIN_SUIT_BTN_2(1653) … └ MAIN_BTN_SURRENDER(1672)
* │ └ MAIN_SUIT_PAIR_TEXT_n(1668+)「N对」文字精灵,叠在按钮帧内的角标底上
* │ EQW_Layout.MAIN_SUIT_PAIR_BADGE: attach 按钮 topRight(-36,+2), 34×18
* └ MAIN_SUIT_BTN_1(1652):一个精灵一帧就含「按钮底 + 花色图标 + 右上角蓝色角标底」
* 下方 MAIN_SUIT_COUNT_n(1674+)「N张」多帧图数字精灵
* EQW_Layout.MAIN_SUIT_COUNT: attach 按钮 center/bottom(-6)
*
* 排布 EQW_Layout.MAIN_SUIT_LINE: line/horizontal, anchor='left',
* anchorX=330 anchorY=373, itemWidth=128 itemHeight=55, spacing=9
* —— anchor 取 left 是有意的:投降按钮不显示时只排前 4 项,前 4 个位置保持不变。
*
* 元素说明:
* - 花色按钮帧 = 花色序号 1–4,按【服务端 flower 编号】:帧1=方块♦ 帧2=梅花♣
* 帧3=红心♥ 帧4=黑桃♠。⚠ 参考图上 4 个按钮自左向右显示为 ♠♥♣♦,与 flower 编号相反;
* 槽位与帧的对应由渲染代码决定,本文件只登记 4 个精灵、不预设槽位顺序。
* - 「N对」= 该花色的对子数;「N张」= 该花色在庄家手中的总张数(两副牌单花色最多 26 张)。
* 两者都【由前端据自己的 cards 本地统计】,协议明确服务端不额外下发。
* - 「N张」用 SpriteManager.setNumberImage(spriteId, '26张', charWidth) 在【一个】精灵上
* 显示整串,后缀「张」= 资源第 16 帧;不拆十位/个位(2026-08-26 定案,见下方空号说明)。
* - 投降按钮仅 touxiang === 1(即叫分 70)时显示;点它直接跳到结算,不选主、不埋牌、不出牌。
* - 交互:点花色 → xuanzhu { seat, flower };点投降 → touxiang { seat }。收包后才收面板。
* - 【时序】收到 xuanzhu 推送后:顶部信息条「主」列显示花色图标 → 手牌按新主牌集合重排
* (正2/正7 升格为主牌)→ 本面板收起 → 进入埋牌。
*
* 资源说明:
* - 标题斜角条: EQW_Images.BAR_CHOOSE_MAIN (604), 1 帧, 425×50
* - 花色/投降按钮: EQW_Images.BTN_SUIT_CHOOSE (533), 5 帧, 128×55
* 帧1–4 = 四花色按钮(各含按钮底 + 花色图标 + 右上角蓝色角标底);帧5 = 投降按钮(含文字)
* - 张数数字: EQW_Images.NUM_MAIN_SUIT_COUNT (636), 16 帧固定帧序, 单字符 24×32,
* 整图 384×32, 第 16 帧 = 后缀「张」;charWidth 取 NUM_STYLE.MAIN_SUIT_COUNT.charWidth
*
* 精灵ID分配: 1650–1655 / 1668–1672 / 1674–1677(群组 221 号段 1650–1699)
* —— 中间的空号是美术改一张图 5 帧出图后作废的键,逐条追溯见下方原注释。
*
* @see docs_dev/二七王-UI资源与精灵清单.md §1.5 选主·投降 / §5.4 / §6.8
* @see docs_dev/uiref/亮主.png
*/
//选主/投降面板:4 花色按钮 + 投降按钮(清单 §5.4,Layer 104 群组 221)
//
//以下键号在源清单中被显式判定「不需要」,本文件不占用,留空号以便追溯(美术改一张图 5 帧出图,
//花色图标与右上角角标底已并入 BTN_SUIT_CHOOSE 各帧,2026-08-26):
// 1656–1659 MAIN_SUIT_ICON_1..4 —— 花色图标并入 BTN_SUIT_CHOOSE 帧1–4
// 1660–1663 MAIN_SUIT_COUNT_1..4 —— 原文字精灵,改为下方数字图片精灵(键名沿用,ID 改到 1674–1677)
// 1664–1667 MAIN_SUIT_PAIR_BG_1..4 —— 角标底并入 BTN_SUIT_CHOOSE 帧1–4
// 1673 MAIN_BTN_SURRENDER_TEXT —— 「投降」二字并入 BTN_SUIT_CHOOSE 帧5
// 1678–1681 原 MAIN_SUIT_COUNT_3/4_TENS/ONES —— 张数改回「一个花色一个精灵」(多帧图数字,见下:
// SpriteManager.setNumberImage 一个精灵就能显示整串),上一轮的十位/个位拆分作废,
// 空出的 4 个 ID 不复用(2026-08-26)
EQW_Sprites.ChooseMainView = {
Layer: EQW_Layers.ACTION, //104
ChooseMain: {
id: EQW_Groups.CHOOSE_MAIN, //221
MAIN_TITLE_BG: 1650, //图片:「亮主」斜角条,资源 EQW_Images.BAR_CHOOSE_MAIN
MAIN_TITLE_TEXT: 1651, //文字:「亮主」
//—— 4 个花色按钮,资源 EQW_Images.BTN_SUIT_CHOOSE,帧 = 花色序号 1–4(服务端 flower 编号),
// 该帧已含「按钮底 + 花色图标 + 右上角蓝色角标底」——
MAIN_SUIT_BTN_1: 1652,
MAIN_SUIT_BTN_2: 1653,
MAIN_SUIT_BTN_3: 1654,
MAIN_SUIT_BTN_4: 1655,
//—— 4 个对数角标文字:「N对」,叠在对应花色按钮的角标底之上 ——
MAIN_SUIT_PAIR_TEXT_1: 1668,
MAIN_SUIT_PAIR_TEXT_2: 1669,
MAIN_SUIT_PAIR_TEXT_3: 1670,
MAIN_SUIT_PAIR_TEXT_4: 1671,
//—— 4 个花色张数数字精灵(多帧图数字精灵,一花色一个):该花色在庄家手中的张数
// (两副牌单花色最多 26 张,两位数),资源 EQW_Images.NUM_MAIN_SUIT_COUNT(16 帧固定帧序,
// 第16帧=后缀「张」),字符宽见 EQW_Layout.NUM_STYLE.MAIN_SUIT_COUNT.charWidth。
// 显示用 SpriteManager.setNumberImage(spriteId, '26张', charWidth),整串一个精灵搞定,
// 不拆十位/个位(2026-08-26)——
MAIN_SUIT_COUNT_1: 1674,
MAIN_SUIT_COUNT_2: 1675,
MAIN_SUIT_COUNT_3: 1676,
MAIN_SUIT_COUNT_4: 1677,
MAIN_BTN_SURRENDER: 1672 //图片:「投降」按钮,资源 EQW_Images.BTN_SUIT_CHOOSE 帧5(已含底与「投降」字)
}
};
// ============================================================================
// 阶段操作条 - Layer 104 (ACTION), Group 222 (BURY_BAR) / 223 (PLAY_BAR)
// ============================================================================
/**
* 埋牌 / 出牌操作条 - OperationBarView
*
* 用途: 自己该行动时,在手牌区【上方】横排一条操作按钮。两条操作条按阶段【互斥】出现:
* 埋牌条只有庄家在埋牌阶段看得到,出牌条在出牌阶段轮到自己时出现。
* 两条都把倒计时 CD_SELF 排在自己的 items 里,故倒计时随操作条一起移动。
*
* 布局说明(参考图 docs_dev/uiref/庄家埋牌.png = 埋牌条;docs_dev/uiref/出牌.png = 出牌条):
*
* 埋牌操作条(群组 222,EQW_Layout.BURY_OPERATION_BAR)
* line/horizontal, anchor center, anchorX=640 anchorY=165, itemHeight=63, spacing=45
* items 逐项不等宽:BURY_BTN_TIP 180 / CD_SELF 60 / BURY_BTN_SUBMIT 180
*
* ┌────────────────┐ ┌────────────────┐ y=165
* │ 提示 │ 12 │ 埋牌 (1/8) │
* └────────────────┘ ↑ └────────────────┘ y=228
* ↑ CD_SELF ↑
* BURY_BTN_TIP(1700) BURY_BTN_SUBMIT(1702)
* +_TEXT(1701) +_TEXT(1703)「埋牌 (N/8)」
* 帧1=可提交(金黄) / 帧2=灰(未满 8 张)
*
* ┌── 手牌【双排】(Layer 102,见 Sprites_Cards.js HandView)──┐ y=285 起
*
* 出牌操作条(群组 223,EQW_Layout.PLAY_OPERATION_BAR)
* line/horizontal, anchor center, anchorX=640 anchorY=345, itemHeight=55, spacing=35
* items:CD_SELF 75 / PLAY_BTN_SUBMIT 157 / PLAY_BTN_TIP 180【估值】
*
* 20 ┌──────────────┐ ┌────────────────┐ y=345
* ↑ │ 出 牌 │ │ 提示 │
* CD_SELF └──────────────┘ └────────────────┘ y=400
* ↑ PLAY_BTN_SUBMIT(1710)+_TEXT(1711) ↑ PLAY_BTN_TIP(1712)
* 帧1=可出 / 帧2=灰(选中牌数为 0 时) +_TEXT(1713)
*
* ┌── 手牌【单排】(Layer 102)──┐ y=455 起
*
* 元素说明:
* - 埋牌条:N 为已选张数,N < 8 时提交按钮置灰;选中的牌在手牌区上浮表示待埋。
* 提交后庄家手牌回到单排 28 张。
* - 出牌条:点手牌选中(上浮)→ 点「出牌」→ 发 chupai { seat, cards }。
* 【只发意图不发结论】——不回传牌型、不回传「我最大」之类判定;前端可用 shared/ 做本地
* 预校验与提示,结果不回传。
* - 【自动选中】服务端会在下发包里带「应当选中的牌」(mustcard, S-2),前端收到后自动把这些
* 牌选中上浮,玩家确认后点「出牌」即可——方向是服务端→前端的建议,不违反红线。
* - 【2026-08-27 补齐】出牌阶段的「提示」按钮 PLAY_BTN_TIP(1712)/PLAY_BTN_TIP_TEXT(1713)
* (清单 §1.7 明确「出牌阶段必须有」)此前只登记了精灵、没有布局槽位——
* EQW_Layout.PLAY_OPERATION_BAR 的 items 只排了倒计时与出牌两项(清单 §6.8 自身也漏了它)。
* 现已按 §1.7 的行文顺序补为第 3 项,宽度取 BTN_TIP 资源原生宽 180【估值,待设计稿复核】:
* 参考图 出牌.png 里未画出该按钮,位置仍属目视推定。
*
* 资源说明:
* - 提示按钮: EQW_Images.BTN_TIP (535), 1 帧, 180×63, 蓝色渐变(埋牌/出牌两条共用同一资源)
* - 埋牌按钮: EQW_Images.BTN_BURY (536), 2 帧, 180×63, 帧1=可提交 帧2=置灰
* - 出牌按钮: EQW_Images.BTN_PLAY (537), 2 帧, 157×55, 帧1=可出 帧2=置灰
*
* 精灵ID分配: 1700–1703(埋牌条)/ 1710–1713(出牌条)
*
* @see docs_dev/二七王-UI资源与精灵清单.md §1.6 埋牌 / §1.7 出牌对局 / §5.4 / §6.8
* @see docs_dev/uiref/庄家埋牌.png
* @see docs_dev/uiref/出牌.png
*/
//操作条:埋牌操作条(群组 222)+ 出牌操作条(群组 223),同一屏幕位置随阶段互斥展示(清单 §5.4)
EQW_Sprites.OperationBarView = {
Layer: EQW_Layers.ACTION, //104
BuryBar: {
id: EQW_Groups.BURY_BAR, //222
BURY_BTN_TIP: 1700, //图片:「提示」按钮,资源 EQW_Images.BTN_TIP
BURY_BTN_TIP_TEXT: 1701, //文字:按钮文案「提示」
BURY_BTN_SUBMIT: 1702, //图片:「埋牌」按钮,资源 EQW_Images.BTN_BURY,帧1=可提交 帧2=灰(未满8张)
BURY_BTN_SUBMIT_TEXT: 1703 //文字:「埋牌 (N/8)」
},
PlayBar: {
id: EQW_Groups.PLAY_BAR, //223
PLAY_BTN_SUBMIT: 1710, //图片:「出牌」按钮,资源 EQW_Images.BTN_PLAY,帧1=可出 帧2=灰
PLAY_BTN_SUBMIT_TEXT: 1711, //文字:按钮文案「出牌」
PLAY_BTN_TIP: 1712, //图片:「提示」按钮(出牌阶段必备),资源 EQW_Images.BTN_TIP
PLAY_BTN_TIP_TEXT: 1713 //文字:按钮文案「提示」
}
};
// ============================================================================
// 倒计时 - Layer 104 (ACTION), Group 224 (COUNTDOWN)
// ============================================================================
/**
* 倒计时 - CountdownView
*
* 用途: 把当前操作者剩余的秒数显示在【他自己的位置旁】。三家各一个精灵,
* 按当前操作者显隐其一——所以看到数字在哪儿,就知道在等谁。
*
* 布局说明(参考图:等待叫分.png 的「12」在左上家、出牌2.png 的「15」在左上家、
* 自己叫分.png 的「12」在不叫按钮左侧、庄家埋牌.png 的「12」在操作条中间):
*
* ┌──────────────────────────────────────────────────────────────┐ y=0
* │ ┌ 顶部信息条 ┐ │
* │ ┌────┐ ┌────┐ │
* │ │头像│ 12 15 │头像│ │ y≈165
* │ └────┘ ↑ ↑ └────┘ │
* │ CD_LEFT(1150) CD_RIGHT(1151) │
* │ point(195,165) point(1010,165) │
* │ │
* │ 20 ┌──────────┐ │ y≈345
* │ ↑ │ 出 牌 │ │
* │ CD_SELF └──────────┘ │
* │ (1152) │
* └──────────────────────────────────────────────────────────────┘ y=720
*
* ⚠ 左右两家的倒计时与该家的状态文字(P_LEFT/RIGHT_STATUS_TEXT,见 Sprites_Table.js)
* 落在【同一块区域】:轮到他时显示秒数,他表态后显示「60分」/「不叫」,两者不共存。
*
* CD_SELF 一个精灵、三处落点,由当前阶段的布局节点决定:
* 叫分阶段 → EQW_Layout.P_COUNTDOWN.SELF = point(445, 345)(不叫按钮左侧)
* 埋牌阶段 → EQW_Layout.BURY_OPERATION_BAR 的 items 第 2 项(提示与埋牌之间,y=165)
* 出牌阶段 → EQW_Layout.PLAY_OPERATION_BAR 的 items 第 1 项(出牌按钮左侧,y=345)
* 选主阶段 → 参考图 亮主.png 显示在「亮主」标题条内、标题文字右侧(布局节点待补)
*
* 元素说明:
* - 三个精灵均为【多帧图数字精灵】而非文字精灵(2026-08-27 订正):玩家视线焦点、视觉权重
* 最高的数字,参考图是带描边/渐变的美术字,文字精灵做不出这个效果。
* 显示走 SpriteManager.setNumberImage(spriteId, '15', charWidth),纯秒数、无后缀。
* - 布局节点 EQW_Layout.P_COUNTDOWN 的 h 恒取 NUM_STYLE.COUNTDOWN.charHeight(SSOT),
* w 留到运行时注入——setNumberImage 会按【字符数 × charWidth】自动改精灵宽。
* - 数据来源:各包的 countdown 字段。
* - 【只做展示,不触发任何操作】倒计时归零后不自动叫分/选主/埋牌/出牌,也不判负、不跳过;
* 前端不得在归零时做任何乐观界面推进(不跳阶段、不清控制权),一切仍等服务端推送。
* 超时裁定在服务端,前端只做插值显示。
* - 【归零后停在 0 继续显示,不隐藏】——隐藏会被误读为「已超时 / 已跳过」。
*
* 资源说明:
* - 数字: EQW_Images.NUM_COUNTDOWN (631), 16 帧固定帧序 0123456789.+-x/p,
* 单字符 44×60, 整图 704×60;帧11–15 本套用不到但必须留占位,帧16 出空白(无后缀)
* - charWidth / charHeight 只在 EQW_Layout.NUM_STYLE.COUNTDOWN 定义一处,业务代码只引用
*
* 精灵ID分配: 1150–1152(群组 224 号段 1150–1159)
*
* @see docs_dev/二七王-UI资源与精灵清单.md §2.4 倒计时 / §5.4 / §6.8
* @see docs_dev/uiref/等待叫分.png
* @see docs_dev/uiref/自己叫分.png
* @see docs_dev/uiref/庄家埋牌.png
*/
//倒计时:左上家/右上家/自己 三处操作区倒计时(清单 §5.4,Layer 104 群组 224)
//
//三处均为【多帧图数字精灵】(非文字精灵):玩家视线焦点、视觉权重最高的数字,参考图是带描边/渐变
//的美术字,文字精灵做不出这个效果。资源 EQW_Images.NUM_COUNTDOWN(16 帧固定帧序),纯秒数无后缀。
//显示用 SpriteManager.setNumberImage(spriteId, '15', charWidth),charWidth 见
//EQW_Layout.NUM_STYLE.COUNTDOWN.charWidth(2026-08-27,与 NUM_STYLE.COUNTDOWN 一起落地)
EQW_Sprites.CountdownView = {
Layer: EQW_Layers.ACTION, //104
Countdown: {
id: EQW_Groups.COUNTDOWN, //224
CD_LEFT: 1150, //多帧图数字精灵:左上家位倒计时
CD_RIGHT: 1151, //多帧图数字精灵:右上家位倒计时
CD_SELF: 1152 //多帧图数字精灵:自己操作区倒计时
}
};
// ============================================================================
// 桌面浮层 - Layer 105 (OVERLAY), Group 230 (OVERLAY)
// ============================================================================
/**
* 桌面浮层 - OverlayView
*
* 用途: 盖在牌桌之上的一次性/短时表现层,三类互不相干的东西共用一个群组:
* (1) 闲家提示气泡 (2) 捡分飘字 (3) 通用状态提示条。
* 都是「浮」在桌面上、不属于任何固定面板的元素,故集中在 Layer 105。
*
* 布局说明(参考图:自己是闲家时,底下的踩有分没分按钮.png = 气泡入口;
* 出牌2.png 中央的「20分」= 捡分飘字;强甩失败提示.png / 等待庄家选主.png = 提示条):
*
* ┌──────────────────────────────────────────────────────────────┐ y=0
* │ ┌ 顶部信息条 ┐ │
* │ ┌────┐ ┌────┐ │
* │ │头像│(踩) (踩)│头像│ │ y≈122
* │ └────┘ ↑ ↑ └────┘ │
* │ BUBBLE_TISHI_LEFT(1750) BUBBLE_TISHI_RIGHT(1751) │
* │ 箭头向左(帧1–3) 箭头向右(帧4–6) │
* │ │
* │ ┌────────────────────────────────┐ │ y=172
* │ │ 强 甩 失 败 │ ← TOAST_INSTANT│ 462,363×50
* │ └────────────────────────────────┘ │
* │ ┌──────────────┐ │ y=180
* │ │ 20分 │ ← GRADE_FLOAT │ 570,170×55
* │ └──────────────┘ │
* │ │
* │ ┌────────────────────────────┐ │ y=373
* │ │ 等待庄家选主 │ ← TOAST_WAIT │ 500,320×47
* │ └────────────────────────────┘ │
* └──────────────────────────────────────────────────────────────┘ y=720
*
* (1) 提示气泡 BUBBLE_TISHI_LEFT/RIGHT/SELF (1750–1752)
* EQW_Layout.TISHI_BUBBLE, attach 平台头像框, 96×44(尺寸为占位估值,资源待定稿):
* LEFT → attach P_LEFT_AVATAR(379) right/middle, offsetX=+10, arrowRight=false
* RIGHT → attach P_RIGHT_AVATAR(377) left/middle, offsetX=-10, arrowRight=true
* SELF → attach P_SELF_AVATAR(376) right/top, offset(+10,-70), arrowRight=false
* 帧 = (arrowRight ? 3 : 0) + tip,tip:1踩 2没分 3有分。
* 【朝向是配置项 arrowRight,不按座位硬判】——头像在气泡左侧就用箭头向左的帧。
* (2) 捡分飘字 GRADE_FLOAT_BG(1755) + GRADE_FLOAT_TEXT(1756)
* EQW_Layout.GRADE_FLOAT = point(570, 180, 170×55) 为【起始】位置;
* 上飘与时长见 EQW_Anim.SCORE_FLOAT = { rise: -60, duration: 800 }(SSOT,布局里不重复)。
* (3) 通用状态提示条 BAR_TOAST(1760) + TXT_TOAST(1761)
* 整局【复用同一套精灵】,只换文案与位置预设,两个预设二选一:
* 等待类(常驻到状态结束)→ EQW_Layout.TOAST_WAIT = point(500, 373, 320×47)
* 即时反馈类(自动淡出) → EQW_Layout.TOAST_INSTANT = point(462, 172, 363×50)
* 自动消失时长见 EQW_Anim.TOAST_DURATION = 1500(SSOT)。
*
* 元素说明:
* - 【提示条文案共 3 条,T-33 已定案】:
* 「等待庄家选主」 状态类,两个闲家可见,收 shangzhuang 后 → 收 xuanzhu 前
* 「等待庄家埋牌」 状态类,两个闲家可见,收 xuanzhu 后 → 收 maipai 前
* 「强甩失败」 即时反馈,全场可见,收到 chupai1.shuaicuo == 1 时,自动淡出
* 明确【不做】:叫分等待 / 出牌等待(倒计时已指明在等谁)、准备等待(平台自带标识)、
* 掉线重连提示(平台已覆盖)、开底提示(画面本身已说清楚)。文案表进配置,不写死字符串。
* - 【甩错 D-3 已定】不做收回动画——甩出的牌不演「飞回手里」,直接按服务端下发的 cards
* (被强制打出的最小主牌单张)落牌即可,另配一条「强甩失败」提示。
* - 【气泡的受控例外】服务端只把提示转发给对家、不回执给发送者,所以发送者【自己的气泡由
* 前端在点击时本地回显】。这是「悲观 UI」红线的一处受控例外,成立理由是提示不含任何对局
* 状态、不改阶段/控制权/分数,服务端也明确不校验其真实性;失败回包到达时须撤掉本地回显。
* 本人看不到自己的气泡(BUBBLE_TISHI_SELF 是给对家看的,见清单 §6.6)。
* - 【报无主 D-4 已定】不做全场提示,唯一界面变化是他家的主N/对N 角标出现(PlayerMarkView)。
*
* 资源说明:
* - 气泡: EQW_Images.BUBBLE_TISHI (651), 6 帧 (3×2), 尺寸待定
* 帧1=踩·左 帧2=没分·左 帧3=有分·左 / 帧4=踩·右 帧5=没分·右 帧6=有分·右
* - 提示条底: EQW_Images.BAR_TOAST (653), 1 帧, 363×50, 可九宫拉伸(按文案长度变宽)
* - 捡分飘字底: EQW_Images.TXT_GRADE_FLOAT (654), 1 帧, 尺寸待定
*
* 精灵ID分配: 1750–1752 / 1755–1756 / 1760–1761(群组 230 号段 1750–1799)
* —— 中间空号(1753/1754/1757/1758/1759)是清单显式判定「不需要」的键,见下方原注释。
*
* @see docs_dev/二七王-UI资源与精灵清单.md §2.8 状态提示条 / §1.7 闲家提示·甩错 / §5.5 / §6.8
* @see docs_dev/uiref/自己是闲家时,底下的踩有分没分按钮.png
* @see docs_dev/uiref/强甩失败提示.png
* @see docs_dev/uiref/等待庄家选主.png
* @see docs_dev/uiref/出牌2.png
*/
//桌面浮层:提示气泡 + 通用状态提示条 + 捡分飘字(清单 §5.5,Layer 105 群组 230)
//
//以下键号在源清单中被显式判定「不需要」,本文件不占用,留空号以便追溯:
// 1753 TXT_BAOZHU —— 报无主不做全场提示(D-4),只让他家 主N/对N 角标出现
// 1754/1759 BAR_SHUAICUO —— 甩错提示并入通用状态提示条 BAR_TOAST/TXT_TOAST(§2.8)
// 1757/1758 BOTTOM_3S_* —— 70分底牌亮3秒复用底牌区翻牌(D-2),仅可见范围不同,不单独建精灵
EQW_Sprites.OverlayView = {
Layer: EQW_Layers.OVERLAY, //105
Overlay: {
id: EQW_Groups.OVERLAY, //230
//—— 提示气泡:踩/没分/有分,按发送者位置三选一显示,资源 EQW_Images.BUBBLE_TISHI,
// 帧 = (arrowRight ? 3 : 0) + tip(tip:1踩 2没分 3有分);朝向为配置项 arrowRight,非按座位硬判(清单 §6.6)——
BUBBLE_TISHI_LEFT: 1750, //左上家气泡:头像在气泡左侧,箭头向左,帧1–3,attach 头像框 hAlign:right
BUBBLE_TISHI_RIGHT: 1751, //右上家气泡:头像在气泡右侧,箭头向右,帧4–6,attach 头像框 hAlign:left
BUBBLE_TISHI_SELF: 1752, //自己气泡:头像在气泡左侧,箭头向左,帧1–3(本人看不到自己气泡,仅对家可见,见清单 §6.6)
GRADE_FLOAT_BG: 1755, //图片:捡分飘字底,资源 EQW_Images.TXT_GRADE_FLOAT
GRADE_FLOAT_TEXT: 1756, //文字:「20分」
BAR_TOAST: 1760, //图片:通用状态提示条底(等待类/即时反馈类共用,收敛为 3 条,见清单 §2.8),资源 EQW_Images.BAR_TOAST,可九宫拉伸
TXT_TOAST: 1761 //文字:提示文案
}
};
@@ -0,0 +1,496 @@
///////////////////////////////////////////////////////////////
////////// EQW_Sprites: 精灵结构(清单 §5.2/§5.3 手牌与桌面牌)//
///////////////////////////////////////////////////////////////
// 子游戏精灵段 1001–2999。结构与 Sprites_Table.js 一致:
// View 下 Layer 为图层声明(一个 View 恰好一个图层),其余键是 group 容器;
// 容器内 id 为群组 ID、其余键才是精灵 id。
// 连号精灵逐条写全,不用循环生成——便于人逐张对照编辑器核对。
var EQW_Sprites = EQW_Sprites || {};
// ============================================================================
// 自己手牌区 - Layer 102 (HAND), Group 210 (HAND)
// ============================================================================
/**
* 自己手牌区 - HandView
*
* 用途: 屏幕下部铺开自己的手牌(重叠排列),并在每张牌上叠加牌型标记。
* 两种形态:常态【单排】、庄家埋牌时【双排】。张数上限 36(庄家摸底后 28+8)。
*
* 布局说明(参考图 docs_dev/uiref/等待叫分.png = 单排;
* docs_dev/uiref/庄家埋牌.png = 双排):
*
* 形态一 · 单排(叫分 / 选主 / 出牌阶段,EQW_Layout.HAND_FAN_SINGLE)
* fan/horizontal, anchor center, anchorX=640 anchorY=455, maxWidth=1215,
* 牌尺寸 CARD_SIZE.L = 110×190, spacingMax=0 spacingMin=-78, overlapFrom='left'
*
* x≈35 x≈1250
* ┌──┬──┬──┬──┬──┬──┬──┬──┬── … ──┬──┬──┬──────────┐ y=455
* │K │K │K │K │K │K │K │K │ │K │K │ K │
* │♦ │♦ │♦ │♦ │♦ │♦ │♦ │♦ │ …… │♦ │♦ │ ♦ │
* └──┴──┴──┴──┴──┴──┴──┴──┴── … ──┴──┴──┴──────────┘ y=645
* ↑ HAND_CARD_1(1200) 起自左,逐张右叠;末张整张露出(110px)
* 36 张时每张只露约 32px(spacing -78),与参考图实测 33px 吻合
*
* 形态二 · 双排(仅庄家埋牌阶段,EQW_Layout.HAND_TWO_ROW)
* splitStrategy='floorTop':上排 floor(n/2)、下排 ceil(n/2)(36 张 = 18/18)
* ROW_TOP anchorX=644 anchorY=285 maxWidth=928 spacingMin=-65
* ROW_BOTTOM anchorX=644 anchorY=455 maxWidth=928 spacingMin=-65
*
* ┌──┬──┬──┬── … ──┬──┬──────┐ y=285 ← ROW_TOP
* │K │K │K │ …… │K │ K │
* └──┴──┴──┴── … ──┴──┴──────┘ y=455
* ┌──┬──┬──┬── … ──┬──┬──────┐ y=455 ← ROW_BOTTOM
* │K │K │K │ …… │K │ K │
* └──┴──┴──┴── … ──┴──┴──────┘ y=645
* ⚠ 参考图画的是 17/19 分行,属填充画法(清单 §0.5),不作依据;分行以 floorTop 为准。
*
* 选中态: 整张牌上浮,偏移量 EQW_Anim.HAND_SELECT_OFFSET_Y = -40(SSOT,布局里不重复定义)。
*
* 元素说明:
* - HAND_CARD_1..36 与 HAND_MARK_1..36 【一一对应】(HAND_MARK_i 属于 HAND_CARD_i),
* 同属群组 210;张数少于 36 时隐藏多余精灵并重算间距。
* - 牌面帧 = cardIdToFrame(id)(清单 §0.4 唯一权威转换,前端只此一处实现)。
* - 手牌标记三种互斥、每张牌至多一个,由前端据 flower(主牌花色)本地标注,服务端不下发:
* 帧1 =「拖」红色圆标 —— 该牌属于一组拖拉机
* 帧2 = 橙色五角星 —— 正 2 / 正 7(主花色的 2 和 7)
* 帧3 = 蓝色五角星 —— 除正2正7外的其他主牌(双王、副2、副7、主花色普通牌)
* 在参考图 庄家埋牌.png 下排牌的左下角可看到这三种标记。
* - 【时序】发牌动画从右往左铺开(EQW_Anim.DEAL: 800ms / 每张 20ms 延迟);须先
* setHandCards() 把牌写进 this.data、再播动画,动画回调里只刷界面不写核心数据。
* - 【时序】选主后主牌集合会变(正2/正7 升格),手牌必须按新主牌序重排(清单 §7.2 T-5)。
*
* 资源说明:
* - 牌面: EQW_Images.CARD_FACE_L (501), 60 帧 (10列×6行行优先), 单帧 110×190, 整图 1100×1140
* 帧1–13 黑桃A–K / 帧14–26 红桃 / 帧27–39 梅花 / 帧40–52 方块 /
* 帧53 小王 / 帧54 大王 / 帧55–60 牌背(二七王用帧55)
* - 牌上标记: EQW_Images.MARK_CARD_GROUP (577), 3 帧, 26×26
* - 选中标记: EQW_Images.MARK_SELECTED (578) 尺寸待定,也可只用上浮表现、不出图
*
* 精灵ID分配: 1200–1235(手牌)+ 2100–2135(手牌标记)
* —— 标记另起 2100 段的原因见下方原注释(1236 起接不下 36 个连号)。
*
* @see docs_dev/二七王-UI资源与精灵清单.md §2.7 手牌区 / §5.2 Layer 102 / §6.7 牌区配置
* @see docs_dev/uiref/等待叫分.png
* @see docs_dev/uiref/庄家埋牌.png
*/
//自己手牌区:36 张手牌 + 36 个手牌标记(每张牌一个,见下方裁决说明),同属群组 210(清单 §5.2,Layer 102)
EQW_Sprites.HandView = {
Layer: EQW_Layers.HAND, //102
HandCards: {
id: EQW_Groups.HAND, //210
//—— 36 张手牌,资源 EQW_Images.CARD_FACE_L,帧 = cardIdToFrame(id)(§0.4 唯一权威转换)——
HAND_CARD_1: 1200,
HAND_CARD_2: 1201,
HAND_CARD_3: 1202,
HAND_CARD_4: 1203,
HAND_CARD_5: 1204,
HAND_CARD_6: 1205,
HAND_CARD_7: 1206,
HAND_CARD_8: 1207,
HAND_CARD_9: 1208,
HAND_CARD_10: 1209,
HAND_CARD_11: 1210,
HAND_CARD_12: 1211,
HAND_CARD_13: 1212,
HAND_CARD_14: 1213,
HAND_CARD_15: 1214,
HAND_CARD_16: 1215,
HAND_CARD_17: 1216,
HAND_CARD_18: 1217,
HAND_CARD_19: 1218,
HAND_CARD_20: 1219,
HAND_CARD_21: 1220,
HAND_CARD_22: 1221,
HAND_CARD_23: 1222,
HAND_CARD_24: 1223,
HAND_CARD_25: 1224,
HAND_CARD_26: 1225,
HAND_CARD_27: 1226,
HAND_CARD_28: 1227,
HAND_CARD_29: 1228,
HAND_CARD_30: 1229,
HAND_CARD_31: 1230,
HAND_CARD_32: 1231,
HAND_CARD_33: 1232,
HAND_CARD_34: 1233,
HAND_CARD_35: 1234,
HAND_CARD_36: 1235,
//—— 36 个手牌标记【T-8】,与 HAND_CARD_1..36 一一对应(HAND_MARK_i 属于 HAND_CARD_i)——
//资源 EQW_Images.MARK_CARD_GROUP,帧1=拖(拖拉机) 帧2=橙五角星(正2/正7) 帧3=蓝五角星(其他主牌)。
//【裁决:按「每张牌」而非「每组」】清单 §5.2 的表格写的是「牌型分组标记 ×8」,但 §1.6 明确
//规定三种标记互斥、【每张牌】至多一个,core/CardMark.js 也是按每张牌返回标记的。
//一手 28 张牌通常带 13 个以上标记、36 张埋牌手更多,8 个远远不够——§1.6 是行为规格,
//比 §5.2 表格的措辞更权威,故按 §1.6 扩到与手牌一一对应。
//ID 另起 2100 段:1236 往后接不下 36 个连号(1250 起已是底牌/埋牌区),
//换整段空号 2100–2135 比给别的 View 重新编号更安全。
HAND_MARK_1: 2100,
HAND_MARK_2: 2101,
HAND_MARK_3: 2102,
HAND_MARK_4: 2103,
HAND_MARK_5: 2104,
HAND_MARK_6: 2105,
HAND_MARK_7: 2106,
HAND_MARK_8: 2107,
HAND_MARK_9: 2108,
HAND_MARK_10: 2109,
HAND_MARK_11: 2110,
HAND_MARK_12: 2111,
HAND_MARK_13: 2112,
HAND_MARK_14: 2113,
HAND_MARK_15: 2114,
HAND_MARK_16: 2115,
HAND_MARK_17: 2116,
HAND_MARK_18: 2117,
HAND_MARK_19: 2118,
HAND_MARK_20: 2119,
HAND_MARK_21: 2120,
HAND_MARK_22: 2121,
HAND_MARK_23: 2122,
HAND_MARK_24: 2123,
HAND_MARK_25: 2124,
HAND_MARK_26: 2125,
HAND_MARK_27: 2126,
HAND_MARK_28: 2127,
HAND_MARK_29: 2128,
HAND_MARK_30: 2129,
HAND_MARK_31: 2130,
HAND_MARK_32: 2131,
HAND_MARK_33: 2132,
HAND_MARK_34: 2133,
HAND_MARK_35: 2134,
HAND_MARK_36: 2135
}
};
// ============================================================================
// 底牌区(发牌留桌 8 张)- Layer 103 (TABLE_CARDS), Group 211 (BOTTOM_CARDS)
// ============================================================================
/**
* 底牌区 - BottomCardsView
*
* 用途: 桌面中央上部的 8 张【底牌】——发牌时没有发给玩家、留在桌面的那 8 张。
* 发牌起显示牌背;庄家摸底 / 70 分开底时【同一批精灵】翻成正面。
*
* 布局说明(背面:docs_dev/uiref/等待叫分.png 中央;正面:docs_dev/uiref/叫分确定庄.png、
* docs_dev/uiref/等待庄家选主.png 中央;
* 位置见 EQW_Layout.BOTTOM_CARDS = line/horizontal, anchor center,
* anchorX=655 anchorY=110, 牌尺寸 CARD_SIZE.L 110×190, spacing=-36):
*
* x 居中于 655
* ┌──────┬──────┬──────┬──────┬──────┬──────┬──────┬────────────┐ y=110
* │ 背 │ 背 │ 背 │ 背 │ 背 │ 背 │ 背 │ 背 │
* │ │ │ │ │ │ │ │ │
* └──────┴──────┴──────┴──────┴──────┴──────┴──────┴────────────┘ y=300
* ↑ ↑ ↑
* BOTTOM_ BOTTOM_ BOTTOM_
* CARD_1 CARD_2 … (等距 spacing -36,每张露 74px)… CARD_8
* (1250) (1251) (1257)
*
* 翻开后同一批精灵改帧为正面(帧 = cardIdToFrame(id)):
* ┌──────┬──────┬──────┬──────┬──────┬──────┬──────┬────────────┐
* │K │K │K │K │K │K │K │K │
* │♦ │♦ │♦ │♦ │♦ │♦ │♦ │♦ │
* └──────┴──────┴──────┴──────┴──────┴──────┴──────┴────────────┘
*
* 元素说明:
* - 未揭示时帧 = 55(牌背);揭示时帧 = cardIdToFrame(id)(清单 §0.4)。
* - 【时序·开底 D-2 已定】翻牌这套 UI 只有一套,两种情形只是【谁能看到】不同:
* 非 70 分坐庄(只摸底)→ 只有庄家看得到这 3 秒,闲家侧保持背面 / 直接收起
* 70 分坐庄(开底) → 全场三人都看得到 3 秒(EQW_Anim.ANCARD_REVEAL_DURATION = 3000)
* 开底【不做倒计时、不做单独的公示面板、不加提示文案】——画面本身已说清楚(§2.8)。
* 3 秒后庄家摸入手牌、闲家侧收起,底牌区隐藏。
* - 【重连不重放】70 分的 3 秒亮牌是上庄时的一次性事件,deskinfo 重连不得补播(§1.4)。
* - 底栏「底牌」按钮再看这 8 张时也复用本区(庄家全程可看,闲家仅开底过的局,S-3)。
* - ⚠ 参考图 等待庄家选主.png 把「开底摊牌」与「等待庄家选主」提示条画在了一起,属叠画
* 示意(§0.5);实际顺序是 开底 3 秒 → 庄家摸底入手 → 选主,摸底后本区即收起。
*
* 资源说明:
* - 牌面: EQW_Images.CARD_FACE_L (501), 60 帧, 单帧 110×190(与手牌同一套资源)
*
* 精灵ID分配: 1250–1257
*
* @see docs_dev/二七王-UI资源与精灵清单.md §1.2 发牌 / §1.4 看底牌·上庄 / §5.3 / §6.7
* @see docs_dev/uiref/等待叫分.png
* @see docs_dev/uiref/叫分确定庄.png
* @see docs_dev/uiref/等待庄家选主.png
*/
//底牌区:发牌留桌 8 张(清单 §5.3,Layer 103 群组 211)
EQW_Sprites.BottomCardsView = {
Layer: EQW_Layers.TABLE_CARDS, //103
BottomCards: {
id: EQW_Groups.BOTTOM_CARDS, //211
//—— 资源 EQW_Images.CARD_FACE_L;未揭示时帧=55(牌背),70分公示/庄家查看时翻正面(帧=cardIdToFrame(id))——
BOTTOM_CARD_1: 1250,
BOTTOM_CARD_2: 1251,
BOTTOM_CARD_3: 1252,
BOTTOM_CARD_4: 1253,
BOTTOM_CARD_5: 1254,
BOTTOM_CARD_6: 1255,
BOTTOM_CARD_7: 1256,
BOTTOM_CARD_8: 1257
}
};
// ============================================================================
// 埋牌底牌展示区 - Layer 103 (TABLE_CARDS), Group 215 (BURY_CARDS)
// ============================================================================
/**
* 埋牌底牌展示区 - BuryCardsView
*
* 用途: 小局结算时亮出【埋牌底牌】——庄家埋牌阶段从手里扣下的那 8 张。
* 用最小的牌面尺寸档 S,摆在小局结算面板的上半部。
*
* 术语区分(清单 §2.6 已统一,别混):
* 「底牌」 = 发牌留桌的 8 张 → BottomCardsView(群组 211),协议字段 bottomcards
* 「埋牌底牌」= 庄家埋下的 8 张 → 本 View(群组 215),协议字段 burycards / bottom.cards
*
* 布局说明(参考图 docs_dev/uiref/小局结算.png 中央面板内那排小牌;
* 位置见 EQW_Layout.BURY_CARDS = line/horizontal, anchor center,
* anchorX=640 anchorY=163, 牌尺寸 CARD_SIZE.S 50×70, spacing=2):
*
* ┌──────────────────────────────────────────┐ y=140 ← RESULT_BOTTOM_PANEL(1800)
* │ ┌──┐┌──┐┌──┐┌──┐┌──┐┌──┐┌──┐┌──┐ │ y=163 (见 Sprites_Result.js)
* │ │10││10││10││10││10││10││10││10│ │
* │ │♠ ││♥ ││♣ ││♦ ││♠ ││♥ ││♣ ││♦ │ │ y=233
* │ └──┘└──┘└──┘└──┘└──┘└──┘└──┘└──┘ │
* │ 叫分2子 底牌 20x0 │ ← RESULT_BOTTOM_TEXT(1801)
* └──────────────────────────────────────────┘ y=288
* ↑ ↑ ↑
* BURY_ BURY_ BURY_
* CARD_1 CARD_2 …(等距 spacing +2,不重叠)… CARD_8
* (1258) (1259) (1265)
*
* 本区居中于 x=640,与外层面板(EQW_Layout.RESULT_BOTTOM_PANEL = 430,140,420×148)
* 的中心 x=640 对齐;8×50 + 7×2 = 414,正好落在面板 420 宽度之内。
*
* 元素说明:
* - 数据取结算包 bottom.cards,【恒有】(design §11:牌局结束需要亮出底牌)——
* 即便闲家未扣底也照常亮 8 张,只是面板文案里的倍数位显示 0。
* - 帧 = cardIdToFrame(id),恒为正面(本区只在结算时出现,不存在牌背态)。
* - 【容错】投降结算只有 aset、无 bottom 分组 → 结算面板必须容忍底牌区缺失,
* 不能因读不到 bottom.cards 就崩或显示空框(§1.8)。
* - 底栏「扣底」按钮(清单 §2.6,尚无精灵)看的也是这 8 张。
*
* 资源说明:
* - 牌面: EQW_Images.CARD_FACE_S (503), 60 帧, 单帧 50×70, 整图 500×420
* 帧排布与 CARD_FACE_L 完全一致(§0.4)
*
* 精灵ID分配: 1258–1265
*
* @see docs_dev/二七王-UI资源与精灵清单.md §1.8 末轮扣底 + 小局结算 / §5.3 / §6.7
* @see docs_dev/uiref/小局结算.png
*/
//埋牌底牌展示区:结算时亮出的埋牌底牌 8 张(清单 §5.3,Layer 103 群组 215)
EQW_Sprites.BuryCardsView = {
Layer: EQW_Layers.TABLE_CARDS, //103
BuryCards: {
id: EQW_Groups.BURY_CARDS, //215
//—— 资源 EQW_Images.CARD_FACE_S,帧 = cardIdToFrame(id)——
BURY_CARD_1: 1258,
BURY_CARD_2: 1259,
BURY_CARD_3: 1260,
BURY_CARD_4: 1261,
BURY_CARD_5: 1262,
BURY_CARD_6: 1263,
BURY_CARD_7: 1264,
BURY_CARD_8: 1265
}
};
// ============================================================================
// 三家已出牌区 - Layer 103 (TABLE_CARDS), Group 212 / 213 / 214
// ============================================================================
/**
* 三家已出牌区 - PlayAreaView
*
* 用途: 出牌阶段,三家各有一块区域展示【本轮】打出的牌(中号牌面 CARD_FACE_M)。
* 每家一个群组、各预置 28 张(= 单次出牌张数上限,甩牌最多可甩满手牌)。
*
* 布局说明(参考图 docs_dev/uiref/出牌2.png;
* 位置见 EQW_Layout.PLAY_AREA,三家共用 base + bySeat 三个变体:
* fan/horizontal, 牌尺寸 CARD_SIZE.M 90×155, maxWidth=170,
* spacingMax=-35 spacingMin=-62):
*
* ┌──────────────────────────────────────────────────────────────┐ y=0
* │ ┌ 顶部信息条 ┐ │
* │ ┌────┐ ┌────┐ ┌────┐ ┌────┐ │
* │ │头像│ │K K │ │K K │ │头像│ │ y≈120
* │ └────┘ │ 大│ │ 大│ └────┘ │
* │ └────┘ └────┘ │
* │ LEFT 258,120 RIGHT 1022,120 │
* │ overlapFrom='right' overlapFrom='left' │
* │ (左右镜像:同参数,堆叠朝向相反) │
* │ │
* │ ┌────┐ │
* │ │K K │ ← SELF 650,265 │ y≈265
* │ │ 庄│ overlapFrom='left' │
* │ ┌──┴────┘ │
* │ │拖拉机│ ← 牌型标签,贴首张牌 bottomLeft │
* │ └──────┘ │
* │ │
* │ ┌─────────── 自己手牌区(Layer 102)────────────┐ │ y≈455
* └──────────────────────────────────────────────────────────────┘ y=720
*
* 单个出牌区的内部结构(以自己为例):
*
* PLAY_SELF_1(1300) 起自左,逐张右叠(fan 压缩,最多 28 张)
* ↓
* ┌────┬────┬────────┐
* │ K │ K │ K │ 每张牌【右上角】可再叠一个角标:
* │ ♦ │ ♦ │ ♦ [庄]│ MARK_BANKER_CORNER(572) 28×28「庄」橙色斜标
* └────┴────┴────────┘ MARK_JOKER_BIG(573) 34×34「大/小」王圆标
* ┌──────┐ —— 贴的是运行时才确定的某一张牌,故
* │拖拉机│ EQW_Layout.PLAY_CARD_CORNER_MARK 只给模板
* └──────┘ (corner/offset),target/w/h 由调用方注入
* ↑ PLAY_SELF_TYPE_BG(1328) + PLAY_SELF_TYPE_TEXT(1329)
* EQW_Layout.PLAY_TYPE_LABEL: attach 首张牌 bottomLeft, offsetY=-24, 76×24
* 首槽是【固定】精灵(PLAY_SELF_1 / PLAY_LEFT_1 / PLAY_RIGHT_1),非动态目标
*
* 元素说明:
* - 牌面帧 = cardIdToFrame(id)(§0.4);数据取 chupai1/2/3 的 seat + cards。
* - 牌型标签当前【只出「毙」一种】——EQW_Images.LABEL_CARDTYPE 只做这一帧;
* 数据层仍保留 毙/垫/混合出牌 三种标识以备扩展,但不出图、不显示。
* ⚠ 参考图 出牌2.png 左下角的橙色「拖拉机」标签是设计示意,与当前资源定案(只做「毙」)
* 不一致,实现时以资源注释为准(清单 §7.2 T-11 标签种类以美术稿为准,尚未定案)。
* - 甩牌的真实结构只能读 chupai1.shuai({tractors, pairs, singles}),
* 【不得据 cardtype 反推】(协议 §11 明确警告)。
* - 【时序·一轮结束】maxseat 高亮 → 三家出牌区收牌 → 若有 grade 播捡分飘字
* (OverlayView 的 GRADE_FLOAT)并累加顶部「抓分」列 → 下一轮由 maxseat 先出。
* - 【为什么预置而不复制】号段每家 100 个、容量充裕,且需要叠压 z 序与出牌动画、
* 逐张独立控制,故不使用 SpriteCopyUtils,避免显式清理负担(清单 §5.6a 判据表)。
*
* 资源说明:
* - 牌面: EQW_Images.CARD_FACE_M (502), 60 帧, 单帧 90×155, 整图 900×930
* - 牌型标签底: EQW_Images.LABEL_CARDTYPE (576), 1 帧, 76×24
* - 牌角标: EQW_Images.MARK_BANKER_CORNER (572) 1 帧 28×28 /
* EQW_Images.MARK_JOKER_BIG (573) 2 帧 34×34(帧1=大王 帧2=小王)
*
* 精灵ID分配: 1300–1329(自己)/ 1400–1429(左上家)/ 1500–1529(右上家)
*
* @see docs_dev/二七王-UI资源与精灵清单.md §2.3 已出牌区 / §1.7 出牌对局 / §5.3 / §6.7
* @see docs_dev/uiref/出牌.png
* @see docs_dev/uiref/出牌2.png
*/
//三家已出牌区:自己/左上家/右上家 各 28 张牌 + 牌型标签底 + 标签文字(清单 §5.3,Layer 103 群组 212/213/214)
EQW_Sprites.PlayAreaView = {
Layer: EQW_Layers.TABLE_CARDS, //103
PlaySelf: {
id: EQW_Groups.PLAY_SELF, //212 自己
//—— 本轮打出的牌,资源 EQW_Images.CARD_FACE_M,帧 = cardIdToFrame(id)——
PLAY_SELF_1: 1300,
PLAY_SELF_2: 1301,
PLAY_SELF_3: 1302,
PLAY_SELF_4: 1303,
PLAY_SELF_5: 1304,
PLAY_SELF_6: 1305,
PLAY_SELF_7: 1306,
PLAY_SELF_8: 1307,
PLAY_SELF_9: 1308,
PLAY_SELF_10: 1309,
PLAY_SELF_11: 1310,
PLAY_SELF_12: 1311,
PLAY_SELF_13: 1312,
PLAY_SELF_14: 1313,
PLAY_SELF_15: 1314,
PLAY_SELF_16: 1315,
PLAY_SELF_17: 1316,
PLAY_SELF_18: 1317,
PLAY_SELF_19: 1318,
PLAY_SELF_20: 1319,
PLAY_SELF_21: 1320,
PLAY_SELF_22: 1321,
PLAY_SELF_23: 1322,
PLAY_SELF_24: 1323,
PLAY_SELF_25: 1324,
PLAY_SELF_26: 1325,
PLAY_SELF_27: 1326,
PLAY_SELF_28: 1327,
PLAY_SELF_TYPE_BG: 1328, //图片:牌型标签底,资源 EQW_Images.LABEL_CARDTYPE,attach 该区首张牌 bottomLeft
PLAY_SELF_TYPE_TEXT: 1329 //文字:标签文案(当前只出「毙」一种,见 EQW_Images.LABEL_CARDTYPE 用途说明)
},
PlayLeft: {
id: EQW_Groups.PLAY_LEFT, //213 左上家,结构同 212
//—— 本轮打出的牌,资源 EQW_Images.CARD_FACE_M,帧 = cardIdToFrame(id)——
PLAY_LEFT_1: 1400,
PLAY_LEFT_2: 1401,
PLAY_LEFT_3: 1402,
PLAY_LEFT_4: 1403,
PLAY_LEFT_5: 1404,
PLAY_LEFT_6: 1405,
PLAY_LEFT_7: 1406,
PLAY_LEFT_8: 1407,
PLAY_LEFT_9: 1408,
PLAY_LEFT_10: 1409,
PLAY_LEFT_11: 1410,
PLAY_LEFT_12: 1411,
PLAY_LEFT_13: 1412,
PLAY_LEFT_14: 1413,
PLAY_LEFT_15: 1414,
PLAY_LEFT_16: 1415,
PLAY_LEFT_17: 1416,
PLAY_LEFT_18: 1417,
PLAY_LEFT_19: 1418,
PLAY_LEFT_20: 1419,
PLAY_LEFT_21: 1420,
PLAY_LEFT_22: 1421,
PLAY_LEFT_23: 1422,
PLAY_LEFT_24: 1423,
PLAY_LEFT_25: 1424,
PLAY_LEFT_26: 1425,
PLAY_LEFT_27: 1426,
PLAY_LEFT_28: 1427,
PLAY_LEFT_TYPE_BG: 1428,
PLAY_LEFT_TYPE_TEXT: 1429
},
PlayRight: {
id: EQW_Groups.PLAY_RIGHT, //214 右上家,结构同 212
//—— 本轮打出的牌,资源 EQW_Images.CARD_FACE_M,帧 = cardIdToFrame(id)——
PLAY_RIGHT_1: 1500,
PLAY_RIGHT_2: 1501,
PLAY_RIGHT_3: 1502,
PLAY_RIGHT_4: 1503,
PLAY_RIGHT_5: 1504,
PLAY_RIGHT_6: 1505,
PLAY_RIGHT_7: 1506,
PLAY_RIGHT_8: 1507,
PLAY_RIGHT_9: 1508,
PLAY_RIGHT_10: 1509,
PLAY_RIGHT_11: 1510,
PLAY_RIGHT_12: 1511,
PLAY_RIGHT_13: 1512,
PLAY_RIGHT_14: 1513,
PLAY_RIGHT_15: 1514,
PLAY_RIGHT_16: 1515,
PLAY_RIGHT_17: 1516,
PLAY_RIGHT_18: 1517,
PLAY_RIGHT_19: 1518,
PLAY_RIGHT_20: 1519,
PLAY_RIGHT_21: 1520,
PLAY_RIGHT_22: 1521,
PLAY_RIGHT_23: 1522,
PLAY_RIGHT_24: 1523,
PLAY_RIGHT_25: 1524,
PLAY_RIGHT_26: 1525,
PLAY_RIGHT_27: 1526,
PLAY_RIGHT_28: 1527,
PLAY_RIGHT_TYPE_BG: 1528,
PLAY_RIGHT_TYPE_TEXT: 1529
}
};
@@ -0,0 +1,132 @@
///////////////////////////////////////////////////////////////
////////// EQW_Sprites: 精灵结构(清单 §5.8 建房规则选项)//////
///////////////////////////////////////////////////////////////
// 子游戏精灵段 1001–2999。结构与 Sprites_Table.js 一致:
// View 下 Layer 为图层声明(一个 View 恰好一个图层),其余键是 group 容器;
// 容器内 id 为群组 ID、其余键才是精灵 id。
// 3 个类别、4 个选项组、8 个选项,选项集固定,全部预置,共 20 个精灵。
// 渲染仍由 §1.1 的配置数据驱动:配置里每个选项声明自己用哪个精灵键、
// type(决定选择框帧号)、bit/value(决定 roomtype 拼串)——本文件不认识
// 「傍王」「爬坡」这些具体玩法名。
var EQW_Sprites = EQW_Sprites || {};
// ============================================================================
// 建房规则选项区 - Layer 27 (平台 CreateRoom_Layer), Group 250 (CREATE_ROOM)
// ============================================================================
/**
* 建房规则选项区 - CreateRoomView
*
* 用途: 创建房间弹窗里【只属于子游戏的那块内容区】——3 个类别标题 + 4 个选项组 + 8 个选项,
* 玩家勾选后拼成 5 位 roomtype 串发给服务端。
* 弹窗背景、标题、关闭按钮、确认按钮(平台约定的 25 号精灵)都【由平台提供】,
* 本 View 不重复建弹窗外壳(D-1 已定)。也因此不新开图层,直接挂平台的 Layer 27。
*
* 布局说明(参考图 docs_dev/uiref/创建房间选项的UI参考内容根据实际二七王游戏改动.png
* ——⚠ 那是【另一个游戏】的建房选项(局数 8/16/24、人数 2/3/4、霸王 ×2/×4,
* 二七王都没有)。【只照搬视觉范式与控件样式,内容一律按二七王的 roomtype 重写】。
* 下图画的是二七王的实际内容):
*
* 两层嵌套:类别竖排(ROOM_CATEGORY_COLUMN)+ 类别内选项槽横排(ROOM_OPTION_ROW)
*
* x=0 x=70 内容区右边缘
* ┌──────┬──────────────────┬──────────────────┬─────────────────┐
* │ 牌局 │ ◉ 6局 │ ◯ 12局 │ │ ← 类别 1 行 1
* │ │ ◉ 房主扣卡 │ ◯ AA每人扣卡 │ 房主2张 │ ← 类别 1 行 2
* ├──────┼──────────────────┼──────────────────┼─────────────────┤
* │ 模式 │ ◉ 可查牌 │ ◯ 不查牌 │ │ ← 类别 2
* ├──────┼──────────────────┼──────────────────┼─────────────────┤
* │ 规则 │ ▣ 傍王 │ ▣ 爬坡 │ │ ← 类别 3
* └──────┴──────────────────┴──────────────────┴─────────────────┘
* ↑ ↑ ↑ ↑
* │ │ └ ROOM_TEXT_*:attach 同槽选择框右侧 offsetX=+8(ROOM_OPTION_TEXT)
* │ └── ROOM_BOX_*:attach 所在选项槽 left/middle,22×22(ROOM_OPTION_BOX)
* └───────── ROOM_CAT_TITLE_1..3 (2050–2052):attach 所在类别行 left/middle,60×24
* 帧1=牌局 帧2=模式 帧3=规则(ROOM_CATEGORY_TITLE)
* ↑
* ROOM_TEXT_CARD_COST(2061) 房卡消耗附注(灰色小字)
* attach ROOM_BOX_CARD_2 右侧 offsetX=+20(ROOM_CARD_COST_NOTE)
*
* 布局参数(EQW_Layout,清单 §6.9b 原文说明数值按参考图比例估、实际以平台建房界面尺寸为准):
* ROOM_CATEGORY_COLUMN line/vertical, anchor='top', itemHeight=32, spacing=8,
* itemWidth 由调用方按平台内容区实际宽度注入
* ⚠ anchor 必须用竖排语义 top/bottom/center,写成 'left' 会被
* 求解器按方向校验拒掉(历史坑:会算出画布外的 y)
* ROOM_OPTION_ROW line/horizontal, anchor='left', anchorX=70, itemWidth=150,
* itemHeight 沿用类别行高 32,spacing=0,anchorY 随所在类别行注入
* ROOM_OPTION_GROUP_GAP 40 同类别下两个选项组之间比组内选项多留的间距
* ROOM_OPTION_MAX_PER_ROW 4 + ROOM_OPTION_OVERFLOW_GRID —— 选项超 4 个才折行;
* 二七王每组最多 2 项,不会触发
*
* 元素说明:
* - 每个选项 = 选择框(图片精灵)+ 文字精灵,成对出现,共 8 组。
* - 选择框帧号 = (useCheckbox ? 3 : 1) + (selected ? 1 : 0),
* useCheckbox = (类型为 checkbox 或 radioOptional)。三个单选组用帧1–2,附加规则组用帧3–4。
* - 选中态文字变橙: EQW_Layout.TEXT_STYLE.ROOM_OPT 的 normal / selected 两态。
* - 【本文件不认识玩法名】渲染由 EQW_RoomOptions(Layout_CreateRoom.js)这份配置数据驱动:
* 配置里每个选项自己声明用哪个精灵键(box / text)、type(决定帧号)、bit/value
* (决定 roomtype 拼串)。渲染器一律【按键名取精灵,不得按下标拼字符串】——
* 组的 key 是 'rules'、精灵却叫 ROOM_BOX_RULE_1,本来就拼不出来。
* 加一个选项 = 配置里加一条,不改渲染逻辑(OCP + 配置优先)。
* - 【roomtype 拼串只在一处实现】遍历配置写 item.value 到 item.bit,不得在别处按下标硬拼
* (与服务端 class.config.js parse() 对称的 SSOT 要求)。二七王 5 位:
* 位0 局数(6局/12局) 位1 扣卡(房主/AA) 位2 傍王 位3 爬坡 位4 查牌(可查/不查)
* 缺省值 roomtype = "00000"。
* - 【房卡消耗联动】随「局数 × 扣卡」两组实时变化,取自 EQW_RoomOptions.cardCost 配置表,
* 不硬编码在 UI 代码里:房主扣卡 6局=房主2张 / 12局=房主4张;AA 6局=每人1张 / 12局=每人2张。
* - 【参考图里不要照抄的部分】「人数」行(二七王固定 3 人,不给玩家选)与「霸王」行。
* - 交互:点平台约定的 25 号精灵 → 拼 roomtype → Net.Send_create_room(...)。
*
* 资源说明:
* - 选择框: EQW_Images.ROOM_OPT_BOX (681), 4 帧 (2×2), 22×22
* 帧1=单选·未选中(灰空心环) 帧2=单选·已选中(橙实心环)
* 帧3=复选·未选中(浅绿空方块) 帧4=复选·已选中
* - 类别标题: EQW_Images.ROOM_CAT_TITLE (682), 3 帧, 60×24, 帧1=牌局 帧2=模式 帧3=规则
* - 选项行底: EQW_Images.ROW_ROOM_OPT (683), 1 帧, 尺寸待定——参考图为无底透明,可选,
* 当前本 View 未使用该资源(没有对应精灵)
*
* 精灵ID分配: 2050–2069(群组 250 号段 2050–2099),共 20 个,全部预置
* —— 选项集固定(3 类别 / 4 选项组 / 8 选项),数量小,不用精灵复制(§5.6a 判据表)
*
* @see docs_dev/二七王-UI资源与精灵清单.md §1.1 建房·房间设置 / §5.8 / §6.9b
* @see docs_dev/uiref/创建房间选项的UI参考内容根据实际二七王游戏改动.png(样式范式,非内容)
*/
//建房规则选项区:挂在平台创建房间界面所在图层(清单 §5.8,群组 250)
EQW_Sprites.CreateRoomView = {
Layer: EQW_Layers.PLATFORM_CREATE_ROOM, //27
CreateRoom: {
id: EQW_Groups.CREATE_ROOM, //250
//—— 3 个类别标题,资源 EQW_Images.ROOM_CAT_TITLE,帧1=牌局 帧2=模式 帧3=规则 ——
ROOM_CAT_TITLE_1: 2050,
ROOM_CAT_TITLE_2: 2051,
ROOM_CAT_TITLE_3: 2052,
//—— 局数选择框(6局/12局),资源 EQW_Images.ROOM_OPT_BOX,单选帧1–2 ——
ROOM_BOX_ASET_1: 2053,
ROOM_BOX_ASET_2: 2054,
ROOM_TEXT_ASET_1: 2055, //文字:局数选项文字
ROOM_TEXT_ASET_2: 2056, //文字:局数选项文字
//—— 扣卡选择框(房主/AA),资源 EQW_Images.ROOM_OPT_BOX,单选帧1–2 ——
ROOM_BOX_CARD_1: 2057,
ROOM_BOX_CARD_2: 2058,
ROOM_TEXT_CARD_1: 2059, //文字:扣卡选项文字
ROOM_TEXT_CARD_2: 2060, //文字:扣卡选项文字
ROOM_TEXT_CARD_COST: 2061, //文字:房卡消耗附注(灰色小字,随局数×扣卡联动)
//—— 查牌选择框(可查/不查),资源 EQW_Images.ROOM_OPT_BOX,单选帧1–2 ——
ROOM_BOX_PEEK_1: 2062,
ROOM_BOX_PEEK_2: 2063,
ROOM_TEXT_PEEK_1: 2064, //文字:查牌选项文字
ROOM_TEXT_PEEK_2: 2065, //文字:查牌选项文字
//—— 附加规则选择框(傍王/爬坡),资源 EQW_Images.ROOM_OPT_BOX,复选帧3–4 ——
ROOM_BOX_RULE_1: 2066,
ROOM_BOX_RULE_2: 2067,
ROOM_TEXT_RULE_1: 2068, //文字:附加规则选项文字
ROOM_TEXT_RULE_2: 2069 //文字:附加规则选项文字
}
};
@@ -0,0 +1,508 @@
///////////////////////////////////////////////////////////////
////////// EQW_Sprites: 精灵结构(清单 §5.6/§5.6a/§5.7a/§5.7b 结算)
///////////////////////////////////////////////////////////////
// 子游戏精灵段 1001–2999。结构与 Sprites_Table.js 一致:
// View 下 Layer 为图层声明(一个 View 恰好一个图层),其余键是 group 容器;
// 容器内 id 为群组 ID、其余键才是精灵 id。
// 连号精灵逐条写全,不用循环生成。
var EQW_Sprites = EQW_Sprites || {};
// ============================================================================
// 小局结算面板 - Layer 302 (POPUP_ASET_RESULT), Group 240 (ASET_RESULT)
// ============================================================================
/**
* 小局结算面板 - AsetResultView
*
* 用途: 一小局打完后盖在牌桌之上,展示【埋牌底牌 + 扣底算式】与【三家得分】,
* 并给出「冲关牌型」「下一局」两个出口。本面板没有整块面板底图——
* 各元素直接摆在半透牌桌上(见参考图,牌桌仍隐约可见)。
*
* 布局说明(参考图 docs_dev/uiref/小局结算.png):
*
* ┌ 175,150 ─────────┐ ┌ 430,140 ────────────────┐ ┌ 865,150 ─────────┐
* │ │ │ ┌┐┌┐┌┐┌┐┌┐┌┐┌┐┌┐ │ │ │
* │ +20 │ │ └┘└┘└┘└┘└┘└┘└┘└┘ ×8 │ │ -10 │
* │ │ │ 叫分2子 底牌 20x0 │ │ │
* └──────────────────┘ └─────────────────────────┘ └──────────────────┘
* 牌局分 10 冲关分 10 牌局分 -5 冲关分 -5
* ↑ ↑ ↑ ↑
* RESULT_JF_LEFT RESULT_AW_LEFT RESULT_JF_RIGHT RESULT_AW_RIGHT
* (1808) (1811) (1809) (1812)
*
* ┌ 490,340 ─────────┐
* │ -10 │ ← RESULT_GLOW_SELF(1804) 光晕 225×100
* └──────────────────┘ RESULT_SCORE_SELF(1807) 总分大字
* 牌局分 -5 冲关分 -5 RESULT_JF_SELF(1810) / RESULT_AW_SELF(1813)
*
* ┌ 535,515 ────┐ ┌ 835,518 ───┐
* │ 冲关牌型 │ │ 下一局 │
* └─────────────┘ └────────────┘
* ↑ RESULT_BTN_CG(1815) ↑ RESULT_BTN_NEXT(1817)
* +_TEXT(1816) 183×63 +_TEXT(1818) 180×65
*
* 三家的位置与牌桌上各自的座位方位一致:左上家在左、右上家在右、自己在中央偏下。
* 底牌区面板 RESULT_BOTTOM_PANEL(1800) = point(430,140,420×148),
* 内含群组 215 的 8 张埋牌底牌(BuryCardsView,见 Sprites_Cards.js)与
* RESULT_BOTTOM_TEXT(1801)(attach 面板 center/bottom, offsetY=-14)。
*
* 每家一组三个数字(EQW_Layout.RESULT_GLOW / RESULT_SCORE_NUM / RESULT_JF_TEXT /
* RESULT_AW_TEXT,都用 bySeat 三变体):
* ┌───── 光晕 225×100 ─────┐
* │ +20 │ ← 总分大字:attach 光晕 center/middle
* └────────────────────────┘
* 牌局分 10 冲关分 10
* ↑ attach 光晕 left/bottom ↑ attach 光晕 right/bottom
* offset(-15,+24) offset(+15,+24)
*
* 元素说明:
* - 光晕帧: GLOW_RESULT 帧1=赢(橙) / 帧2=输(蓝),按该家 grade 的正负取帧。
* - 总分大字: 取 aset.seatlist[i].grade,正分橙带「+」、负分蓝带「-」;
* 按符号在 NUM_RESULT_WIN / NUM_RESULT_LOSE 两套资源里二选一绑定,
* 显示用 SpriteManager.setNumberImage(spriteId, '+20', charWidth)。
* 与大局结算的 ACC_TPL_SCORE_NUM(1914) 同一套资源与用法,两处得分显示保持一致。
* - 扣底文案: 格式「叫分{aset.multiple}子 底牌 {bottom.grade1}x{bottom.multiple}」——
* x 后面是【扣底倍数】(单张 ×1、主对 ×2、N 连对 ×2N)。闲家未扣底时 bottom 只有 cards,
* multiple/grade1/grade2 都不下发 → 倍数位显示 0(参考图的「底牌 20x0」即此情形)。
* - 【标签文案与字段】参考图两个小字标签已定案为「牌局分」/「冲关分」(清单 §1.8):
* 牌局分取 grade_jf(捡分子数得分)、冲关分取 grade_cg(算奖里的冲关分量,S-6 新增,
* 开傍王时不含傍王那部分)。精灵键名沿用早期的 _JF / _AW,与文案不完全同名,勿混。
* - 【判定结果不在本面板显示,D-8】大光/小光/过庄/升N级/投降由【动画】呈现,
* 故 1814 RESULT_JUDGE_TEXT 被判定不需要(见下方空号说明)。动画在两个时机播:
* 对局过程中 —— 依 curmultiple(随 chupai1/2/3 下发,带符号,S-8)档位跳变时提示
* 结算前 —— 依 aset.upgrade(带符号:3大光 2小光 1过庄 -N升N级 -99投降 0解散)
* 末轮打完、本面板出现【之前】播一次最终判定
* 【待设计稿】动画资源 EQW_Images.ANI_JUDGE(634) 帧数/尺寸待定,
* 播放参数在 EQW_Anim 里留白(AnimConstants.js 末尾「D-8 待补」)——本 View 无对应精灵。
* - 【容错】投降结算只有 aset、无 chupai 无 bottom → 面板必须容忍底牌区缺失,
* 不能因读不到 bottom.cards 就崩或显示空框。
* - 【时序】先 setXxx 把结算数据写进 this.data、再播动画;动画的开始/结束/出错回调里
* 只刷界面、不写核心数据。动画缺失或卡住时,面板与数据仍须正确。
*
* 资源说明:
* - 底牌区面板底: EQW_Images.PANEL_BOTTOM_CARDS (606), 1 帧, 420×148
* - 分数光晕: EQW_Images.GLOW_RESULT (607), 2 帧, 225×100, 帧1=赢(橙) 帧2=输(蓝)
* - 得分数字: EQW_Images.NUM_RESULT_WIN (632) / NUM_RESULT_LOSE (633),
* 各 16 帧固定帧序, 单字符 46×62, 整图 736×62;帧12=「+」帧13=「-」必出;
* charWidth 取 EQW_Layout.NUM_STYLE.RESULT_WIN / RESULT_LOSE.charWidth
* - 冲关牌型按钮: EQW_Images.BTN_CG_CARDS (542), 1 帧, 183×63, 蓝色渐变
* - 下一局按钮: EQW_Images.BTN_NEXT_ASET (541), 1 帧, 180×65, 金黄渐变
*
* 精灵ID分配: 1800–1813 / 1815–1818(群组 240);埋牌底牌 8 张复用群组 215(1258–1265)
*
* @see docs_dev/二七王-UI资源与精灵清单.md §1.8 末轮扣底 + 小局结算 / §5.6 / §6.9
* @see docs_dev/uiref/小局结算.png
*/
//小局结算面板(清单 §5.6,Layer 302 群组 240)
//
//以下键号在源清单中被显式判定「不需要」,本文件不占用,留空号以便追溯:
// 1814 RESULT_JUDGE_TEXT —— 结算面板不显示判定文字,改由动画呈现(§1.8,D-8)
EQW_Sprites.AsetResultView = {
Layer: EQW_Layers.POPUP_ASET_RESULT, //302
AsetResult: {
id: EQW_Groups.ASET_RESULT, //240
RESULT_BOTTOM_PANEL: 1800, //图片:底牌区面板底,资源 EQW_Images.PANEL_BOTTOM_CARDS
RESULT_BOTTOM_TEXT: 1801, //文字:「叫分{multiple}子 底牌 {grade1}x{bottom.multiple}」
//—— 三家分数光晕,资源 EQW_Images.GLOW_RESULT,帧1=赢(橙) 帧2=输(蓝) ——
RESULT_GLOW_LEFT: 1802,
RESULT_GLOW_RIGHT: 1803,
RESULT_GLOW_SELF: 1804,
//—— 三家总分(aset.seatlist[i].grade),多帧图数字精灵,正分橙带 `+`、负分蓝带 `-`,
// 资源 EQW_Images.NUM_RESULT_WIN(赢)/ NUM_RESULT_LOSE(输),按符号二选一绑资源,
// 显示用 SpriteManager.setNumberImage(spriteId, '+20', charWidth),charWidth 见
// EQW_Layout.NUM_STYLE.RESULT_WIN/RESULT_LOSE——与 ACC_TPL_SCORE_NUM(1914,大局结算总分)
// 同一套资源与用法,两处结算得分显示保持一致(2026-08-27 由文字精灵改为数字图精灵)——
RESULT_SCORE_LEFT: 1805,
RESULT_SCORE_RIGHT: 1806,
RESULT_SCORE_SELF: 1807,
//—— 三家捡分子数得分文字 grade_jf【T-13】——
RESULT_JF_LEFT: 1808,
RESULT_JF_RIGHT: 1809,
RESULT_JF_SELF: 1810,
//—— 三家算奖得分文字 grade_aw【T-13】——
RESULT_AW_LEFT: 1811,
RESULT_AW_RIGHT: 1812,
RESULT_AW_SELF: 1813,
RESULT_BTN_CG: 1815, //图片:「冲关牌型」按钮,资源 EQW_Images.BTN_CG_CARDS
RESULT_BTN_CG_TEXT: 1816, //文字:按钮文案「冲关牌型」
RESULT_BTN_NEXT: 1817, //图片:「下一局」按钮,资源 EQW_Images.BTN_NEXT_ASET
RESULT_BTN_NEXT_TEXT: 1818 //文字:按钮文案「下一局」
}
};
//> 底牌 8 张精灵复用 Sprites_Cards.js 群组 215(BuryCardsView.BuryCards,1258–1265),不在此重复。
// ============================================================================
// 冲关牌型叠加层 - Layer 302 (POPUP_ASET_RESULT), Group 244 (CHONGGUAN)
// ============================================================================
/**
* 冲关牌型叠加层 - ChongGuanOverlayView
*
* 用途: 在小局结算面板【之上】再盖一层半透黑遮罩,三家各摊开自己参与冲关的牌
* (aset.seatlist[i].cards),并在旁边标出奖数。点「冲关牌型」按钮打开。
*
* 布局说明(参考图 docs_dev/uiref/冲关牌型显示.png;牌的排布见
* EQW_Layout.CHONGGUAN_FAN: fan/horizontal, anchor center,
* 牌尺寸 CARD_SIZE.L 110×190, maxWidth=420, spacingMax=-40 spacingMin=-88):
*
* ┌──────────────────────────────────────────────────────────────┐ y=0
* │ ┌──┬──┬──┬────┐ ┌──┬──┬──┬────┐ │
* │ │K │K │K │ K │ │K │K │K │ K │ │ 260,120 / 1035,120
* │ └──┴──┴──┴────┘ └──┴──┴──┴────┘ │
* │ CG_BOX_LEFT(1821) CG_BOX_RIGHT(1822) │
* │ CG_COUNT_LEFT(1825) CG_COUNT_RIGHT(1826)│
* │ │
* │ (半透黑遮罩 CG_MASK(1820) 铺满全屏) │
* │ │
* │ ┌──┬──┬──┬────┐ │
* │ │K │K │K │ K │ ← CG_BOX_SELF(1823) 640,400│
* │ └──┴──┴──┴────┘ CG_COUNT_SELF(1827) │
* │ │
* │ ┌────────────┐ │
* │ │ 下一局 │ ← 遮罩之上│ 835,518
* │ └────────────┘ 仍可见可点│
* └──────────────────────────────────────────────────────────────┘ y=720
*
* 三家牌位与结算面板同方位:左上家、右上家在各自头像内侧,自己在中央偏下。
* 遮罩 EQW_Layout.CG_MASK = point(0,0,1280×720, opacity 0.72)。
*
* 元素说明:
* - 【牌用精灵复制,不预置固定张数】cards 长度不定——四王 + 一条长连对链 + 八个 7 +
* 八个 2 叠加时可达 20 余张,预置既可能不够、又在多数局里白占 ID。
* 编辑器里只需建 5 个精灵:3 个容器 + 1 个牌模板 + 1 个遮罩;奖数文字 3 个照常预置。
* 生成方式见下方原注释与清单 §5.6a 的代码片段(SpriteCopyUtils.create + setFrame)。
* - 【清理是硬要求】关闭叠加层 / 组件 onDestroy 时必须
* SpriteCopyUtils.removeRange(boxId, 0, cards.length),否则复制精灵残留。
* - 【关闭方式】遮罩本身就是关闭热区,点遮罩即关闭,不另设关闭按钮 →
* CG_MASK 必须注册点击事件;同时 RESULT_BTN_NEXT(1817/1818) 的层内层级须【高于】遮罩,
* 点它不被遮罩吃掉(「冲关牌型」按钮本身被遮住则无妨)。
* ⚠ 三个遮罩面板的关闭方式各不相同,别写混:
* 冲关牌型(本 View)→ 点遮罩关闭 遮罩可点
* 明牌 / 出牌历史 → 点遮罩关闭,明牌另可再点按钮切换 遮罩可点
* 亮牌 → 仅自动时长,到点自动收起 遮罩【不】注册点击
* - 奖数取 aset.seatlist[i].naward(该家总奖数,支付基数);chongguan / wang 可作明细选展。
* 前端【不重算冲关】,一律直接取服务端下发值。
*
* 资源说明:
* - 遮罩: EQW_Images.MASK_DIM (609), 1 帧, 纯色可拉伸(出 1 张小图即可,拉伸满 1280×720)
* - 牌模板: EQW_Images.CARD_FACE_L (501), 60 帧, 单帧 110×190
*
* 精灵ID分配: 1820–1827(群组 244)
*
* @see docs_dev/二七王-UI资源与精灵清单.md §1.8「冲关」是什么 / §5.6a / §6.7
* @see docs_dev/uiref/冲关牌型显示.png
*/
//冲关牌型叠加层(清单 §5.6a,Layer 302 群组 244,精灵复制)——
//本层的牌用 SpriteCopyUtils 运行时复制,不预置固定张数(cards 长度不定,最多可达 20 余张)。
//编辑器里只需建 5 个精灵(3 容器 + 1 牌模板 + 1 遮罩);奖数文字 3 个照常预置。
//RESULT_BTN_NEXT(1817/1818)在遮罩之上仍需可见可点,其图层内层级须高于 CG_MASK。
//清理是硬要求:关闭叠加层/组件 onDestroy 时必须 SpriteCopyUtils.removeRange(boxId, 0, cards.length),否则复制精灵残留。
EQW_Sprites.ChongGuanOverlayView = {
Layer: EQW_Layers.POPUP_ASET_RESULT, //302
ChongGuan: {
id: EQW_Groups.CHONGGUAN, //244
CG_MASK: 1820, //图片:半透黑遮罩(拉伸满屏,兼作点击关闭热区),资源 EQW_Images.MASK_DIM
CG_BOX_LEFT: 1821, //容器:左上家冲关牌容器(SpriteCopyUtils 复制的父精灵)
CG_BOX_RIGHT: 1822, //容器:右上家冲关牌容器
CG_BOX_SELF: 1823, //容器:自己冲关牌容器
CG_CARD_TPL: 1824, //模板:冲关牌模板(编辑器里放好、默认隐藏,只作样式来源),资源 EQW_Images.CARD_FACE_L
//—— 三家奖数文字,与该家的冲关牌并列显示,取 aset.seatlist[i].naward ——
CG_COUNT_LEFT: 1825,
CG_COUNT_RIGHT: 1826,
CG_COUNT_SELF: 1827
}
};
// ============================================================================
// 大局总结算 / 解散结算 - Layer 303 (POPUP_ACCOUNT), Group 241 (ACCOUNT)
// ============================================================================
/**
* 大局总结算 / 解散结算 - AccountView
*
* 用途: 打完最后一局(或房间解散)时弹出的总账面板:标题栏 + 三条玩家栏 + 规则栏,
* 面板【外】底部另有 4 个功能按钮。解散结算复用同一面板,只是 aset 各项为 0。
*
* 布局说明(参考图 docs_dev/uiref/大局结算.png;面板居中于 1280×720,
* 实测约 x≈80–1180, y≈88–620,按钮条 y≈640–700):
*
* ┌──────────────────────────────────────────────────────────┐ ← ACC_TITLE_BAR(1901)
* │ 二七王-3人 房号654216 共24局 2025-12-14 11:45 [×] │ 1100×56,青绿底
* ├──────────────────────────────────────────────────────────┤ ↑ACC_TITLE_TEXT(1902)
* │ │ ↑ACC_BTN_CLOSE(1903)
* │ ⑴ [头像] 玩家1 基础分30 算奖分12 傍王分15 +135 │ ← 玩家栏(复制生成)
* │ ID:100001 总得分30 │
* │ ──────────────────────────────────────────────────────── │ ← ACC_TPL_DIVIDER(1915)
* │ ⑵ [头像] 玩家2 基础分-30 算奖分-12 傍王分-15 -45 │
* │ ID:100002 总得分-30 │
* │ ──────────────────────────────────────────────────────── │
* │ ⑶ [头像] 玩家3 …… -45 │
* │ │
* ├──────────────────────────────────────────────────────────┤ ← ACC_RULE_BAR(1904)
* │ 规则:可查牌、傍王、爬坡算子 │ 1100×62,浅绿底
* └──────────────────────────────────────────────────────────┘ ↑ACC_RULE_TEXT(1905)
* ↑ ACC_PANEL_BG(1900) 圆角浅灰绿 1100×540(标题栏与规则栏是它的上下封边)
*
* ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ ← 面板【外】底部
* │ 再来一局 │ │ 分享好友 │ │ 复制战绩 │ │ 退出房间 │ 各 190×60
* └──────────┘ └──────────┘ └──────────┘ └──────────┘
* (1920/21) (1922/23) (1924/25) (1926/27)
* 黄 蓝 蓝 红
*
* 单条玩家栏的列序(左→右):
* 名次徽章 → 头像 → 昵称 /「ID:{playerid}」(两行) → 「基础分{n}」/「总得分{n}」(两行)
* → 「冲关分{n}」→「傍王分{n}」→ 右侧总分大字
* ⚠ 参考图第三列小字写的是「算奖分」,清单 §1.9 定案的字段是 grade_cg_total(冲关分);
* 四项 grade_jf_total / grade_cg_total / grade_bw_total 之和恒等于 score(S-6)。
*
* 元素说明:
* - 【玩家栏用精灵复制】三条结构完全相同、仅数据不同,用 SpriteCopyUtils 从 5 个模板
* (ACC_TPL_RANK / AVATAR / TEXT / SCORE_NUM / DIVIDER)复制生成;面板外壳与按钮预置。
* tag 分段见下方原注释;关闭面板时按各段 removeRange 清理。
* - 名次徽章按总分排名取帧(1金 / 2橙 / 3蓝,花瓣形)。
* - 总分大字与小局结算的 RESULT_SCORE_* 同一套资源与用法:正分橙带「+」、负分蓝带「-」,
* setNumberImage 走 TEXT/WIDTH 属性,_validateSpriteId 也接受复制精灵的字符串 ID,
* 故复制出来的玩家栏总分同样【一个精灵显示整串】。
* - 规则栏文案据 roomtype 位串拼出已启用项,不硬编码。
* - 【解散结算】投递方式与小局结算完全不同(协议 §14.1):rpc 是平台的 free_room、
* route 是 room,真实取值路径是 data.deskfree.data.aset / .account(多一层 data 包装),
* 且 data.deskfree 可能缺失(开战后、首局发牌前解散)→ 前端必须容忍。
* 解散的申请/投票/同意拒绝界面【由平台全包】(Layer 420),子游戏不做投票界面。
* - 【布局节点已补齐 · 2026-08-27】见 Layout_Result.js「大局总结算」一节:
* 外壳 —— ACC_PANEL_BG / ACC_TITLE_BAR / ACC_TITLE_TEXT / ACC_BTN_CLOSE /
* ACC_RULE_BAR / ACC_RULE_TEXT
* 玩家栏(精灵复制)—— ACC_ROW_CONTAINER(容器)+ ACC_ROW(行模板,itemHeight 即行高,
* 行数由 ctx.count 给出)+ ACC_TPL_RANK / ACC_TPL_AVATAR / ACC_ROW_TEXT_GRID /
* ACC_TPL_SCORE_NUM / ACC_TPL_DIVIDER(都 attach 到 ACC_ROW 的行矩形上)
* 按钮 —— ACC_FUNC_BUTTONS(面板外底部 4 个)
* ⚠ 行内各节点求出的是【相对 ACC_ROW_CONTAINER 的】坐标,可直接喂 SpriteCopyUtils.create。
* ⚠ 这些坐标是参考图【目视实测估值】(w/h 取资源尺寸),待设计稿复核;
* 但配置已成型,渲染代码一律引用节点,不得就地硬编码坐标(零裸值红线)。
*
* 资源说明:
* - 面板底: EQW_Images.PANEL_ACCOUNT (610), 1 帧, 1100×540, 圆角浅灰绿
* - 标题栏: EQW_Images.BAR_ACCOUNT_TITLE (611), 1 帧, 1100×56, 青绿底含圆角上沿
* - 规则栏: EQW_Images.BAR_ACCOUNT_RULE (612), 1 帧, 1100×62, 浅绿底含圆角下沿
* - 关闭钮: EQW_Images.BTN_ACC_CLOSE (547), 1 帧, 36×36
* - 名次徽章: EQW_Images.BADGE_RANK (579), 3 帧, 44×44, 帧=名次 1–3
* - 分隔线: EQW_Images.LINE_DIVIDER (613), 1 帧, 1060×2
* - 总分数字: EQW_Images.NUM_RESULT_WIN (632) / NUM_RESULT_LOSE (633), 16 帧, 单字符 46×62
* - 四个按钮: EQW_Images.BTN_ACCOUNT_AGAIN (543) / SHARE (544) / COPY (545) / EXIT (546),
* 各 1 帧, 190×60
*
* 精灵ID分配: 1900–1905 / 1910–1915 / 1920–1927(群组 241)
*
* @see docs_dev/二七王-UI资源与精灵清单.md §1.9 大局总结算 / §1.10 解散结算 / §5.7a
* @see docs_dev/uiref/大局结算.png
*/
//大局总结算 / 解散结算(清单 §5.7a,Layer 303 群组 241,玩家栏用精灵复制)——
//三条玩家栏结构完全相同、仅数据不同,用 SpriteCopyUtils 复制生成;面板外壳与按钮预置。
//tag 分段(SpriteCopyUtils.createTagManager):rank 1–20 / avatar 21–40 / text 41–140(每行 6 条文字)/ score 141–160 / divider 161–180。
//清理:关闭面板时按各段 removeRange。
//解散结算复用本面板(协议 §14.1,解散包也带 account),只是 aset 各项为 0。
EQW_Sprites.AccountView = {
Layer: EQW_Layers.POPUP_ACCOUNT, //303
Account: {
id: EQW_Groups.ACCOUNT, //241
ACC_PANEL_BG: 1900, //图片:面板底(圆角浅灰绿),资源 EQW_Images.PANEL_ACCOUNT
ACC_TITLE_BAR: 1901, //图片:标题栏(青绿底),资源 EQW_Images.BAR_ACCOUNT_TITLE
ACC_TITLE_TEXT: 1902, //文字:「二七王-3人 房号… 共…局 …」
ACC_BTN_CLOSE: 1903, //图片:右上角关闭按钮,资源 EQW_Images.BTN_ACC_CLOSE
ACC_RULE_BAR: 1904, //图片:底部规则栏(浅绿底),资源 EQW_Images.BAR_ACCOUNT_RULE
ACC_RULE_TEXT: 1905, //文字:「规则:可查牌、傍王、爬坡算子」
ACC_ROW_CONTAINER: 1910, //容器:玩家栏的复制父精灵
//—— 玩家栏复制模板(tag 分段见上)——
ACC_TPL_RANK: 1911, //模板:名次徽章,资源 EQW_Images.BADGE_RANK,帧=名次1–3
ACC_TPL_AVATAR: 1912, //模板:头像,运行时贴玩家头像
ACC_TPL_TEXT: 1913, //模板:通用文字(昵称/ID/各项分数,一行一个)
ACC_TPL_SCORE_NUM: 1914, //模板:右侧总分大字(多帧图数字精灵),资源 EQW_Images.NUM_RESULT_WIN / NUM_RESULT_LOSE,
//显示用 SpriteManager.setNumberImage(复制精灵ID, '+120', charWidth)——
//setNumberImage 走的是 TEXT/WIDTH 属性,_validateSpriteId 也接受复制精灵的字符串 ID,
//故复制出来的玩家栏总分同样一个精灵显示整串(2026-08-26 核对)
ACC_TPL_DIVIDER: 1915, //模板:行间分隔线,资源 EQW_Images.LINE_DIVIDER
//—— 底部 4 个功能按钮 ——
ACC_BTN_AGAIN: 1920, //图片:「再来一局」按钮,资源 EQW_Images.BTN_ACCOUNT_AGAIN
ACC_BTN_AGAIN_TEXT: 1921, //文字:按钮文案「再来一局」
ACC_BTN_SHARE: 1922, //图片:「分享好友」按钮,资源 EQW_Images.BTN_ACCOUNT_SHARE
ACC_BTN_SHARE_TEXT: 1923, //文字:按钮文案「分享好友」
ACC_BTN_COPY: 1924, //图片:「复制战绩」按钮,资源 EQW_Images.BTN_ACCOUNT_COPY
ACC_BTN_COPY_TEXT: 1925, //文字:按钮文案「复制战绩」
ACC_BTN_EXIT: 1926, //图片:「退出房间」按钮,资源 EQW_Images.BTN_ACCOUNT_EXIT
ACC_BTN_EXIT_TEXT: 1927 //文字:按钮文案「退出房间」
}
};
// ============================================================================
// 出牌历史 / 上一轮 / 亮牌 - Layer 304 (POPUP_HISTORY), Group 242 (HISTORY)
// ============================================================================
/**
* 出牌历史 / 上一轮 / 亮牌 - HistoryView
*
* 用途: 半透黑遮罩 + 每家一排牌的查看面板,【一套精灵三种用途】,只换数据源:
* 出牌历史 = pushlist 全部轮次摊平聚合到各家,三排
* 上一轮 = pushlist 末尾一项,三排
* 亮牌 = liangpai.cards(庄家的固定主牌),只用一排
*
* 布局说明(版式照 docs_dev/uiref/冲关牌型显示.png——本 View 与 MingPaiView、
* ChongGuanOverlayView 三者是同一种版式:遮罩 + 每家一排重叠排列的大牌):
*
* ┌──────────────────────────────────────────────────────────────┐ y=0
* │ ┌──┬──┬──┬──┬────┐ ┌──┬──┬──┬──┬────┐ │
* │ │K │K │K │K │ K │ │K │K │K │K │ K │ │
* │ └──┴──┴──┴──┴────┘ └──┴──┴──┴──┴────┘ │
* │ ↑CG_BOX 位同左上家 ↑位同右上家 │
* │ HIST_BOX_LEFT(1951) HIST_BOX_RIGHT(1952) │
* │ HIST_LABEL_LEFT(1955) HIST_LABEL_RIGHT(1956)│
* │ │
* │ (半透黑遮罩 HIST_MASK(1950) 铺满全屏,兼关闭热区)│
* │ │
* │ ┌──┬──┬──┬──┬────┐ │
* │ │K │K │K │K │ K │ ← HIST_BOX_SELF(1953) │
* │ └──┴──┴──┴──┴────┘ HIST_LABEL_SELF(1957) │
* └──────────────────────────────────────────────────────────────┘ y=720
*
* 三排的落点与各自座位方位一致(左上家在左、右上家在右、自己中央偏下),
* 与 ChongGuanOverlayView 同构;【亮牌】只显示庄家那一排,其余两个容器隐藏。
*
* 元素说明:
* - 【牌用精灵复制】张数不定(出牌历史摊平后可达整手 28 张以上),
* 编辑器里只建 1 个遮罩 + 3 个容器 + 1 个牌模板 + 3 个座位标签文字。
* 关闭 / onDestroy 时必须 removeRange 清理复制精灵。
* - 出牌历史与上一轮【仅可查牌模式】;不可查牌模式下底栏那两个按钮整体不出现。
* 数据在出牌过程中由前端逐包(chupai1/2/3)自行累积,重连时从 pushlist 恢复。
* - 出牌历史【不按轮次分组】(D-7 已定):每家出过的牌全部摊平聚合到该玩家名下,
* 再按手牌那套主牌序排序,三家各一排。前端自行摊平 + 排序,不需要改服务端。
* - 【亮牌的关闭方式与其余两个面板不同】:只按自动时长关闭
* (EQW_Anim.LIANGPAI_AUTO_CLOSE = 3000,清单原文时长「默认值待定」,现值为起始估值),
* 遮罩【不注册点击事件】;而出牌历史 / 上一轮是点遮罩关闭。同一批精灵、两种关闭策略,
* 实现时按用途分别挂事件,别写混。
* - 【亮牌时机 T-34 已定】埋牌后、出牌前——庄家埋牌完成、服务端下发 maipai 包(带 liangpai)
* 时弹出,出牌开始前收起;只有【闲家】会收到并弹出,庄家自己不弹。
* 一次性弹出、不是常驻入口;不阻塞出牌,若面板还开着就收到出牌推送,直接收起即可。
* - 【与 MingPaiView 的关系】版式相同、渲染逻辑可共用,但数据独立、图层不同
* (304 vs 305),故拆成两个 View——一个 View 恰好一个图层。
* - 【布局节点已补齐 · 2026-08-27】见 Layout_Result.js「出牌历史 / 上一轮 / 亮牌」一节:
* HIST_MASK(遮罩)/ HIST_BOX(三个容器同摆原点、铺满屏)/ HIST_FAN(每家一排牌,
* bySeat 三变体,锚点沿用 §6.7 冲关牌的 LEFT 260,120 · RIGHT 1035,120 · SELF 640,400)/
* HIST_LABEL(三家座位标签,w/h 运行时注入)。
* 与 CHONGGUAN_FAN 的差别只有张数上限:出牌历史摊平后可达 28 张以上,
* 故 maxWidth 放宽到 500、spacingMin 收到 -96【估值,待设计稿复核】。
*
* 资源说明:
* - 遮罩: EQW_Images.MASK_DIM (609), 1 帧, 纯色可拉伸(与冲关/明牌共用)
* - 牌模板: EQW_Images.CARD_FACE_L (501), 60 帧, 单帧 110×190
*
* 精灵ID分配: 1950–1957(群组 242)
*
* @see docs_dev/二七王-UI资源与精灵清单.md §1.6 亮牌 / §1.7 出牌历史 / §5.7b
* @see docs_dev/uiref/冲关牌型显示.png
*/
//出牌历史 / 上一轮 / 亮牌(清单 §5.7b,Layer 304,群组 242,牌用精灵复制)——
//三家都显示,数据 = pushlist 摊平聚合到各家(§1.7 D-7);上一轮同一套精灵,只取 pushlist 末尾一项;
//亮牌只显示一排(庄家固定主牌),数据 = liangpai.cards(§1.6),复用同一套遮罩+容器+牌模板,仅数据源与排数不同。
//与下方 MingPaiView(Layer 305)版式相同(遮罩 + 每家一排牌),差别只在数据源与家数,两个 View 共用同一套渲染逻辑,
//但各自数据独立、图层不同,故拆成两个 View(一个 View 恰好一个图层)。
EQW_Sprites.HistoryView = {
Layer: EQW_Layers.POPUP_HISTORY, //304
History: {
id: EQW_Groups.HISTORY, //242
HIST_MASK: 1950, //图片:半透黑遮罩(兼关闭热区),资源 EQW_Images.MASK_DIM
HIST_BOX_LEFT: 1951, //容器:左上家牌容器
HIST_BOX_RIGHT: 1952, //容器:右上家牌容器
HIST_BOX_SELF: 1953, //容器:自己牌容器
HIST_CARD_TPL: 1954, //模板:牌模板,资源 EQW_Images.CARD_FACE_L
HIST_LABEL_LEFT: 1955, //文字:左上家座位标签(昵称/张数)
HIST_LABEL_RIGHT: 1956, //文字:右上家座位标签
HIST_LABEL_SELF: 1957 //文字:自己座位标签
}
};
// ============================================================================
// 明牌面板 - Layer 305 (POPUP_MINGPAI), Group 243 (MINGPAI)
// ============================================================================
/**
* 明牌面板 - MingPaiView
*
* 用途: 半透黑遮罩 + 【另两家】各一排牌,展示他们手中全部未出主牌的具体牌面。
* 与 HistoryView 版式相同,差别只在数据源与家数(明牌只看他家,没有自己那排)。
*
* 布局说明(版式照 docs_dev/uiref/冲关牌型显示.png,去掉中央偏下的自己那一排):
*
* ┌──────────────────────────────────────────────────────────────┐ y=0
* │ ┌──┬──┬──┬──┬────┐ ┌──┬──┬──┬──┬────┐ │
* │ │K │K │K │K │ K │ │K │K │K │K │ K │ │
* │ └──┴──┴──┴──┴────┘ └──┴──┴──┴──┴────┘ │
* │ ↑ MING_BOX_A(2001) ↑ MING_BOX_B(2002) │
* │ MING_LABEL_A(2004) MING_LABEL_B(2005) │
* │ │
* │ (半透黑遮罩 MING_MASK(2000) 铺满全屏,兼关闭热区) │
* │ │
* │ (自己那排不存在——自己的牌自己看得见) │
* │ │
* └──────────────────────────────────────────────────────────────┘ y=720
*
* A / B 两个容器分别落在两个他家的方位(左上 / 右上),与 HistoryView 的
* HIST_BOX_LEFT / HIST_BOX_RIGHT 同位;牌用 CARD_FACE_L 重叠排列(fan)。
*
* 元素说明:
* - 【按钮显示条件 D-6 已定,按 design 手册】可查牌模式 + 出牌阶段 +
* 【场上已有任一玩家报无主】(baozhu == 1) → 三家都显示「明牌」按钮,可随时查看、不限次数。
* 判定的是「场上有人报无主」,不是「自己报无主」——报无主的那位自己也能看。
* - 交互: 点按钮 → 发 mingpai { seat } → 收 mingpai { seat, others:[{seat, zhucards}] }
* → 弹出本面板。【再点一次取消是纯前端开关】,不再请求服务端。
* - 关闭方式: 点遮罩关闭;另可再点底栏按钮切换(与亮牌的「仅自动时长关闭」不同)。
* - 【牌用精灵复制】张数不定;编辑器里只建 1 遮罩 + 2 容器 + 1 牌模板 + 2 标签文字。
* 关闭 / onDestroy 时必须 removeRange 清理。
* - 【为什么与 HistoryView 拆成两个 View】版式相同、渲染逻辑可共用,但两者数据独立、
* 图层不同(304 vs 305);本项目约定「一个 View 恰好一个图层」,故各自成 View。
* - 【布局节点已补齐 · 2026-08-27】见 Layout_Result.js「明牌面板」一节:
* MING_MASK / MING_BOX / MING_FAN / MING_LABEL,与 HIST_* 同构,差别只在【家数】——
* bySeat 只有 LEFT / RIGHT 两个变体(MING_BOX_A 落 LEFT 位、MING_BOX_B 落 RIGHT 位),
* 没有 SELF 那排。坐标同为目视实测估值,待设计稿复核。
*
* 资源说明:
* - 遮罩: EQW_Images.MASK_DIM (609), 1 帧, 纯色可拉伸(与冲关/出牌历史共用同一资源)
* - 牌模板: EQW_Images.CARD_FACE_L (501), 60 帧, 单帧 110×190
*
* 精灵ID分配: 2000–2005(群组 243)
*
* @see docs_dev/二七王-UI资源与精灵清单.md §1.7 明牌(D-6)/ §2.6 底栏 / §5.7b
* @see docs_dev/uiref/冲关牌型显示.png
*/
//明牌(清单 §5.7b,Layer 305,群组 243,牌用精灵复制)——
//只显示另两家,数据 = mingpai.others[].zhucards(§1.7 D-6);
//版式与上方 HistoryView(Layer 304)相同(遮罩 + 每家一排牌),差别只在数据源与家数,两个 View 共用同一套渲染逻辑,
//但各自数据独立、图层不同,故拆成两个 View(一个 View 恰好一个图层)。
EQW_Sprites.MingPaiView = {
Layer: EQW_Layers.POPUP_MINGPAI, //305
MingPai: {
id: EQW_Groups.MINGPAI, //243
MING_MASK: 2000, //图片:明牌遮罩,资源 EQW_Images.MASK_DIM
MING_BOX_A: 2001, //容器:另一家A牌容器
MING_BOX_B: 2002, //容器:另一家B牌容器
MING_CARD_TPL: 2003, //模板:牌模板,资源 EQW_Images.CARD_FACE_L
MING_LABEL_A: 2004, //文字:A座位标签
MING_LABEL_B: 2005 //文字:B座位标签
}
};
@@ -0,0 +1,344 @@
///////////////////////////////////////////////////////////////
////////// EQW_Sprites: 精灵结构(清单 §5.1 牌桌常驻)/////////
///////////////////////////////////////////////////////////////
// 子游戏精灵段 1001–2999(段内仅 3000 被平台占用)。
// 以 UI View 为组织单位:View 下 Layer 为图层声明(一个 View 恰好一个图层),
// 其余键一律是「group 容器」——容器内 id 为群组 ID、其余键才是精灵 id。
// 每个精灵注明:类型(图片/文字)+ 用途 + 资源键 + 帧说明——
// 后期即照这些注释在编辑器里逐个创建。
var EQW_Sprites = EQW_Sprites || {};
// ============================================================================
// 顶部信息条 - Layer 101 (TABLE_STATIC), Group 201 (TOP_INFO)
// ============================================================================
/**
* 顶部信息条 - TopInfoView
*
* 用途: 屏幕顶部正中常驻的三列小表格,展示本局的「主牌花色 / 庄家叫分 / 闲家抓分」。
* 发牌起出现、整局不收起;收到 fapai 时三列清零(主=无、叫分=0、抓分=0)。
*
* 布局说明(参考图 docs_dev/uiref/等待叫分.png 顶部中央;
* 位置见 EQW_Layout.TOP_INFO_BG = point(486, 4, 276×68)):
*
* ┌────────┬───────────────┬───────────────┐ y=4
* │ 主 │ 叫分 │ 抓分 │ ← 表头三列:已画在底图上,无独立精灵
* ├────────┼───────────────┼───────────────┤
* │ [花] │ 60 [1子] │ 45 [1倍] │ ← 数据行(图中数值均为填充假数据)
* └────────┴───────────────┴───────────────┘ y=72
* ^ ^ ^ ^ ^
* │ │ │ │ └ TOP_GRADE_BADGE_BG(1007) + TOP_GRADE_BADGE_TEXT(1008)
* │ │ │ └─── TOP_GRADE_TEXT(1006)
* │ │ └──────────────── TOP_CALL_BADGE_BG(1004) + TOP_CALL_BADGE_TEXT(1005)
* │ └─────────────────── TOP_CALL_TEXT(1003)
* └──────────────────────────── TOP_SUIT_ICON(1002)
*
* TOP_INFO_BG(1001) 铺满整块(含表头文字与竖分隔线),其余 7 个精灵叠在其上。
*
* 元素说明:
* - 「主」列花色图标: 帧 = flower(1方块 2梅花 3红心 4黑桃,服务端编号),选主前隐藏;
* 贴信息条内部左下角(EQW_Layout.TOP_SUIT_ICON: attach TOP_INFO_BG left/bottom, 30×30)。
* - 「叫分」列: 数值取 shangzhuang.grade,角标「N子」取 multiple(随房间「爬坡」开关变)。
* - 「抓分」列: 数值为闲家累计捡分(逐轮 chupai3.grade 累加,结算时以 aset.grade 为准);
* 角标取 Math.abs(curmultiple)(T-16/S-8:curmultiple 带符号,角标只显示大小)。
* - 两个角标底都贴各自数值文字的 topRight(EQW_Layout.TOP_CALL_BADGE_BG / TOP_GRADE_BADGE_BG),
* 角标内的文字精灵复用同一矩形、不单独配布局。
* - 【待设计稿】TOP_CALL_TEXT / TOP_GRADE_TEXT 两处数值文字的 x/y/w/h 清单 §6.5 未给,
* 布局里标为 runtime 注入,设计稿到位后回填成定点值。
*
* 资源说明:
* - 面板底: EQW_Images.PANEL_TOP_INFO (601), 1 帧, 276×68
* - 花色图标: EQW_Images.SUIT_ICON_S (511), 4 帧, 30×30, 帧序按 flower 编号(与牌面资源的
* 花色顺序不同,两套互不换算)
* - 角标底: EQW_Images.BADGE_MULTIPLE (575), 2 帧, 34×18, 帧1=蓝底(叫分) 帧2=橙底(抓分)
*
* 精灵ID分配: 1001–1008(群组 201 号段 1001–1029)
*
* @see docs_dev/二七王-UI资源与精灵清单.md §2.1 顶部信息条 / §5.1 Layer 101 牌桌常驻
* @see docs_dev/uiref/等待叫分.png
*/
//顶部信息条:主 / 叫分 / 抓分 三列(清单 §5.1 群组 201)
EQW_Sprites.TopInfoView = {
Layer: EQW_Layers.TABLE_STATIC, //101
TopInfo: {
id: EQW_Groups.TOP_INFO, //201
TOP_INFO_BG: 1001, //图片:三列表格底,资源 EQW_Images.PANEL_TOP_INFO
TOP_SUIT_ICON: 1002, //图片:「主」列花色图标,资源 EQW_Images.SUIT_ICON_S,帧 = flower(1方块 2梅花 3红心 4黑桃,服务端 flower 编号)
TOP_CALL_TEXT: 1003, //文字:叫分数值
TOP_CALL_BADGE_BG: 1004, //图片:叫分角标底,资源 EQW_Images.BADGE_MULTIPLE 帧1(蓝底)
TOP_CALL_BADGE_TEXT: 1005, //文字:「N子」
TOP_GRADE_TEXT: 1006, //文字:抓分数值
TOP_GRADE_BADGE_BG: 1007, //图片:抓分角标底,资源 EQW_Images.BADGE_MULTIPLE 帧2(橙底)
TOP_GRADE_BADGE_TEXT: 1008 //文字:「N倍」,取 Math.abs(curmultiple)(T-16/S-8:curmultiple 带符号,展示取绝对值)
}
};
// ============================================================================
// 玩家位附加标记 - Layer 101 (TABLE_STATIC), Group 202 / 203 / 204
// ============================================================================
/**
* 三家玩家位附加标记 - PlayerMarkView
*
* 用途: 在【平台画的头像框】旁叠加子游戏自己的标记——庄印章、余主公示的「主N/对N」角标、
* 叫分阶段的状态文字。三个座位各一个群组,结构对称。
*
* 布局说明(参考图 docs_dev/uiref/等待叫分.png;三人局座位映射见清单 §0.2:
* ind0=南=自己 / ind1=东=右上家 / ind3=西=左上家):
*
* 全屏 1280×720 的三处落点 ——
* ┌──────────────────────────────────────────────────────────────┐ y=0
* │ ┌ 顶部信息条 ┐ │
* │ [左上家] [右上家] │ y≈122
* │ │
* │ (桌面牌 / 操作区) │
* │ │
* │ [自己·底栏] │ y≈648
* └──────────────────────────────────────────────────────────────┘ y=720
*
* 左上家近景(EQW_Layout.PLAYER_AVATAR_FRAME_REF.LEFT = 22,122, 91×118,平台精灵 379):
*
* ┌─────────┐┌──┐
* │ ││庄│ ← P_LEFT_BANKER(1100) attach 头像框 topRight, offset(+7,+8), 40×40
* │ 头像 │└──┘
* │ (平台) │ 60分 ← P_LEFT_STATUS_TEXT(1105) point(170,165) 130×70
* │ │ 12 ← 同一块区域也是倒计时 CD_LEFT 的位置(见 CountdownView)
* ├─────────┤
* │ 10 │ ← 昵称/分数,平台渲染,子游戏不碰
* └─────────┘
* ┌────┐┌────┐
* │主 1││对 0│ ← P_LEFT_ZHU_BG(1101)+P_LEFT_ZHU_TEXT(1102)
* └────┘└────┘ P_LEFT_PAIR_BG(1103)+P_LEFT_PAIR_TEXT(1104)
* 主N 贴头像框 bottomLeft(0,+6) 40×19;对N 再贴主N 右侧 +44
*
* 右上家(PLAYER_AVATAR_FRAME_REF.RIGHT = 1160,122,平台精灵 377)与左上家【左右镜像】:
* 庄印章改贴 topLeft(-40,+8),状态文字改到 point(980,165);主N/对N 仍贴 bottomLeft。
*
* 自己(P_SELF_MARK, 群组 204)只有两个精灵,且落在【底栏】而非头像框旁:
* P_SELF_BANKER(1120) = point(338,668) 32×34(EQW_Layout.P_BANKER_MARK.SELF 把 kind
* 覆盖成 point,不贴附任何目标,与两个上家的 attach 定位方式不同,非笔误);
* 该位置与底栏「踩/有分/没分」按钮【互斥共用】,见 FooterView。
*
* 元素说明:
* - 庄印章: 数据取 shangzhuang.banker / aset.banker,三家三个独立精灵、按 banker 显隐其一。
* - 主N / 对N 角标: 数据取 seatlist[seat][4] = [剩余主牌数, 剩余主对数];
* 【仅可查牌模式 + 场上已有人报无主】才出现,初始 [-1,-1] 时隐藏;不可查牌模式下永不出现。
* 自己没有这组角标——自己的主/对数在手牌区旁的统计条上已经可见(见 ZhuStatView)。
* - 状态文字: 叫分阶段显示「60分」(金) / 「不叫」(白),进入选主后清空;
* 样式 EQW_Layout.TEXT_STYLE.STATUS_BIG (46px 白字居中)。自己(204)也有一个状态文字精灵
* P_SELF_STATUS_TEXT(1121),但清单 §6.6 未给它独立坐标(自己叫的分显示在中央大字区)。
* - ⚠ 参考图上三家同时挂着「庄」印章,是叠画示意(清单 §0.5),实际同时只有一家是庄。
*
* 资源说明:
* - 庄印章: EQW_Images.MARK_BANKER (571), 1 帧, 40×40
* - 主N/对N 角标底: EQW_Images.BADGE_ZHU_PAIR (574), 2 帧, 44×20, 帧1=黄底(主) 帧2=白底(对)
* - 头像框本身由平台提供,子游戏【不建】,仅登记 ID 供 attach 引用——见本文件末尾
* EQW_PlatformSprites(376+ind,落在框架保留段 1–1000)。
*
* 精灵ID分配: 1100–1105(左)/ 1110–1115(右)/ 1120–1121(自己),群组号段 1100–1149
*
* @see docs_dev/二七王-UI资源与精灵清单.md §2.2 玩家位附加标记 / §2.5 余主公示 / §6.6 玩家位配置
* @see docs_dev/uiref/等待叫分.png
*/
//三家玩家位附加标记:庄印章 + 主N/对N 角标 + 状态文字,跨三个群组
//(头像框本身是平台提供,登记见本文件末尾 EQW_PlatformSprites;本 View 的精灵全部 attach 到平台头像框上,见清单 §6.6)
EQW_Sprites.PlayerMarkView = {
Layer: EQW_Layers.TABLE_STATIC, //101
LeftMark: {
id: EQW_Groups.P_LEFT_MARK, //202 左上家
P_LEFT_BANKER: 1100, //图片:「庄」印章,资源 EQW_Images.MARK_BANKER,attach 头像框 topRight
P_LEFT_ZHU_BG: 1101, //图片:「主N」角标底,资源 EQW_Images.BADGE_ZHU_PAIR 帧1(黄底),attach 头像框 bottomLeft
P_LEFT_ZHU_TEXT: 1102, //文字:剩余主牌数
P_LEFT_PAIR_BG: 1103, //图片:「对N」角标底,资源 EQW_Images.BADGE_ZHU_PAIR 帧2(白底),attach 主N角标右侧
P_LEFT_PAIR_TEXT: 1104, //文字:剩余主对数
P_LEFT_STATUS_TEXT: 1105 //文字:「60分」/「不叫」
},
RightMark: {
id: EQW_Groups.P_RIGHT_MARK, //203 右上家,结构同 202
P_RIGHT_BANKER: 1110, //图片:「庄」印章,资源 EQW_Images.MARK_BANKER,attach 头像框 topLeft
P_RIGHT_ZHU_BG: 1111, //图片:「主N」角标底,资源 EQW_Images.BADGE_ZHU_PAIR 帧1(黄底),attach 头像框 bottomLeft
P_RIGHT_ZHU_TEXT: 1112, //文字:剩余主牌数
P_RIGHT_PAIR_BG: 1113, //图片:「对N」角标底,资源 EQW_Images.BADGE_ZHU_PAIR 帧2(白底),attach 主N角标右侧
P_RIGHT_PAIR_TEXT: 1114, //文字:剩余主对数
P_RIGHT_STATUS_TEXT: 1115 //文字:「60分」/「不叫」
},
SelfMark: {
id: EQW_Groups.P_SELF_MARK, //204 自己,无主N/对N角标——自己主/对数已在手牌区可见
P_SELF_BANKER: 1120, //图片:底栏「庄」印章,资源 EQW_Images.MARK_BANKER,point (338,668)
P_SELF_STATUS_TEXT: 1121 //文字:「50分」/「不叫」
}
};
// ============================================================================
// 手牌上方的主牌统计条 - Layer 101 (TABLE_STATIC), Group 206 (ZHU_STAT_BAR)
// ============================================================================
/**
* 自己的主牌统计条 - ZhuStatView
*
* 用途: 手牌区【上方偏左】的一条半透明圆角小条,显示一行统计文案。
*
* 布局说明(参考图 docs_dev/uiref/等待叫分.png 左侧「主牌对子: 1对」那条;
* 位置见 EQW_Layout.ZHU_STAT_BG = point(35, 400, 210×35)):
*
* x=35 x=245
* ┌──────────────────────────────┐ y=400
* │ 主牌对子: 1对 │ ← ZHU_STAT_TEXT(1031),居中白字
* └──────────────────────────────┘ y=435
* ↑ ZHU_STAT_BG(1030) 半透圆角条底,210×35
* ┌──────────────────────────────────────────────────── … ─┐ y=455
* │ 自己手牌区(单排) │
* └──────────────────────────────────────────────────── … ─┘ y=645
*
* 即:紧贴手牌区上沿、左对齐到手牌左边缘附近,整局常驻不遮牌。
*
* 元素说明:
* - 文案为【前端据 flower 本地统计自己手牌】得出,不需要服务端下发,也不受查牌模式与
* 报无主影响;有手牌时始终显示。文案格式暂定「x对 x主」(清单 §7.2 T-9 未定案,
* 参考图写的是「主牌对子: 1对」)——文案进配置,不在代码里写死字符串。
* - 【命名已统一 · 2026-08-27】本 View、EQW_Groups.ZHU_STAT_BAR 与清单 §5.1 原先都叫
* 「亮牌条 / LIANGPAI_*」,与 design §8.2 的「亮牌」【同名异实】,极易写错地方,故改名。
* 两者的区别(清单 §0.0 专节区分四类「公开信息」):
* 本条(群组 206)—— 自己的主牌【数量】统计,本地算、有手牌就常显,不属"公开信息"
* 亮牌(群组 242)—— 庄家埋牌后向两个闲家亮出自己全部固定主牌的【具体牌面】,
* 走遮罩面板(复用 HistoryView 精灵,见 Sprites_Result.js),
* 数据来自 maipai 包的 liangpai.cards
*
* 资源说明:
* - 条底: EQW_Images.BAR_ZHU_STAT (605), 1 帧, 210×35, 半透明圆角
*
* 精灵ID分配: 1030–1031(群组 206)
*
* @see docs_dev/二七王-UI资源与精灵清单.md §2.5 自己的主牌统计 + 余主公示 / §5.1 / §0.0
* @see docs_dev/uiref/等待叫分.png
*/
//自己的主牌统计条(清单 §5.1 群组 206;非 design §8.2 的「亮牌」)
EQW_Sprites.ZhuStatView = {
Layer: EQW_Layers.TABLE_STATIC, //101
ZhuStatBar: {
id: EQW_Groups.ZHU_STAT_BAR, //206
ZHU_STAT_BG: 1030, //图片:半透圆角条,资源 EQW_Images.BAR_ZHU_STAT
ZHU_STAT_TEXT: 1031 //文字:自己主牌统计文案「x对 x主」
}
};
// ============================================================================
// 底栏 - Layer 101 (TABLE_STATIC), Group 205 (FOOTER)
// ============================================================================
/**
* 底栏 - FooterView
*
* 用途: 屏幕最下方一条深色横条(整局常驻),承载局数、右侧功能按钮组、
* 以及闲家专用的「踩/有分/没分」提示按钮。左侧头像/昵称/分数由平台渲染。
*
* 布局说明(参考图 docs_dev/uiref/等待叫分.png 底栏 +
* docs_dev/uiref/自己是闲家时,底下的踩有分没分按钮.png;
* 条底见 EQW_Layout.FOOTER_BG = point(0, 648, 1280×72)):
*
* y=648 ┌──────────────────────────────────────────────────────────────────────┐
* │ ┌────┐ │
* │ │头像│ 玩家昵称 10 [庄] 1/4 局 [明牌][已出牌][上一轮][扣底][底牌] │
* │ └────┘ │
* y=720 └──────────────────────────────────────────────────────────────────────┘
* └── 平台渲染,子游戏不碰 ──┘
* (A) (B) (C)
*
* (A) x≈335–573:庄印章与三个提示按钮【互斥】共用同一块区域(清单 §1.7)——
* 自己是庄 → P_SELF_BANKER(1120)(属 PlayerMarkView),point(338,668) 32×34
* 自己是闲 → 三个提示按钮,EQW_Layout.FOOTER_TISHI_BUTTONS:
* line/horizontal, anchor left, anchorX=335, anchorY=672,
* itemWidth=52, itemHeight=30, spacing=26
* ┌────┐ ┌────┐ ┌────┐
* │ 踩 │ │有分│ │没分│
* └────┘ └────┘ └────┘
* BTN_TISHI_CAI BTN_TISHI_HAS BTN_TISHI_NONE
* (1050) (1052) (1054)
* ⚠ 按钮顺序是 踩→有分→没分,与协议 tip 编号(1踩 2没分 3有分)
* 【不同序】,映射不要按位置下标推。
* (B) FOOTER_ASET_TEXT(1041)「1/4 局」,point(635,672) 65×26,
* 样式 EQW_Layout.TEXT_STYLE.ASET_COUNT (20px 白字居中),常驻,取 asetidx/asetcount。
* (C) 右侧功能钮组 EQW_Layout.FOOTER_FUNC_BUTTONS:line/horizontal,右对齐到 x=1250,
* anchorY=672,itemHeight=30,spacing=15,【逐项不等宽】(T-25):
* BTN_MINGPAI(1042) 74 / BTN_HISTORY(1044) 87 / BTN_LAST_ROUND(1046) 93 /
* BTN_BURY_CARDS(1056) 78【估值】/ BTN_BOTTOM_CARDS(1048) 78
* 五个按钮各按条件显隐、不全常驻(清单 §2.6);参考图里同时画出属叠画示意。
*
* 元素说明:
* - 每个按钮 = 一个图片精灵(按钮底)+ 一个文字精灵(按钮文案),成对出现。
* - 显隐条件(服务端权威字段驱动,前端不自行推导):
* 明牌 —— 可查牌 + 出牌阶段 + 场上已有人报无主(baozhu==1),三家都显示
* 已出牌 —— 可查牌 + 出牌阶段
* 上一轮 —— 可查牌 + 出牌阶段 + 已打过至少一轮
* 扣底 —— 仅庄家 + 已埋牌之后
* 底牌 —— 庄家全程;闲家仅开底过的局(70 分坐庄),见 S-3
* 踩/有分/没分 —— 自己是闲家 + 出牌阶段
* - 【扣底 ≠ 底牌,别写反】(清单 §0.0 / §2.6,验收清单第 13 条):
* 扣底 → 看【埋牌底牌】:庄家埋牌时从手里扣下的 8 张,字段 burycards(结算包为 bottom.cards)
* 底牌 → 看【底牌】 :发牌时没发给玩家、留在桌面的 8 张,字段 bottomcards
* 两批牌完全不同;内部变量建议按 buryCards / bottomCards 与字段名一一对应。
*
* 资源说明:
* - 底栏条: EQW_Images.BAR_FOOTER (608), 1 帧, 1280×72
* - 功能钮底: EQW_Images.BTN_BOTTOM_FUNC (538), 1 帧, 88×30, 深色圆角(5 个按钮共用)
* - 提示钮底: EQW_Images.BTN_TISHI (540), 1 帧, 52×30, 同风格但更窄(3 个按钮共用)
*
* 精灵ID分配: 1040–1057(群组 205 号段 1040–1069)
*
* @see docs_dev/二七王-UI资源与精灵清单.md §2.6 底栏 / §1.7 闲家提示 / §5.1 / §6.5
* @see docs_dev/uiref/等待叫分.png
* @see docs_dev/uiref/自己是闲家时,底下的踩有分没分按钮.png
*/
//底栏:局数 + 功能按钮(明牌/已出牌/上一轮/扣底/底牌)+ 提示按钮(清单 §5.1 群组 205)
EQW_Sprites.FooterView = {
Layer: EQW_Layers.TABLE_STATIC, //101
Footer: {
id: EQW_Groups.FOOTER, //205
FOOTER_BG: 1040, //图片:底栏深色条,资源 EQW_Images.BAR_FOOTER
FOOTER_ASET_TEXT: 1041, //文字:「1/4 局」
BTN_MINGPAI: 1042, //图片:「明牌」按钮,资源 EQW_Images.BTN_BOTTOM_FUNC
BTN_MINGPAI_TEXT: 1043, //文字:按钮文案「明牌」
BTN_HISTORY: 1044, //图片:「已出牌」按钮,资源 EQW_Images.BTN_BOTTOM_FUNC
BTN_HISTORY_TEXT: 1045, //文字:按钮文案「已出牌」
BTN_LAST_ROUND: 1046, //图片:「上一轮」按钮,资源 EQW_Images.BTN_BOTTOM_FUNC
BTN_LAST_ROUND_TEXT: 1047, //文字:按钮文案「上一轮」
BTN_BOTTOM_CARDS: 1048, //图片:「底牌」按钮,资源 EQW_Images.BTN_BOTTOM_FUNC
BTN_BOTTOM_CARDS_TEXT: 1049, //文字:按钮文案「底牌」
BTN_TISHI_CAI: 1050, //图片:「踩」按钮(闲家,占庄标区),资源 EQW_Images.BTN_TISHI
BTN_TISHI_CAI_TEXT: 1051, //文字:「踩」
BTN_TISHI_HAS: 1052, //图片:「有分」按钮,资源 EQW_Images.BTN_TISHI
BTN_TISHI_HAS_TEXT: 1053, //文字:「有分」
BTN_TISHI_NONE: 1054, //图片:「没分」按钮,资源 EQW_Images.BTN_TISHI
BTN_TISHI_NONE_TEXT: 1055, //文字:「没分」
//——「扣底」按钮(2026-08-27 补齐):看【埋牌底牌】= 庄家埋下的 8 张(字段 burycards),
// 与上面的「底牌」按钮(看发牌留桌的 8 张,字段 bottomcards)是两批不同的牌,勿写反 ——
BTN_BURY_CARDS: 1056, //图片:「扣底」按钮,资源 EQW_Images.BTN_BOTTOM_FUNC
BTN_BURY_CARDS_TEXT: 1057 //文字:按钮文案「扣底」
}
};
///////////////////////////////////////////////////////////////
////////// EQW_PlatformSprites: 平台提供精灵登记表 //////////////
///////////////////////////////////////////////////////////////
//平台提供的精灵,子游戏【不建】,仅登记 ID 供布局 attach 引用。
//落在框架保留段 1–1000,故独立于 EQW_Sprites、不参与子游戏号段校验。
//三人局座位映射(清单 §0.2):ind0=南=自己 ind1=东=右上家 ind3=西=左上家;
//头像框基址 376+ind(清单 §6.6)。
var EQW_PlatformSprites = EQW_PlatformSprites || {
P_SELF_AVATAR: 376, //平台头像框 376+ind0,自己(南)
P_RIGHT_AVATAR: 377, //平台头像框 376+ind1,右上家(东)
P_LEFT_AVATAR: 379 //平台头像框 376+ind3,左上家(西)
};
@@ -0,0 +1,22 @@
///////////////////////////////////////////////////////////////
////////// EQW_CardCodec: 牌 id → 牌面资源帧号(纯逻辑)//////////
///////////////////////////////////////////////////////////////
// 清单 §0.4:牌面资源统一 10 列 × 6 行 = 60 帧,行优先编号
// 帧 1–13 黑桃 A–K / 14–26 红桃 / 27–39 梅花 / 40–52 方块
// 帧 53 小王 / 54 大王 / 55–60 牌背 1–6(二七王用帧 55)
// 服务端牌 id 的花色段序是 方块→梅花→红心→黑桃(class.pai.js:16),
// 与美术帧的花色顺序【正好相反】,转换必须把花色段反过来数。
// 【全前端只此一处实现】其余模块一律调用它,不得另写一份。
var EQW_CardCodec = EQW_CardCodec || {
//牌背帧号(二七王固定用牌背 1)
CARD_BACK_FRAME: 55,
//牌 id → 牌面资源帧号。deck1 与 deck2 的同一张牌共用同一帧(牌面完全相同)
cardIdToFrame: function (cardId) {
var n = cardId % 54; // 去掉副数
if (n === 52) { return 53; } // 小王
if (n === 53) { return 54; } // 大王
return (3 - Math.floor(n / 13)) * 13 + (n % 13) + 1;
}
};
+110
View File
@@ -0,0 +1,110 @@
///////////////////////////////////////////////////////////////
////////// EQW_CardMark: 手牌牌面标记与花色统计(纯逻辑)///////
///////////////////////////////////////////////////////////////
// 清单 §1.6 / T-8:牌面三种标记,前端据主花色本地推导,服务端不下发。
// 'tractor' 红色圆标「拖」= 该牌属于一组拖拉机
// 'zheng' 橙色五角星 = 正2 / 正7(选定花色的 2 和 7)
// 'zhu' 蓝色五角星 = 其余主牌(双王、副2、副7、主花色普通牌)
// 三者【互斥】、每张牌至多一个,优先级 tractor > zheng > zhu。
//
// 牌编码区间边界一律取自 shared/cards.js 导出的具名常量(CODE_ZHU_MIN / CODE_ZHENG2_* /
// CODE_ZHENG7_* …)——那里是 id_to_code 的权威说明处,本文件不重抄 1000/3000/8000 之类裸值。
// ⇒ 主牌 ⟺ code > CODE_ZHU_MIN
//
// 拖拉机只在【同一牌组】内成立:全部主牌算一组(副2/副7/王与主花色牌可相连),
// 副牌按各自花色分组。极大连续段的扫描与「几对起算拖拉机」是前后端共享算法
// (shared/cards.js 的 group_tractor_runs / TRACTOR_MIN_PAIRS,服务端 decompose_trump 同源),
// 本文件只负责按段落标记,不再自己实现一遍扫描。
var EQW_CardMark = EQW_CardMark || {
MARK_TRACTOR: 'tractor',
MARK_ZHENG: 'zheng',
MARK_ZHU: 'zhu',
//整手牌的标记表:{ 牌id: 标记 };没有标记的牌不出现在结果里
marksOfHand: function (cards, mainflower) {
var marks = {};
if (!cards || cards.length === 0) { return marks; }
var mf = mainflower || 0;
var S = youle_erqiwang_shared_cards;
var i, code;
//1) 分组:主牌一组(键 'Z'),副牌按花色分组(键为花色号)
var groups = {};
for (i = 0; i < cards.length; i++) {
code = S.id_to_code(mf, cards[i]);
var key = (code > S.CODE_ZHU_MIN) ? 'Z' : String(S.id_to_flower(cards[i]));
if (!groups[key]) { groups[key] = []; }
groups[key].push(cards[i]);
}
//2) 每组内找拖拉机
for (var g in groups) {
if (!groups.hasOwnProperty(g)) { continue; }
var ordered = S.order_cards(mf, groups[g].concat());
var pairs = S.get_pairlist(mf, ordered);
var runs = S.group_tractor_runs(mf, pairs);
for (var j = 0; j < runs.length; j++) {
var run = runs[j];
if (run.length < S.TRACTOR_MIN_PAIRS) { continue; } //孤立对子(长度1的段)不是拖拉机
for (var r = 0; r < run.length; r++) {
marks[run[r][0]] = this.MARK_TRACTOR;
marks[run[r][1]] = this.MARK_TRACTOR;
}
}
}
//3) 其余牌按 zheng / zhu 标注(拖拉机优先,已标的不覆盖)
for (i = 0; i < cards.length; i++) {
if (marks[cards[i]]) { continue; }
code = S.id_to_code(mf, cards[i]);
if ((code > S.CODE_ZHENG2_MIN && code < S.CODE_ZHENG2_MAX) ||
(code > S.CODE_ZHENG7_MIN && code < S.CODE_ZHENG7_MAX)) {
marks[cards[i]] = this.MARK_ZHENG; //正2 / 正7
} else if (code > S.CODE_ZHU_MIN) {
marks[cards[i]] = this.MARK_ZHU; //其余主牌
}
}
return marks;
},
//单张牌的标记;cards 是它所在的整手牌(拖拉机需要上下文才能判定)
markOf: function (cardId, cards, mainflower) {
var marks = this.marksOfHand(cards, mainflower);
return marks[cardId] || null;
},
//选主面板用:各花色的张数与对子数(清单 §1.5,服务端不下发、前端自算)
//按【牌面花色】统计,与主牌无关;王不属于任何花色,不计入
countByFlower: function (cards) {
var result = { 1: { count: 0, pairs: 0 }, 2: { count: 0, pairs: 0 },
3: { count: 0, pairs: 0 }, 4: { count: 0, pairs: 0 } };
if (!cards || cards.length === 0) { return result; }
var S = youle_erqiwang_shared_cards;
var byFlowerNumber = {}; //{花色: {点数: 张数}}
var i, f, n;
for (i = 0; i < cards.length; i++) {
f = S.id_to_flower(cards[i]);
if (f === 5) { continue; } //王
n = S.id_to_number(cards[i]);
if (!byFlowerNumber[f]) { byFlowerNumber[f] = {}; }
byFlowerNumber[f][n] = (byFlowerNumber[f][n] || 0) + 1;
result[f].count++;
}
for (f = 1; f <= 4; f++) {
var nums = byFlowerNumber[f];
if (!nums) { continue; }
for (n in nums) {
if (!nums.hasOwnProperty(n)) { continue; }
result[f].pairs += Math.floor(nums[n] / 2);
}
}
return result;
}
};
@@ -0,0 +1,17 @@
///////////////////////////////////////////////////////////////
////////// EQW_CardOrder: 手牌主牌序排序(纯逻辑)//////////////
///////////////////////////////////////////////////////////////
// 清单 T-5:前端排序【对齐服务端主牌序,不另造一套】——
// 权威实现是 shared/cards.js 的 order_cards(与服务端同一份文件)。
// 本模块只负责两件事:不改调用方的数组、把「未选主」表达为 mainflower = 0。
// 选主前(叫分阶段):flower 未定,只有固定主牌(双王 + 全部 2、7)是主牌
// 选主后:正2/正7 升格、主花色普通牌并入主牌段,必须整体重排(design §3)
// 排序从大到小,主牌段自然落在左侧。
var EQW_CardOrder = EQW_CardOrder || {
//返回排好序的【新数组】;cards 不被修改。mainflower:1方块 2梅花 3红心 4黑桃,0 = 尚未选主
sort: function (cards, mainflower) {
if (!cards) { return []; }
return youle_erqiwang_shared_cards.order_cards(mainflower || 0, cards.concat());
}
};
@@ -0,0 +1,42 @@
///////////////////////////////////////////////////////////////
////////// EQW_SeatMap: 服务端座位 ↔ 屏幕显示位(纯逻辑)///////
///////////////////////////////////////////////////////////////
// 清单 §0.1:服务端座位是绝对序号 0/1/2;前端一律把自己放底部,
// 另两家按 design §4 的逆时针顺序分居左上、右上。
// 底部(自己) = mySeat
// 右上(下家) = (mySeat + 1) % 3 ← 服务端 class.paiju.js:264 get_nextseat
// 左上(上家) = (mySeat + 2) % 3
// 【全前端只此一处实现】布局配置的 bySeat 键、各界面的座位换算都用它。
var EQW_SeatMap = EQW_SeatMap || {
SELF: 'SELF',
LEFT: 'LEFT',
RIGHT: 'RIGHT',
//服务端座位 → 显示位键。
//入参必须是 0/1/2:非法值(undefined / 3 / 小数)会算出 NaN,NaN 既不等于 0 也不等于 1,
//最后落到 return 'LEFT'——一个看起来很合理的错误答案。故与 toSeat 一样显式抛错
toDisplay: function (seat, mySeat) {
this._requireSeat(seat, 'seat');
this._requireSeat(mySeat, 'mySeat');
var d = ((seat - mySeat) % 3 + 3) % 3;
if (d === 0) { return 'SELF'; }
if (d === 1) { return 'RIGHT'; }
return 'LEFT';
},
_requireSeat: function (v, name) {
if (v !== 0 && v !== 1 && v !== 2) {
throw new Error('[EQW_SeatMap] ' + name + ' 必须是 0/1/2,收到: ' + v);
}
},
//显示位键 → 服务端座位。mySeat 同样必须是 0/1/2(否则算出 NaN 座位号)
toSeat: function (displayKey, mySeat) {
this._requireSeat(mySeat, 'mySeat');
if (displayKey === 'SELF') { return mySeat; }
if (displayKey === 'RIGHT') { return (mySeat + 1) % 3; }
if (displayKey === 'LEFT') { return (mySeat + 2) % 3; }
throw new Error('[EQW_SeatMap] 未知显示位: ' + displayKey);
}
};
@@ -0,0 +1,101 @@
///////////////////////////////////////////////////////////////
////////// EQW_SpriteIndex: 精灵键名 → ID 扁平索引 /////////////
///////////////////////////////////////////////////////////////
// 精灵常量以 UI View 为组织单位,View 下是一个个 group 容器:
// EQW_Sprites.XxxView = { Layer: 图层, GroupName: { id: 群组, 精灵键: id, ... } }
// 而布局配置里 attach.target 写的是【键名】(清单 §6.1:ID 回填时只改一处
// 映射表),故需把这棵树展平成 { 键名: 精灵ID } 供求解器查用;
// group 容器把精灵与群组 ID 绑在一起,顺带能给出 { 键名: 群组ID }。
// 【显式失败】重复键、值非数字、结构不合法、查不到的键一律抛错——静默覆盖
// 会让某个界面指向另一个界面的精灵,且极难排查(工程总则 §7)。
var EQW_SpriteIndex = EQW_SpriteIndex || {
_index: null,
_groupMap: null,
//遍历 树 → View → group → 精灵,对每个精灵回调 fn(key, spriteId, groupId)
//【不变式】一个 View 恰好一个图层:View 必须有数字型的 Layer 键。
//跨图层的界面应拆成两个 View(各带自己的 Layer)——一个 View 将来对应一个
//BaseComponent 组件,把两个图层塞进一个 View 会让「哪个 group 属于哪个图层」
//只能靠注释隐含、无法机械判定。
_walk: function (spriteTree, fn) {
for (var viewName in spriteTree) {
if (!spriteTree.hasOwnProperty(viewName)) { continue; }
var view = spriteTree[viewName];
if (typeof view.Layer !== 'number') {
throw new Error('[EQW_SpriteIndex] View ' + viewName +
' 缺少数字型的 Layer(一个 View 恰好一个图层;跨图层请拆成两个 View)');
}
for (var groupName in view) {
if (!view.hasOwnProperty(groupName)) { continue; }
if (groupName === 'Layer') { continue; } //View 级图层声明
var group = view[groupName];
if (!group || typeof group !== 'object') {
throw new Error('[EQW_SpriteIndex] ' + viewName + '.' + groupName +
' 不是 group 容器(值应为对象)——图层声明请用保留键名 Layer');
}
if (typeof group.id !== 'number') {
throw new Error('[EQW_SpriteIndex] group 容器 ' + viewName + '.' + groupName +
' 缺少数字型的 id(群组 ID)');
}
for (var key in group) {
if (!group.hasOwnProperty(key)) { continue; }
if (key === 'id') { continue; } //群组 ID
if (typeof group[key] !== 'number') {
throw new Error('[EQW_SpriteIndex] 精灵 ' + key + ' 的值不是数字(' +
viewName + '.' + groupName + ')');
}
fn(key, group[key], group.id, viewName, groupName);
}
}
}
},
//展平成 { 键名: 精灵ID }
build: function (spriteTree) {
var index = {};
this._walk(spriteTree, function (key, spriteId, groupId, viewName, groupName) {
if (index.hasOwnProperty(key)) {
throw new Error('[EQW_SpriteIndex] 精灵键名重复: ' + key +
'(' + viewName + '.' + groupName + ' 与更早的定义冲突)');
}
index[key] = spriteId;
});
return index;
},
//展平成 { 键名: 群组ID },供「显隐走群组」时查精灵属于哪个群组
buildGroupMap: function (spriteTree) {
var map = {};
this._walk(spriteTree, function (key, spriteId, groupId) {
map[key] = groupId;
});
return map;
},
//用给定的树(默认 EQW_Sprites)建立两张内部索引
init: function (spriteTree) {
var tree = spriteTree || EQW_Sprites;
this._index = this.build(tree);
this._groupMap = this.buildGroupMap(tree);
return this._index;
},
//按键名取精灵 ID;查不到抛错,绝不返回 undefined 让 SpriteManager 静默无效
idOf: function (key) {
if (!this._index) { throw new Error('[EQW_SpriteIndex] 尚未 init()'); }
if (!this._index.hasOwnProperty(key)) {
throw new Error('[EQW_SpriteIndex] 未知精灵键名: ' + key);
}
return this._index[key];
},
//按键名取它所属的群组 ID
groupIdOf: function (key) {
if (!this._groupMap) { throw new Error('[EQW_SpriteIndex] 尚未 init()'); }
if (!this._groupMap.hasOwnProperty(key)) {
throw new Error('[EQW_SpriteIndex] 未知精灵键名: ' + key);
}
return this._groupMap[key];
}
};
@@ -0,0 +1,66 @@
///////////////////////////////////////////////////////////////
////////// EQW_Dispatcher: 唯一收包入口 + 纯路由表 ////////////
///////////////////////////////////////////////////////////////
// 前端 04 §2:收包统一分发,一 rpc 一处理器。
// 【本文件只分发,不写业务】——新增一种服务端推送 = 注册一条 rpc → handler。
//
// 判定顺序(关键):先看 data.success,为 false 一律交 FailHandler、不进业务 handler。
// 因为协议 §0.2 规定失败回包的 rpc 与【请求】同名(chupai 失败回 chupai,
// 而成功走 chupai1/2/3),不先分流的话每个业务 handler 都得自己判一遍。
var EQW_Dispatcher = EQW_Dispatcher || {
_handlers: {},
//注册一条 rpc → handler
register: function (rpc, handler) {
if (typeof rpc !== 'string' || typeof handler !== 'function') {
throw new Error('[EQW_Dispatcher] register 参数非法: ' + rpc);
}
if (this._handlers.hasOwnProperty(rpc)) {
throw new Error('[EQW_Dispatcher] rpc 重复注册: ' + rpc);
}
this._handlers[rpc] = handler;
},
hasHandler: function (rpc) {
return this._handlers.hasOwnProperty(rpc);
},
//唯一收包入口。msg = { rpc, data }
dispatch: function (msg) {
if (!msg || typeof msg.rpc !== 'string') {
console.warn('[EQW_Dispatcher] 畸形数据包,已忽略');
return;
}
var data = msg.data || {};
//成败只认 data.success(前端 04 §4):失败包一律先分流
if (data.success === false) {
EQW_FailHandler.handle(msg.rpc, data);
return;
}
var h = this._handlers[msg.rpc];
if (h) {
h(data);
} else {
//平台日后新增推送不该让前端崩
console.warn('[EQW_Dispatcher] 未处理的 rpc: ' + msg.rpc);
}
}
};
//—— 路由表:一 rpc 一处理器(协议里每个服务端推送都要在这里注册)——
//新增一种推送 = 加一条注册 + 写对应 handler;本文件不写业务。
EQW_Dispatcher.register('fapai', function (d) { EQW_DealHandler.handle(d); });
EQW_Dispatcher.register('jiaofen', function (d) { EQW_CallHandler.handleJiaofen(d); });
EQW_Dispatcher.register('shangzhuang', function (d) { EQW_CallHandler.handleShangzhuang(d); });
EQW_Dispatcher.register('xuanzhu', function (d) { EQW_MainHandler.handleXuanzhu(d); });
EQW_Dispatcher.register('maipai', function (d) { EQW_BuryHandler.handle(d); });
EQW_Dispatcher.register('chupai1', function (d) { EQW_PlayHandler.handle(d, 1); });
EQW_Dispatcher.register('chupai2', function (d) { EQW_PlayHandler.handle(d, 2); });
EQW_Dispatcher.register('chupai3', function (d) { EQW_PlayHandler.handle(d, 3); });
EQW_Dispatcher.register('mingpai', function (d) { EQW_QueryHandler.handleMingpai(d); });
EQW_Dispatcher.register('tishi', function (d) { EQW_QueryHandler.handleTishi(d); });
EQW_Dispatcher.register('jiesuan', function (d) { EQW_ResultHandler.handleJiesuan(d); });
EQW_Dispatcher.register('zhunbei', function (d) { EQW_ReadyHandler.handle(d); });
+123
View File
@@ -0,0 +1,123 @@
///////////////////////////////////////////////////////////////
////////// EQW_Rpc: 语义化发包(一操作一方法)/////////////////
///////////////////////////////////////////////////////////////
// 前端 04 §1:业务不直接拼包,走两层封装——
// 业务 → EQW_Rpc.xxx(意图) → RpcHelper(注入平台字段)→ 引擎发送
//
// 【route 必须是 erqiwang】RpcHelper.sendGameRpc 预设 route="room"(平台房间模块),
// 用它包会被路由到平台、永远到不了子游戏 mod.js,而且不会有任何报错。
//
// 【请求包只带意图,不带结论】(前端 04 §1 / 服务端 04 §8)
// 可以带:操作类型、目标标识(牌 id / 花色 / 提示类型)、座位号(仅供一致性校验)。
// 禁止带:分数、番数、判定结果、阶段推进指令、nextSeat、handCards。
// 前端 shared/ 的计算只用于本地提示与预校验,【结果不回传】。
// 一句话:前端能自己推出来的东西,就是玩家能改的东西——这是防作弊的根本。
//
// 本文件只做【形状】校验(牌 id 范围/去重/张数),不做【规则】校验
// (这手牌能不能出、叫分够不够低由服务端裁定)。形状不合法就 fail-fast 抛错,
// 不发出残缺包——服务端会按 PARAM 拒绝,但让错误暴露在最近处更省事。
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 () {
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;
},
//—— 内部:发送 ——
_send: function (rpc, data) {
var pack = { seat: this._seat() };
var key;
for (key in data) {
if (data.hasOwnProperty(key)) {
pack[key] = data[key];
}
}
RpcHelper.sendRpc(this.APP, this.ROUTE, rpc, pack);
},
//—— 内部:牌 id 列表形状校验(协议 §0.2)——
//必须是非空数组,元素是 0–107 的整数,互不重复
_checkCards: function (cards, expectCount) {
if (Object.prototype.toString.call(cards) !== '[object Array]' || cards.length === 0) {
throw new Error('[EQW_Rpc] cards 必须是非空数组');
}
if (typeof expectCount === 'number' && cards.length !== expectCount) {
throw new Error('[EQW_Rpc] cards 张数应为 ' + expectCount + ',实际 ' + cards.length);
}
var seen = {};
for (var i = 0; i < cards.length; i++) {
var c = cards[i];
if (typeof c !== 'number' || c !== Math.floor(c) || c < 0 || c > 107) {
throw new Error('[EQW_Rpc] 非法牌 id: ' + c + '(须为 0–107 的整数)');
}
if (seen[c]) { throw new Error('[EQW_Rpc] 牌 id 重复: ' + c); }
seen[c] = true;
}
},
//—— 叫分(call: 5–70 的 5 的倍数;0 = 不叫)——
jiaofen: function (call) {
if (typeof call !== 'number' || call !== Math.floor(call)) {
throw new Error('[EQW_Rpc] call 必须是整数');
}
if (call !== 0 && (call < 5 || call > 70 || call % 5 !== 0)) {
throw new Error('[EQW_Rpc] 非法叫分: ' + call + '(须为 0 或 5–70 的 5 的倍数)');
}
this._send('jiaofen', { call: call });
},
//—— 投降(仅 70 分坐庄、选主阶段;合法性由服务端裁定)——
touxiang: function () {
this._send('touxiang', {});
},
//—— 选主(flower: 1方块 2梅花 3红心 4黑桃)——
xuanzhu: function (flower) {
if (typeof flower !== 'number' || flower < 1 || flower > 4) {
throw new Error('[EQW_Rpc] 非法花色: ' + flower + '(须为 1–4)');
}
this._send('xuanzhu', { flower: flower });
},
//—— 埋牌(恒 8 张)——
maipai: function (cards) {
this._checkCards(cards, 8);
this._send('maipai', { cards: cards });
},
//—— 出牌 ——
chupai: function (cards) {
this._checkCards(cards);
this._send('chupai', { cards: cards });
},
//—— 明牌(查看他家未出主牌)——
mingpai: function () {
this._send('mingpai', {});
},
//—— 出牌提示(tip: 1踩 2没分 3有分)——
tishi: function (tip) {
if (tip !== 1 && tip !== 2 && tip !== 3) {
throw new Error('[EQW_Rpc] 非法提示类型: ' + tip + '(须为 1踩 2没分 3有分)');
}
this._send('tishi', { tip: tip });
},
//—— 准备 ——
zhunbei: function () {
this._send('zhunbei', {});
}
};
@@ -0,0 +1,56 @@
///////////////////////////////////////////////////////////////
////////// EQW_BuryHandler: 埋牌(maipai)////////////////////
///////////////////////////////////////////////////////////////
// 协议 §9:cards 埋牌后手牌(仅庄家) / burycards 埋牌底牌(仅庄家) /
// step 阶段(=5 出牌) / seat 控制权=首出者(必为庄家) / countdown 出牌倒计时 /
// seatlist 三家牌况(仅可查牌模式) /
// liangpai 亮牌(仅闲家 + 可查牌模式 + 庄家固定主牌达门槛)
//
// 注意 burycards(埋牌底牌,庄家埋下的 8 张)与 bottomcards(底牌,发牌留桌的 8 张)
// 是两批不同的牌,协议 §0.0 与验收清单专门警告过别写反。
//
// 【seatlist 此刻是全初值,但必须接】:埋牌完成时这张表刚初始化
// (每家 [[0,0],[0,0],[0,0],[0,0],[-1,-1]]),界面上看不出差别——但「埋牌完成 → 庄家首出」
// 这段窗口内重连拿到的 PushCards.seatlist 就是它,漏接会让增量路径此刻为空、重连路径却有表。
//
// 【bottomcards 到此失效,要清空】:底牌在上庄时已翻给玩家看并并入手牌,埋牌完成后
// 只剩「埋牌底牌 burycards」有意义;deskinfo 的 PushCards 不带 bottomcards,重连重建恒为 []。
//
// 【playproc 是服务端的「本轮进行态」,原样拷贝】:埋牌完成时服务端已就地开好 round-1
// 的进行态(庄家首出、桌面全空),恒有、三家同值,与 chupai1/2/3.playproc / 重连包
// PushCards.playproc 同源同结构(服务端同一个 get_playproc())。没有它,「埋牌完成 →
// 庄家首出」这段窗口增量路径 table.playproc 会停在上一阶段的 null,与重连路径不一致。
var EQW_BuryHandler = EQW_BuryHandler || {
handle: function (data) {
if (!data.hasOwnProperty('seat')) {
console.error('[EQW_BuryHandler] maipai 缺 seat,已跳过该包');
return;
}
//庄家才有的两项,闲家缺字段时保持原值、不抹手牌
EQW_GameState._apply(EQW_GameState.my, data, {
cards: 'cards',
buryCards: 'burycards'
});
//底牌已并入手牌、埋牌完成即失效(重连的 PushCards 不带它),显式清空保持两条路径一致
EQW_GameState.my.bottomCards = [];
//三家牌况(仅可查牌模式)、闲家才可能有的亮牌、本轮进行态(恒有,见上方注释)
EQW_GameState._apply(EQW_GameState.table, data, {
seatlist: 'seatlist',
liangpai: 'liangpai',
playproc: 'playproc'
});
//阶段只读包字段(服务端权威),不按 rpc 名硬编码 step=5
EQW_GameState._apply(EQW_GameState.aset, data, { step: 'step' });
//控制权:本包的 seat 就是「轮到谁」(即将首出的庄家,服务端取自 playproc.currseat)
EQW_GameState._apply(EQW_GameState.turn, data, {
seat: 'seat',
countdown: 'countdown'
});
EventBus.emit(EQW_Events.EQW_BURY_DONE);
if (data.hasOwnProperty('cards')) { EventBus.emit(EQW_Events.EQW_HAND_CHANGED); }
EventBus.emit(EQW_Events.EQW_TURN_CHANGED);
}
};
@@ -0,0 +1,78 @@
///////////////////////////////////////////////////////////////
////////// EQW_CallHandler: 叫分(jiaofen)与上庄(shangzhuang)
///////////////////////////////////////////////////////////////
// 协议 §3 jiaofen:seat 叫分者 / call 分数(0=不叫) / currcall 当前叫到的分 /
// multiple 该叫分对应基础子数 / step 阶段(仍=1) /
// nextseat 下一个叫分者(控制权) / countdown
// 协议 §4 shangzhuang:seat / call / banker / grade 庄家叫分 / multiple /
// step 阶段(=2 选主/投降) / nextseat 控制权(=庄家) /
// bottomcards 底牌(庄家恒有,闲家仅 70 分时有) / ancard3s 开底标志 /
// cards 摸底后的 36 张(仅庄家) / countdown 选主倒计时 /
// touxiang 是否允许投降 / curmultiple 当前抓分倍数(带符号)
//
// 【shangzhuang 的 seat 与 nextseat 别混用】seat 是【最后一个叫分者】,nextseat 才是
// 「轮到谁」(选主/投降只由庄家做,服务端 mod.xuanzhu/mod.touxiang 以 banker 为准)。
// 曾经这里写 turn.seat = aset.banker 自己推——那是把「选主 = 庄家」这条规则搬到了前端;
// 同一条规则在 ResyncHandler 里还有一份,同一份数据两个写入处(SSOT)。现一律读包字段。
var EQW_CallHandler = EQW_CallHandler || {
handleJiaofen: function (data) {
if (!data.hasOwnProperty('seat')) {
console.error('[EQW_CallHandler] jiaofen 缺 seat,已跳过该包');
return;
}
//记录这一家叫了多少:0 = 不叫,与「还没叫」(null) 区分
if (data.hasOwnProperty('call') && data.seat >= 0 && data.seat < 3) {
EQW_GameState.call.calls[data.seat] = data.call;
}
EQW_GameState._apply(EQW_GameState.call, data, { currcall: 'currcall' });
EQW_GameState._apply(EQW_GameState.aset, data, {
multiple: 'multiple',
step: 'step'
});
EQW_GameState._apply(EQW_GameState.turn, data, {
seat: 'nextseat',
countdown: 'countdown'
});
EventBus.emit(EQW_Events.EQW_CALL_CHANGED);
EventBus.emit(EQW_Events.EQW_TURN_CHANGED);
},
handleShangzhuang: function (data) {
if (!data.hasOwnProperty('banker')) {
console.error('[EQW_CallHandler] shangzhuang 缺 banker,已跳过该包');
return;
}
EQW_GameState._apply(EQW_GameState.aset, data, {
banker: 'banker',
call: 'grade', //协议里庄家叫分叫 grade
multiple: 'multiple',
touxiang: 'touxiang',
curmultiple:'curmultiple',
step: 'step' //阶段服务端权威给出,不按 rpc 名硬编码
});
EQW_GameState._apply(EQW_GameState.table, data, { ancard3s: 'ancard3s' });
//底牌与摸底后的手牌只有庄家(及 70 分时的闲家)才有,缺就不写、不抹已有值
EQW_GameState._apply(EQW_GameState.my, data, {
bottomCards: 'bottomcards',
cards: 'cards'
});
//控制权:读包里的 nextseat(服务端权威),不用本地的 banker 反推
EQW_GameState._apply(EQW_GameState.turn, data, {
seat: 'nextseat',
countdown: 'countdown'
});
//叫分过程量(currcall/calls)step2 起无语义:deskinfo 从 ChooseMain 起就不再带 CallRun,
//这里清成 reset() 的默认值,避免前端留着一份 deskinfo 不会再给的数据(视图=f(快照))
EQW_GameState.call.currcall = 0;
EQW_GameState.call.calls = [null, null, null];
EventBus.emit(EQW_Events.EQW_BANKER_SET);
if (data.hasOwnProperty('cards')) { EventBus.emit(EQW_Events.EQW_HAND_CHANGED); }
EventBus.emit(EQW_Events.EQW_TURN_CHANGED);
}
};
@@ -0,0 +1,37 @@
///////////////////////////////////////////////////////////////
////////// EQW_DealHandler: 发牌(fapai)//////////////////////
///////////////////////////////////////////////////////////////
// 协议 §1:asetidx 当前局数 / asetcount 总局数 / cards 自己的 28 张 /
// step 阶段(=1 叫分) / seat 当前等待叫分者 / countdown 叫分倒计时
//
// 【step 只读包字段,不按 rpc 硬编码】阶段与控制权由服务端唯一维护、逐包显式下发
//(前端红线「数据驱动、前端无对局状态机」/ 服务端红线「任何状态变更都要有包承载」)。
// 曾经这里写死 aset.step = 1:服务端一旦插入中间阶段,前端不会红、只会静默画错。
//
// fapai 是一局的起点,先 reset 清掉上一局的残留(reset 保留 mySeat 与 options)。
var EQW_DealHandler = EQW_DealHandler || {
handle: function (data) {
if (!data.hasOwnProperty('cards')) {
console.error('[EQW_DealHandler] fapai 缺 cards,已跳过该包');
return;
}
EQW_GameState.reset();
EQW_GameState._apply(EQW_GameState.room, data, {
asetIdx: 'asetidx',
asetCount: 'asetcount'
});
EQW_GameState._apply(EQW_GameState.my, data, { cards: 'cards' });
EQW_GameState._apply(EQW_GameState.aset, data, { step: 'step' });
EQW_GameState._apply(EQW_GameState.turn, data, {
seat: 'seat',
countdown: 'countdown'
});
EventBus.emit(EQW_Events.EQW_RESET);
EventBus.emit(EQW_Events.EQW_HAND_CHANGED);
EventBus.emit(EQW_Events.EQW_TURN_CHANGED);
}
};
@@ -0,0 +1,24 @@
///////////////////////////////////////////////////////////////
////////// EQW_FailHandler: 失败回包 //////////////////////////
///////////////////////////////////////////////////////////////
// 协议 §0.2:服务端受理请求时任一校验不通过,回一个【rpc 与请求同名】的失败包,
// data 只含 success:false 与 errcode,且只回给请求者。
//
// errcode(服务端 youle_erqiwang.ERR):
// 1 PLAYER 玩家/房间/座位校验不通过 2 NODESK 牌桌或牌局不存在
// 3 STEP 当前阶段不允许该操作 4 SEAT 位置不符或还没轮到
// 5 PARAM 参数非法 6 RULE 规则不允许
//
// 失败信息是【一次性】的,不属于对局状态,故不进 GameState,只随事件带出去。
var EQW_FailHandler = EQW_FailHandler || {
ERR: {
PLAYER: 1, NODESK: 2, STEP: 3, SEAT: 4, PARAM: 5, RULE: 6
},
handle: function (rpc, data) {
var errcode = (data && typeof data.errcode === 'number') ? data.errcode : 0;
console.warn('[EQW] 请求失败: rpc=' + rpc + ' errcode=' + errcode);
EventBus.emit(EQW_Events.EQW_RPC_FAILED, { rpc: rpc, errcode: errcode });
}
};
@@ -0,0 +1,39 @@
///////////////////////////////////////////////////////////////
////////// EQW_MainHandler: 选主(xuanzhu)///////////////////
///////////////////////////////////////////////////////////////
// 协议 §7:banker 庄家 / flower 主牌花色(1方块 2梅花 3红心 4黑桃) /
// step 阶段(=3 埋牌) / nextseat 控制权(=庄家,埋牌由庄家做) /
// countdown 埋牌倒计时 / cards 选主后自己手上的牌
//
// 【step 与 nextseat 只读包字段】曾经这里写死 step=3 且 turn.seat = aset.banker:
// 阶段与「埋牌 = 庄家」两条规则都被搬到了前端(前端红线「数据驱动、前端无对局状态机」)。
//
// 选主后正 2 / 正 7 升格、主花色普通牌并入主牌段(design §3),手牌需整体重排——
// 但【重排是渲染时的事】,由 core/CardOrder 现算,不在这里落地成状态。
//
// 投降(touxiang)没有成功推送:点投降后服务端直接结算,前端收到的是 jiesuan。
// 失败时才回一个同名的 touxiang 失败包,由 FailHandler 统一处理。
var EQW_MainHandler = EQW_MainHandler || {
handleXuanzhu: function (data) {
if (!data.hasOwnProperty('flower')) {
console.error('[EQW_MainHandler] xuanzhu 缺 flower,已跳过该包');
return;
}
EQW_GameState._apply(EQW_GameState.aset, data, {
banker: 'banker',
flower: 'flower',
step: 'step'
});
EQW_GameState._apply(EQW_GameState.my, data, { cards: 'cards' });
EQW_GameState._apply(EQW_GameState.turn, data, {
seat: 'nextseat',
countdown: 'countdown'
});
EventBus.emit(EQW_Events.EQW_MAIN_SET);
if (data.hasOwnProperty('cards')) { EventBus.emit(EQW_Events.EQW_HAND_CHANGED); }
EventBus.emit(EQW_Events.EQW_TURN_CHANGED);
}
};
@@ -0,0 +1,109 @@
///////////////////////////////////////////////////////////////
////////// EQW_PlayHandler: 出牌(chupai1 / 2 / 3)///////////
///////////////////////////////////////////////////////////////
// 协议 §11–§13。三个包字段不完全相同:
// chupai1 首家独有:shuaicuo 甩错标志 / count 出牌数量 / flower 出牌花色 /
// cardtype 牌型 / shuai 甩牌分量构成
// chupai3 末家独有:maxseat 本轮谁最大 / grade 本轮闲家得分
// 共有:seat / cards / seatlist(仅可查牌) / countdown / baozhu / nextseat /
// cardsinhand(仅出牌者自己有) / curmultiple(1 和 3 有)
// mustcard:三个包都可能带,但只发给 nextseat 那一家(其余两家无此属性)
//
// 【cardsinhand 只有出牌者自己有】:收到就更新自己的手牌;别人出牌时没这个字段,
// 绝不能抹掉自己的手牌——这正是「缺字段不兜底」要防的。
//
// 【mustcard 没有就要清空】:它是「本轮跟牌的必出牌」,只对当前这一轮有效。
// 上一轮的建议残留下来会让界面自动选中错误的牌。
//
// 【playproc 是服务端的「本轮进行态」,原样拷贝】:它的结构是
// {round, start, currseat, startcount, startflower, starttype, maxseat, maxcard, cards, shuai_demand}
// (协议 §11),与重连包 PushCards.playproc 同源同结构(服务端同一个 get_playproc())。
// 注意它的 cards 是【本轮三家各自出的牌、下标 = 座位】,与本包的 cards(这一手出的牌)
// 语义相反,绝不能混写。轮次/最大者由服务端给,前端不累积、不推导。
//
// 【这一手的信息走事件载荷,不进 GameState】:谁出了什么、几张、什么牌型是【一次性表现】
// (落牌动画),不是对局状态;deskinfo 也不下发它。放进 GameState 会立刻制造一处
// 增量/全量不一致。故随 EQW_CARD_PLAYED 事件下发给 UI。
// 【本墩结果同理】:chupai3 的 maxseat / grade(谁最大、这墩闲家得几分)也是一次性表现,
// 随 EQW_TRICK_END 事件载荷下发,同样不进 GameState。
var EQW_PlayHandler = EQW_PlayHandler || {
//order: 1/2/3,对应 chupai1/2/3
handle: function (data, order) {
if (!data.hasOwnProperty('seat') || !data.hasOwnProperty('cards')) {
console.error('[EQW_PlayHandler] chupai' + order + ' 缺 seat 或 cards,已跳过该包');
return;
}
//—— 本轮进行态:服务端权威快照,原样拷贝,前端零推导 ——
EQW_GameState._apply(EQW_GameState.table, data, { playproc: 'playproc' });
//—— 出牌历史:把这一手记进本轮(chupai1 = 一轮的第一手,开新的一轮)——
EQW_GameState.pushPlay(data.seat, data.cards, order === 1);
//—— 自己的手牌:只有出牌者本人才收到 cardsinhand ——
EQW_GameState._apply(EQW_GameState.my, data, { cards: 'cardsinhand' });
//—— 必出牌:本轮有效,没给就清空 ——
if (data.hasOwnProperty('mustcard')) {
EQW_GameState.my.mustCard = data.mustcard;
} else {
EQW_GameState.my.mustCard = [];
}
//—— 牌况与报无主(仅可查牌模式下发)——
EQW_GameState._apply(EQW_GameState.table, data, { seatlist: 'seatlist' });
EQW_GameState._apply(EQW_GameState.aset, data, {
baozhu: 'baozhu',
curmultiple: 'curmultiple'
});
//—— 控制权与倒计时 ——
EQW_GameState._apply(EQW_GameState.turn, data, {
seat: 'nextseat',
countdown: 'countdown'
});
EventBus.emit(EQW_Events.EQW_CARD_PLAYED, this._playPayload(data, order));
if (data.hasOwnProperty('cardsinhand')) { EventBus.emit(EQW_Events.EQW_HAND_CHANGED); }
//—— 末家出完:本轮结束 ——
//【本墩结果走事件载荷,不进 GameState】谁最大、这墩闲家得了几分,是【一次性表现】
//(收牌动画、得分飘字),与 EQW_CARD_PLAYED 同类;任何阶段的 deskinfo 都不下发它,
//写进 GameState 就多出一处「只活在前端、服务端不知道」的对局态——出牌中途断线重连
//即归零,违反前端红线「丢弃 this.data、仅凭最近一次服务端快照重画,界面必须一致」。
//小局累计捡分 aset.grade 不同:它有服务端来源(PushCards.grade / chupai3.grade),
//属于镜像量,仍留在 GameState。
if (order === 3) {
//本轮闲家得分累加进小局捡分;没得分时不带 grade,累计值保持不变
if (data.hasOwnProperty('grade')) {
EQW_GameState.aset.grade = EQW_GameState.aset.grade + data.grade;
}
EventBus.emit(EQW_Events.EQW_TRICK_END, this._trickPayload(data));
}
EventBus.emit(EQW_Events.EQW_TURN_CHANGED);
},
//EQW_CARD_PLAYED 的事件载荷:这一手的一次性信息(落牌动画用),不落 GameState。
//【缺字段不兜底】:包里没有的键就不带(count/flower/cardtype 只有 chupai1 有,
//shuai 仅合法甩牌时有,shuaicuo 仅甩错时有),订阅方按 hasOwnProperty 判断。
_playPayload: function (data, order) {
var payload = { order: order, seat: data.seat, cards: data.cards };
var keys = ['count', 'flower', 'cardtype', 'shuai', 'shuaicuo'];
for (var i = 0; i < keys.length; i++) {
if (data.hasOwnProperty(keys[i])) { payload[keys[i]] = data[keys[i]]; }
}
return payload;
},
//EQW_TRICK_END 的事件载荷:这一墩的一次性结果(收牌动画、得分飘字用),不落 GameState。
//【缺字段不兜底】grade 只在这墩被闲家收走且确实有分时才有,包里没有就不带这个键。
//与 ResultHandler 为收尾墩发的那份载荷字段一致(收尾墩不发 chupai3,整包换成 jiesuan)。
_trickPayload: function (data) {
var payload = { seat: data.seat, cards: data.cards };
if (data.hasOwnProperty('maxseat')) { payload.maxseat = data.maxseat; }
if (data.hasOwnProperty('grade')) { payload.grade = data.grade; }
return payload;
}
};
@@ -0,0 +1,33 @@
///////////////////////////////////////////////////////////////
////////// EQW_QueryHandler: 明牌(mingpai)与提示(tishi)///
///////////////////////////////////////////////////////////////
// 协议 §13.6 mingpai(只回给请求者):
// seat 请求者 / others 另外两家各自未出的全部主牌 [{seat, zhucards}]
// 受理条件:可查牌模式 + 出牌阶段 + 已有【任一】玩家报无主(design §9.3)
//
// 协议 §13.8 tishi(只转发给对家,即另一闲家):
// seat 发出提示的闲家 / tip 1=踩 2=没分 3=有分
// 注意:成功时【不给发送者回执】,只转发给对家;只有失败才回给发送者。
//
// 明牌数据进 GameState(面板可反复开关查看),提示不进——
// 提示是一次性的通知、不是对局状态,重连也不重放,存下来只会变成幽灵数据。
var EQW_QueryHandler = EQW_QueryHandler || {
handleMingpai: function (data) {
if (!data.hasOwnProperty('others')) {
console.error('[EQW_QueryHandler] mingpai 缺 others,已跳过该包');
return;
}
EQW_GameState.table.mingpai = data.others;
EventBus.emit(EQW_Events.EQW_MINGPAI);
},
handleTishi: function (data) {
if (!data.hasOwnProperty('seat') || !data.hasOwnProperty('tip')) {
console.error('[EQW_QueryHandler] tishi 缺 seat 或 tip,已跳过该包');
return;
}
//一次性通知:不进 GameState,随事件把内容带给订阅者
EventBus.emit(EQW_Events.EQW_TIP, { seat: data.seat, tip: data.tip });
}
};
@@ -0,0 +1,16 @@
///////////////////////////////////////////////////////////////
////////// EQW_ReadyHandler: 准备(zhunbei)//////////////////
///////////////////////////////////////////////////////////////
// 协议 §16:seat 位置序号。
// 准备状态由平台的玩家信息 UI 呈现(清单 T-15:准备标识用平台的),
// 子游戏只需把事件发出去,让关心的组件(如结算面板的「下一局」按钮)响应。
var EQW_ReadyHandler = EQW_ReadyHandler || {
handle: function (data) {
if (!data.hasOwnProperty('seat')) {
console.error('[EQW_ReadyHandler] zhunbei 缺 seat,已跳过该包');
return;
}
EventBus.emit(EQW_Events.EQW_READY_CHANGED, { seat: data.seat });
}
};
@@ -0,0 +1,121 @@
///////////////////////////////////////////////////////////////
////////// EQW_ResultHandler: 结算(jiesuan)与解散(Free)///
///////////////////////////////////////////////////////////////
// 协议 §14:结算包由 chupai + bottom + aset 拼装,末局再加 account。
// 三种来源的组成不同:
// 正常出牌结算:chupai + bottom + aset(末局加 account)
// 投降结算: 只有 aset(末局加 account),无 chupai、无 bottom
// 解散结算: 只有 aset + account,且【走另一个入口】,见下
//
// 【解散的特殊性】(协议 §14.1,已对平台代码核实):
// 解散包的 route 是平台的 "room",平台 12_Logic.js 按 route 分流时把它交给
// Net[rpc] 而【不是】Game_Modify._ReceiveData——所以它根本进不了子游戏的分发表。
// 平台另有专门入口:07_Desk.js 的 Game_Modify.Free(Desk.deskfree)。
// 两个后果:
// 1. 取值路径比协议文档少一层——平台已把 deskfree 取出来传进来,
// 所以是 deskfree.data.aset,不是 data.deskfree.data.aset;
// 2. 参数可能是 null——解散若发生在开战后、首局发牌前,服务端 get_disbandRoom
// 返回 null,平台走「不带 deskfree」分支。这时只做房间收尾、不弹结算。
var EQW_ResultHandler = EQW_ResultHandler || {
handleJiesuan: function (data) {
if (!data.hasOwnProperty('aset')) {
console.error('[EQW_ResultHandler] jiesuan 缺 aset,已跳过该包');
return;
}
this._applyResult(data);
},
//平台解散入口。deskfree = { rpc:"jiesuan", data:{ success, aset, account } },可能为 null
handleFree: function (deskfree) {
if (!deskfree || !deskfree.data) {
//首局发牌前解散:没有结算数据,只做房间收尾,不弹结算面板
console.warn('[EQW_ResultHandler] 解散包无 deskfree 数据(首局发牌前解散)');
return;
}
var d = deskfree.data;
if (!d.hasOwnProperty('aset')) {
console.error('[EQW_ResultHandler] 解散包缺 aset,已跳过');
return;
}
this._applyResult(d);
},
//三种来源共用的落地逻辑:有哪组就写哪组,缺的保持 null
_applyResult: function (d) {
//—— 收尾轮的第三手:牌局在最后一手打完时【不发 chupai3】,整包换成 jiesuan(协议 §13/§14)——
//夹具实跑一局的包数就是证据:chupai1×28 / chupai2×28 / chupai3×27。
//只在 chupai3 归档会永远丢掉最后一轮,故在这里补齐——它必是本轮第三手,落进当前这一轮。
//jiesuan.chupai 的字段与 chupai3 不同:只有 seat / cards / maxseat(+ 有分时的 grade),
//没有 order / playproc / cardsinhand。
//归档随后会被下面的结算收敛清掉(deskinfo step6 不带 PushCards),但 emit 是同步的:
//落牌与收牌表现在清空之前就已取到完整的收尾轮。
//【本墩结果只走事件载荷、不进 GameState】:谁最大、这墩得几分是一次性表现,
//deskinfo 的 Balance 不带它(它属于「出牌过程」而非结算数据),写进 GameState
//就会多出一处服务端不知道的对局态。收尾墩与 chupai3 走同一对事件、同一份载荷字段。
if (d.chupai && d.chupai.hasOwnProperty('seat') && d.chupai.hasOwnProperty('cards')) {
EQW_GameState.pushPlay(d.chupai.seat, d.chupai.cards, false);
var lastPlay = { seat: d.chupai.seat, cards: d.chupai.cards };
if (d.chupai.hasOwnProperty('maxseat')) { lastPlay.maxseat = d.chupai.maxseat; }
if (d.chupai.hasOwnProperty('grade')) { lastPlay.grade = d.chupai.grade; }
EventBus.emit(EQW_Events.EQW_CARD_PLAYED, lastPlay);
EventBus.emit(EQW_Events.EQW_TRICK_END, lastPlay);
}
//结算是"清空过程量、只留结算数据"的快照点:deskinfo step6 只带 Balance(=aset),
//其余分组一律回到 reset() 默认值——jiesuan 也要收敛到同一状态,否则重连前后画面不一致
//(清单以 ResyncHandler.handleDeskinfo 在 step6 时实际重建出的字段集为准,不多清不少清)。
EQW_GameState.aset.banker = -1;
EQW_GameState.aset.call = -1;
EQW_GameState.aset.multiple = 0;
EQW_GameState.aset.flower = 0;
EQW_GameState.aset.curmultiple = 0;
EQW_GameState.aset.grade = 0;
EQW_GameState.aset.baozhu = 0;
EQW_GameState.aset.touxiang = 0;
EQW_GameState.turn.seat = -1;
EQW_GameState.turn.countdown = 0;
EQW_GameState.call.currcall = 0;
EQW_GameState.call.calls = [null, null, null];
//末轮若由 jiesuan.chupai 带出(无 cardsinhand),出牌者自己的手牌不会被清空,这里显式清掉,
//不留幽灵牌——deskinfo 在结算阶段的 MyCards 恒为 []
EQW_GameState.my.cards = [];
EQW_GameState.my.mustCard = [];
EQW_GameState.my.bottomCards = [];
EQW_GameState.my.buryCards = [];
EQW_GameState.table.ancard3s = 0;
EQW_GameState.table.playproc = null;
EQW_GameState.table.pushlist = [];
EQW_GameState.table.seatlist = [];
EQW_GameState.table.liangpai = null;
EQW_GameState.table.mingpai = null;
//【不写 chupai】收尾墩已在上面随事件载荷发走;deskinfo step6 的 Balance 只有
//aset / bottom / account 三份,多存一个 chupai 就是增量路径独有的私有对局态
EQW_GameState._apply(EQW_GameState.result, d, {
bottom: 'bottom',
aset: 'aset',
account: 'account'
});
//阶段只读包字段:jiesuan 的 step 由服务端 get_paiju_account 统一给出(三种结算来源恒为 6),
//前端不按 rpc 名硬编码——解散结算走的是另一条投递路径,同样带着这个字段
EQW_GameState._apply(EQW_GameState.aset, d, { step: 'step' });
//三家总积分:jiesuan 的 aset.seatlist[i].score 就是"累积得分"(协议 §14),
//与 deskinfo.PlayerInfo 同源同值——deskinfo 恒有的 PlayerInfo 在增量路径上没有对应来源,
//只能从这里回填,否则一局中段(fapai 后至 jiesuan 前)room.playerScores 永远追不上服务端
if (d.aset && Object.prototype.toString.call(d.aset.seatlist) === '[object Array]') {
var scores = [];
for (var i = 0; i < d.aset.seatlist.length; i++) {
scores.push(d.aset.seatlist[i] && d.aset.seatlist[i].score);
}
EQW_GameState.room.playerScores = scores;
}
EventBus.emit(EQW_Events.EQW_ASET_RESULT);
//account 只在末局或解散时才有
if (d.hasOwnProperty('account')) {
EventBus.emit(EQW_Events.EQW_ACCOUNT_RESULT);
}
}
};
@@ -0,0 +1,159 @@
///////////////////////////////////////////////////////////////
////////// EQW_ResyncHandler: 重连(deskinfo)与开局(StartWar)
///////////////////////////////////////////////////////////////
// 前端 04 §3:重连 = 重画。断线重连与硬刷新本质相同,复用同一条路径:
// 填 GameState → emit EQW_RESYNC_ALL → 各组件据数据重建界面
// 【绝不为重连单写一套渲染】。
//
// deskinfo 按 step 只带对应阶段的分组(协议文末):
// 恒有 count / idx / PlayerInfo / step / MyCards
// CallRun(step1) ChooseMain(step2) BuryCards(step3) PushCards(step5) Balance(step6)
//
// 【重连不重放一次性事件】:70 分坐庄的 3 秒开底是上庄时的一次性事件,
// deskinfo 里没有 ancard3s,前端也不得补播。
var EQW_ResyncHandler = EQW_ResyncHandler || {
//平台房间信息(roomcode / 总局数 / roomtype 位串)
handleSetRoomDes: function (roomcode, asetcount, roomtype) {
if (typeof asetcount === 'number') { EQW_GameState.room.asetCount = asetcount; }
EQW_GameState.room.options = EQW_RoomOptions_Parse.parse(roomtype);
},
//开局:makewar 可能差异化下发(sendtype:1 + seatlist[]),先取自己那份
handleStartWar: function (msg) {
if (!msg || !msg.data) {
console.warn('[EQW_ResyncHandler] StartWar 无数据,已忽略');
return;
}
var raw = msg.data.deskwar || msg.data;
var mine = raw;
if (raw && raw.sendtype === 1 && Object.prototype.toString.call(raw.seatlist) === '[object Array]') {
mine = null;
for (var i = 0; i < raw.seatlist.length; i++) {
if (raw.seatlist[i] && raw.seatlist[i].seat === EQW_GameState.room.mySeat) {
mine = raw.seatlist[i].data;
break;
}
}
if (!mine) {
console.error('[EQW_ResyncHandler] StartWar 差异化下发里没有本座位的数据');
return;
}
}
this.handleDeskinfo(mine);
},
//重连:deskinfo 全量快照
handleDeskinfo: function (info) {
if (!info) {
console.warn('[EQW_ResyncHandler] deskinfo 为空,已忽略');
return;
}
EQW_GameState.reset();
//—— 恒有的部分 ——
EQW_GameState._apply(EQW_GameState.room, info, {
asetCount: 'count',
asetIdx: 'idx',
playerScores: 'PlayerInfo'
});
EQW_GameState._apply(EQW_GameState.aset, info, { step: 'step' });
EQW_GameState._apply(EQW_GameState.my, info, { cards: 'MyCards' });
//—— 按阶段分组 ——
if (info.CallRun) { this._applyCallRun(info.CallRun); }
if (info.ChooseMain) { this._applyChooseMain(info.ChooseMain); }
if (info.BuryCards) { this._applyBuryCards(info.BuryCards); }
if (info.PushCards) { this._applyPushCards(info.PushCards); }
if (info.Balance) { this._applyBalance(info.Balance); }
EventBus.emit(EQW_Events.EQW_RESYNC_ALL);
},
_applyCallRun: function (g) {
EQW_GameState._apply(EQW_GameState.call, g, {
currcall: 'nowcall',
calls: 'call'
});
EQW_GameState._apply(EQW_GameState.aset, g, { multiple: 'multiple' });
EQW_GameState._apply(EQW_GameState.turn, g, {
seat: 'seat',
countdown: 'countdown'
});
},
_applyChooseMain: function (g) {
EQW_GameState._apply(EQW_GameState.aset, g, {
banker: 'banker',
call: 'call',
multiple: 'multiple',
touxiang: 'touxiang',
curmultiple: 'curmultiple' //与 shangzhuang 推送同源同值,漏读会让「抓分」角标掉档
});
EQW_GameState._apply(EQW_GameState.my, g, { bottomCards: 'bottomcards' });
//控制权由服务端在分组里显式给出(与 shangzhuang.nextseat 同源同值);
//曾经这里写 turn.seat = aset.banker 自推,与 CallHandler 里那份是同一条规则的第二个写入处
EQW_GameState._apply(EQW_GameState.turn, g, {
seat: 'seat',
countdown: 'countdown'
});
},
_applyBuryCards: function (g) {
EQW_GameState._apply(EQW_GameState.aset, g, {
banker: 'banker',
call: 'call',
multiple: 'multiple',
flower: 'flower',
curmultiple: 'curmultiple' //同上:一张牌未出时恒为 +3(大光),也必须重建
});
EQW_GameState._apply(EQW_GameState.my, g, { bottomCards: 'bottomcards' });
//控制权同上,读服务端给的 seat(与 xuanzhu.nextseat 同源同值)
EQW_GameState._apply(EQW_GameState.turn, g, {
seat: 'seat',
countdown: 'countdown'
});
},
_applyPushCards: function (g) {
EQW_GameState._apply(EQW_GameState.aset, g, {
banker: 'banker',
call: 'call',
multiple: 'multiple',
flower: 'flower',
grade: 'grade',
curmultiple: 'curmultiple',
//报无主:明牌按钮与余主公示的开关,增量路径由 chupai1/2/3 的 baozhu 维护,
//重连路径必须从 PushCards.baozhu 一起恢复,否则重连后按钮凭空消失
baozhu: 'baozhu'
});
EQW_GameState._apply(EQW_GameState.my, g, {
buryCards: 'burycards',
mustCard: 'mustcard'
});
EQW_GameState._apply(EQW_GameState.table, g, {
playproc: 'playproc',
pushlist: 'pushlist',
seatlist: 'seatlist',
liangpai: 'liangpai'
});
EQW_GameState._apply(EQW_GameState.turn, g, { countdown: 'countdown' });
//当前该谁出牌由 playproc.currseat 给出(服务端权威,前端不推导)
if (g.playproc && typeof g.playproc.currseat === 'number') {
EQW_GameState.turn.seat = g.playproc.currseat;
}
},
//结算阶段(step6):Balance 与 jiesuan 推送给的是同一份结算数据(服务端同源快照)。
//bottom(抠底明细)只有正常出牌结算才有、account(大局结算)只有末局/解散才有——
//缺就不写、保持 reset() 的 null,与 jiesuan 那条路径的取舍完全一致。
//【为什么必须接】结算面板还开着时断线重连/硬刷新,只恢复 aset 会让抠底明细与末局大结算空白。
_applyBalance: function (g) {
EQW_GameState._apply(EQW_GameState.result, g, {
aset: 'aset',
bottom: 'bottom',
account: 'account'
});
}
};
+304
View File
@@ -0,0 +1,304 @@
///////////////////////////////////////////////////////////////
////// youle_erqiwang_shared_cards: 前后端共享牌值算法 //////////
///////////////////////////////////////////////////////////////
// 【权威源】本文件是前后端共享算法的唯一可修改处。
// 前端副本 client/js/01_SubGame/codes/shared/cards.js 由仓库根目录的
// sync_shared.cmd 单向同步生成,为只读;任何改动都必须落在本文件,
// 改完跑一次 sync_shared.cmd,否则 client/tests/test_shared_sync.js 会红。
//
// 内容:牌值编码与牌型分组的纯函数,不依赖对局状态、不依赖平台 API。
// 牌编码区间约定(id_to_code 的产物,各处判定据此):
// 9554 大王 / 9553 小王 / 8000–9000 正7 / 7000–8000 副7
// 3000–4000 正2 / 2000–3000 副2 / 1000–2000 主花色普通牌 / 100–500 副牌普通牌
// ⇒ 主牌 ⟺ code > 1000
var youle_erqiwang_shared_cards = youle_erqiwang_shared_cards || {
//——— 牌编码区间边界:上面那张区间表的机器可读形式,是【全部区间判定的唯一权威来源】———
//一律是【开区间】:CODE_ZHENG7_MIN < code < CODE_ZHENG7_MAX ⟺ 正7,其余同理。
//本文件内部(is_continuous / trump_rank / id_to_code)与前端 core/CardMark.js 都引用这些常量,
//不再各处重抄 3000/4000/8000/9000 之类裸字面量(工程总则 §1 SSOT / §6 配置优先于硬编码)。
CODE_XIAOWANG: 9553, //小王(单值,非区间)
CODE_DAWANG: 9554, //大王(单值,非区间)
CODE_ZHENG7_MIN: 8000, //正7:8000 < code < 9000
CODE_ZHENG7_MAX: 9000,
CODE_FU7_MIN: 7000, //副7:7000 < code < 8000
CODE_FU7_MAX: 8000,
CODE_ZHENG2_MIN: 3000, //正2:3000 < code < 4000
CODE_ZHENG2_MAX: 4000,
CODE_FU2_MIN: 2000, //副2:2000 < code < 3000
CODE_FU2_MAX: 3000,
CODE_ZHU_MIN: 1000, //主牌 ⟺ code > CODE_ZHU_MIN;主花色普通牌:1000 < code < 2000
CODE_ZHU_MAX: 2000,
CODE_A_IN_ZHU: 14, //主A 的 code % 100(A 在 id_to_code 里 +13 提到 14)
//几对连续对子起算拖拉机(design §5.3)。group_tractor_runs 返回极大连续段,
//段长 >= 本值即拖拉机——服务端分解甩牌与前端手牌标记共用这一个阈值
TRACTOR_MIN_PAIRS: 2,
//牌id转花色
id_to_flower: function(cardid){
if (cardid == 52 || cardid == 106){
//小王
return 5;
}
if (cardid == 53 || cardid == 107){
//大王
return 5;
}
return parseInt((cardid % 54) / 13) + 1;
},
//牌id转牌面数值
id_to_number: function(cardid){
if (cardid == 52 || cardid == 106){
//小王
return 53;
}
if (cardid == 53 || cardid == 107){
//大王
return 54;
}
return (cardid % 54) % 13 + 1;
},
//牌id转牌编码
id_to_code: function(mainflower, cardid){
if (cardid == 52 || cardid == 106){
//小王
return youle_erqiwang_shared_cards.CODE_XIAOWANG;
}
if (cardid == 53 || cardid == 107){
//大王
return youle_erqiwang_shared_cards.CODE_DAWANG;
}
var flower = youle_erqiwang_shared_cards.id_to_flower(cardid);
var number = youle_erqiwang_shared_cards.id_to_number(cardid);
var code = flower * 100 + number;
//特殊处理
switch (number)
{
case 1:
//A 提到 K 之上(14)
code = code + 13;
break;
case 2:
//2 提到副2 段
code = code + youle_erqiwang_shared_cards.CODE_FU2_MIN;
break;
case 7:
//7 提到副7 段
code = code + youle_erqiwang_shared_cards.CODE_FU7_MIN;
break;
}
//主牌花色处理:整体再上移一个主牌段(副2→正2、副7→正7、普通牌→主花色普通牌)
if (flower == mainflower){
code = code + youle_erqiwang_shared_cards.CODE_ZHU_MIN;
}
return code;
},
//从大到小排序(冒泡排序)
order_cards: function(mainflower, aryCardIDs){
if (aryCardIDs){
for (var i = 0; i < aryCardIDs.length; i++) {
for (var j = i + 1; j < aryCardIDs.length; j++) {
var i_code = youle_erqiwang_shared_cards.id_to_code(mainflower, aryCardIDs[i]);
var j_code = youle_erqiwang_shared_cards.id_to_code(mainflower, aryCardIDs[j]);
if (i_code < j_code) {
var tmp = aryCardIDs[j];
aryCardIDs[j] = aryCardIDs[i];
aryCardIDs[i] = tmp;
}
}
}
}
return aryCardIDs;
},
//判断两个牌是否是连续的
is_continuous: function(bigcode, smallcode){
var S = youle_erqiwang_shared_cards;
//是小王
var is_xiaowang = function(_code){
if (_code == S.CODE_XIAOWANG){
return true;
}
return false;
}
//是正7
var is_zheng7 = function(_code){
if (_code > S.CODE_ZHENG7_MIN && _code < S.CODE_ZHENG7_MAX){
return true;
}
return false;
}
//是负7
var is_fu7 = function(_code){
if (_code > S.CODE_FU7_MIN && _code < S.CODE_FU7_MAX){
return true;
}
return false;
}
//是正2
var is_zheng2 = function(_code){
if (_code > S.CODE_ZHENG2_MIN && _code < S.CODE_ZHENG2_MAX){
return true;
}
return false;
}
//是负2
var is_fu2 = function(_code){
if (_code > S.CODE_FU2_MIN && _code < S.CODE_FU2_MAX){
return true;
}
return false;
}
//是主A
var is_zhuA = function(_code){
if (_code > S.CODE_ZHU_MIN && _code < S.CODE_ZHU_MAX && _code % 100 == S.CODE_A_IN_ZHU){
return true;
}
return false;
}
//是8
var is_8 = function(_code){
if (_code % 100 == 8){
return true;
}
return false;
}
//是6
var is_6 = function(_code){
if (_code % 100 == 6){
return true;
}
return false;
}
//普通的连牌
if (bigcode - smallcode == 1){
return true;
}
//小王、正7
if (is_xiaowang(bigcode) && is_zheng7(smallcode)){
return true;
}
//正7、负7
if (is_zheng7(bigcode) && is_fu7(smallcode)){
return true;
}
//负7、正2
if (is_fu7(bigcode) && is_zheng2(smallcode)){
return true;
}
//正2、负2
if (is_zheng2(bigcode) && is_fu2(smallcode)){
return true;
}
//负2、主A
if (is_fu2(bigcode) && is_zhuA(smallcode)){
return true;
}
//8、6(design §5.3:7 不与 6/8 相连,6 与 8 相连)
//必须是同一花色的 8 与 6:牌编码同花色相差 2(主花色 1408-1406=2、副牌 308-306=2),
//跨花色则相差 100 的倍数。只判 %100 会把「♥8 + ♣6」误判为连对,
//进而在 get_chongguan 扫描整手牌时让副牌的 6 对错误接续主 8、多算奖(design §8.1 连对链)
if (is_8(bigcode) && is_6(smallcode) && (bigcode - smallcode == 2)){
return true;
}
return false;
},
//获取对子列表,cards为同一花色的排过序的牌列表
get_pairlist: function(mainflower, flowercards){
var pairlist = [];
var i = 0;
while (i < flowercards.length - 1){
//牌编码,两张牌两张牌一取
var code_i = youle_erqiwang_shared_cards.id_to_code(mainflower, flowercards[i]);
var code_j = youle_erqiwang_shared_cards.id_to_code(mainflower, flowercards[i + 1]);
if (code_i == code_j){
pairlist.push([flowercards[i], flowercards[i + 1]]);
i = i + 2;
} else {
i++;
}
}
return pairlist;
},
//把对子列表扫成【极大连续段】。pairlist 为同一牌组内、已按牌编码从大到小排好的对子列表
//(形如 get_pairlist 的返回值:[[cardid, cardid], ...])。
//返回 [[pair, ...], ...]:每一段是若干【相邻且 is_continuous】的对子,段与段之间必不连续;
//孤立对子也作为长度 1 的段返回,故 concat 所有段 == 原 pairlist(顺序不变)。
//段长 >= TRACTOR_MIN_PAIRS 即拖拉机。
//【唯一实现】服务端 decompose_trump(甩牌分解)与前端 core/CardMark.js(手牌「拖」标记)
//此前各写过一遍同样的扫描,规则内容(什么算连续、几对起算拖拉机)散在两处——现收敛到这里。
group_tractor_runs: function(mainflower, pairlist){
var runs = [];
var i = 0;
while (i < pairlist.length){
var run = [pairlist[i]];
var k = i;
while (k + 1 < pairlist.length){
var code_k = youle_erqiwang_shared_cards.id_to_code(mainflower, pairlist[k][0]);
var code_k1 = youle_erqiwang_shared_cards.id_to_code(mainflower, pairlist[k + 1][0]);
if (!youle_erqiwang_shared_cards.is_continuous(code_k, code_k1)){
break;
}
run.push(pairlist[k + 1]);
k++;
}
runs.push(run);
i = k + 1;
}
return runs;
},
//获取拖拉机列表,pairlist为同一花色的排过序的对子列表,cardtype为拖拉机牌型
get_tuolaji_list: function(mainflower, pairlist, cardtype){
var tuolaji = [];
var count = cardtype - 300; //拖拉机的对子数量
var i = 0;
for (var i = 0; i < pairlist.length - count + 1; i++){
//每次按拖拉机的对子数量取N对
var tmplist = pairlist.slice(i, i + count);
//判断是否是cardtype类型的拖拉机
var is_tlj = true;
for (var j = 0; j < tmplist.length - 1; j++) {
var code_j1 = youle_erqiwang_shared_cards.id_to_code(mainflower, tmplist[j][0]);
var code_j2 = youle_erqiwang_shared_cards.id_to_code(mainflower, tmplist[j+1][0]);
if (!youle_erqiwang_shared_cards.is_continuous(code_j1, code_j2)){
is_tlj = false;
break;
}
}
if (is_tlj){
var tlj = [];
for (var k = 0; k < tmplist.length; k++){
tlj = tlj.concat(tmplist[k]);
}
tuolaji.push(tlj);
}
}
return tuolaji;
},
//主牌归一化大小(副7 同级取 7000、副2 同级取 2000,其余用牌编码),仅用于甩牌分量比大小
trump_rank: function(mainflower, cardid){
var code = youle_erqiwang_shared_cards.id_to_code(mainflower, cardid);
if (code > youle_erqiwang_shared_cards.CODE_FU7_MIN && code < youle_erqiwang_shared_cards.CODE_FU7_MAX){ //副7
return youle_erqiwang_shared_cards.CODE_FU7_MIN;
}
if (code > youle_erqiwang_shared_cards.CODE_FU2_MIN && code < youle_erqiwang_shared_cards.CODE_FU2_MAX){ //副2
return youle_erqiwang_shared_cards.CODE_FU2_MIN;
}
return code;
},
}
//Node 导出;友乐/浏览器无 module 时跳过(dev-guide 01 §1)
if (typeof module !== "undefined"){
module.exports = youle_erqiwang_shared_cards;
}
@@ -0,0 +1,41 @@
///////////////////////////////////////////////////////////////
////////// EQW_Events: 玩法事件常量 ///////////////////////////
///////////////////////////////////////////////////////////////
// 前端红线:框架保持游戏中立,玩法专属事件由子游戏追加到 EventBus.Events。
//
// 【对局状态不进事件载荷】——凡是 GameState 里有的,订阅者一律从 EQW_GameState 读,
// 同一份数据不在事件载荷与状态里各存一份(SSOT)。
// 例外只有「一次性、不属于对局状态、deskinfo 也不下发」的信息,它们不该进 GameState
// (进了就会立刻制造一处增量/全量不一致),只能随事件载荷走:
// EQW_RPC_FAILED {rpc, errcode} 一次性错误信息
// EQW_CARD_PLAYED {order, seat, cards, count?, flower?, cardtype?, shuai?, shuaicuo?}
// 这一手的落牌信息(一次性表现)。带 ? 的键【缺字段不兜底】,
// 包里没有就不带;收尾轮由 jiesuan.chupai 带出时只有 {seat, cards, maxseat?}
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', //一墩结束;载荷 {seat,cards,maxseat[,grade]},不落 GameState
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];
}
}
}
@@ -0,0 +1,136 @@
///////////////////////////////////////////////////////////////
////////// 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 / jiesuan.aset.seatlist[].score,跨局不清
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
},
//—— 结算 ——
//字段集与 deskinfo step6 的 Balance 严格对齐(aset / bottom / account)。
//【不存 chupai】收尾墩的「谁最大、得几分」是一次性表现,随 EQW_TRICK_END 事件载荷走,
//任何 deskinfo 都不下发它——存进来就是一处服务端不知道的私有对局态(见 ResultHandler / PlayHandler)
result: {
bottom: null,
aset: null,
account: null
},
//新一局开局时清空对局态;room.mySeat / room.options / room.playerScores 跨局不变,不清
//(playerScores 由 deskinfo.PlayerInfo 或 jiesuan.aset.seatlist[].score 增量更新,见 ResyncHandler / ResultHandler)
reset: function () {
this.room.asetIdx = 0;
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 = { 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;
},
//把「一手牌」记进出牌历史 table.pushlist。
//结构与 deskinfo.PushCards.pushlist 完全一致:外层下标 = 轮次-1,内层恒 3 个数组、
//下标 = 座位序号(协议 §断线重连 pushlist)。
//
//【只记录服务端发来的事实】:谁、出了哪几张。轮次的推进不在这里推导——
//startRound 由调用方按【包类型】给出:chupai1 就是「一轮的第一手」(服务端定义的包,
//不是前端算出来的),其余各手落进当前这一轮。
//
//【为什么不能读 chupai3.playproc.cards 直接归档】:服务端 do_playcard 在第三家出完后
//就地调用 new_playround,chupai3 带的是【下一轮】的进行态(round+1、cards 全空,
//协议 §13 明标),本轮那三手只在 chupai1/2/3 各自的 seat + cards 里。
//
//【仅可查牌模式累积】:pushlist 是「查牌」历史,不查牌模式下服务端的 deskinfo 压根
//不下发它(design §9),前端也不能自己攒一份——否则增量路径会凭空多出重连路径没有的历史。
pushPlay: function (seat, cards, startRound) {
var opts = this.room.options;
if (!opts || opts.nocheck) { return; }
var list = this.table.pushlist;
if (startRound || list.length === 0) { list.push([[], [], []]); }
list[list.length - 1][seat] = cards;
},
//把包里的字段写进目标分组。map = { 目标键: 源键 }。
//【缺字段不兜底】源键在包里不存在就不写,保持原值——不填默认值掩盖漏发。
_apply: function (target, src, map) {
for (var dst in map) {
if (!map.hasOwnProperty(dst)) { continue; }
var srcKey = map[dst];
if (src.hasOwnProperty(srcKey)) { target[dst] = src[srcKey]; }
}
}
};
@@ -0,0 +1,34 @@
///////////////////////////////////////////////////////////////
////////// EQW_RoomOptions_Parse: roomtype 位串解析 ///////////
///////////////////////////////////////////////////////////////
// 协议 §0.5:roomtype 是建房时拼好的定长数字位串(字符串),每一位 '0'/'1'。
// 服务端解析入口是 class.config.js 的 parse(),本文件必须与之【同规则】。
//
// 位0 局数 '0'=6局(缺省) '1'=12局
// 位1 扣卡 '0'=房主扣卡 '1'=AA 每人扣卡
// 位2 傍王 '0'=关 '1'=开
// 位3 爬坡 '0'=常规算子 '1'=爬坡
// 位4 查牌 '0'=可查牌 '1'=不查牌
//
// 对【缺失、非字符串、过短】的 roomtype 一律按 '0' 处理(与服务端一致),
// 这是协议明文规定的行为,不是隐式兜底。
var EQW_RoomOptions_Parse = (function() {
//取第 idx 位,越界或非字符串时按 '0'
function _bit(roomtype, idx) {
if (typeof roomtype !== 'string') { return 0; }
if (idx >= roomtype.length) { return 0; }
return roomtype.charAt(idx) === '1' ? 1 : 0;
}
return {
parse: function (roomtype) {
return {
asetCount: _bit(roomtype, 0) ? 12 : 6,
deductAA: _bit(roomtype, 1),
bangwang: _bit(roomtype, 2),
climb: _bit(roomtype, 3),
nocheck: _bit(roomtype, 4)
};
}
};
})();
@@ -0,0 +1,336 @@
///////////////////////////////////////////////////////////////
////////// EQW_LayoutSolver: 五型布局求解器 ////////////////////
///////////////////////////////////////////////////////////////
// 清单 §6:布局配置是【纯数据】,一切求解逻辑集中在本文件。
// point 定点 line 等距排列(可 items 逐项不等宽)
// fan 自适应压缩 grid 网格 attach 相对贴附
//
// solve(node, ctx) 是【纯函数】:不碰精灵、不查引擎,故可完整单测。
// node: 一份布局配置(五型之一,可含 bySeat)
// ctx : { seat, count, rects, <运行时注入字段> }
// seat —— 'SELF'|'LEFT'|'RIGHT',仅 bySeat 需要
// count —— 运行时项数(清单 §6.1:count 由数据给出、不进配置)
// rects —— { 键名: {x,y,width,height} },仅 attach 需要
// 返回恒为数组,point / attach 长度为 1。
//
// 【运行时注入】配置里写不出固定值的字段(动态文字宽高、贴哪张牌、随数据变化的行数/锚点…)
// 一律留空,由调用方在 ctx 里补上;可注入字段见 INJECT_KEYS,优先级恒为 ctx > bySeat > base。
// 布局节点用 runtime: ['target','w',…] 显式声明自己有哪些字段延后到运行时——那是给人和
// 机械守卫看的契约(区分「忘了写」与「故意留空」),求解器本身对所有 INJECT_KEYS 一视同仁。
//
// 参数名一律沿用框架 AlignmentUtils 的入参名(清单 §6.0),配置可原样喂入。
// 精灵锚点恒在左上角,配置里的 anchorX/anchorY 是【对齐基准点】,不是精灵左上角。
// 出错一律抛异常,不返回 {x:0,y:0} 之类的兜底值(工程总则 §7 显式失败)。
var EQW_LayoutSolver = EQW_LayoutSolver || {
//可由 ctx 运行时注入的配置字段(与布局节点 runtime 声明里允许出现的键名同一份清单)
INJECT_KEYS: ['target', 'x', 'y', 'w', 'h', 'anchorX', 'anchorY', 'rows', 'itemWidth', 'itemHeight'],
//各方向合法的对齐基准:框架 AlignmentUtils 的 switch 对不认识的 anchor 一律落到
//default(居中),写错方向的 anchor(如竖排写 'left')会被静默吞掉、界面偏到画外也不报错。
//故在此按方向白名单校验,把笔误挡在求解入口(工程总则 §7 显式失败)
ANCHORS_HORIZONTAL: ['left', 'right', 'center'],
ANCHORS_VERTICAL: ['top', 'bottom', 'center'],
//——— 对外:求解 ———
solve: function (node, ctx) {
if (!node) { throw new Error('[EQW_LayoutSolver] node 为空'); }
ctx = ctx || {};
var n = this._mergeCtx(this._mergeBySeat(node, ctx.seat), ctx);
switch (n.kind) {
case 'point': return this._solvePoint(n);
case 'line': return this._solveLine(n, ctx);
case 'fan': return this._solveFan(n, ctx);
case 'grid': return this._solveGrid(n, ctx);
case 'attach': return this._solveAttach(n, ctx);
default: throw new Error('[EQW_LayoutSolver] 未知 kind: ' + n.kind);
}
},
//——— 对外:求解并摆精灵(唯一触碰 SpriteManager 之处)———
//精灵个数与求解出的矩形数必须一一对应:数量不等时显式抛错,【不静默截断】——
//少摆的那几个精灵会停在上一手牌的旧坐标上,是最难反查的一类错位(工程总则 §7 显式失败)
apply: function (node, spriteIds, ctx) {
var rects = this.solve(node, ctx);
if (!spriteIds || spriteIds.length !== rects.length) {
throw new Error('[EQW_LayoutSolver] apply 精灵数与矩形数不一致: spriteIds=' +
(spriteIds ? spriteIds.length : 0) + ' rects=' + rects.length);
}
for (var i = 0; i < spriteIds.length; i++) {
SpriteManager.setPosition(spriteIds[i], rects[i].x, rects[i].y);
}
return rects;
},
//——— ctx 运行时注入:ctx 里给出的 INJECT_KEYS 字段覆盖配置同名字段 ———
_mergeCtx: function (node, ctx) {
var hit = false;
var k, i;
for (i = 0; i < this.INJECT_KEYS.length; i++) {
if (typeof ctx[this.INJECT_KEYS[i]] !== 'undefined') { hit = true; break; }
}
if (!hit) { return node; }
var merged = {};
for (k in node) {
if (node.hasOwnProperty(k)) { merged[k] = node[k]; }
}
for (i = 0; i < this.INJECT_KEYS.length; i++) {
k = this.INJECT_KEYS[i];
if (typeof ctx[k] !== 'undefined') { merged[k] = ctx[k]; }
}
return merged;
},
//——— 对齐基准点校验:line/fan/grid 都要 anchorX/anchorY ———
//框架 AlignmentUtils 内部是 options.anchorY || 0,缺失会被静默当成 0(整排贴到画布顶边),
//故在此显式要求;写不出固定值的(如随类别行浮动的 anchorY)用 runtime 声明 + ctx 注入
_requireAnchorPoint: function (n) {
if (typeof n.anchorX !== 'number' || typeof n.anchorY !== 'number') {
throw new Error('[EQW_LayoutSolver] ' + n.kind + ' 需要数字 anchorX/anchorY:配置未写,' +
'且 ctx.anchorX/ctx.anchorY 未给出(anchorX=' + n.anchorX + ' anchorY=' + n.anchorY + ')');
}
},
//——— 方向 × 对齐基准校验 ———
_checkAnchor: function (direction, anchor) {
var allowed;
if (direction === 'vertical') {
allowed = this.ANCHORS_VERTICAL;
} else if (direction === 'horizontal' || typeof direction === 'undefined') {
allowed = this.ANCHORS_HORIZONTAL; //不写 direction 等同水平(框架默认)
} else {
throw new Error('[EQW_LayoutSolver] 未知 direction: ' + direction);
}
//不写 anchor 等同框架默认 center,两个方向都合法;写了就必须与方向匹配
if (typeof anchor === 'undefined') { return; }
for (var i = 0; i < allowed.length; i++) {
if (allowed[i] === anchor) { return; }
}
throw new Error('[EQW_LayoutSolver] direction=' + (direction || 'horizontal') +
' 不接受 anchor=' + anchor + ',只能是 ' + allowed.join('/'));
},
//——— bySeat 合并:bySeat[seat] 覆盖外层 base,未列出的字段继承(清单 §6.2)———
_mergeBySeat: function (node, seat) {
if (!node.bySeat) { return node; }
if (!seat) { throw new Error('[EQW_LayoutSolver] 配置含 bySeat,但 ctx.seat 未给出'); }
if (!node.bySeat[seat]) { throw new Error('[EQW_LayoutSolver] bySeat 无此显示位: ' + seat); }
var merged = {};
var k;
for (k in node) {
if (node.hasOwnProperty(k) && k !== 'bySeat') { merged[k] = node[k]; }
}
var v = node.bySeat[seat];
for (k in v) {
if (v.hasOwnProperty(k)) { merged[k] = v[k]; }
}
return merged;
},
//point:定点。x/y 缺失即无法定位,显式抛错——不返回 {x:undefined} 让 NaN 流到界面上
//(x/y 写不出固定值的节点,用 runtime 声明并由 ctx.x/ctx.y 注入)
_solvePoint: function (n) {
if (typeof n.x !== 'number' || typeof n.y !== 'number') {
throw new Error('[EQW_LayoutSolver] point 需要数字 x/y:配置未写,且 ctx.x/ctx.y 未给出(x=' +
n.x + ' y=' + n.y + ')');
}
return [{ x: n.x, y: n.y, width: n.w, height: n.h }];
},
//line:等距排列。带 items 时逐项取宽(itemWidth 失效),此时不能走 distribute(它假设等宽)
_solveLine: function (n, ctx) {
this._checkAnchor(n.direction, n.anchor);
this._requireAnchorPoint(n);
if (n.items) { return this._solveLineItems(n); }
var count = ctx.count;
if (typeof count !== 'number') { throw new Error('[EQW_LayoutSolver] line 需要 ctx.count'); }
if (count <= 0) { return []; }
var positions = AlignmentUtils.distribute({
direction: n.direction,
anchorX: n.anchorX,
anchorY: n.anchorY,
count: count,
itemWidth: n.itemWidth,
itemHeight: n.itemHeight,
spacing: n.spacing || 0,
anchor: n.anchor
});
return this._withSize(positions, n.itemWidth, n.itemHeight);
},
//line + items:逐项不等宽(底栏功能钮组、埋牌操作条等,清单 §6.1)
//只实现了水平方向(逐项取宽、沿 x 推进);竖排逐项不等高暂无用例,显式拒绝而不是按水平算
_solveLineItems: function (n) {
if (n.direction === 'vertical') {
throw new Error('[EQW_LayoutSolver] line + items 仅支持 direction=horizontal');
}
var items = n.items;
var spacing = n.spacing || 0;
var i, total = 0;
for (i = 0; i < items.length; i++) { total += items[i].width; }
total += (items.length - 1) * spacing;
var start;
switch (n.anchor) {
case 'left': start = n.anchorX; break;
case 'right': start = n.anchorX - total; break;
default: start = n.anchorX - total / 2; break;
}
var out = [];
var cursor = start;
for (i = 0; i < items.length; i++) {
out.push({ x: cursor, y: n.anchorY, width: items[i].width, height: n.itemHeight });
cursor += items[i].width + spacing;
}
return out;
},
//fan:张数可变、总宽受限、动态压缩间距(清单 §6.1,间距算法【唯一实现】)
_solveFan: function (n, ctx) {
this._checkAnchor(n.direction, n.anchor);
this._requireAnchorPoint(n);
var count = ctx.count;
if (typeof count !== 'number') { throw new Error('[EQW_LayoutSolver] fan 需要 ctx.count'); }
if (count <= 0) { return []; }
var spacing = 0;
if (count > 1) {
var raw = (n.maxWidth - n.itemWidth) / (count - 1) - n.itemWidth;
spacing = Math.max(n.spacingMin, Math.min(n.spacingMax, raw));
}
var positions = AlignmentUtils.distribute({
direction: n.direction,
anchorX: n.anchorX,
anchorY: n.anchorY,
count: count,
itemWidth: n.itemWidth,
itemHeight: n.itemHeight,
spacing: spacing,
anchor: n.anchor
});
return this._withSize(positions, n.itemWidth, n.itemHeight);
},
//grid:固定行列的按钮阵,逐行调 distributeHorizontally(清单 §6.1)
_solveGrid: function (n, ctx) {
//fillOrder 目前只实现行优先;不写等同 row,写了别的值一律显式拒绝(YAGNI,不做无人用的列优先)
if (n.fillOrder && n.fillOrder !== 'row') {
throw new Error('[EQW_LayoutSolver] grid 仅支持 fillOrder=row,暂不支持: ' + n.fillOrder);
}
//cols/rows 缺一即容量为 NaN:超载守卫失效(count > NaN 恒为 false)、循环一次都不跑、
//静默返回空数组——摆不下与「一个都没摆」同样必须显式失败(工程总则 §7)。
//rows 允许由 ctx.rows 运行时注入(行数随实际项数而定的场景),此处校验的是合并后的有效值
if (typeof n.cols !== 'number' || typeof n.rows !== 'number') {
throw new Error('[EQW_LayoutSolver] grid 需要数字 cols/rows:配置未写,且 ctx.rows 未给出(cols=' +
n.cols + ' rows=' + n.rows + ')');
}
this._checkAnchor('horizontal', n.anchor); //grid 逐行走水平分布,anchor 同水平语义
this._requireAnchorPoint(n);
var capacity = n.cols * n.rows;
var count = (typeof ctx.count === 'number') ? ctx.count : capacity;
//配置与运行时项数不一致时必须显式报错,不能静默截断多出的项(工程总则 §7 显式失败)
if (count > capacity) {
throw new Error('[EQW_LayoutSolver] grid 项数超出容量: count=' + count + ' > cols(' + n.cols + ')*rows(' + n.rows + ')=' + capacity);
}
if (count <= 0) { return []; }
var out = [];
for (var row = 0; row < n.rows && out.length < count; row++) {
var remain = count - out.length;
var inRow = Math.min(n.cols, remain);
var positions = AlignmentUtils.distributeHorizontally({
anchorX: n.anchorX,
anchorY: n.anchorY + row * (n.itemHeight + (n.spacingY || 0)),
count: n.cols, //按满格算位置,保证不足一行时列位不漂移
itemWidth: n.itemWidth,
itemHeight: n.itemHeight,
spacing: n.spacingX || 0,
anchor: n.anchor
});
for (var c = 0; c < inRow; c++) {
out.push({ x: positions[c].x, y: positions[c].y, width: n.itemWidth, height: n.itemHeight });
}
}
return out;
},
//attach:贴在另一个精灵上。corner 为空 = 在目标【内部】对齐;有值 = 与目标对应角重合
//target/w/h 都支持运行时注入:叫分档位贴自己的按钮、花色图标贴自己的按钮、牌角标贴"打出的那张牌",
//这类目标配置里写不出固定值;动态文字标签的真实宽高要等文案渲染出来才知道,配置里也留空。
//这些节点写成模板并用 runtime 声明留空字段,由 ctx 在运行时补上(已在 solve 里合并进 n)
_solveAttach: function (n, ctx) {
if (!ctx.rects) { throw new Error('[EQW_LayoutSolver] attach 需要 ctx.rects'); }
var targetKey = n.target;
if (!targetKey) { throw new Error('[EQW_LayoutSolver] attach 配置未写 target,且 ctx.target 未给出'); }
var target = ctx.rects[targetKey];
if (!target) { throw new Error('[EQW_LayoutSolver] attach 的 target 未在 ctx.rects 中: ' + targetKey); }
var w = n.w;
var h = n.h;
var sprite = { width: w, height: h };
var offset = { x: n.offsetX || 0, y: n.offsetY || 0 };
var pos;
//w/h 是否必填取决于本次实际走的对齐分支是否用到精灵尺寸(AlignmentUtils 源码逐分支核对):
//纯文字标签在 left/top/bottom 对齐下不需要尺寸,一刀切要求 w/h 会逼配置去编造假数值
if (n.corner) {
switch (n.corner) {
case 'topLeft':
pos = AlignmentUtils.alignCornerTopLeft(target, sprite, offset); break;
case 'topRight':
this._requireW(w, targetKey, 'corner=topRight');
pos = AlignmentUtils.alignCornerTopRight(target, sprite, offset); break;
case 'bottomLeft':
this._requireH(h, targetKey, 'corner=bottomLeft');
pos = AlignmentUtils.alignCornerBottomLeft(target, sprite, offset); break;
case 'bottomRight':
this._requireW(w, targetKey, 'corner=bottomRight');
this._requireH(h, targetKey, 'corner=bottomRight');
pos = AlignmentUtils.alignCornerBottomRight(target, sprite, offset); break;
default: throw new Error('[EQW_LayoutSolver] 未知 corner: ' + n.corner);
}
} else {
//hAlign: center/right 要用 sprite.width;left 不用(AlignmentUtils.js 第106/111/101行)
if (n.hAlign === 'center' || n.hAlign === 'right') {
this._requireW(w, targetKey, 'hAlign=' + n.hAlign);
}
//vAlign: 只有 middle 用 sprite.height;top/bottom 都不用(bottom 用的是 target.height,第132行)
if (n.vAlign === 'middle') {
this._requireH(h, targetKey, 'vAlign=' + n.vAlign);
}
pos = AlignmentUtils.alignTo(target, sprite, n.hAlign, n.vAlign, offset);
}
return [{ x: pos.x, y: pos.y, width: w, height: h }];
},
_requireW: function (w, targetKey, mode) {
if (typeof w !== 'number') {
throw new Error('[EQW_LayoutSolver] attach target=' + targetKey + '(' + mode + ')需要数字 w:配置未写 w,且 ctx.w 未给出');
}
},
_requireH: function (h, targetKey, mode) {
if (typeof h !== 'number') {
throw new Error('[EQW_LayoutSolver] attach target=' + targetKey + '(' + mode + ')需要数字 h:配置未写 h,且 ctx.h 未给出');
}
},
//distribute* 只返回 {x,y},这里补上尺寸
_withSize: function (positions, w, h) {
var out = [];
for (var i = 0; i < positions.length; i++) {
out.push({ x: positions[i].x, y: positions[i].y, width: w, height: h });
}
return out;
}
};
@@ -1062,7 +1062,7 @@ var SpriteManager = (function() {
*
* 说明:
* - 多帧图片资源需要包含16帧:0123456789.+-x/p
* - 帧序号:0-9对应数字,10='.', 11='+', 12='-', 13='x', 14='/', 15='p'(可变)
* - 帧序号为 1-based:帧1-10对应数字0-9,帧11='.', 帧12='+', 帧13='-', 帧14='x', 帧15='/', 帧16='p'(可变后缀)
* - 精灵宽度会根据字符数量自动调整
* - 编码转换:. → b, + → c, - → d, x → e, / → f, 分/倍/张 → g
* - 第16帧(p位)为可变内容,需要调用者确保资源图与使用场景匹配
@@ -1088,17 +1088,19 @@ var SpriteManager = (function() {
// gameabc引擎帧索引计算(1-based):
// - 数字 '0'-'9' → charCode - 47 = 帧1-10
// - 字母 'b'-'g' → charCode - 87 = 帧10-16
//
// - 字母 'b'-'g' → charCode - 87 = 帧11-16
// 编码故意从 'b' 起跳、跳过 'a':若用 'a'(charCode 97) 则 97-87=10,
// 会与 '9' → 帧10 撞帧,所以把 '.' 的编码让给 'b'、从帧11 开始,避免撞车
//
// 编码映射:
// - '.' → 'b' → 帧10 (实际帧11,因为0是帧1)
// - '+' → 'c' → 帧11 (实际帧12)
// - '-' → 'd' → 帧12 (实际帧13)
// - 'x' → 'e' → 帧13 (实际帧14)
// - '/' → 'f' → 帧14 (实际帧15)
// - '分/倍/张' → 'g' → 帧16 (第16帧)
//
// 帧顺序(资源图片):0123456789.+-x/分 (共16帧)
// - '.' → 'b' → 帧11
// - '+' → 'c' → 帧12
// - '-' → 'd' → 帧13
// - 'x' → 'e' → 帧14
// - '/' → 'f' → 帧15
// - '分/倍/张' → 'g' → 帧16 (可变后缀)
//
// 帧顺序(资源图片):0123456789.+-x/后缀 (共16帧)
var encodedText = textStr
.replace(/\./g, 'b')
.replace(/\+/g, 'c')
+16
View File
@@ -0,0 +1,16 @@
// 极简断言助手(测试代码不受严格 ES5 限制,dev-guide 05 §10)
module.exports = function () {
let pass = true;
let count = 0;
function eq(name, got, exp) {
const p = JSON.stringify(got) === JSON.stringify(exp);
count++;
if (!p) pass = false;
console.log((p ? 'PASS' : 'FAIL') + ' ' + name + ' got=' + JSON.stringify(got) + ' exp=' + JSON.stringify(exp));
}
function done(label) {
console.log((pass ? 'ALL PASS' : 'HAS FAILURE') + ' [' + label + '] ' + count + ' checks');
return pass;
}
return { eq, done };
};
+27
View File
@@ -0,0 +1,27 @@
// 把前端 ES5 全局脚本加载进 Node global。
// 正式代码是浏览器脚本(var X = {...},无 module.exports),
// 这里用 vm.runInThisContext 还原浏览器语义——顶层 var 挂到 globalThis。
// 【关键】不得为了测试而给正式代码加 module.exports(client 05 §10)。
const fs = require('fs');
const path = require('path');
const vm = require('vm');
const ROOT = path.resolve(__dirname, '..', '..'); // 仓库根
function load(rel) {
const abs = path.join(ROOT, rel);
const code = fs.readFileSync(abs, 'utf8');
vm.runInThisContext(code, { filename: rel });
}
function loadAll(rels) {
rels.forEach(load);
}
// 断言抛错的小工具:期望 fn 抛异常
function throws(fn) {
try { fn(); } catch (e) { return true; }
return false;
}
module.exports = { ROOT, load, loadAll, throws };
+319
View File
@@ -0,0 +1,319 @@
// 导出脚本(测试代码,跑在 Node,非正式代码):
// 用服务端 test/_rpc.js + class.desk.js 的既有测试脚手架跑一局完整流程
//(发牌 fapai → 叫分 jiaofen → 上庄 shangzhuang → 选主 xuanzhu → 埋牌 maipai
// → 出牌 chupai1/2/3 若干轮 → 结算 jiesuan),把真实下发包按座位快照导出成
// client/tests/fixtures/packets.json,供前端测试回放(不手写假包,测的是真实契约)。
//
// 用法:node client/tests/fixtures/export_packets.js
//
// 装配方式参照服务端既有测试的两种建立法:
// - _rpc.js 的 setup() 只接受"已构造好的 o_paiju",不走 class.desk.js,
// 因此不会产生 fapai(发牌)包——它是给"单个 RPC 处理器"用的轻量桩。
// - test_flow.js / test_endgame.js 的 mkRoom() 模式改用真实 class.desk.js
// 的 D.new()/D.do_new_paiju(),才会真的下发 fapai;本脚本需要完整流程
// (含 fapai),故照抄这一种既有用法,而不是 setup()。
// 两条路径殊途同归:都是把 mod.app / mod.import 指向同一个 sent 捕获数组,
// 区别只在"谁负责建立 o_room/o_desk/o_paiju"。
'use strict';
const fs = require('fs');
const path = require('path');
// 装配 mod.js 及其依赖类,把关键类挂到 global(与 _rpc.js 内部做法一致)
const R = require('../../../server/games/erqiwang/test/_rpc.js');
const mod = R.mod;
const P = global.cls_youle_erqiwang_paiju;
const A = global.cls_youle_erqiwang_arith;
const D = require('../../../server/games/erqiwang/class.desk.js');
const E = require('../../../server/games/erqiwang/class.export.js').new();
const ROOT = path.resolve(__dirname, '..', '..', '..');
const OUT = path.join(ROOT, 'client/tests/fixtures/packets.json');
// 常规房:可查牌、不爬坡、不傍王(roomtype 位串全 0,见 class.config.js)
const ROOMTYPE = '00000';
// 固定种子:只在本导出脚本进程内替换 global.min_random(不改 server/_shim.js),
// 让「同一份脚本重复运行」产出逐字节相同的 packets.json,便于 review diff 与回归。
// 写法与服务端既有测试(test_flow.js/test_fuzz.js/test_leak.js)的 xorshift 种子一致。
// 改这个值会重新生成整局牌(发牌结果、叫分走向、出牌过程全变),产物 diff 会很大。
const DEAL_SEED = 0x5A5A2026;
(function seedDeal(seed) {
let state = seed >>> 0;
const rnd = function (n) {
state ^= state << 13; state >>>= 0;
state ^= state >>> 17;
state ^= state << 5; state >>>= 0;
return state % n;
};
global.min_random = function (min, max) { return min + rnd(max - min + 1); };
})(DEAL_SEED);
const clone = function (m) { return JSON.parse(JSON.stringify(m)); };
// 装配一个"真实牌桌":D.new() 建牌桌,global.youle_erqiwang.app/import 与
// mod.app/mod.import 同时指向同一个 sent 捕获数组——class.desk.js 的 fapai/zhunbei
// 走前者(bare 全局引用),mod.js 的 RPC handler 走后者(mod 自身的模块变量),
// 两条路径必须都接上,否则 fapai 会走到 _shim.js 的默认空实现,捕获不到。
function makeRoom(roomtype) {
const sent = [];
const o_room = {
roomtype: roomtype, asetcount: 6, roomcode: 1, createtime: 'T', makewartime: 'T',
seatlist: [0, 1, 2].map(function (i) {
return { conmode: 0, fromid: i, playerid: 100 + i, nickname: 'P' + i, avatar: '', gameinfo: {} };
}),
method: { sendpack_toother: function (m) { sent.push(clone(m)); } }
};
const desk = D.new(o_room);
o_room.o_desk = desk;
global.youle_erqiwang.app = { SendPack: function (m) { sent.push(clone(m)); } };
global.youle_erqiwang.import = { check_player: function () { return o_room; }, deduct_roomcard: function () { }, save_grade: function () { } };
mod.import = global.youle_erqiwang.import;
mod.app = global.youle_erqiwang.app;
return { o_room: o_room, desk: desk, sent: sent };
}
const pk = function (seat, extra) {
return {
conmode: 0, fromid: seat,
data: Object.assign({ agentid: 'a', playerid: seat, gameid: 'g', roomcode: 1, seat: seat }, extra || {})
};
};
const room = makeRoom(ROOMTYPE);
const o_room = room.o_room;
const desk = room.desk;
const sent = room.sent;
// ---- 按座位收集下发包(保留差异化下发:谁收到什么、谁没收到) ----
// sent 里每一项要么带明确的 fromid(0/1/2,逐座位分别下发,内容可能互不相同),
// 要么是 sendpack_toother(msg, -1) 的整体广播(未逐座位改写 conmode/fromid,
// fromid 为 undefined)——广播内容三家相同,按"三家都收到"处理。
const seatPackets = [[], [], []];
const deskinfo = [];
let drained = 0;
function drain() {
for (; drained < sent.length; drained++) {
const raw = sent[drained];
const packet = { rpc: raw.rpc, data: raw.data };
if (raw.fromid === 0 || raw.fromid === 1 || raw.fromid === 2) {
seatPackets[raw.fromid].push(packet);
} else {
seatPackets[0].push(packet);
seatPackets[1].push(packet);
seatPackets[2].push(packet);
}
}
}
// packetIndex 是关键对齐锚点:取快照时先 drain,记下该座位此刻已收到的包数,
// 与 Task 12 一致性测试"喂完 seats[seat] 的前 packetIndex 个包"对齐。
//
// 【必须 clone】get_deskinfo 的返回值里仍有若干【无拷贝的活引用赋值】——
// 例如 Balance.readystate = o_desk.prepare、Balance.aset/bottom/account 三份结算快照。
// (PushCards.playproc / PushCards.seatlist / pushlist / burycards 已各自走深拷贝快照函数,
// 这段注释早先把它们也算在内,已过时,现更正。)
// 真实链路里这份返回值被平台立刻序列化下发,活引用不会被观测到;但本脚本要跑完整局之后才
// 统一 JSON.stringify 写盘,若不在这里当场拷贝一份,写盘时这些字段会被后续操作篡改成终局
// 状态,导致同一座位不同 packetIndex 下的快照"逐字节相同"——这是本脚本自身的缺陷,与服务端
// 无关(服务端没有"延后序列化"这个用法)。
function snap(step, seat) {
drain();
deskinfo.push({
step: step,
seat: seat,
packetIndex: seatPackets[seat].length,
info: clone(E.get_deskinfo(o_room, seat))
});
}
// ================= 跑一局 =================
// 发牌(fapai):class.desk.js 内部随机发牌 + 广播
D.do_new_paiju(desk, 0);
const pj = desk.method.curr_paiju();
// StartWar(开局握手,前端 04/05 讲的"增量路径真实起点"):真实链路里客户端开局收到的
// 第一包,结构就是 deskwar —— 即 get_deskinfo() 的返回值(见 server/youle/server_room/
// rpc.js 里 pack.data.deskwar = youle_room.import.makewar_deskwar(o_room) 一段)。
// 当前 erqiwang 的 exp.makewar(class.export.js)尚未把 get_deskinfo 接成返回值,这是
// 另一项任务;本脚本独立调用 get_deskinfo 反映"接线后客户端此刻会收到什么",让前端测试
// 能提前对齐这条起点(尤其是 room.playerScores 这类 fapai 包不带、只有 StartWar 能给的初值)。
// 与紧随其后的 step1 快照取同一时刻(do_new_paiju 之后、任何 jiaofen 之前),按座位各存
// 一份(三家 MyCards 视角不同),套一层 { deskwar: ... } 外壳与生产环境 pack.data.deskwar
// 的包裹方式对齐,供 EQW_ResyncHandler.handleStartWar 直接消费。
const startwar = [0, 1, 2].map(function (seat) {
return { deskwar: clone(E.get_deskinfo(o_room, seat)) };
});
snap(1, 0); snap(1, 1); snap(1, 2); // 叫分阶段(step1):三家快照,验证 MyCards 各不相同
// 叫分:0 号位叫 65,其余不叫 → 0 号位上庄(触发 jiaofen 广播 + shangzhuang 差异化下发)
mod.jiaofen(pk(pj.method.get_callgrade_seat(), { call: 65 }));
mod.jiaofen(pk(pj.method.get_callgrade_seat(), { call: 0 }));
mod.jiaofen(pk(pj.method.get_callgrade_seat(), { call: 0 }));
snap(2, 0); snap(2, 1); snap(2, 2); // 选主阶段(step2):庄家有 cards/bottomcards,闲家没有
// 选主:庄家选方块为主
mod.xuanzhu(pk(pj.banker, { flower: 1 }));
snap(3, pj.banker); // 埋牌阶段(step3):庄家视角
// 埋牌:庄家埋掉当前手上最后 8 张(get_seat_cards 已按主牌排序,任取8张即可)
mod.maipai(pk(pj.banker, { cards: P.get_seat_cards(pj, pj.banker).slice(-8) }));
snap(5, 0); snap(5, 1); snap(5, 2); // 出牌阶段开局(step5):出牌者 cardsinhand、下一位 mustcard 差异
// ================= 出牌策略 =================
// 【为什么不再是"全出单张"】:单张一手无所谓顺序,服务端「这一手打出的牌」的两个顺序口径
// (chupai/playproc.cards 与 deskinfo.PushCards.pushlist)在单张局面下永远相等,夹具测不出
// 分岔。甩牌(design §5.4)与领对子会产生"一手多张",才真正压到这条契约。
//
// 策略(确定性,不含随机):
// 首家:优先甩牌(多分量主牌,且服务端 can_playcard 判定合法)→ 其次领最小的一对
// → 否则最小单张
// 跟家:先取 get_followcard 的必出牌,再从可出牌里补足张数,逐个组合试到
// can_followcard(+ 甩牌时的 flush_follow_ok)通过为止
//
// 【提交顺序刻意用升序】:真实客户端提交的是玩家的点击顺序,服务端不得依赖它
//(server 04 §8「前端不是数据源」;class.arith.js can_followcard 里也有同样的告诫)。
// 服务端的权威展示顺序是 order_cards 的【降序】,所以这里一律把选好的一手【反转成升序】
// 再提交,模拟"玩家从小到大点选"。这不是伪造数据——包仍然是服务端真实产出的,
// 只是让请求侧的顺序与权威顺序刻意不同,把"下发顺序是否被请求顺序污染"暴露出来。
const asc = function (cards) {
return A.order_cards(pj.flower, cards.concat()).reverse();
};
// 两名对手未出的主牌(供甩牌最大性判定),与 class.paiju.js do_playcard 的取法一致
function oppZhuList(seat) {
const list = [];
for (let os = 0; os < 3; os++) {
if (os !== seat) { list.push(P.get_seat_zhucards(pj, os)); }
}
return list;
}
// 首家:尝试甩牌——把手上主牌分解成分量,按分量牌力从大到小取前 k 个(k 从多到少),
// 交给服务端 can_playcard 判定;只接受 result===true 的(合法甩牌),
// 不提交会被判"甩错"的组合(那会被服务端收回成单张,反而回到单张局面)。
// 注意 can_playcard 会【就地排序】入参数组,故一律传副本。
function tryShuai(seat, hand) {
const trumps = hand.filter(function (c) { return A.id_to_code(pj.flower, c) > 1000; });
if (trumps.length < 2) { return null; }
const comps = A.decompose_trump(pj.flower, trumps).slice()
.sort(function (a, b) { return b.value - a.value; });
const opp = oppZhuList(seat);
for (let k = Math.min(comps.length, 3); k >= 2; k--) {
let pick = [];
for (let i = 0; i < k; i++) { pick = pick.concat(comps[i].cards); }
if (A.can_playcard(pj.flower, pick.concat(), seat, pj.seatlist, opp).result) {
return pick;
}
}
return null;
}
// 首家:尝试领一对。取【最大的一对】——甩牌合法的前提是两名对手都压不过任一分量
//(§5.4.2 opp_can_beat_flush),大牌先走能更快拔掉对手的主牌,后续局面才会出现合法甩牌。
// 这不是"为了凑测试"的取巧:本夹具的目的就是覆盖甩牌,而甩牌本来就只在这种局面下成立。
// 同一颗种子下:领最小的一对 → 全局只有 1 手合法甩牌;领最大的一对 → 4 手。
function tryLeadPair(seat, hand) {
const pairs = A.get_pairlist(pj.flower, hand.concat());
if (!pairs.length) { return null; }
const pick = pairs[0].concat();
if (A.can_playcard(pj.flower, pick.concat(), seat, pj.seatlist, oppZhuList(seat)).result) {
return pick;
}
return null;
}
// 跟家:这一手是否被服务端接受(与 class.paiju.js do_playcard 的跟牌校验同一组判定)
function followOk(hand, cand) {
const proc = pj.playproc;
if (!A.can_followcard(pj.flower, hand, cand, proc.startcount, proc.startflower, proc.starttype).result) {
return false;
}
if (proc.shuai_demand && !A.flush_follow_ok(pj.flower, hand, cand, proc.shuai_demand)) {
return false;
}
return true;
}
// 跟家:凑出 startcount 张的合法跟牌
function pickFollow(seat, hand) {
const proc = pj.playproc;
const n = proc.startcount;
const get = A.get_followcard(pj.flower, hand.concat(), n, proc.startflower, proc.starttype);
const base = (get && get.mustcard) ? get.mustcard.concat() : [];
if (base.length === n) {
return followOk(hand, base) ? base : null;
}
const need = n - base.length;
if (need < 0) { return null; }
// 候选池:优先服务端给出的"可出的牌",为空则退回整手牌
let pool = (get && get.cancard && get.cancard.length) ? get.cancard.concat() : hand.concat();
pool = pool.filter(function (c) { return base.indexOf(c) < 0; });
let found = null;
const combo = [];
(function walk(start) {
if (found) { return; }
if (combo.length === need) {
const cand = base.concat(combo);
if (followOk(hand, cand)) { found = cand; }
return;
}
for (let i = start; i < pool.length && !found; i++) {
combo.push(pool[i]);
walk(i + 1);
combo.pop();
}
})(0);
return found;
}
// 打到一半时的 step5 快照锚点(按已出手数计)。
// 【为什么要不止一个】:出牌阶段开局那三张 step5 快照的 pushlist 都还是空的,
// 只有"打到一半"的快照才真的比对到出牌历史(一致性测试里唯一压到 pushlist 的点)。
// 一个锚点=一个比对点,太薄;多取几个覆盖不同轮次/不同座位/轮中不同位次。
const MID_SNAP_AT = [4, 9, 16, 25, 34];
let guard = 0;
let shuaiHands = 0; // 统计:合法甩牌手数
let multiHands = 0; // 统计:一手多张的手数(含跟牌)
while (pj.step === 5 && guard < 400) {
guard++;
const seat = pj.playproc.currseat;
const hand = P.get_seat_cards(pj, seat);
let pick = null;
if (seat === pj.playproc.start) {
pick = tryShuai(seat, hand);
if (pick) { shuaiHands++; }
if (!pick) { pick = tryLeadPair(seat, hand); }
if (!pick) { pick = [hand[hand.length - 1]]; }
} else {
pick = pickFollow(seat, hand);
}
// 找不到合法出牌属脚本缺陷(服务端保证当前座位总有牌可出),显式失败、不静默跳过
if (!pick) {
throw new Error('export_packets: 座位 ' + seat + ' 第 ' + guard + ' 手凑不出合法出牌');
}
if (pick.length > 1) { multiHands++; }
mod.chupai(pk(seat, { cards: asc(pick) }));
// 出牌进行到中段时额外拍 step5 快照,覆盖"打到一半"的重连场景
if (MID_SNAP_AT.indexOf(guard) >= 0 && pj.step === 5) {
snap(5, pj.playproc.currseat);
}
}
snap(6, 0); snap(6, 1); snap(6, 2); // 结算阶段(step6):jiesuan 广播后
drain(); // 保险:吸收循环末尾可能残留的未 drain 包
const fixture = {
roomtype: ROOMTYPE,
startwar: startwar,
seats: seatPackets,
deskinfo: deskinfo
};
fs.writeFileSync(OUT, JSON.stringify(fixture, null, 2));
console.log('已写入 ' + OUT);
console.log('座位包数: ' + seatPackets.map(function (a) { return a.length; }).join(' / '));
console.log('deskinfo 快照数: ' + deskinfo.length);
console.log('出牌手数: ' + guard + '(其中一手多张 ' + multiHands + ' 手,合法甩牌 ' + shuaiHands + ' 手)');
console.log('paiju.step(结束时)=' + pj.step + ' result=' + pj.result);
+19669
View File
File diff suppressed because it is too large Load Diff
+16
View File
@@ -0,0 +1,16 @@
// 二七王前端单测总运行器:逐个 spawn test_*.js(各自独立进程/全局),汇总结果
// 用法:node client/tests/run.js
const fs = require('fs');
const path = require('path');
const cp = require('child_process');
const dir = __dirname;
const files = fs.readdirSync(dir).filter(f => /^test_.*\.js$/.test(f)).sort();
let allOk = true;
for (const f of files) {
console.log('\n===== ' + f + ' =====');
const r = cp.spawnSync(process.execPath, [path.join(dir, f)], { stdio: 'inherit' });
if (r.status !== 0) allOk = false;
}
console.log('\n' + (allOk ? '===== 全部单测通过 =====' : '===== 存在失败单测 ====='));
process.exit(allOk ? 0 : 1);
+150
View File
@@ -0,0 +1,150 @@
// 【C-1 回归】room.mySeat 的同步必须跑在【真实的平台初始化时序】上。
//
// 平台 12_Logic.js 的 Logic.AppStart 跨 332–531 行,里面:
// line 389: Game_Modify.appStart() ← 此时 C_Player 只是 `var C_Player;`,未赋值
// line 480: C_Player = new Player(-1) ← 对象到这里才存在,seat 仍是 -1
// 之后玩家登录,07_Desk.js 的 C_Player.SetSeat(真座位),
// 但 Game_Modify.appStart() 【再也不会被调用】。
//
// 所以「只在 appStart 里读一次 C_Player.seat」必然拿不到座位,mySeat 永远停在 -1:
// 发包带 seat:-1(服务端 check_player 必拒,叫分/选主/埋牌/出牌全发不出去)、
// StartWar 差异化下发认领不到本座位而整包丢弃、SeatMap.toDisplay(-1) 抛错。
// ——全程无一处报错。本测试就是拿这条时序当尺子量。
//
// 本文件【不允许】用 `S.room.mySeat = N` 直接赋值来制造前提(其余 8 个测试文件那样做,
// 正是它们全部漏掉 C-1 的原因)。座位只能经由平台入口同步进来。
const { load, throws } = require('./_load');
const t = require('./_assert')();
global.window = global;
load('client/js/gameabc-framework/system/EventBus.js');
load('client/js/gameabc-framework/system/SpriteEventController.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');
['DealHandler','CallHandler','MainHandler','BuryHandler','PlayHandler',
'QueryHandler','ReadyHandler','ResultHandler','ResyncHandler','FailHandler']
.forEach(h => load('client/js/01_SubGame/codes/net/handlers/' + h + '.js'));
load('client/js/01_SubGame/codes/net/Dispatcher.js');
load('client/js/01_SubGame/codes/core/SeatMap.js');
const sent = [];
global.RpcHelper = { sendRpc: (app, route, rpc, data) => sent.push({ rpc, data }) };
load('client/js/01_SubGame/codes/net/Rpc.js');
load('client/js/01_SubGame/codes/SubGameHooks.js');
const S = EQW_GameState;
// 平台 06_Player.js 的最小复刻:只有 seat 与 SetSeat 与本用例相关
function Player(seat) { this.seat = seat; }
Player.prototype.SetSeat = function (seat) { this.seat = seat; };
// ============================================================================
// 阶段 1:Logic.AppStart 第 389 行——C_Player 尚未 new
// ============================================================================
t.eq('前提:C_Player 此刻确实不存在', typeof global.C_Player, 'undefined');
t.eq('前提:mySeat 初始值是 -1', S.room.mySeat, -1);
let boom = false;
try { SubGameHooks.appStart(); } catch (e) { boom = true; }
t.eq('appStart 在 C_Player 缺席时不崩', boom, false);
t.eq('appStart 拿不到座位,mySeat 仍是 -1(不是缺陷,是时序事实)', S.room.mySeat, -1);
// 这个 -1 一旦被发包无声带出去就是 C-1 的第一重后果
t.eq('座位未同步时发包 fail-fast', throws(() => EQW_Rpc.zhunbei()), true);
t.eq('座位未同步时一个包都没发出去', sent.length, 0);
// ============================================================================
// 阶段 2:Logic.AppStart 第 480 行——C_Player = new Player(-1)
// ============================================================================
global.C_Player = new Player(-1);
SubGameHooks.setRoomDes(1234, 6, '00010');
t.eq('C_Player 存在但座位还是 -1 时,不写坏值', S.room.mySeat, -1);
t.eq('setRoomDes 该干的正事照干(解析 roomtype)', S.room.options.climb, 1);
// ============================================================================
// 阶段 3:玩家登录,07_Desk.js:320 C_Player.SetSeat(真座位)
// 紧接着 07_Desk.js:358 Game_Modify.setRoomDes(...)
// ============================================================================
C_Player.SetSeat(2);
t.eq('SetSeat 本身不写 GameState(平台不知道子游戏)', S.room.mySeat, -1);
SubGameHooks.setRoomDes(1234, 6, '00010');
t.eq('【核心】走完平台时序后 mySeat = 真座位', S.room.mySeat, 2);
// ---- 第一重后果解除:发包带真座位 ----
sent.length = 0;
EQW_Rpc.zhunbei();
t.eq('发包带上真座位', sent[0].data.seat, 2);
// ---- 第二重后果解除:StartWar 差异化下发能认领到自己那份 ----
const share = (seat, cards) => ({ seat, data: { count: 6, idx: 1, PlayerInfo: [0,0,0], step: 1, MyCards: cards } });
SubGameHooks.StartWar({ data: { deskwar: { sendtype: 1, seatlist: [
share(0, [10, 11]), share(1, [20, 21]), share(2, [30, 31])
] } } });
t.eq('StartWar 认领到本座位那份手牌', S.my.cards, [30, 31]);
// ---- 第三重后果解除:SeatMap 不再对 -1 抛错 ----
t.eq('SeatMap.toDisplay 用 mySeat 不抛错', throws(() => EQW_SeatMap.toDisplay(0, S.room.mySeat)), false);
t.eq('自己映射到 SELF', EQW_SeatMap.toDisplay(S.room.mySeat, S.room.mySeat), 'SELF');
// ============================================================================
// 阶段 4:换座(07_Desk.js:186-191 先改 C_Player.seat,再回调 changeSeat)
// 这是唯一「座位中途变化且不重走 setRoomDes」的路径
// ============================================================================
C_Player.SetSeat(0);
SubGameHooks.changeSeat(2, 0);
t.eq('换座后 mySeat 跟着变', S.room.mySeat, 0);
sent.length = 0;
EQW_Rpc.zhunbei();
t.eq('换座后发包带新座位', sent[0].data.seat, 0);
// ============================================================================
// 阶段 5:断线重连(07_Desk.js:320 SetSeat → 358 setRoomDes → 423 Reconnect)
// 这里单独校验 Reconnect 自身也同步,不依赖前面的入口是否跑过
// ============================================================================
S.room.mySeat = -1; // 人为退回未同步态,验证 Reconnect 能独立自愈
C_Player.SetSeat(1);
SubGameHooks.Reconnect({ count: 6, idx: 3, PlayerInfo: [0,0,0], step: 1, MyCards: [5,6] });
t.eq('Reconnect 自身也同步座位', S.room.mySeat, 1);
t.eq('Reconnect 的 reset() 不会把刚同步的座位清掉', S.room.mySeat, 1);
t.eq('Reconnect 正常填状态', S.my.cards, [5, 6]);
S.room.mySeat = -1;
C_Player.SetSeat(2);
SubGameHooks.ReconnectNoMakewar();
t.eq('ReconnectNoMakewar 也同步座位', S.room.mySeat, 2);
S.room.mySeat = -1;
C_Player.SetSeat(0);
SubGameHooks.StartWar({ data: { deskwar: { sendtype: 1, seatlist: [
share(0, [7, 8]), share(1, [9]), share(2, [1]) ] } } });
t.eq('StartWar 自身也同步座位', S.room.mySeat, 0);
t.eq('StartWar 同步后认领正确的那份', S.my.cards, [7, 8]);
// ============================================================================
// 幂等 / 防倒退:_syncMySeat 可重复调用,且绝不用坏值覆盖好值
// ============================================================================
SubGameHooks._syncMySeat();
SubGameHooks._syncMySeat();
t.eq('重复同步结果不变(幂等)', S.room.mySeat, 0);
C_Player.SetSeat(-1); // 平台 new Player(-1) 的初值又冒出来
SubGameHooks._syncMySeat();
t.eq('座位为 -1 时保持原值,不倒退', S.room.mySeat, 0);
C_Player.SetSeat(3);
SubGameHooks._syncMySeat();
t.eq('座位越界时保持原值,不倒退', S.room.mySeat, 0);
C_Player.SetSeat('1');
SubGameHooks._syncMySeat();
t.eq('座位是字符串时保持原值,不倒退', S.room.mySeat, 0);
delete global.C_Player;
boom = false;
try { SubGameHooks._syncMySeat(); } catch (e) { boom = true; }
t.eq('C_Player 消失时同步不崩', boom, false);
t.eq('C_Player 消失时保持原值', S.room.mySeat, 0);
process.exit(t.done('appstart_timing') ? 0 : 1);
+59
View File
@@ -0,0 +1,59 @@
// cardId → 牌面帧号(清单 §0.4 唯一权威转换)
const { load } = require('./_load');
const t = require('./_assert')();
load('client/js/01_SubGame/codes/core/CardCodec.js');
// ---- 清单 §0.4 的 10 条校验样例,逐条锁死 ----
const cases = [
[0, 40, '方块A'],
[4, 44, '方块5'],
[12, 52, '方块K'],
[13, 27, '梅花A'],
[26, 14, '红桃A'],
[39, 1, '黑桃A'],
[51, 13, '黑桃K'],
[52, 53, '小王'],
[53, 54, '大王'],
[106, 53, '小王(deck2)']
];
cases.forEach(c => t.eq('帧号 ' + c[2] + ' id=' + c[0], EQW_CardCodec.cardIdToFrame(c[0]), c[1]));
// ---- 两副牌同一张牌共用同一帧 ----
// 逐张比对;不再补一条 t.eq(true, true) 的恒真断言——那种检查在循环被删掉后仍然 PASS
const deckMismatch = [];
for (let id = 0; id < 54; id++) {
if (EQW_CardCodec.cardIdToFrame(id) !== EQW_CardCodec.cardIdToFrame(id + 54)) {
deckMismatch.push(id);
}
}
t.eq('deck1/deck2 逐张同帧', deckMismatch, []);
// ---- 帧号值域:54 张牌只落在 1–54 ----
let inRange = true;
for (let id = 0; id < 108; id++) {
const f = EQW_CardCodec.cardIdToFrame(id);
if (!(f >= 1 && f <= 54)) { inRange = false; }
}
t.eq('帧号值域 1-54', inRange, true);
// ---- 单射:deck1 的 54 张牌映射出 54 个互不相同的帧 ----
const seen = {};
let dup = false;
for (let id = 0; id < 54; id++) {
const f = EQW_CardCodec.cardIdToFrame(id);
if (seen[f]) { dup = true; }
seen[f] = true;
}
t.eq('54 张牌帧号互不重复', dup, false);
t.eq('恰好覆盖 54 个帧', Object.keys(seen).length, 54);
// ---- 二七王不使用 3、4 的帧(design §2 去掉 3 和 4)----
// 方块3 id=2 → 帧42,方块4 id=3 → 帧43;资源仍按 60 帧通用排版出图,只是不引用
t.eq('方块3 帧号', EQW_CardCodec.cardIdToFrame(2), 42);
t.eq('方块4 帧号', EQW_CardCodec.cardIdToFrame(3), 43);
// ---- 牌背常量 ----
t.eq('牌背帧', EQW_CardCodec.CARD_BACK_FRAME, 55);
process.exit(t.done('cardcodec') ? 0 : 1);
+119
View File
@@ -0,0 +1,119 @@
// 手牌三种标记推导 + 选主面板的花色统计(清单 §1.6 / §1.5)
const { load } = require('./_load');
const t = require('./_assert')();
load('client/js/01_SubGame/codes/shared/cards.js');
load('client/js/01_SubGame/codes/core/CardOrder.js');
load('client/js/01_SubGame/codes/core/CardMark.js');
const id = (d, f, n) => (d - 1) * 54 + (f - 1) * 13 + (n - 1);
const big = d => (d - 1) * 54 + 53;
const small = d => (d - 1) * 54 + 52;
const MF = 1; // 主花色 = 方块
// ---- 正2 / 正7(主花色的 2 和 7)标 zheng ----
t.eq('正2 标 zheng', EQW_CardMark.markOf(id(1, MF, 2), [id(1, MF, 2)], MF), 'zheng');
t.eq('正7 标 zheng', EQW_CardMark.markOf(id(1, MF, 7), [id(1, MF, 7)], MF), 'zheng');
// ---- 其他主牌标 zhu:双王、副2、副7、主花色普通牌 ----
t.eq('大王 标 zhu', EQW_CardMark.markOf(big(1), [big(1)], MF), 'zhu');
t.eq('小王 标 zhu', EQW_CardMark.markOf(small(1), [small(1)], MF), 'zhu');
t.eq('副2 标 zhu', EQW_CardMark.markOf(id(1, 2, 2), [id(1, 2, 2)], MF), 'zhu');
t.eq('副7 标 zhu', EQW_CardMark.markOf(id(1, 3, 7), [id(1, 3, 7)], MF), 'zhu');
t.eq('主花色A 标 zhu', EQW_CardMark.markOf(id(1, MF, 1), [id(1, MF, 1)], MF), 'zhu');
// ---- 副牌无标记 ----
t.eq('副牌普通牌无标记', EQW_CardMark.markOf(id(1, 2, 5), [id(1, 2, 5)], MF), null);
// ---- 拖拉机:同花色两个连续对子 → 四张全标 tractor ----
// 副牌花色2 的 9对 + 10对(编码相差1,连续)
const tractorHand = [id(1, 2, 9), id(2, 2, 9), id(1, 2, 10), id(2, 2, 10)];
const m1 = EQW_CardMark.marksOfHand(tractorHand, MF);
t.eq('连续两对全标 tractor', tractorHand.map(c => m1[c]), ['tractor', 'tractor', 'tractor', 'tractor']);
// ---- 反面:不连续的两个对子不成拖拉机(副牌无标记)----
const notTractor = [id(1, 2, 9), id(2, 2, 9), id(1, 2, 12), id(2, 2, 12)];
const m2 = EQW_CardMark.marksOfHand(notTractor, MF);
t.eq('不连续两对不成拖', notTractor.map(c => m2[c] || null), [null, null, null, null]);
// ---- 反面:单个对子不是拖拉机 ----
const onePair = [id(1, 2, 9), id(2, 2, 9)];
const m3 = EQW_CardMark.marksOfHand(onePair, MF);
t.eq('单对不成拖', onePair.map(c => m3[c] || null), [null, null]);
// ---- 反面:跨花色的对子不成拖(花色2的9对 + 花色3的10对)----
const crossFlower = [id(1, 2, 9), id(2, 2, 9), id(1, 3, 10), id(2, 3, 10)];
const m4 = EQW_CardMark.marksOfHand(crossFlower, MF);
t.eq('跨花色不成拖', crossFlower.map(c => m4[c] || null), [null, null, null, null]);
// ---- 三连对:整段 6 张全标 tractor ----
const tractor3 = [id(1, 2, 9), id(2, 2, 9), id(1, 2, 10), id(2, 2, 10), id(1, 2, 11), id(2, 2, 11)];
const m3a = EQW_CardMark.marksOfHand(tractor3, MF);
t.eq('三连对全标 tractor', tractor3.map(c => m3a[c]), ['tractor', 'tractor', 'tractor', 'tractor', 'tractor', 'tractor']);
// ---- 极大连续段:连续的 10/J 成拖,同花色不相连的 8 对不成拖(段与段互不粘连)----
const runAndLone = [id(1, 2, 10), id(2, 2, 10), id(1, 2, 11), id(2, 2, 11), id(1, 2, 8), id(2, 2, 8)];
const m3b = EQW_CardMark.marksOfHand(runAndLone, MF);
t.eq('连续段内标 tractor', [m3b[id(1, 2, 10)], m3b[id(1, 2, 11)]], ['tractor', 'tractor']);
t.eq('段外孤立对子不标', m3b[id(1, 2, 8)] || null, null);
// ---- 与服务端同源:标记扫描走 shared 的 group_tractor_runs(前后端唯一实现)----
t.eq('shared 导出极大连续段扫描', typeof youle_erqiwang_shared_cards.group_tractor_runs, 'function');
t.eq('shared 导出拖拉机起算对数', youle_erqiwang_shared_cards.TRACTOR_MIN_PAIRS, 2);
// ---- 区间边界取自 shared 具名常量(CardMark 内不再有裸字面量)----
t.eq('shared 导出主牌下界', youle_erqiwang_shared_cards.CODE_ZHU_MIN, 1000);
t.eq('正2 落在 CODE_ZHENG2 区间',
youle_erqiwang_shared_cards.id_to_code(MF, id(1, MF, 2)) > youle_erqiwang_shared_cards.CODE_ZHENG2_MIN &&
youle_erqiwang_shared_cards.id_to_code(MF, id(1, MF, 2)) < youle_erqiwang_shared_cards.CODE_ZHENG2_MAX, true);
t.eq('正7 落在 CODE_ZHENG7 区间',
youle_erqiwang_shared_cards.id_to_code(MF, id(1, MF, 7)) > youle_erqiwang_shared_cards.CODE_ZHENG7_MIN &&
youle_erqiwang_shared_cards.id_to_code(MF, id(1, MF, 7)) < youle_erqiwang_shared_cards.CODE_ZHENG7_MAX, true);
// ---- 标记值取自 MARK_* 常量,不在别处重写字面量 ----
t.eq('MARK_TRACTOR 常量', EQW_CardMark.MARK_TRACTOR, 'tractor');
t.eq('MARK_ZHENG 常量', EQW_CardMark.MARK_ZHENG, 'zheng');
t.eq('MARK_ZHU 常量', EQW_CardMark.MARK_ZHU, 'zhu');
// ---- 优先级:属于拖拉机的正2,标 tractor 而非 zheng ----
// 主牌序里 正2 与 副2 相连(is_continuous: 正2、负2)
const zhengInTractor = [id(1, MF, 2), id(2, MF, 2), id(1, 2, 2), id(2, 2, 2)];
const m5 = EQW_CardMark.marksOfHand(zhengInTractor, MF);
t.eq('拖拉机优先于 zheng', m5[id(1, MF, 2)], 'tractor');
t.eq('拖拉机优先于 zhu', m5[id(1, 2, 2)], 'tractor');
// ---- 互斥:任何一张牌至多一个标记(marksOfHand 的值域)----
const mixed = [big(1), small(1), id(1, MF, 2), id(1, MF, 7), id(1, 2, 5), id(1, 2, 9), id(2, 2, 9), id(1, 2, 10), id(2, 2, 10)];
const m6 = EQW_CardMark.marksOfHand(mixed, MF);
const validMarks = Object.keys(m6).every(k => ['tractor', 'zheng', 'zhu'].indexOf(m6[k]) >= 0);
t.eq('标记值域合法', validMarks, true);
// ---- markOf 与 marksOfHand 结果一致 ----
const consistent = mixed.every(c => (m6[c] || null) === EQW_CardMark.markOf(c, mixed, MF));
t.eq('markOf 与 marksOfHand 一致', consistent, true);
// ---- 选主前(mainflower=0):只有固定主牌成主,主花色普通牌无标记 ----
t.eq('选主前 方块A 无标记', EQW_CardMark.markOf(id(1, 1, 1), [id(1, 1, 1)], 0), null);
t.eq('选主前 2 仍是主', EQW_CardMark.markOf(id(1, 1, 2), [id(1, 1, 2)], 0), 'zhu');
t.eq('选主前 王 仍是主', EQW_CardMark.markOf(big(1), [big(1)], 0), 'zhu');
// ---- countByFlower:选主面板的张数与对数 ----
const forCount = [
id(1, 1, 5), id(2, 1, 5), // 方块5 一对
id(1, 1, 9), // 方块9 单张
id(1, 2, 3), id(1, 2, 6), id(2, 2, 6), // 梅花:一张3 + 一对6
big(1), small(1) // 王不计入任何花色
];
const cnt = EQW_CardMark.countByFlower(forCount);
t.eq('方块张数', cnt[1].count, 3);
t.eq('方块对数', cnt[1].pairs, 1);
t.eq('梅花张数', cnt[2].count, 3);
t.eq('梅花对数', cnt[2].pairs, 1);
t.eq('红心张数', cnt[3].count, 0);
t.eq('黑桃对数', cnt[4].pairs, 0);
// ---- 边界 ----
t.eq('空手牌 marks', EQW_CardMark.marksOfHand([], MF), {});
t.eq('空手牌 count', EQW_CardMark.countByFlower([]), { 1: { count: 0, pairs: 0 }, 2: { count: 0, pairs: 0 }, 3: { count: 0, pairs: 0 }, 4: { count: 0, pairs: 0 } });
process.exit(t.done('cardmark') ? 0 : 1);
+49
View File
@@ -0,0 +1,49 @@
// 主牌序排序:必须与服务端 order_cards 同序(清单 T-5)
const { load } = require('./_load');
const t = require('./_assert')();
load('client/js/01_SubGame/codes/shared/cards.js');
load('client/js/01_SubGame/codes/core/CardOrder.js');
// 服务端权威实现,用来做对照
const SRV = require('../../server/games/erqiwang/class.arith.js');
// 牌id构造(与服务端测试同款):deck d(1-2), flower f(1-4), number n(1-13)
const id = (d, f, n) => (d - 1) * 54 + (f - 1) * 13 + (n - 1);
const big = d => (d - 1) * 54 + 53; // 大王
const small = d => (d - 1) * 54 + 52; // 小王
// 一手混牌:两副牌、四花色、含王与 2/7
const hand = [
id(1, 1, 5), id(1, 4, 13), big(1), id(2, 2, 7), id(1, 3, 2),
id(1, 1, 1), small(2), id(2, 4, 7), id(1, 2, 2), id(2, 3, 10)
];
// ---- 与服务端逐一对照:选主前(0) 与四种主花色 ----
[0, 1, 2, 3, 4].forEach(mf => {
const expected = SRV.order_cards(mf, hand.concat());
t.eq('与服务端同序 mainflower=' + mf, EQW_CardOrder.sort(hand, mf), expected);
});
// ---- 不修改入参数组 ----
const orig = hand.concat();
EQW_CardOrder.sort(hand, 1);
t.eq('入参未被修改', hand, orig);
// ---- 返回的是新数组,不是同一引用 ----
t.eq('返回新数组', EQW_CardOrder.sort(hand, 1) === hand, false);
// ---- 选主前后顺序确实不同(正2/正7 升格、主花色并入主牌段)----
const beforeChoose = EQW_CardOrder.sort(hand, 0);
const afterChoose = EQW_CardOrder.sort(hand, 1);
t.eq('选主后顺序变化', JSON.stringify(beforeChoose) === JSON.stringify(afterChoose), false);
// ---- 主牌在左:排序从大到小,最大的一张恒为大王 ----
t.eq('首张是大王', EQW_CardOrder.sort(hand, 1)[0], big(1));
// ---- 边界 ----
t.eq('空数组', EQW_CardOrder.sort([], 1), []);
t.eq('null 入参', EQW_CardOrder.sort(null, 1), []);
t.eq('单张', EQW_CardOrder.sort([id(1, 1, 5)], 1), [id(1, 1, 5)]);
process.exit(t.done('cardorder') ? 0 : 1);
+114
View File
@@ -0,0 +1,114 @@
// 【核心验收】增量累积的 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);
+375
View File
@@ -0,0 +1,375 @@
// 常量层机械守卫:号段合规、ID 不重复、布局引用可解析
const { load } = require('./_load');
const t = require('./_assert')();
load('client/js/01_SubGame/codes/config/Layers.js');
load('client/js/01_SubGame/codes/config/Groups.js');
load('client/js/01_SubGame/codes/config/ImageResources.js');
load('client/js/01_SubGame/codes/config/SoundResources.js');
load('client/js/01_SubGame/codes/config/Sprites_Table.js');
load('client/js/01_SubGame/codes/config/Sprites_Cards.js');
load('client/js/01_SubGame/codes/config/Sprites_Action.js');
load('client/js/01_SubGame/codes/config/Sprites_Result.js');
load('client/js/01_SubGame/codes/config/Sprites_CreateRoom.js');
load('client/js/01_SubGame/codes/config/LayoutConstants.js');
load('client/js/01_SubGame/codes/config/Layout_Table.js');
load('client/js/01_SubGame/codes/config/Layout_Cards.js');
load('client/js/01_SubGame/codes/config/Layout_Action.js');
load('client/js/01_SubGame/codes/config/Layout_Result.js');
load('client/js/01_SubGame/codes/config/Layout_CreateRoom.js');
load('client/js/01_SubGame/codes/config/AnimConstants.js');
load('client/js/01_SubGame/codes/core/SpriteIndex.js');
// ---- 图层号段:101–200 / 301–400 / 501–600 / 701+(client 02 §1)----
// 平台图层 27(建房)是引用而非子游戏自建,单独排除
const layerVals = Object.keys(EQW_Layers).filter(k => k !== 'PLATFORM_CREATE_ROOM').map(k => EQW_Layers[k]);
const layerOk = layerVals.every(v =>
(v >= 101 && v <= 200) || (v >= 301 && v <= 400) || (v >= 501 && v <= 600) || v >= 701);
t.eq('图层落在子游戏段', layerOk, true);
t.eq('图层 27 是平台的建房界面', EQW_Layers.PLATFORM_CREATE_ROOM, 27);
// ---- 群组 ≥201 ----
const groupVals = Object.keys(EQW_Groups).map(k => EQW_Groups[k]);
t.eq('群组均 >= 201', groupVals.every(v => v >= 201), true);
// ---- 图片资源 ≥501;声音 ≥101 ----
const imgVals = Object.keys(EQW_Images).map(k => EQW_Images[k]);
t.eq('图片资源均 >= 501', imgVals.every(v => v >= 501), true);
const soundVals = Object.keys(EQW_Sounds).map(k => EQW_Sounds[k]);
t.eq('声音资源均 >= 101', soundVals.every(v => v >= 101), true); // T-20 未定时为空数组,恒真
// ---- 精灵 ID 1001–2999(段内 3000 已被平台占用,清单 §0.3)----
const index = EQW_SpriteIndex.build(EQW_Sprites);
const spriteVals = Object.keys(index).map(k => index[k]);
// 钉住精确数量:松下界(如 >200)删掉整个 View 都还能通过。
// 增删精灵时连同本数字一起改——有意的顺手更新,不是有意的就红给你看
t.eq('精灵总数', spriteVals.length, 380);
const spriteOk = spriteVals.every(v => v >= 1001 && v <= 2999);
if (!spriteOk) { console.log('越界精灵: ' + JSON.stringify(spriteVals.filter(v => v < 1001 || v > 2999))); }
t.eq('精灵落在 1001-2999', spriteOk, true);
// ---- 手牌标记与手牌一一对应(清单 §1.6:三种标记互斥、【每张牌】至多一个)----
// core/CardMark.js 也是按每张牌返回标记的:一手 28 张常带 13 个以上标记、36 张埋牌手更多,
// 标记精灵必须与手牌等量,否则渲染时必然有牌的标记画不出来
const groupMap = EQW_SpriteIndex.buildGroupMap(EQW_Sprites);
const handPairing = [];
for (let i = 1; i <= 36; i++) {
if (!index.hasOwnProperty('HAND_CARD_' + i)) { handPairing.push('缺 HAND_CARD_' + i); }
if (!index.hasOwnProperty('HAND_MARK_' + i)) { handPairing.push('缺 HAND_MARK_' + i); }
else if (groupMap['HAND_MARK_' + i] !== groupMap['HAND_CARD_' + i]) {
handPairing.push('HAND_MARK_' + i + ' 与手牌不同群组');
}
}
if (handPairing.length) { console.log('手牌标记配对问题:\n ' + handPairing.join('\n ')); }
t.eq('手牌标记与手牌 1..36 一一对应', handPairing, []);
t.eq('手牌标记恰好 36 个(无第 37 个)', index.hasOwnProperty('HAND_MARK_37'), false);
// ---- 各类 ID 全局不重复 ----
function dupsOf(vals) {
const seen = {}, dups = [];
vals.forEach(v => { if (seen[v]) { dups.push(v); } seen[v] = true; });
return dups;
}
t.eq('精灵 ID 无重复', dupsOf(spriteVals), []);
t.eq('图层 ID 无重复', dupsOf(layerVals), []);
t.eq('群组 ID 无重复', dupsOf(groupVals), []);
t.eq('图片资源 ID 无重复', dupsOf(imgVals), []);
// ---- attach.target 的来源判别:targetKind 指明去哪个命名空间取矩形 ----
// 'sprite' 子游戏精灵键名(EQW_Sprites 索引)
// 'layout' EQW_Layout 的布局节点名(矩形来自该节点的求解结果,可能是其中第 N 项)
// 'platform' 平台精灵键名(如平台头像框,落在框架保留段 1–1000,不参与子游戏号段校验)
// 只在【对应的那一个】命名空间里校验存在性——查三者并集范围太宽,几乎任何标识符都能"解析"。
const NAMESPACES = { sprite: index, layout: EQW_Layout, platform: EQW_PlatformSprites };
const resolvable = {};
Object.keys(NAMESPACES).forEach(kind => {
Object.keys(NAMESPACES[kind]).forEach(k => { resolvable[k] = true; });
});
// 收集所有布局节点,供解析检查与求解冒烟共用
const allNodes = []; // { path, node }
function collectNodes(node, path) {
if (!node || typeof node !== 'object' || Array.isArray(node)) { return; }
if (node.kind) { allNodes.push({ path, node }); }
Object.keys(node).forEach(k => {
if (k !== 'bySeat' && node[k] && typeof node[k] === 'object' && !Array.isArray(node[k])) {
collectNodes(node[k], path + '.' + k);
}
});
}
Object.keys(EQW_Layout).forEach(k => collectNodes(EQW_Layout[k], k));
// 每个 attach 节点必须声明合法 targetKind;非 attach 节点不得声明(避免复制粘贴留下的无意义字段)
const kindDecl = [];
allNodes.forEach(entry => {
const tk = entry.node.targetKind;
if (entry.node.kind === 'attach') {
if (!NAMESPACES.hasOwnProperty(tk)) { kindDecl.push(entry.path + ' -> targetKind=' + tk); }
} else if (typeof tk !== 'undefined') {
kindDecl.push(entry.path + ' -> 非 attach 却声明了 targetKind=' + tk);
}
});
if (kindDecl.length) { console.log('targetKind 声明有问题的节点:\n ' + kindDecl.join('\n ')); }
t.eq('attach 节点均声明合法 targetKind', kindDecl, []);
// 配置里写死的 target 必须存在于 targetKind 指定的那个命名空间
const missing = [];
allNodes.forEach(entry => {
const ns = NAMESPACES[entry.node.targetKind];
if (!ns) { return; } // targetKind 非法已由上一条断言报出
const targets = [];
if (entry.node.target) { targets.push([entry.path, entry.node.target]); }
if (entry.node.bySeat) {
Object.keys(entry.node.bySeat).forEach(s => {
const v = entry.node.bySeat[s];
if (v && v.target) { targets.push([entry.path + '.bySeat.' + s, v.target]); }
});
}
targets.forEach(pair => {
if (!Object.prototype.hasOwnProperty.call(ns, pair[1])) {
missing.push(pair[0] + ' -> ' + pair[1] + '(不在 ' + entry.node.targetKind + ' 命名空间里)');
}
});
});
if (missing.length) { console.log('无法解析的 attach.target:\n ' + missing.join('\n ')); }
t.eq('attach.target 在其 targetKind 命名空间内可解析', missing, []);
// line + items 节点:每项 key 同样是"指向另一个命名空间对象"的标识(多为精灵键),
// 与 attach.target 同一职责,此前完全没有守卫——写错 key 不会报错,只会在渲染时摆错精灵。
// items 没有 targetKind 字段,按 attach.target 同样的三命名空间并集解析
const missingItemKeys = [];
allNodes.forEach(entry => {
if (!entry.node.items) { return; }
entry.node.items.forEach((it, i) => {
if (!resolvable[it.key]) {
missingItemKeys.push(entry.path + '.items[' + i + '] -> key 不存在: ' + it.key);
}
});
});
if (missingItemKeys.length) { console.log('无法解析的 items[].key:\n ' + missingItemKeys.join('\n ')); }
t.eq('items[].key 在三命名空间并集内可解析', missingItemKeys, []);
// ---- 求解冒烟:每个布局节点都真的能被求解器算出来 ----
// 这是配置与求解器之间契约的最强守卫——字段名写错、缺 w/h、kind 不认识、
// bySeat 缺座位,全都会在这里当场抛错,而不是等到界面上静默摆歪。
load('client/js/gameabc-framework/ui/AlignmentUtils.js');
load('client/js/01_SubGame/codes/ui/LayoutSolver.js');
// 合成一个万能 ctx:所有可解析的键都给一个矩形,count 给一个合理值
const fakeRects = {};
Object.keys(resolvable).forEach(k => { fakeRects[k] = { x: 100, y: 100, width: 80, height: 40 }; });
// 运行时注入字段的假值表:【只对节点 runtime 里显式声明过的键】注入。
// 没声明却缺字段的节点,求解器照常抛错、本守卫照常变红——这正是 runtime 声明的意义:
// 区分「故意延后到运行时」与「忘了写」,守卫不再无差别地自动补齐缺失字段。
const RUNTIME_FAKE = {
target: 'TOP_INFO_BG', x: 10, y: 20, w: 60, h: 24,
anchorX: 30, anchorY: 40, rows: 2, itemWidth: 150, itemHeight: 32
};
// 求解器新开了注入通道却忘了给假值 → 这里立刻红
t.eq('运行时假值表覆盖全部可注入字段',
EQW_LayoutSolver.INJECT_KEYS.filter(k => !RUNTIME_FAKE.hasOwnProperty(k)), []);
t.eq('假值表里的 target 是真实可解析键', resolvable[RUNTIME_FAKE.target], true);
// bySeat 覆盖后的有效配置(bySeat 可覆盖包括 kind 在内的任意字段)
function effectiveNode(node, seat) {
if (!node.bySeat || !seat) { return node; }
const out = {};
Object.keys(node).forEach(k => { if (k !== 'bySeat') { out[k] = node[k]; } });
Object.keys(node.bySeat[seat]).forEach(k => { out[k] = node.bySeat[seat][k]; });
return out;
}
// runtime 声明本身要合法:必须是字符串数组、键名在求解器的可注入清单里、
// 且该字段在配置里确实留空(配置已经写了值还声明 runtime,说明声明是过期的)
const runtimeDecl = [];
allNodes.forEach(entry => {
const seats = entry.node.bySeat ? Object.keys(entry.node.bySeat) : [null];
seats.forEach(seat => {
const eff = effectiveNode(entry.node, seat);
const label = entry.path + (seat ? '[' + seat + ']' : '');
if (typeof eff.runtime === 'undefined') { return; }
if (!Array.isArray(eff.runtime)) { runtimeDecl.push(label + ' -> runtime 不是数组'); return; }
eff.runtime.forEach(k => {
if (EQW_LayoutSolver.INJECT_KEYS.indexOf(k) < 0) {
runtimeDecl.push(label + ' -> runtime 含不可注入的字段: ' + k);
} else if (typeof eff[k] !== 'undefined') {
runtimeDecl.push(label + ' -> runtime 声明了 ' + k + ',但配置里已写了值');
}
});
});
});
if (runtimeDecl.length) { console.log('runtime 声明有问题的节点:\n ' + runtimeDecl.join('\n ')); }
t.eq('runtime 声明合法且确实留空', runtimeDecl, []);
const SMOKE_COUNT = 8;
const solveFailures = [];
allNodes.forEach(entry => {
// 有 bySeat 的按三个显示位各求一次,没有的求一次
const seats = entry.node.bySeat ? Object.keys(entry.node.bySeat) : [null];
seats.forEach(seat => {
const eff = effectiveNode(entry.node, seat);
const ctx = { rects: fakeRects };
if (seat) { ctx.seat = seat; }
(eff.runtime || []).forEach(k => { ctx[k] = RUNTIME_FAKE[k]; });
// 期望产出多少个矩形——只断言"是数组、坐标是数字"的话,返回空数组也照样通过,
// 「少画了几张牌」这类缺陷对守卫完全不可见(曾经 grid 缺 rows 就是这么漏过去的)
let expected;
if (eff.kind === 'grid') {
const rows = (typeof ctx.rows === 'number') ? ctx.rows : eff.rows;
const capacity = eff.cols * rows;
ctx.count = Math.min(SMOKE_COUNT, capacity); // 不制造超载(超载抛错另有专门用例)
expected = ctx.count;
} else if (eff.kind === 'line' || eff.kind === 'fan') {
ctx.count = SMOKE_COUNT;
expected = eff.items ? eff.items.length : SMOKE_COUNT;
} else {
expected = 1; // point / attach 恒为 1
}
const label = entry.path + (seat ? '[' + seat + ']' : '');
try {
const r = EQW_LayoutSolver.solve(entry.node, ctx);
if (!Array.isArray(r)) { solveFailures.push(label + ' -> 返回值不是数组'); return; }
if (r.length !== expected) {
solveFailures.push(label + ' -> 矩形数 ' + r.length + ',期望 ' + expected);
return;
}
r.forEach((rect, i) => {
if (typeof rect.x !== 'number' || isNaN(rect.x) ||
typeof rect.y !== 'number' || isNaN(rect.y)) {
solveFailures.push(label + ' -> 第' + i + '项坐标非法: ' + JSON.stringify(rect));
}
});
} catch (e) {
solveFailures.push(label + ' -> ' + e.message);
}
});
});
if (solveFailures.length) { console.log('求解失败的节点:\n ' + solveFailures.join('\n ')); }
t.eq('全部布局节点可求解、矩形数与坐标都对', solveFailures, []);
// 钉住节点总数:松下界(如 >40)删掉整个 View 都还能通过,任何意外的增删都要在这里可见。
// 增删布局节点时,连同本数字一起改——改动是有意的就顺手更新,不是有意的就红给你看
t.eq('布局节点总数', allNodes.length, 83);
// ---- 布局文件是纯数据:不含函数 ----
const funcs = [];
function checkPure(node, path) {
if (!node || typeof node !== 'object') { return; }
Object.keys(node).forEach(k => {
const v = node[k];
if (typeof v === 'function') { funcs.push(path + '.' + k); }
else if (v && typeof v === 'object') { checkPure(v, path + '.' + k); }
});
}
Object.keys(EQW_Layout).forEach(k => checkPure(EQW_Layout[k], k));
t.eq('布局配置不含函数', funcs, []);
// ---- 每个布局节点的 kind 合法 ----
const badKinds = [];
function checkKind(node, path) {
if (!node || typeof node !== 'object') { return; }
if (node.kind && ['point', 'line', 'fan', 'grid', 'attach'].indexOf(node.kind) < 0) {
badKinds.push(path + ' -> ' + node.kind);
}
Object.keys(node).forEach(k => {
if (node[k] && typeof node[k] === 'object' && !Array.isArray(node[k])) { checkKind(node[k], path + '.' + k); }
});
}
Object.keys(EQW_Layout).forEach(k => checkKind(EQW_Layout[k], k));
t.eq('布局 kind 全部合法', badKinds, []);
// ---- 建房选项配置:精灵键由配置给出,渲染器不按下标拼字符串 ----
const optProblems = [];
const usedOptSprites = {};
function useSprite(where, key) {
if (!key) { optProblems.push(where + ' 未声明精灵键'); return; }
if (!index.hasOwnProperty(key)) { optProblems.push(where + ' -> 精灵键不存在: ' + key); return; }
if (usedOptSprites[key]) { optProblems.push(where + ' -> 精灵键被重复引用: ' + key); }
usedOptSprites[key] = true;
}
const bitsSeen = {};
EQW_RoomOptions.categories.forEach((cat, ci) => {
useSprite('类别' + ci + ' titleSprite', cat.titleSprite);
cat.groups.forEach(g => {
if (g.hasOwnProperty('bit')) { optProblems.push('组 ' + g.key + ' 仍把 bit 挂在组上(应统一到 item)'); }
if (g.note) { useSprite('组 ' + g.key + ' noteSprite', g.noteSprite); }
if (['radio', 'checkbox', 'radioOptional'].indexOf(g.type) < 0) {
optProblems.push('组 ' + g.key + ' -> 未知 type: ' + g.type);
}
g.items.forEach((it, ii) => {
const where = '组 ' + g.key + ' 第' + ii + '项';
useSprite(where + ' box', it.box);
useSprite(where + ' text', it.text);
if (typeof it.bit !== 'number') { optProblems.push(where + ' 缺 bit'); }
if (it.value !== '0' && it.value !== '1') { optProblems.push(where + ' value 非 0/1: ' + it.value); }
bitsSeen[it.bit] = true;
// 单选组内各项必须共用同一个 bit(同一位互斥取值)
if (g.type !== 'checkbox' && it.bit !== g.items[0].bit) {
optProblems.push(where + ' 与同组首项 bit 不一致');
}
});
// 多选组各项必须占用互不相同的位
if (g.type === 'checkbox') {
const bs = g.items.map(it => it.bit);
if (new Set(bs).size !== bs.length) { optProblems.push('组 ' + g.key + ' 多选各项 bit 重复'); }
}
});
});
// 配置声明的精灵必须恰好覆盖建房 View 里预置的全部选项精灵(多一个少一个都说明两边不同步)
Object.keys(EQW_Sprites.CreateRoomView.CreateRoom).forEach(k => {
if (k === 'id') { return; }
if (!usedOptSprites[k]) { optProblems.push('精灵 ' + k + ' 没有任何配置项引用它'); }
});
if (optProblems.length) { console.log('建房选项配置问题:\n ' + optProblems.join('\n ')); }
t.eq('建房选项精灵键与 bit 声明完整', optProblems, []);
// roomtype 是 5 位串(服务端 class.config.js IDX_ASET..IDX_NOCHECK = 0..4),位号必须刚好铺满
t.eq('roomtype 位号铺满 0-4', Object.keys(bitsSeen).map(Number).sort((a, b) => a - b), [0, 1, 2, 3, 4]);
// ---- 牌尺寸档引用的资源键存在 ----
['L', 'M', 'S'].forEach(size => {
t.eq('CARD_SIZE.' + size + ' 的资源存在', EQW_Images.hasOwnProperty(EQW_Layout.CARD_SIZE[size].res), true);
});
// ---- 多帧图数字样式(配合 SpriteManager.setNumberImage):资源键存在、字段齐全合法 ----
// 与 CARD_SIZE 同一约定:纯数据配置只存资源【键名】,调用方用 EQW_Images[res] 取真实 ID
const numStyleProblems = [];
Object.keys(EQW_Layout.NUM_STYLE).forEach(key => {
const s = EQW_Layout.NUM_STYLE[key];
if (!EQW_Images.hasOwnProperty(s.res)) { numStyleProblems.push(key + ' 的资源键不存在: ' + s.res); }
// charWidth 是 setNumberImage 的必需入参(该接口对非正数直接返回 false)
['charWidth', 'charHeight'].forEach(f => {
if (typeof s[f] !== 'number' || s[f] <= 0) { numStyleProblems.push(key + '.' + f + ' 非正数: ' + s[f]); }
});
// 后缀落在资源第 16 帧,只能是 0 或 1 个字符;且必须是 setNumberImage 认识的那三个
// (编码表 /[分倍张]/ → 'g' → 帧16;写别的字会被原样丢给引擎,按未知字符渲染)
if (typeof s.suffix !== 'string') { numStyleProblems.push(key + '.suffix 必须是字符串: ' + s.suffix); }
else if (s.suffix !== '' && ['分', '倍', '张'].indexOf(s.suffix) < 0) {
numStyleProblems.push(key + '.suffix 不是 setNumberImage 支持的后缀(分/倍/张): ' + s.suffix);
}
});
if (numStyleProblems.length) { console.log('数字样式问题:\n ' + numStyleProblems.join('\n ')); }
t.eq('NUM_STYLE 资源键存在且字段合法', numStyleProblems, []);
// 引用 NUM_STYLE 的布局节点必须与样式同源(SSOT:数值只在 LayoutConstants 定义一处,
// 有人在 Layout_Action.js 里手抄一个不同的数就在这里变红)。
// 注意 w 不在此列:setNumberImage 会按【字符数 × charWidth】自动改精灵宽,故 w 由运行时注入
[
['CALL_BTN_SCORE_NUM', 'CALL_SCORE'],
['MAIN_SUIT_COUNT', 'MAIN_SUIT_COUNT'],
['P_COUNTDOWN', 'COUNTDOWN'],
['RESULT_SCORE_NUM', 'RESULT_WIN'],
['RESULT_SCORE_NUM', 'RESULT_LOSE'],
['ACC_TPL_SCORE_NUM', 'RESULT_WIN'],
['ACC_TPL_SCORE_NUM', 'RESULT_LOSE']
].forEach(pair => {
const node = EQW_Layout[pair[0]];
const st = EQW_Layout.NUM_STYLE[pair[1]];
t.eq(pair[0] + '.h 与 NUM_STYLE.' + pair[1] + '.charHeight 同源', node.h, st.charHeight);
t.eq(pair[0] + '.w 留到运行时注入', node.runtime.indexOf('w') >= 0 && typeof node.w === 'undefined', true);
});
process.exit(t.done('constants') ? 0 : 1);
+59
View File
@@ -0,0 +1,59 @@
// 分发器:success 优先分流、未知 rpc 不崩、路由表完整性
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');
load('client/js/01_SubGame/codes/state/RoomOptions.js');
load('client/js/01_SubGame/codes/net/handlers/FailHandler.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 got = [];
EQW_Dispatcher.register('testRpc', function (d) { got.push(d); });
EQW_Dispatcher.dispatch({ rpc: 'testRpc', data: { success: true, v: 1 } });
t.eq('成功包进业务 handler', got, [{ success: true, v: 1 }]);
// ---- success:false 优先走 FailHandler,不进业务 handler ----
const failed = [];
EventBus.on(EQW_Events.EQW_RPC_FAILED, function (e) { failed.push(e); });
got.length = 0;
EQW_Dispatcher.dispatch({ rpc: 'testRpc', data: { success: false, errcode: 5 } });
t.eq('失败包不进业务 handler', got, []);
t.eq('失败包 emit RPC_FAILED', failed, [{ rpc: 'testRpc', errcode: 5 }]);
// ---- 失败包即使 rpc 没注册过也要被处理(如 chupai 只会以失败包出现)----
failed.length = 0;
EQW_Dispatcher.dispatch({ rpc: 'chupai', data: { success: false, errcode: 6 } });
t.eq('未注册 rpc 的失败包也处理', failed, [{ rpc: 'chupai', errcode: 6 }]);
// ---- 未知 rpc 的成功包:只警告、不抛错 ----
let threw = false;
try { EQW_Dispatcher.dispatch({ rpc: 'brandNewRpc', data: { success: true } }); }
catch (e) { threw = true; }
t.eq('未知 rpc 不抛错', threw, false);
// ---- 畸形入参不崩 ----
[null, undefined, {}, { rpc: 'testRpc' }, { data: {} }].forEach((bad, i) => {
let boom = false;
try { EQW_Dispatcher.dispatch(bad); } catch (e) { boom = true; }
t.eq('畸形入参 #' + i + ' 不崩', boom, false);
});
// ---- 路由表完整性:协议里每个服务端推送 rpc 都要有 handler ----
['fapai','jiaofen','shangzhuang','xuanzhu','maipai',
'chupai1','chupai2','chupai3','mingpai','tishi','jiesuan','zhunbei'].forEach(rpc => {
t.eq('已注册 handler: ' + rpc, EQW_Dispatcher.hasHandler(rpc), true);
});
process.exit(t.done('dispatcher') ? 0 : 1);
+111
View File
@@ -0,0 +1,111 @@
// 夹具自检:真包必须覆盖全部阶段,且保留座位差异
const fs = require('fs');
const path = require('path');
const { ROOT } = require('./_load');
const t = require('./_assert')();
const fx = JSON.parse(fs.readFileSync(path.join(ROOT, 'client/tests/fixtures/packets.json'), 'utf8'));
t.eq('三个座位', fx.seats.length, 3);
const rpcsOf = seat => fx.seats[seat].map(p => p.rpc);
const allRpcs = [].concat(rpcsOf(0), rpcsOf(1), rpcsOf(2));
['fapai','jiaofen','shangzhuang','xuanzhu','maipai','chupai1','chupai2','chupai3','jiesuan']
.forEach(rpc => t.eq('夹具覆盖 ' + rpc, allRpcs.indexOf(rpc) >= 0, true));
// 差异化下发确实存在:bottomcards 恰好只发给庄家一人(不是"少于三家"这种可被空集合蒙混过去的弱断言)
const withBottom = [0,1,2].filter(s =>
fx.seats[s].some(p => p.rpc === 'shangzhuang' && p.data.hasOwnProperty('bottomcards')));
t.eq('底牌恰好发给1个座位(庄家)', withBottom.length, 1);
// deskinfo 快照必须覆盖全部 5 个阶段(下游任务要用 step3 埋牌快照做回归断言)
const steps = fx.deskinfo.map(d => d.step).filter((v, i, a) => a.indexOf(v) === i).sort((a,b) => a - b);
t.eq('deskinfo 覆盖全部阶段 1/2/3/5/6', steps, [1, 2, 3, 5, 6]);
// roomtype 必须存在且非空,Task 12 要用它还原房间选项
t.eq('roomtype 存在且非空', typeof fx.roomtype === 'string' && fx.roomtype.length > 0, true);
// startwar 是增量路径的真实起点(开局握手,结构=deskwar=deskinfo),必须每座位各一份,
// 且不得混进 seats[] 数组头部——那会让全部 packetIndex 集体错位一位(见夹具补捕说明)
t.eq('startwar 存在且为 3 份', Array.isArray(fx.startwar) && fx.startwar.length, 3);
[0, 1, 2].forEach(s => {
const dw = fx.startwar[s] && fx.startwar[s].deskwar;
t.eq('startwar[' + s + '].deskwar 存在', !!dw, true);
t.eq('startwar[' + s + '] 结构=deskinfo(带 step/PlayerInfo/MyCards)',
dw && dw.hasOwnProperty('step') && dw.hasOwnProperty('PlayerInfo') && dw.hasOwnProperty('MyCards'), true);
t.eq('startwar[' + s + '].PlayerInfo 为开局初值(三家均 0)', dw && dw.PlayerInfo, [0, 0, 0]);
});
// seats[] 的第一包必须仍是 fapai——确认 startwar 没有被误插进 seats[] 头部
[0, 1, 2].forEach(s => {
t.eq('seats[' + s + '][0] 仍是 fapai(startwar 未混入 seats)', fx.seats[s][0].rpc, 'fapai');
});
// packetIndex 必须落在 (0, 该座位包总数] 区间内,且与 info.step 自洽
fx.deskinfo.forEach(d => {
const inRange = d.packetIndex > 0 && d.packetIndex <= fx.seats[d.seat].length;
t.eq('packetIndex 落在有效区间 (step' + d.step + ' seat' + d.seat + ')', inRange, true);
t.eq('deskinfo.step 与 info.step 一致 (seat' + d.seat + ' pi' + d.packetIndex + ')', d.step, d.info.step);
});
// C-1 回归锁:同一座位的两条 step5 快照,playproc(当前轮桌面牌/currseat/round)不得相同——
// 否则说明 deskinfo 快照存的是活引用,被后续出牌篡改成了终局状态(曾经的真实缺陷)
const step5BySeat = {};
fx.deskinfo.filter(d => d.step === 5).forEach(d => {
(step5BySeat[d.seat] = step5BySeat[d.seat] || []).push(d);
});
Object.keys(step5BySeat).forEach(seat => {
const list = step5BySeat[seat];
if (list.length < 2) { return; }
for (let i = 1; i < list.length; i++) {
const same = JSON.stringify(list[0].info.PushCards.playproc) === JSON.stringify(list[i].info.PushCards.playproc);
t.eq('seat' + seat + ' 两条 step5 快照 playproc 不相同(未被活引用污染)', same, false);
}
});
// I-3:推进阶段的下发包必须自带 step,且与该时刻的 deskinfo.step 属于同一套取值。
// 前端不得按 rpc 名硬编码阶段(前端红线「数据驱动、前端无对局状态机」)。
const STEP_OF = { fapai: 1, jiaofen: 1, shangzhuang: 2, xuanzhu: 3, maipai: 5, jiesuan: 6 };
const noStep = [], badStep = [];
[0, 1, 2].forEach(s => fx.seats[s].forEach(p => {
if (!STEP_OF.hasOwnProperty(p.rpc)) { return; }
if (!p.data.hasOwnProperty('step')) { noStep.push(s + ':' + p.rpc); return; }
if (p.data.step !== STEP_OF[p.rpc]) { badStep.push(s + ':' + p.rpc + '=' + p.data.step); }
}));
t.eq('推进阶段的下发包都带 step', noStep, []);
t.eq('各包的 step 就是该时点的真实阶段', badStep, []);
// I-3:选主/埋牌阶段的「轮到谁」必须由服务端显式给出(增量 nextseat / 重连分组的 seat)
const noNext = [];
[0, 1, 2].forEach(s => fx.seats[s].forEach(p => {
if ((p.rpc === 'shangzhuang' || p.rpc === 'xuanzhu') && !p.data.hasOwnProperty('nextseat')) {
noNext.push(s + ':' + p.rpc);
}
}));
t.eq('shangzhuang/xuanzhu 都带 nextseat(控制权)', noNext, []);
fx.deskinfo.filter(d => d.step === 2).forEach(d => {
t.eq('重连 ChooseMain 带控制权 seat (seat' + d.seat + ')', typeof d.info.ChooseMain.seat, 'number');
});
fx.deskinfo.filter(d => d.step === 3).forEach(d => {
t.eq('重连 BuryCards 带控制权 seat (seat' + d.seat + ')', typeof d.info.BuryCards.seat, 'number');
});
// I-2:结算阶段的重连包必须与 jiesuan 推送给同一份结算数据(抠底明细 bottom 恒有;
// account 只在末局,本夹具是第 1/6 局,故两条路径都没有)
const jiesuanPk = fx.seats[0].filter(p => p.rpc === 'jiesuan')[0];
t.eq('夹具的 jiesuan 带抠底明细 bottom', !!(jiesuanPk && jiesuanPk.data.bottom), true);
fx.deskinfo.filter(d => d.step === 6).forEach(d => {
t.eq('重连 Balance.bottom == jiesuan.bottom (seat' + d.seat + ')',
JSON.stringify(d.info.Balance.bottom), JSON.stringify(jiesuanPk.data.bottom));
t.eq('非末局:两条路径都不带 account (seat' + d.seat + ')',
[d.info.Balance.hasOwnProperty('account'), jiesuanPk.data.hasOwnProperty('account')], [false, false]);
});
// 每个包都有 success(协议 §0.1:每个下发包的 data 必带 success)
const noSuccess = [];
[0,1,2].forEach(s => fx.seats[s].forEach(p => {
if (!p.data || !p.data.hasOwnProperty('success')) { noSuccess.push(s + ':' + p.rpc); }
}));
t.eq('每个下发包都带 success', noSuccess, []);
process.exit(t.done('fixture') ? 0 : 1);
+56
View File
@@ -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);
+85
View File
@@ -0,0 +1,85 @@
// 选主 / 埋牌 handler
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');
load('client/js/01_SubGame/codes/net/handlers/MainHandler.js');
load('client/js/01_SubGame/codes/net/handlers/BuryHandler.js');
const S = EQW_GameState;
const evts = [];
Object.keys(EQW_Events).forEach(k => EventBus.on(EQW_Events[k], () => evts.push(k)));
// ================= xuanzhu =================
S.reset(); evts.length = 0;
EQW_MainHandler.handleXuanzhu({ success: true, banker: 1, flower: 3, step: 3, nextseat: 1,
countdown: 30, cards: [5, 6, 7] });
t.eq('主牌花色', S.aset.flower, 3);
t.eq('庄家', S.aset.banker, 1);
t.eq('选主后手牌', S.my.cards, [5, 6, 7]);
t.eq('step 读包字段(埋牌=3)', S.aset.step, 3);
t.eq('控制权读包里的 nextseat', S.turn.seat, 1);
t.eq('倒计时', S.turn.countdown, 30);
t.eq('选主发事件',
['EQW_MAIN_SET','EQW_HAND_CHANGED'].every(e => evts.indexOf(e) >= 0), true);
// 闲家视角:没有 cards,不抹已有手牌
S.reset(); S.my.cards = [1, 2];
EQW_MainHandler.handleXuanzhu({ success: true, banker: 0, flower: 2, step: 3, nextseat: 0, countdown: 30 });
t.eq('闲家手牌不被抹', S.my.cards, [1, 2]);
t.eq('闲家也拿到花色', S.aset.flower, 2);
// 【守卫】阶段与控制权都必须来自包,不得按 rpc 名硬编码、也不得用本地 banker 反推。
// 真实链路里 nextseat 恒 == banker,相等时"读哪个"分辨不出来;这里刻意让它们不同,
// 退回 `aset.step = 3` / `turn.seat = aset.banker` 的写法必转红。
S.reset();
EQW_MainHandler.handleXuanzhu({ success: true, banker: 0, flower: 2, step: 4, nextseat: 2, countdown: 30 });
t.eq('xuanzhu step 照读包字段', S.aset.step, 4);
t.eq('xuanzhu 控制权取 nextseat,不取 banker', S.turn.seat, 2);
// ================= maipai =================
S.reset(); evts.length = 0;
EQW_BuryHandler.handle({ success: true, cards: [1,2,3], burycards: [9,10,11,12,13,14,15,16],
step: 5, seat: 1, countdown: 20 });
t.eq('埋牌后手牌', S.my.cards, [1,2,3]);
t.eq('埋牌底牌', S.my.buryCards, [9,10,11,12,13,14,15,16]);
t.eq('step 读包字段(出牌=5)', S.aset.step, 5);
t.eq('首出者(控制权读包里的 seat)', S.turn.seat, 1);
t.eq('埋牌发事件',
['EQW_BURY_DONE','EQW_HAND_CHANGED','EQW_TURN_CHANGED'].every(e => evts.indexOf(e) >= 0), true);
// 闲家视角:无 cards/burycards,但可能有 liangpai
S.reset(); S.my.cards = [4, 5]; evts.length = 0;
EQW_BuryHandler.handle({ success: true, step: 5, seat: 0, countdown: 20,
liangpai: { cards: [52, 53, 2, 15] } });
t.eq('闲家手牌不被抹', S.my.cards, [4, 5]);
t.eq('闲家无埋牌底牌', S.my.buryCards, []);
t.eq('亮牌数据', S.table.liangpai, { cards: [52, 53, 2, 15] });
// 不达标/不查牌:无 liangpai 字段,保持 null
S.reset();
EQW_BuryHandler.handle({ success: true, step: 5, seat: 0, countdown: 20 });
t.eq('无亮牌时保持 null', S.table.liangpai, null);
// 【守卫】maipai 的 step 同样只读包字段
S.reset();
EQW_BuryHandler.handle({ success: true, step: 4, seat: 0, countdown: 20 });
t.eq('maipai step 照读包字段(不按 rpc 名硬编码)', S.aset.step, 4);
// playproc:埋牌完成时服务端已就地开好 round-1 的进行态,恒有、原样拷贝进 table.playproc,
// 与 chupai1/2/3.playproc 同源同结构(协议 §9),漏接会让「埋牌完成→庄家首出」这段窗口
// 增量路径 table.playproc 停在上一阶段的 null、与重连路径不一致
S.reset();
EQW_BuryHandler.handle({ success: true, step: 5, seat: 0, countdown: 20, cards: [1, 2, 3],
burycards: [9, 10, 11, 12, 13, 14, 15, 16],
playproc: { round: 1, start: 0, currseat: 0, startcount: -1,
startflower: -1, starttype: -1, maxseat: -1, maxcard: -1,
cards: [null, null, null], shuai_demand: null } });
t.eq('埋牌 playproc 原样拷贝服务端本轮进行态', S.table.playproc, {
round: 1, start: 0, currseat: 0, startcount: -1, startflower: -1, starttype: -1,
maxseat: -1, maxcard: -1, cards: [null, null, null], shuai_demand: null
});
process.exit(t.done('handlers_bury') ? 0 : 1);
+96
View File
@@ -0,0 +1,96 @@
// 发牌 / 叫分 / 上庄 handler
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');
load('client/js/01_SubGame/codes/net/handlers/DealHandler.js');
load('client/js/01_SubGame/codes/net/handlers/CallHandler.js');
const S = EQW_GameState;
const evts = [];
Object.keys(EQW_Events).forEach(k => EventBus.on(EQW_Events[k], () => evts.push(k)));
// ================= fapai =================
S.reset(); S.room.mySeat = 1; evts.length = 0;
EQW_DealHandler.handle({ success: true, asetidx: 2, asetcount: 6,
cards: [1, 2, 3], step: 1, seat: 0, countdown: 15 });
t.eq('局数', S.room.asetIdx, 2);
t.eq('总局数', S.room.asetCount, 6);
t.eq('手牌', S.my.cards, [1, 2, 3]);
t.eq('当前叫分者', S.turn.seat, 0);
t.eq('倒计时', S.turn.countdown, 15);
t.eq('step 读包字段(叫分=1)', S.aset.step, 1);
t.eq('mySeat 未被 reset 冲掉', S.room.mySeat, 1);
t.eq('fapai 发了 RESET/HAND/TURN 事件',
['EQW_RESET','EQW_HAND_CHANGED','EQW_TURN_CHANGED'].every(e => evts.indexOf(e) >= 0), true);
// fapai 必须先 reset:上一局的残留要清掉
S.aset.banker = 2; S.table.pushlist = [[9]];
EQW_DealHandler.handle({ success: true, asetidx: 3, asetcount: 6, cards: [4], step: 1, seat: 1, countdown: 15 });
t.eq('fapai 清掉上局 banker', S.aset.banker, -1);
t.eq('fapai 清掉上局 pushlist', S.table.pushlist, []);
// 【守卫】step 必须来自包,不得按 rpc 名硬编码。这里刻意给一个"服务端将来新插的阶段"值:
// 前端应照单收下(服务端权威),而不是把它改回 1。退回 `aset.step = 1` 的写法必转红。
EQW_DealHandler.handle({ success: true, asetidx: 3, asetcount: 6, cards: [4], step: 4, seat: 1, countdown: 15 });
t.eq('fapai step 照读包字段(不按 rpc 名硬编码)', S.aset.step, 4);
// ================= jiaofen =================
S.reset(); evts.length = 0;
EQW_CallHandler.handleJiaofen({ success: true, seat: 0, call: 60, currcall: 60,
multiple: 3, step: 1, nextseat: 1, countdown: 15 });
t.eq('记录该家叫分', S.call.calls, [60, null, null]);
t.eq('当前叫到的分', S.call.currcall, 60);
t.eq('基础子数', S.aset.multiple, 3);
t.eq('jiaofen step 读包字段(仍在叫分=1)', S.aset.step, 1);
t.eq('下一个叫分者', S.turn.seat, 1);
t.eq('jiaofen 发事件', evts.indexOf('EQW_CALL_CHANGED') >= 0, true);
// 不叫(call = 0)也要记下来,与「还没叫」(null) 区分
EQW_CallHandler.handleJiaofen({ success: true, seat: 1, call: 0, currcall: 60,
multiple: 3, step: 1, nextseat: 2, countdown: 15 });
t.eq('不叫记为 0,与未叫 null 区分', S.call.calls, [60, 0, null]);
// ================= shangzhuang =================
S.reset(); evts.length = 0;
EQW_CallHandler.handleShangzhuang({ success: true, seat: 0, call: 70, banker: 0, grade: 70,
multiple: 2, step: 2, nextseat: 0,
bottomcards: [1,2,3,4,5,6,7,8], ancard3s: 1,
cards: [1,2,3], countdown: 20, touxiang: 1, curmultiple: 3 });
t.eq('庄家', S.aset.banker, 0);
t.eq('庄家叫分', S.aset.call, 70);
t.eq('基础子数', S.aset.multiple, 2);
t.eq('允许投降', S.aset.touxiang, 1);
t.eq('抓分倍数', S.aset.curmultiple, 3);
t.eq('开底标志', S.table.ancard3s, 1);
t.eq('底牌', S.my.bottomCards, [1,2,3,4,5,6,7,8]);
t.eq('摸底后手牌', S.my.cards, [1,2,3]);
t.eq('step 读包字段(选主=2)', S.aset.step, 2);
t.eq('控制权读包里的 nextseat', S.turn.seat, 0);
t.eq('shangzhuang 发事件',
['EQW_BANKER_SET','EQW_HAND_CHANGED'].every(e => evts.indexOf(e) >= 0), true);
// 【守卫】控制权必须来自包里的 nextseat,不得用本地 banker 反推。
// 真实链路里 nextseat 恒 == banker,两者相等时"读哪个"分辨不出来;这里刻意让它们不同,
// 退回 `turn.seat = EQW_GameState.aset.banker` 的写法必转红。
// 同理 data.seat(=最后一个叫分者)也不是控制权,这里也给成第三个值。
S.reset();
EQW_CallHandler.handleShangzhuang({ success: true, seat: 1, call: 55, banker: 0, grade: 55,
multiple: 4, step: 2, nextseat: 2,
countdown: 20, touxiang: 0, curmultiple: 3 });
t.eq('控制权取 nextseat,不取 banker', S.turn.seat, 2);
t.eq('控制权不取 data.seat(最后一个叫分者)', S.turn.seat !== 1, true);
// 闲家视角:没有 cards / bottomcards(非 70 分),缺字段不能把已有值抹掉
S.reset(); S.my.cards = [7, 8, 9];
EQW_CallHandler.handleShangzhuang({ success: true, seat: 0, call: 55, banker: 0, grade: 55,
multiple: 4, step: 2, nextseat: 0,
countdown: 20, touxiang: 0, curmultiple: 3 });
t.eq('闲家手牌不被抹', S.my.cards, [7, 8, 9]);
t.eq('闲家无底牌', S.my.bottomCards, []);
t.eq('非 70 分无开底标志', S.table.ancard3s, 0);
process.exit(t.done('handlers_call') ? 0 : 1);
+107
View File
@@ -0,0 +1,107 @@
// 出牌 handler(chupai1/2/3)
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');
load('client/js/01_SubGame/codes/net/handlers/PlayHandler.js');
const S = EQW_GameState;
const evts = [];
Object.keys(EQW_Events).forEach(k => EventBus.on(EQW_Events[k], () => evts.push(k)));
// ================= chupai1(首家出牌,自己出的)=================
S.reset(); S.room.mySeat = 0; S.my.cards = [1,2,3,4]; evts.length = 0;
EQW_PlayHandler.handle({ success: true, seat: 0, cards: [1,2], count: 2, flower: 3,
cardtype: 201, nextseat: 1, countdown: 15, baozhu: 0,
cardsinhand: [3,4], curmultiple: 2,
seatlist: [[0,0,0,0,[5,2]],[0,0,0,0,[6,1]],[0,0,0,0,[7,3]]] }, 1);
t.eq('出牌者手牌被更新', S.my.cards, [3,4]);
t.eq('下一个出牌者', S.turn.seat, 1);
t.eq('倒计时', S.turn.countdown, 15);
t.eq('抓分倍数', S.aset.curmultiple, 2);
t.eq('牌况表', S.table.seatlist.length, 3);
t.eq('出牌发事件', evts.indexOf('EQW_CARD_PLAYED') >= 0, true);
// ================= chupai2(别人出牌)=================
// 别人出牌时没有 cardsinhand,不能抹掉自己的手牌
S.reset(); S.room.mySeat = 0; S.my.cards = [3,4]; evts.length = 0;
EQW_PlayHandler.handle({ success: true, seat: 1, cards: [5,6], nextseat: 2,
countdown: 15, baozhu: 0 }, 2);
t.eq('别人出牌不抹自己手牌', S.my.cards, [3,4]);
t.eq('控制权推进', S.turn.seat, 2);
// mustcard 只发给下一个出牌者
S.reset(); S.room.mySeat = 2;
EQW_PlayHandler.handle({ success: true, seat: 1, cards: [5,6], nextseat: 2,
countdown: 15, mustcard: [7,8] }, 2);
t.eq('必出牌', S.my.mustCard, [7,8]);
// 没有 mustcard 时要清空——否则上一轮的建议会残留
S.reset(); S.my.mustCard = [7,8];
EQW_PlayHandler.handle({ success: true, seat: 1, cards: [5,6], nextseat: 0, countdown: 15 }, 2);
t.eq('无 mustcard 时清空', S.my.mustCard, []);
// ================= chupai3(末家出牌,本轮结束)=================
// 真实的 chupai3 也带 nextseat(下一轮首家):服务端在 switch 前统一设置,
// 只有整轮结束进入结算时才会整体换成 jiesuan rpc(不会以 chupai3 到达 handler)
// 本墩结果(谁最大 / 这墩得几分)是一次性表现,走 EQW_TRICK_END 事件载荷,【不进 GameState】——
// 任何 deskinfo 都不下发它,存进来就是一处服务端不知道的私有对局态(出牌中途重连即归零)
S.reset(); evts.length = 0;
let lastTrick = null;
EventBus.on(EQW_Events.EQW_TRICK_END, function (p) { lastTrick = p; });
EQW_PlayHandler.handle({ success: true, seat: 2, cards: [9,10], maxseat: 0, grade: 25,
nextseat: 0, countdown: 15, baozhu: 1, curmultiple: -2 }, 3);
t.eq('本墩结果走事件载荷', lastTrick, { seat: 2, cards: [9,10], maxseat: 0, grade: 25 });
t.eq('本墩结果不进 GameState', S.result.hasOwnProperty('chupai'), false);
t.eq('累计捡分', S.aset.grade, 25);
t.eq('报无主标志', S.aset.baozhu, 1);
t.eq('抓分倍数(带符号)', S.aset.curmultiple, -2);
t.eq('下一轮首家', S.turn.seat, 0);
t.eq('本轮结束发事件', evts.indexOf('EQW_TRICK_END') >= 0, true);
// 本轮闲家没得分时不带 grade,累计分不该被抹;事件载荷也不得凭空补 grade(缺字段不兜底)
S.reset(); S.aset.grade = 40; lastTrick = null;
EQW_PlayHandler.handle({ success: true, seat: 2, cards: [9,10], maxseat: 1,
nextseat: 1, countdown: 15 }, 3);
t.eq('无 grade 时累计分保持', S.aset.grade, 40);
t.eq('无 grade 时事件载荷不带该键', lastTrick.hasOwnProperty('grade'), false);
t.eq('无 grade 时仍推进控制权', S.turn.seat, 1);
// grade 是「本轮」得分,要跨包累加进小局捡分,不是赋值——
// 若实现改成 aset.grade = data.grade,这条会把 25 覆盖成 15 而非累加到 25
S.reset();
EQW_PlayHandler.handle({ success: true, seat: 2, cards: [1,2], maxseat: 0, grade: 10,
nextseat: 0, countdown: 15 }, 3);
t.eq('第一轮捡分', S.aset.grade, 10);
t.eq('第一轮下一轮首家', S.turn.seat, 0);
EQW_PlayHandler.handle({ success: true, seat: 2, cards: [3,4], maxseat: 1, grade: 15,
nextseat: 1, countdown: 15 }, 3);
t.eq('第二轮跨包累加捡分', S.aset.grade, 25);
t.eq('第二轮下一轮首家(证明第二个包确被处理)', S.turn.seat, 1);
// ================= 甩错 =================
// shuaicuo 是「这一手」的一次性信息,走 EQW_CARD_PLAYED 事件载荷,不进 GameState;
// playproc 是服务端下发的本轮进行态,原样拷贝,两者结构不同、不得混写(见 PlayHandler 顶部注释)。
S.reset(); evts.length = 0;
var lastPlayed = null;
EventBus.on(EQW_Events.EQW_CARD_PLAYED, function (p) { lastPlayed = p; });
EQW_PlayHandler.handle({ success: true, seat: 0, cards: [11], shuaicuo: 1, count: 1,
flower: 3, cardtype: 101, nextseat: 1, countdown: 15,
playproc: { round: 2, start: 0, currseat: 1, cards: [[11], null, null] } }, 1);
t.eq('甩错标志走事件载荷', lastPlayed.shuaicuo, 1);
t.eq('本手信息走事件载荷', [lastPlayed.order, lastPlayed.seat, lastPlayed.cards,
lastPlayed.count, lastPlayed.flower, lastPlayed.cardtype],
[1, 0, [11], 1, 3, 101]);
t.eq('本手信息不进 GameState', S.table.playproc.hasOwnProperty('shuaicuo'), false);
t.eq('playproc 原样拷贝服务端本轮进行态', S.table.playproc,
{ round: 2, start: 0, currseat: 1, cards: [[11], null, null] });
// ================= 缺必需字段 =================
S.reset();
EQW_PlayHandler.handle({ success: true, cards: [1] }, 1); //缺 seat
t.eq('缺 seat 时跳过该包', S.turn.seat, -1);
process.exit(t.done('handlers_play') ? 0 : 1);
+47
View File
@@ -0,0 +1,47 @@
// 明牌 / 提示 / 准备 handler
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');
load('client/js/01_SubGame/codes/net/handlers/QueryHandler.js');
load('client/js/01_SubGame/codes/net/handlers/ReadyHandler.js');
const S = EQW_GameState;
const payloads = [];
EventBus.on(EQW_Events.EQW_TIP, d => payloads.push(d));
const evts = [];
Object.keys(EQW_Events).forEach(k => EventBus.on(EQW_Events[k], () => evts.push(k)));
// ================= mingpai =================
S.reset(); evts.length = 0;
EQW_QueryHandler.handleMingpai({ success: true, seat: 1,
others: [{ seat: 0, zhucards: [52, 2] }, { seat: 2, zhucards: [53, 15] }] });
t.eq('明牌数据入状态', S.table.mingpai,
[{ seat: 0, zhucards: [52, 2] }, { seat: 2, zhucards: [53, 15] }]);
t.eq('明牌发事件', evts.indexOf('EQW_MINGPAI') >= 0, true);
// ================= tishi =================
// 提示是一次性的,不进 GameState,只随事件带出去
S.reset(); payloads.length = 0; evts.length = 0;
const before = S.snapshot(); // 处理前的状态快照
EQW_QueryHandler.handleTishi({ success: true, seat: 2, tip: 1 });
t.eq('提示发事件', evts.indexOf('EQW_TIP') >= 0, true);
t.eq('提示内容随事件带出', payloads, [{ seat: 2, tip: 1 }]);
t.eq('提示未改动 GameState 任何字段', S.snapshot(), before);
// ================= zhunbei =================
S.reset(); evts.length = 0;
EQW_ReadyHandler.handle({ success: true, seat: 1 });
t.eq('准备发事件', evts.indexOf('EQW_READY_CHANGED') >= 0, true);
// ================= 缺必需字段 =================
S.reset();
EQW_QueryHandler.handleMingpai({ success: true }); //缺 others
t.eq('明牌缺 others 时不写', S.table.mingpai, null);
payloads.length = 0;
EQW_QueryHandler.handleTishi({ success: true, seat: 0 }); //缺 tip
t.eq('提示缺 tip 时不发事件', payloads, []);
process.exit(t.done('handlers_query') ? 0 : 1);
+85
View File
@@ -0,0 +1,85 @@
// 结算 / 解散 handler
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');
load('client/js/01_SubGame/codes/net/handlers/ResultHandler.js');
const S = EQW_GameState;
const evts = [];
Object.keys(EQW_Events).forEach(k => EventBus.on(EQW_Events[k], () => evts.push(k)));
let lastTrick = null;
EventBus.on(EQW_Events.EQW_TRICK_END, p => { lastTrick = p; });
const ASET = { banker: 0, call: 60, multiple: 3, flower: 3, grade: 45, upgrade: 1,
bangwang: 0, climb: 0,
seatlist: [{ grade: 6, score: 6 }, { grade: -3, score: -3 }, { grade: -3, score: -3 }] };
// ================= 正常出牌结算 =================
S.reset(); evts.length = 0; lastTrick = null;
EQW_ResultHandler.handleJiesuan({ success: true, step: 6,
chupai: { seat: 2, cards: [9], maxseat: 0, grade: 10 },
bottom: { cards: [1,2,3,4,5,6,7,8], multiple: 2, grade1: 20, grade2: 40 },
aset: ASET });
t.eq('小局结算入状态', S.result.aset, ASET);
t.eq('抠底入状态', S.result.bottom.grade2, 40);
// 收尾墩(服务端不发 chupai3,整包换成 jiesuan)走事件载荷,与 chupai3 同一对事件、同一份字段;
// 【不进 GameState】——deskinfo step6 的 Balance 只有 aset/bottom/account,多存就是私有对局态
t.eq('收尾墩走事件载荷', lastTrick, { seat: 2, cards: [9], maxseat: 0, grade: 10 });
t.eq('收尾墩也发本墩结束事件', evts.indexOf('EQW_TRICK_END') >= 0, true);
t.eq('收尾墩不进 GameState', S.result.hasOwnProperty('chupai'), false);
t.eq('step 读包字段(结算=6)', S.aset.step, 6);
t.eq('结算发事件', evts.indexOf('EQW_ASET_RESULT') >= 0, true);
t.eq('非末局不发大局结算事件', evts.indexOf('EQW_ACCOUNT_RESULT') < 0, true);
// ================= 投降结算(无 chupai / 无 bottom)=================
S.reset(); evts.length = 0; lastTrick = null;
EQW_ResultHandler.handleJiesuan({ success: true, step: 6, aset: { banker: 0, call: 70, multiple: 1,
upgrade: -99, seatlist: [] } });
t.eq('投降结算入状态', S.result.aset.upgrade, -99);
t.eq('投降无 chupai → 不发本墩结束事件', lastTrick, null);
t.eq('投降无 bottom', S.result.bottom, null);
t.eq('投降也发结算事件', evts.indexOf('EQW_ASET_RESULT') >= 0, true);
// ================= 末局:带 account =================
const ACCOUNT = [{ score: 12, grades: [6,6], grade_jf_total: 8, grade_cg_total: 4, grade_bw_total: 0 },
{ score: -6, grades: [-3,-3], grade_jf_total: -4, grade_cg_total: -2, grade_bw_total: 0 },
{ score: -6, grades: [-3,-3], grade_jf_total: -4, grade_cg_total: -2, grade_bw_total: 0 }];
S.reset(); evts.length = 0;
EQW_ResultHandler.handleJiesuan({ success: true, step: 6, aset: ASET, account: ACCOUNT });
t.eq('大局结算入状态', S.result.account, ACCOUNT);
t.eq('末局发大局结算事件', evts.indexOf('EQW_ACCOUNT_RESULT') >= 0, true);
// ================= 解散:走 Free 入口,取值少一层 =================
S.reset(); evts.length = 0;
EQW_ResultHandler.handleFree({ rpc: 'jiesuan',
data: { success: true, step: 6, aset: { banker: -1, call: -1, multiple: 0, upgrade: 0, seatlist: [] },
account: ACCOUNT } });
t.eq('解散取到 aset', S.result.aset.multiple, 0);
t.eq('解散取到 account', S.result.account, ACCOUNT);
t.eq('解散发大局结算事件', evts.indexOf('EQW_ACCOUNT_RESULT') >= 0, true);
t.eq('解散包的 step 也读包字段', S.aset.step, 6);
// 【守卫】结算 step 只读包字段,不按 rpc 名硬编码为 6。
// 退回 `aset.step = 6` 的写法必转红。
S.reset();
EQW_ResultHandler.handleJiesuan({ success: true, step: 4, aset: ASET });
t.eq('jiesuan step 照读包字段', S.aset.step, 4);
// ================= 解散:deskfree 为 null(首局发牌前解散)=================
S.reset(); evts.length = 0;
let boom = false;
try { EQW_ResultHandler.handleFree(null); } catch (e) { boom = true; }
t.eq('deskfree 为 null 不崩', boom, false);
t.eq('deskfree 为 null 不写状态', S.result.aset, null);
t.eq('deskfree 为 null 不发结算事件', evts.indexOf('EQW_ACCOUNT_RESULT') < 0, true);
// deskfree 存在但 data 缺失也要容忍
boom = false;
try { EQW_ResultHandler.handleFree({ rpc: 'jiesuan' }); } catch (e) { boom = true; }
t.eq('deskfree.data 缺失不崩', boom, false);
process.exit(t.done('handlers_result') ? 0 : 1);
+72
View File
@@ -0,0 +1,72 @@
// SubGameHooks 接线:平台入口 → 新架构
const { load } = require('./_load');
const t = require('./_assert')();
global.window = global; // 转发壳用 window.SubGameHooks 判定
load('client/js/gameabc-framework/system/EventBus.js');
load('client/js/gameabc-framework/system/SpriteEventController.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');
['DealHandler','CallHandler','MainHandler','BuryHandler','PlayHandler',
'QueryHandler','ReadyHandler','ResultHandler','ResyncHandler','FailHandler']
.forEach(h => load('client/js/01_SubGame/codes/net/handlers/' + h + '.js'));
load('client/js/01_SubGame/codes/net/Dispatcher.js');
load('client/js/01_SubGame/codes/SubGameHooks.js');
const S = EQW_GameState;
// ---- _ReceiveData:完整包 → 分发 ----
S.reset(); S.room.mySeat = 0;
SubGameHooks._ReceiveData({ app: 'youle', route: 'erqiwang', rpc: 'fapai',
data: { success: true, asetidx: 1, asetcount: 6,
cards: [1,2], seat: 0, countdown: 15 } });
t.eq('_ReceiveData 分发到 handler', S.my.cards, [1,2]);
// ---- setRoomDes ----
SubGameHooks.setRoomDes(123, 12, '00010');
t.eq('setRoomDes 解析选项', S.room.options.climb, 1);
// ---- Free:null 不崩 ----
let boom = false;
try { SubGameHooks.Free(null); } catch (e) { boom = true; }
t.eq('Free(null) 不崩', boom, false);
// ---- Reconnect ----
S.reset(); S.room.mySeat = 1;
SubGameHooks.Reconnect({ count: 6, idx: 3, PlayerInfo: [0,0,0], step: 1, MyCards: [5,6] });
t.eq('Reconnect 填状态', S.my.cards, [5,6]);
t.eq('Reconnect 填局数', S.room.asetIdx, 3);
// ---- DeskInfo(中途进桌)----
// 平台 07_Desk.js 在「牌局已开始 + 走加入房间而非重连」时调 Game_Modify.DeskInfo(_msg.data.deskinfo)。
// 入参结构与 Reconnect 完全一致(服务端两条路径都是 get_deskinfo(o_room, 本人座位)),
// 故必须走同一条重画路径;不接线的话整份 deskinfo 被转发壳静默丢弃、界面空白且不报错。
const MIDJOIN = { count: 6, idx: 2, PlayerInfo: [3,-1,-2], step: 5, MyCards: [10,11],
PushCards: { banker: 1, call: 60, multiple: 3, flower: 2, grade: 25,
curmultiple: -1, baozhu: 0, countdown: 30,
playproc: { round: 4, start: 1, currseat: 2, cards: [null,[12],null] } } };
S.reset(); S.room.mySeat = 2;
SubGameHooks.DeskInfo(MIDJOIN);
t.eq('DeskInfo 已接线(不再静默丢包)', S.my.cards, [10,11]);
t.eq('DeskInfo 复用重连的重画路径(阶段)', S.aset.step, 5);
t.eq('DeskInfo 复用重连的重画路径(控制权读 playproc.currseat)', S.turn.seat, 2);
t.eq('DeskInfo 与 Reconnect 落地结果逐字段相同',
(function () { const a = JSON.stringify(EQW_GameState.snapshot());
S.reset(); S.room.mySeat = 2; SubGameHooks.Reconnect(MIDJOIN);
return a === JSON.stringify(EQW_GameState.snapshot()); })(), true);
// ---- 畸形入参一律不崩 ----
boom = false;
try {
SubGameHooks._ReceiveData(null);
SubGameHooks._ReceiveData({});
SubGameHooks.StartWar(null);
SubGameHooks.Reconnect(null);
SubGameHooks.DeskInfo(null);
SubGameHooks.setRoomDes();
} catch (e) { boom = true; }
t.eq('畸形入参不崩', boom, false);
process.exit(t.done('hooks') ? 0 : 1);
+109
View File
@@ -0,0 +1,109 @@
// index.html 加载顺序自检(task-13 步骤6守卫):
// 按 index.html 里 <script src> 的实际先后顺序,抽取「codes/ 全部文件 + 它们依赖的
// 3 个框架文件(EventBus/SpriteEventController/AlignmentUtils)」,依次用 vm 加载,
// 确认:1) 无异常抛出(顺序错了会因引用未定义全局而报错)
// 2) 关键全局全部就绪
// 3) EQW_Dispatcher 的路由表确已注册(hasHandler('chupai1') === true)
// 这份顺序即是 index.html 的真实加载顺序,不是我们另行假设的一份。
const fs = require('fs');
const path = require('path');
const vm = require('vm');
const t = require('./_assert')();
const ROOT = path.resolve(__dirname, '..', '..');
const indexHtml = fs.readFileSync(path.join(ROOT, 'client', 'index.html'), 'utf8');
// 抽取全部本地 <script src="..."> 路径,按文档出现顺序
const scriptRe = /<script[^>]*\ssrc="([^"]+)"[^>]*><\/script>/g;
const allSrcs = [];
let m;
while ((m = scriptRe.exec(indexHtml)) !== null) {
allSrcs.push(m[1]);
}
t.eq('index.html 至少解析出脚本标签', allSrcs.length > 0, true);
// 只保留:codes/ 下全部文件 + 3 个框架依赖文件,其余(vendor/引擎/大厅平台代码等)
// 无法在 Node 里单独跑,本自检不覆盖,留给「浏览器人工验证」。
const NEEDED_FRAMEWORK = [
'js/gameabc-framework/system/EventBus.js',
'js/gameabc-framework/system/SpriteEventController.js',
'js/gameabc-framework/ui/AlignmentUtils.js'
];
const relevant = allSrcs.filter(function (src) {
return NEEDED_FRAMEWORK.indexOf(src) !== -1 || src.indexOf('js/01_SubGame/codes/') === 0;
});
// 期望齐全的清单:本任务新增/相关的 state/ net/ 文件,一个都不能漏
const EXPECT_PRESENT = [
'js/01_SubGame/codes/state/Events.js',
'js/01_SubGame/codes/state/GameState.js',
'js/01_SubGame/codes/state/RoomOptions.js',
'js/01_SubGame/codes/net/handlers/FailHandler.js',
'js/01_SubGame/codes/net/handlers/DealHandler.js',
'js/01_SubGame/codes/net/handlers/CallHandler.js',
'js/01_SubGame/codes/net/handlers/MainHandler.js',
'js/01_SubGame/codes/net/handlers/BuryHandler.js',
'js/01_SubGame/codes/net/handlers/PlayHandler.js',
'js/01_SubGame/codes/net/handlers/QueryHandler.js',
'js/01_SubGame/codes/net/handlers/ReadyHandler.js',
'js/01_SubGame/codes/net/handlers/ResultHandler.js',
'js/01_SubGame/codes/net/handlers/ResyncHandler.js',
'js/01_SubGame/codes/net/Dispatcher.js',
'js/01_SubGame/codes/net/Rpc.js',
'js/01_SubGame/codes/SubGameHooks.js'
];
EXPECT_PRESENT.forEach(function (src) {
t.eq('index.html 含脚本 ' + src, allSrcs.indexOf(src) !== -1, true);
});
// 相对顺序断言:handler 全部排在 Dispatcher 之前;state/ 排在 net/handlers/ 之前
const idx = {};
relevant.forEach(function (src, i) { idx[src] = i; });
var dispatcherIdx = idx['js/01_SubGame/codes/net/Dispatcher.js'];
[
'js/01_SubGame/codes/net/handlers/FailHandler.js',
'js/01_SubGame/codes/net/handlers/DealHandler.js',
'js/01_SubGame/codes/net/handlers/CallHandler.js',
'js/01_SubGame/codes/net/handlers/MainHandler.js',
'js/01_SubGame/codes/net/handlers/BuryHandler.js',
'js/01_SubGame/codes/net/handlers/PlayHandler.js',
'js/01_SubGame/codes/net/handlers/QueryHandler.js',
'js/01_SubGame/codes/net/handlers/ReadyHandler.js',
'js/01_SubGame/codes/net/handlers/ResultHandler.js',
'js/01_SubGame/codes/net/handlers/ResyncHandler.js'
].forEach(function (h) {
t.eq('handler 排在 Dispatcher 之前: ' + h, idx[h] < dispatcherIdx, true);
});
t.eq('state/ 排在 net/handlers/ 之前', idx['js/01_SubGame/codes/state/GameState.js'] < idx['js/01_SubGame/codes/net/handlers/FailHandler.js'], true);
t.eq('SubGameHooks 排在 Dispatcher 之后', idx['js/01_SubGame/codes/SubGameHooks.js'] > dispatcherIdx, true);
// 依次实际加载,确认不抛异常,且顺序自洽
global.window = global;
let loadErr = null;
try {
relevant.forEach(function (src) {
const abs = path.join(ROOT, 'client', src);
const code = fs.readFileSync(abs, 'utf8');
vm.runInThisContext(code, { filename: src });
});
} catch (e) {
loadErr = e;
}
t.eq('按 index.html 顺序加载无异常', loadErr ? (loadErr.stack || String(loadErr)) : null, null);
// 关键全局就绪
[
'EQW_Events', 'EQW_GameState', 'EQW_RoomOptions_Parse', 'EQW_Dispatcher', 'EQW_Rpc',
'EQW_DealHandler', 'EQW_CallHandler', 'EQW_MainHandler', 'EQW_BuryHandler', 'EQW_PlayHandler',
'EQW_QueryHandler', 'EQW_ReadyHandler', 'EQW_ResultHandler', 'EQW_ResyncHandler', 'EQW_FailHandler',
'SubGameHooks'
].forEach(function (name) {
t.eq('全局就绪: ' + name, typeof global[name] !== 'undefined', true);
});
// 路由表确已注册
if (typeof global.EQW_Dispatcher !== 'undefined') {
t.eq("EQW_Dispatcher.hasHandler('chupai1')", global.EQW_Dispatcher.hasHandler('chupai1'), true);
}
process.exit(t.done('index_load_order') ? 0 : 1);
+341
View File
@@ -0,0 +1,341 @@
// 五型布局求解器(清单 §6.1 / §6.2)
const { load, throws } = require('./_load');
const t = require('./_assert')();
load('client/js/gameabc-framework/ui/AlignmentUtils.js');
load('client/js/01_SubGame/codes/ui/LayoutSolver.js');
// ============ point ============
const point = { kind: 'point', x: 486, y: 4, w: 276, h: 68 };
t.eq('point 返回单元素数组', EQW_LayoutSolver.solve(point, {}), [{ x: 486, y: 4, width: 276, height: 68 }]);
// ============ line 等距 ============
// 底牌区:8 张 110×190,spacing -36,anchor center,基准 (655,110)
// totalWidth = 8*110 + 7*(-36) = 880 - 252 = 628;startX = 655 - 314 = 341
const line8 = { kind: 'line', direction: 'horizontal', anchorX: 655, anchorY: 110,
itemWidth: 110, itemHeight: 190, spacing: -36, anchor: 'center' };
const r1 = EQW_LayoutSolver.solve(line8, { count: 8 });
t.eq('line 张数', r1.length, 8);
t.eq('line 首张 x', r1[0].x, 341);
t.eq('line 次张 x', r1[1].x, 341 + 110 - 36);
t.eq('line y 恒定', r1[7].y, 110);
t.eq('line 带上尺寸', { w: r1[0].width, h: r1[0].height }, { w: 110, h: 190 });
// ============ line + items 逐项不等宽 ============
// 底栏功能钮组:宽 74/87/93/78,spacing 15,anchor right,基准 x=1250
// totalWidth = 74+87+93+78 + 3*15 = 332 + 45 = 377;startX = 1250 - 377 = 873
const lineItems = { kind: 'line', direction: 'horizontal', anchorX: 1250, anchorY: 672,
itemHeight: 30, spacing: 15, anchor: 'right',
items: [{ key: 'A', width: 74 }, { key: 'B', width: 87 },
{ key: 'C', width: 93 }, { key: 'D', width: 78 }] };
const r2 = EQW_LayoutSolver.solve(lineItems, {});
t.eq('items 项数(count 由 items 决定)', r2.length, 4);
t.eq('items 首项 x', r2[0].x, 873);
t.eq('items 第二项 x', r2[1].x, 873 + 74 + 15);
t.eq('items 第三项 x', r2[2].x, 873 + 74 + 15 + 87 + 15);
t.eq('items 逐项宽度', r2.map(r => r.width), [74, 87, 93, 78]);
t.eq('items 末项右边缘贴基准', r2[3].x + r2[3].width, 1250);
// ============ line 垂直 ============
const lineV = { kind: 'line', direction: 'vertical', anchorX: 100, anchorY: 0,
itemWidth: 200, itemHeight: 32, spacing: 8, anchor: 'top' };
const r3 = EQW_LayoutSolver.solve(lineV, { count: 3 });
t.eq('垂直 y 递增', r3.map(r => r.y), [0, 40, 80]);
t.eq('垂直 x 恒定', r3.map(r => r.x), [100, 100, 100]);
// ============ fan 自适应压缩 ============
// 手牌单排:maxWidth 1215,itemWidth 110,spacingMin -78,spacingMax 0
// 36 张:raw = (1215-110)/35 - 110 = 31.571... - 110 = -78.43 → clamp 到 -78
const fan = { kind: 'fan', direction: 'horizontal', anchorX: 640, anchorY: 455,
maxWidth: 1215, itemWidth: 110, itemHeight: 190,
spacingMax: 0, spacingMin: -78, anchor: 'center', overlapFrom: 'left' };
const f36 = EQW_LayoutSolver.solve(fan, { count: 36 });
t.eq('fan 36 张间距取下限', f36[1].x - f36[0].x, 110 - 78);
// 少量牌时 raw 为正 → clamp 到 spacingMax
const f2 = EQW_LayoutSolver.solve(fan, { count: 2 });
t.eq('fan 2 张间距取上限', f2[1].x - f2[0].x, 110 + 0);
// count <= 1 的边界
const f1 = EQW_LayoutSolver.solve(fan, { count: 1 });
t.eq('fan 单张', f1.length, 1);
t.eq('fan 单张居中', f1[0].x, 640 - 55);
t.eq('fan 零张', EQW_LayoutSolver.solve(fan, { count: 0 }), []);
// clamp 中间值:raw 落在 [min,max] 内时原样取用
// maxWidth 500、itemWidth 100、count 5:raw = (500-100)/4 - 100 = 0 → 落在 [-60, 10] 内
const fanMid = { kind: 'fan', direction: 'horizontal', anchorX: 0, anchorY: 0,
maxWidth: 500, itemWidth: 100, itemHeight: 100,
spacingMax: 10, spacingMin: -60, anchor: 'left' };
const fm = EQW_LayoutSolver.solve(fanMid, { count: 5 });
t.eq('fan clamp 中间值', fm[1].x - fm[0].x, 100 + 0);
// ============ grid ============
// 叫分 14 档:7 列 2 行,74×56,间距 7/24,anchor center,基准 (640,160)
// 行宽 = 7*74 + 6*7 = 518 + 42 = 560;startX = 640 - 280 = 360
const grid = { kind: 'grid', anchorX: 640, anchorY: 160, cols: 7, rows: 2,
itemWidth: 74, itemHeight: 56, spacingX: 7, spacingY: 24,
anchor: 'center', fillOrder: 'row' };
const g = EQW_LayoutSolver.solve(grid, { count: 14 });
t.eq('grid 项数', g.length, 14);
t.eq('grid 首项 x', g[0].x, 360);
t.eq('grid 首项 y', g[0].y, 160);
t.eq('grid 第2项 x', g[1].x, 360 + 74 + 7);
t.eq('grid 第8项换行 x', g[7].x, 360);
t.eq('grid 第8项 y', g[7].y, 160 + 56 + 24);
// count 少于满格时只出 count 个
t.eq('grid 不足一行', EQW_LayoutSolver.solve(grid, { count: 3 }).length, 3);
// 边界:count 恰好等于容量(cols*rows=14)时正常返回,不抛错
t.eq('grid 恰好满格不抛错', EQW_LayoutSolver.solve(grid, { count: 14 }).length, 14);
// count 超出容量:显式抛错,不静默截断多出的项
t.eq('grid 超载抛错', throws(() => EQW_LayoutSolver.solve(grid, { count: 15 })), true);
// fillOrder 校验:目前只支持行优先(YAGNI,未实现列优先),不写等同 row
const gridNoFillOrder = { kind: 'grid', anchorX: 640, anchorY: 160, cols: 7, rows: 2,
itemWidth: 74, itemHeight: 56, spacingX: 7, spacingY: 24, anchor: 'center' };
t.eq('grid 不写 fillOrder 正常', EQW_LayoutSolver.solve(gridNoFillOrder, { count: 14 }).length, 14);
t.eq('grid fillOrder=row 正常', EQW_LayoutSolver.solve(grid, { count: 14 }).length, 14);
const gridColumnOrder = { kind: 'grid', anchorX: 640, anchorY: 160, cols: 7, rows: 2,
itemWidth: 74, itemHeight: 56, spacingX: 7, spacingY: 24, anchor: 'center', fillOrder: 'column' };
t.eq('grid fillOrder=column 抛错', throws(() => EQW_LayoutSolver.solve(gridColumnOrder, { count: 14 })), true);
// ============ attach 内部对齐(corner 为空)============
const rects = { TARGET: { x: 100, y: 200, width: 80, height: 40 } };
const attachIn = { kind: 'attach', target: 'TARGET', hAlign: 'right', vAlign: 'top',
offsetX: 8, offsetY: -4, w: 20, h: 10 };
const a1 = EQW_LayoutSolver.solve(attachIn, { rects: rects });
t.eq('attach 内部右上', a1, [{ x: 100 + 80 - 20 + 8, y: 200 - 4, width: 20, height: 10 }]);
// ============ attach 贴外侧角 ============
// 注意:AlignmentUtils.alignCornerTopRight(client/js/gameabc-framework/ui/AlignmentUtils.js:292-298)
// 的真实语义是【精灵的右上角与目标的右上角重合】,即
// x = target.x + target.width - sprite.width + offsetX
// y = target.y + offsetY
// 这与「贴在目标外侧右上角」(x = target.x + target.width + offsetX, y = target.y - sprite.height + offsetY)
// 不是一回事——它是角对角重合(精灵会与目标重叠在右上角内侧),不是贴到外侧。
// 期望值以框架实际实现为准:x = 100 + 80 - 20 + 4 = 164,y = 200 + 6 = 206。
const attachCorner = { kind: 'attach', target: 'TARGET', corner: 'topRight',
offsetX: 4, offsetY: 6, w: 20, h: 10 };
const a2 = EQW_LayoutSolver.solve(attachCorner, { rects: rects });
t.eq('attach 外侧右上角', a2, [{ x: 100 + 80 - 20 + 4, y: 200 + 6, width: 20, height: 10 }]);
// 四个角都要能解(通用检查:长度与类型)
['topLeft', 'topRight', 'bottomLeft', 'bottomRight'].forEach(c => {
const r = EQW_LayoutSolver.solve({ kind: 'attach', target: 'TARGET', corner: c, w: 20, h: 10 }, { rects: rects });
t.eq('attach 角 ' + c + ' 可解', r.length === 1 && typeof r[0].x === 'number', true);
});
// 四角精确坐标核对:target={x:100,y:200,width:80,height:40},sprite={w:20,h:10},offset 缺省=0
// topLeft(AlignmentUtils.js:266-272):x=target.x=100,y=target.y=200
const cTopLeft = EQW_LayoutSolver.solve({ kind: 'attach', target: 'TARGET', corner: 'topLeft', w: 20, h: 10 }, { rects: rects });
t.eq('attach 角 topLeft 精确坐标', cTopLeft, [{ x: 100, y: 200, width: 20, height: 10 }]);
// topRight(AlignmentUtils.js:292-298):x=target.x+target.width-sprite.width=100+80-20=160,y=target.y=200
const cTopRight = EQW_LayoutSolver.solve({ kind: 'attach', target: 'TARGET', corner: 'topRight', w: 20, h: 10 }, { rects: rects });
t.eq('attach 角 topRight 精确坐标', cTopRight, [{ x: 160, y: 200, width: 20, height: 10 }]);
// bottomLeft(AlignmentUtils.js:318-324):x=target.x=100,y=target.y+target.height-sprite.height=200+40-10=230
const cBottomLeft = EQW_LayoutSolver.solve({ kind: 'attach', target: 'TARGET', corner: 'bottomLeft', w: 20, h: 10 }, { rects: rects });
t.eq('attach 角 bottomLeft 精确坐标', cBottomLeft, [{ x: 100, y: 230, width: 20, height: 10 }]);
// bottomRight(AlignmentUtils.js:344-350):x=target.x+target.width-sprite.width=100+80-20=160,y=同 bottomLeft=230
const cBottomRight = EQW_LayoutSolver.solve({ kind: 'attach', target: 'TARGET', corner: 'bottomRight', w: 20, h: 10 }, { rects: rects });
t.eq('attach 角 bottomRight 精确坐标', cBottomRight, [{ x: 160, y: 230, width: 20, height: 10 }]);
// ============ attach:按实际对齐模式校验 w/h(缺失且用得到时抛错,用不到时不强制) ============
// hAlign=right 用到 sprite.width(AlignmentUtils.js:111),缺 w 抛错;hAlign=left(101行)不用,缺 w 不抛错
t.eq('attach hAlign=right 缺 w 抛错',
throws(() => EQW_LayoutSolver.solve({ kind: 'attach', target: 'TARGET', hAlign: 'right', vAlign: 'top', h: 10 }, { rects: rects })), true);
t.eq('attach hAlign=left 缺 w 不抛错',
throws(() => EQW_LayoutSolver.solve({ kind: 'attach', target: 'TARGET', hAlign: 'left', vAlign: 'top', h: 10 }, { rects: rects })), false);
// vAlign=middle 用到 sprite.height(127行),缺 h 抛错;vAlign=top(122行)不用,缺 h 不抛错
t.eq('attach vAlign=middle 缺 h 抛错',
throws(() => EQW_LayoutSolver.solve({ kind: 'attach', target: 'TARGET', hAlign: 'left', vAlign: 'middle', w: 20 }, { rects: rects })), true);
t.eq('attach vAlign=top 缺 h 不抛错',
throws(() => EQW_LayoutSolver.solve({ kind: 'attach', target: 'TARGET', hAlign: 'left', vAlign: 'top', w: 20 }, { rects: rects })), false);
// vAlign=bottom(132行)用的是 target.height,不是 sprite.height,缺 h 不应抛错——这条容易想当然写错
t.eq('attach vAlign=bottom 缺 h 不抛错',
throws(() => EQW_LayoutSolver.solve({ kind: 'attach', target: 'TARGET', hAlign: 'left', vAlign: 'bottom', w: 20 }, { rects: rects })), false);
// corner=topLeft(266-272行)不用 w/h,都缺不抛错;corner=bottomRight(344-350行)两者都用,缺一即抛错
t.eq('attach corner=topLeft 缺 w/h 不抛错',
throws(() => EQW_LayoutSolver.solve({ kind: 'attach', target: 'TARGET', corner: 'topLeft' }, { rects: rects })), false);
t.eq('attach corner=bottomRight 缺 w 抛错',
throws(() => EQW_LayoutSolver.solve({ kind: 'attach', target: 'TARGET', corner: 'bottomRight', h: 10 }, { rects: rects })), true);
t.eq('attach corner=bottomRight 缺 h 抛错',
throws(() => EQW_LayoutSolver.solve({ kind: 'attach', target: 'TARGET', corner: 'bottomRight', w: 20 }, { rects: rects })), true);
// ============ attach w/h 运行时注入(ctx.w/ctx.h,供动态文字标签/牌角标等尺寸未知的模板节点用)============
// 配置无 w、hAlign=right(用得到 sprite.width)、靠 ctx.w 补上 → 正常求解,返回 width 就是 ctx.w
const wFromCtx = EQW_LayoutSolver.solve(
{ kind: 'attach', target: 'TARGET', hAlign: 'right', vAlign: 'top', h: 10 },
{ rects: rects, w: 42 }
);
t.eq('ctx.w 补全缺失的 w,正常求解', throws(() => EQW_LayoutSolver.solve(
{ kind: 'attach', target: 'TARGET', hAlign: 'right', vAlign: 'top', h: 10 }, { rects: rects, w: 42 }
)), false);
t.eq('返回的 width 等于 ctx.w', wFromCtx[0].width, 42);
// 配置有 w,同时给 ctx.w → ctx.w 优先覆盖配置值
const wOverride = EQW_LayoutSolver.solve(
{ kind: 'attach', target: 'TARGET', hAlign: 'right', vAlign: 'top', w: 20, h: 10 },
{ rects: rects, w: 99 }
);
t.eq('ctx.w 覆盖配置里的 w', wOverride[0].width, 99);
// 配置无 h、vAlign=middle(用得到 sprite.height)、靠 ctx.h 补上 → 正常求解,返回 height 就是 ctx.h
const hFromCtx = EQW_LayoutSolver.solve(
{ kind: 'attach', target: 'TARGET', hAlign: 'left', vAlign: 'middle', w: 20 },
{ rects: rects, h: 33 }
);
t.eq('返回的 height 等于 ctx.h', hFromCtx[0].height, 33);
// 配置无 w、hAlign=right、ctx.w 也没给 → 仍然抛错(不能因为加了通道就失效)
t.eq('两处都没给 w 仍抛错', throws(() => EQW_LayoutSolver.solve(
{ kind: 'attach', target: 'TARGET', hAlign: 'right', vAlign: 'top', h: 10 }, { rects: rects }
)), true);
// ctx.w/ctx.h 对不需要该维度的对齐模式(hAlign=left)不产生影响,仍正常求解
t.eq('hAlign=left 时给不给 ctx.w 都不影响', throws(() => EQW_LayoutSolver.solve(
{ kind: 'attach', target: 'TARGET', hAlign: 'left', vAlign: 'top' }, { rects: rects, w: 7 }
)), false);
// ============ attach target 运行时注入(ctx.target,供叫分档位/花色图标/牌角标等模板节点用)============
const rects2 = { TARGET: { x: 100, y: 200, width: 80, height: 40 }, OTHER: { x: 5, y: 6, width: 10, height: 10 } };
// ctx.target 覆盖配置里写死的 target
const attachOverride = { kind: 'attach', target: 'TARGET', corner: 'topLeft' };
const aOverride = EQW_LayoutSolver.solve(attachOverride, { rects: rects2, target: 'OTHER' });
t.eq('ctx.target 覆盖配置 target', { x: aOverride[0].x, y: aOverride[0].y }, { x: 5, y: 6 });
// 配置无 target(模板节点)时,ctx.target 生效
const attachTemplate = { kind: 'attach', corner: 'topLeft' };
const aFromCtxOnly = EQW_LayoutSolver.solve(attachTemplate, { rects: rects, target: 'TARGET' });
t.eq('配置无 target 时 ctx.target 生效', { x: aFromCtxOnly[0].x, y: aFromCtxOnly[0].y }, { x: 100, y: 200 });
// 配置与 ctx 都没给 target:抛错
t.eq('target 与 ctx.target 都缺失抛错',
throws(() => EQW_LayoutSolver.solve(attachTemplate, { rects: rects })), true);
// ============ bySeat 合并:列出的覆盖、未列出的继承 ============
const bySeat = {
kind: 'fan', direction: 'horizontal', itemWidth: 90, itemHeight: 155,
maxWidth: 170, spacingMax: -35, spacingMin: -62,
bySeat: {
SELF: { anchorX: 650, anchorY: 265, anchor: 'center', overlapFrom: 'left' },
RIGHT: { anchorX: 1022, anchorY: 120, anchor: 'center', overlapFrom: 'left' },
LEFT: { anchorX: 258, anchorY: 120, anchor: 'center', overlapFrom: 'right' }
}
};
const sSelf = EQW_LayoutSolver.solve(bySeat, { seat: 'SELF', count: 1 });
const sLeft = EQW_LayoutSolver.solve(bySeat, { seat: 'LEFT', count: 1 });
t.eq('bySeat SELF 用自己的锚点', sSelf[0].x, 650 - 45);
t.eq('bySeat LEFT 用自己的锚点', sLeft[0].x, 258 - 45);
t.eq('bySeat 继承 base 的尺寸', sLeft[0].width, 90);
// ============ 显式失败:不兜底 ============
t.eq('未知 kind 抛错', throws(() => EQW_LayoutSolver.solve({ kind: 'blob' }, {})), true);
t.eq('attach target 缺失抛错', throws(() => EQW_LayoutSolver.solve(attachIn, { rects: {} })), true);
t.eq('attach 未给 rects 抛错', throws(() => EQW_LayoutSolver.solve(attachIn, {})), true);
t.eq('bySeat 缺 seat 抛错', throws(() => EQW_LayoutSolver.solve(bySeat, { count: 1 })), true);
t.eq('bySeat 未知 seat 抛错', throws(() => EQW_LayoutSolver.solve(bySeat, { seat: 'TOP', count: 1 })), true);
t.eq('node 为空抛错', throws(() => EQW_LayoutSolver.solve(null, {})), true);
t.eq('未知 corner 抛错', throws(() => EQW_LayoutSolver.solve({ kind: 'attach', target: 'TARGET', corner: 'middle', w: 1, h: 1 }, { rects: rects })), true);
// ============ 方向 × 对齐基准:写错方向的 anchor 必须抛错,不被框架 default 静默吞掉 ============
// AlignmentUtils.distributeVertically 只认 top/bottom/center,'left' 会落到 default(居中)——
// 3 项 32 高 8 间距时算出 y=[-56,-16,24],前两行跑到画布外,界面上却看不出是配置写错了
const vLine = (anchor) => ({ kind: 'line', direction: 'vertical', anchorX: 0, anchorY: 0, itemWidth: 200, itemHeight: 32, spacing: 8, anchor: anchor });
const hLine = (anchor) => ({ kind: 'line', direction: 'horizontal', anchorX: 0, anchorY: 0, itemWidth: 200, itemHeight: 32, spacing: 8, anchor: anchor });
t.eq('竖排 anchor=left 抛错', throws(() => EQW_LayoutSolver.solve(vLine('left'), { count: 3 })), true);
t.eq('竖排 anchor=right 抛错', throws(() => EQW_LayoutSolver.solve(vLine('right'), { count: 3 })), true);
t.eq('竖排 anchor=top 正常', throws(() => EQW_LayoutSolver.solve(vLine('top'), { count: 3 })), false);
t.eq('竖排 anchor=bottom 正常', throws(() => EQW_LayoutSolver.solve(vLine('bottom'), { count: 3 })), false);
t.eq('竖排 anchor=center 正常', throws(() => EQW_LayoutSolver.solve(vLine('center'), { count: 3 })), false);
t.eq('竖排 不写 anchor 正常(框架默认 center)', throws(() => EQW_LayoutSolver.solve(vLine(undefined), { count: 3 })), false);
t.eq('横排 anchor=top 抛错', throws(() => EQW_LayoutSolver.solve(hLine('top'), { count: 3 })), true);
t.eq('横排 anchor=bottom 抛错', throws(() => EQW_LayoutSolver.solve(hLine('bottom'), { count: 3 })), true);
t.eq('横排 anchor=left 正常', throws(() => EQW_LayoutSolver.solve(hLine('left'), { count: 3 })), false);
t.eq('横排 anchor=right 正常', throws(() => EQW_LayoutSolver.solve(hLine('right'), { count: 3 })), false);
t.eq('未知 direction 抛错', throws(() => EQW_LayoutSolver.solve({ kind: 'line', direction: 'diagonal', anchorX: 0, anchorY: 0, itemWidth: 10, itemHeight: 10, anchor: 'left' }, { count: 3 })), true);
// fan 走同一条 distribute,校验一致
t.eq('fan 竖排 anchor=left 抛错', throws(() => EQW_LayoutSolver.solve(
{ kind: 'fan', direction: 'vertical', anchorX: 0, anchorY: 0, maxWidth: 100, itemWidth: 10, itemHeight: 10, spacingMax: 0, spacingMin: -5, anchor: 'left' }, { count: 3 })), true);
// grid 逐行走水平分布,竖排语义的 anchor 同样拒绝
t.eq('grid anchor=top 抛错', throws(() => EQW_LayoutSolver.solve(
{ kind: 'grid', anchorX: 0, anchorY: 0, cols: 2, rows: 2, itemWidth: 10, itemHeight: 10, anchor: 'top' }, { count: 4 })), true);
// line + items 只实现了水平:竖排显式拒绝,不按水平硬算
t.eq('line+items 竖排抛错', throws(() => EQW_LayoutSolver.solve(
{ kind: 'line', direction: 'vertical', anchorX: 0, anchorY: 0, itemHeight: 10, anchor: 'top', items: [{ key: 'A', width: 10 }] }, {})), true);
// ============ 对齐基准点必填:框架的 anchorY||0 会把缺失静默变成 0 ============
t.eq('line 缺 anchorY 抛错', throws(() => EQW_LayoutSolver.solve(
{ kind: 'line', direction: 'horizontal', anchorX: 0, itemWidth: 10, itemHeight: 10, anchor: 'left' }, { count: 2 })), true);
t.eq('line 由 ctx.anchorY 补上则正常', EQW_LayoutSolver.solve(
{ kind: 'line', direction: 'horizontal', anchorX: 0, itemWidth: 10, itemHeight: 10, anchor: 'left' },
{ count: 2, anchorY: 77 })[0].y, 77);
t.eq('ctx.anchorX 覆盖配置 anchorX', EQW_LayoutSolver.solve(
{ kind: 'line', direction: 'horizontal', anchorX: 0, anchorY: 0, itemWidth: 10, itemHeight: 10, anchor: 'left' },
{ count: 2, anchorX: 500 })[0].x, 500);
t.eq('fan 缺 anchorX 抛错', throws(() => EQW_LayoutSolver.solve(
{ kind: 'fan', direction: 'horizontal', anchorY: 0, maxWidth: 100, itemWidth: 10, itemHeight: 10, spacingMax: 0, spacingMin: -5, anchor: 'left' }, { count: 3 })), true);
// ============ grid:cols/rows 缺失必须抛错(曾静默返回空数组)============
// cols*undefined = NaN → count > NaN 恒 false(超载守卫失效)、for(row<undefined) 一次不跑 → 返回 []
const gridNoRows = { kind: 'grid', anchorX: 0, anchorY: 0, cols: 4, itemWidth: 10, itemHeight: 10, anchor: 'left' };
t.eq('grid 缺 rows 抛错', throws(() => EQW_LayoutSolver.solve(gridNoRows, { count: 3 })), true);
t.eq('grid 缺 cols 抛错', throws(() => EQW_LayoutSolver.solve(
{ kind: 'grid', anchorX: 0, anchorY: 0, rows: 2, itemWidth: 10, itemHeight: 10, anchor: 'left' }, { count: 3 })), true);
// ctx.rows 运行时注入:行数由实际项数决定的场景
t.eq('ctx.rows 注入后正常求解', EQW_LayoutSolver.solve(gridNoRows, { count: 6, rows: 2 }).length, 6);
t.eq('ctx.rows 参与容量计算(满格)', EQW_LayoutSolver.solve(gridNoRows, { count: 8, rows: 2 }).length, 8);
t.eq('ctx.rows 参与容量计算(超载仍抛错)', throws(() => EQW_LayoutSolver.solve(gridNoRows, { count: 9, rows: 2 })), true);
t.eq('ctx.rows 覆盖配置 rows', throws(() => EQW_LayoutSolver.solve(
{ kind: 'grid', anchorX: 0, anchorY: 0, cols: 4, rows: 4, itemWidth: 10, itemHeight: 10, anchor: 'left' },
{ count: 9, rows: 2 })), true);
// ============ point:x/y 缺失抛错 + 运行时注入 ============
t.eq('point 缺 x/y 抛错', throws(() => EQW_LayoutSolver.solve({ kind: 'point', w: 10, h: 10 }, {})), true);
t.eq('point 缺 y 抛错', throws(() => EQW_LayoutSolver.solve({ kind: 'point', x: 1, w: 10, h: 10 }, {})), true);
t.eq('point 由 ctx.x/ctx.y 注入', EQW_LayoutSolver.solve({ kind: 'point', w: 10, h: 10 }, { x: 5, y: 6 }),
[{ x: 5, y: 6, width: 10, height: 10 }]);
t.eq('point 的 ctx.w/ctx.h 也生效', EQW_LayoutSolver.solve({ kind: 'point', x: 1, y: 2 }, { w: 33, h: 44 }),
[{ x: 1, y: 2, width: 33, height: 44 }]);
// ============ 运行时注入通道清单 ============
t.eq('INJECT_KEYS 覆盖 target/x/y/w/h/anchorX/anchorY/rows/itemWidth/itemHeight',
EQW_LayoutSolver.INJECT_KEYS.slice().sort(),
['anchorX', 'anchorY', 'h', 'itemHeight', 'itemWidth', 'rows', 'target', 'w', 'x', 'y']);
t.eq('ctx.itemWidth 注入生效', EQW_LayoutSolver.solve(
{ kind: 'line', direction: 'vertical', anchorX: 0, anchorY: 0, itemHeight: 32, spacing: 8, anchor: 'top' },
{ count: 2, itemWidth: 240 })[0].width, 240);
// runtime 声明只是给人和守卫看的契约,不影响求解(求解器对所有 INJECT_KEYS 一视同仁)
t.eq('runtime 声明不影响求解', EQW_LayoutSolver.solve(
{ kind: 'point', runtime: ['x', 'y'] }, { x: 7, y: 8 }), [{ x: 7, y: 8, width: undefined, height: undefined }]);
// ============ apply:精灵数与矩形数不一致必须抛错,不静默截断 ============
// 少摆的精灵会停在上一手牌的旧坐标上(陈旧精灵 + 陈旧坐标),是最难反查的一类错位
const moved = [];
global.SpriteManager = { setPosition: function (id, x, y) { moved.push([id, x, y]); } };
const applyLine = { kind: 'line', direction: 'horizontal', anchorX: 0, anchorY: 0, itemWidth: 10, itemHeight: 10, spacing: 0, anchor: 'left' };
moved.length = 0;
EQW_LayoutSolver.apply(applyLine, [11, 12, 13], { count: 3 });
t.eq('apply 数量相等时逐个摆位', moved, [[11, 0, 0], [12, 10, 0], [13, 20, 0]]);
moved.length = 0;
t.eq('apply 精灵多于矩形抛错', throws(() => EQW_LayoutSolver.apply(applyLine, [11, 12, 13], { count: 2 })), true);
t.eq('apply 精灵少于矩形抛错', throws(() => EQW_LayoutSolver.apply(applyLine, [11, 12], { count: 3 })), true);
t.eq('apply 抛错时一个都没摆', moved, []);
t.eq('apply 未传 spriteIds 抛错', throws(() => EQW_LayoutSolver.apply(applyLine, null, { count: 3 })), true);
t.eq('apply 空数组 + 零矩形不抛错', throws(() => EQW_LayoutSolver.apply(applyLine, [], { count: 0 })), false);
// ============ 纯函数:不修改入参 ============
const before = JSON.stringify(bySeat);
EQW_LayoutSolver.solve(bySeat, { seat: 'SELF', count: 3 });
t.eq('solve 不修改配置对象', JSON.stringify(bySeat), before);
process.exit(t.done('layoutsolver') ? 0 : 1);
+158
View File
@@ -0,0 +1,158 @@
// 重连(deskinfo)与开局(StartWar)
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');
load('client/js/01_SubGame/codes/state/RoomOptions.js');
load('client/js/01_SubGame/codes/net/handlers/ResyncHandler.js');
const S = EQW_GameState;
const evts = [];
Object.keys(EQW_Events).forEach(k => EventBus.on(EQW_Events[k], () => evts.push(k)));
// ================= setRoomDes:房间选项 =================
S.reset(); S.room.options = null;
EQW_ResyncHandler.handleSetRoomDes(123456, 12, '10100');
t.eq('总局数', S.room.asetCount, 12);
t.eq('傍王开', S.room.options.bangwang, 1);
t.eq('可查牌', S.room.options.nocheck, 0);
// ================= deskinfo:step 5 出牌阶段 =================
S.reset(); S.room.mySeat = 1; evts.length = 0;
EQW_ResyncHandler.handleDeskinfo({
count: 6, idx: 2, PlayerInfo: [10, -5, -5], step: 5, MyCards: [1,2,3],
PushCards: {
banker: 0, call: 60, multiple: 3, flower: 3, countdown: 15,
grade: 25, playproc: { round: 4, currseat: 1, cards: [[9],[],[]] },
seatlist: [[0,0,0,0,[5,2]],[0,0,0,0,[6,1]],[0,0,0,0,[7,3]]],
liangpai: { cards: [52,53] }, pushlist: [[[1],[2],[3]]],
curmultiple: 2, mustcard: [4,5], baozhu: 1
}
});
t.eq('总局数', S.room.asetCount, 6);
t.eq('当前局', S.room.asetIdx, 2);
t.eq('三家总分', S.room.playerScores, [10, -5, -5]);
t.eq('阶段', S.aset.step, 5);
t.eq('手牌', S.my.cards, [1,2,3]);
t.eq('庄家', S.aset.banker, 0);
t.eq('主牌花色', S.aset.flower, 3);
t.eq('捡分', S.aset.grade, 25);
t.eq('抓分倍数', S.aset.curmultiple, 2);
t.eq('当前轮出牌情况', S.table.playproc.round, 4);
t.eq('出牌历史', S.table.pushlist.length, 1);
t.eq('亮牌', S.table.liangpai, { cards: [52,53] });
t.eq('必出牌', S.my.mustCard, [4,5]);
t.eq('控制权(由 playproc.currseat 给出)', S.turn.seat, 1);
t.eq('重连发全量重画事件', evts.indexOf('EQW_RESYNC_ALL') >= 0, true);
// 报无主:明牌按钮与余主公示的开关(协议 PushCards.baozhu)。
// 增量路径由 chupai1/2/3 的 baozhu 维护,重连路径必须一并恢复,
// 否则重连后按钮凭空消失(两条路径对同一状态给出不同结果)。
t.eq('报无主(PushCards.baozhu)', S.aset.baozhu, 1);
// 反面:不查牌房服务端恒发 0,前端照写不反推
S.reset(); S.room.mySeat = 1;
EQW_ResyncHandler.handleDeskinfo({
count: 6, idx: 2, PlayerInfo: [0,0,0], step: 5, MyCards: [1],
PushCards: { banker: 0, flower: 3, playproc: { round: 1, currseat: 1, cards: [[],[],[]] }, baozhu: 0 }
});
t.eq('不查牌 baozhu 恒 0', S.aset.baozhu, 0);
// ================= deskinfo:step 1 叫分阶段 =================
S.reset(); evts.length = 0;
EQW_ResyncHandler.handleDeskinfo({
count: 6, idx: 1, PlayerInfo: [0,0,0], step: 1, MyCards: [7,8],
CallRun: { seat: 2, countdown: 15, nowcall: 65, multiple: 2, call: [null, 0, 65] }
});
t.eq('叫分阶段', S.aset.step, 1);
t.eq('当前叫分者', S.turn.seat, 2);
t.eq('当前叫到的分', S.call.currcall, 65);
t.eq('三家叫分', S.call.calls, [null, 0, 65]);
// ================= deskinfo:step 2 选主阶段(庄家有 bottomcards)=================
S.reset(); evts.length = 0;
EQW_ResyncHandler.handleDeskinfo({
count: 6, idx: 1, PlayerInfo: [0,0,0], step: 2, MyCards: [1,2],
ChooseMain: { seat: 0, banker: 0, call: 70, multiple: 2, countdown: 20,
bottomcards: [1,2,3,4,5,6,7,8], touxiang: 1 }
});
t.eq('选主阶段', S.aset.step, 2);
t.eq('底牌', S.my.bottomCards, [1,2,3,4,5,6,7,8]);
t.eq('允许投降', S.aset.touxiang, 1);
t.eq('控制权读 ChooseMain.seat', S.turn.seat, 0);
// 重连【不重放】70 分开底:deskinfo 里没有 ancard3s
t.eq('重连不重放开底', S.table.ancard3s, 0);
// 【守卫】控制权取分组里的 seat(服务端权威),不用本地 banker 反推。
// 真实链路里 ChooseMain.seat 恒 == banker,相等时"读哪个"分辨不出来;这里刻意让它们不同,
// 退回 `turn.seat = aset.banker` 的写法必转红。
S.reset();
EQW_ResyncHandler.handleDeskinfo({
count: 6, idx: 1, PlayerInfo: [0,0,0], step: 2, MyCards: [1,2],
ChooseMain: { seat: 2, banker: 0, call: 70, multiple: 2, countdown: 20, touxiang: 1 }
});
t.eq('ChooseMain 控制权取 seat,不取 banker', S.turn.seat, 2);
// ================= deskinfo:step 3 埋牌阶段 =================
S.reset();
EQW_ResyncHandler.handleDeskinfo({
count: 6, idx: 1, PlayerInfo: [0,0,0], step: 3, MyCards: [1,2],
BuryCards: { seat: 0, banker: 0, call: 65, multiple: 2, flower: 1, countdown: 25,
bottomcards: [1,2,3,4,5,6,7,8], curmultiple: 3 }
});
t.eq('埋牌阶段', S.aset.step, 3);
t.eq('控制权读 BuryCards.seat', S.turn.seat, 0);
// 同样的守卫:seat 与 banker 刻意不同
S.reset();
EQW_ResyncHandler.handleDeskinfo({
count: 6, idx: 1, PlayerInfo: [0,0,0], step: 3, MyCards: [1,2],
BuryCards: { seat: 1, banker: 0, call: 65, multiple: 2, flower: 1, countdown: 25 }
});
t.eq('BuryCards 控制权取 seat,不取 banker', S.turn.seat, 1);
// ================= deskinfo:step 6 结算阶段 =================
S.reset(); evts.length = 0;
EQW_ResyncHandler.handleDeskinfo({
count: 6, idx: 6, PlayerInfo: [12,-6,-6], step: 6, MyCards: [],
Balance: { readystate: [1,0,0], aset: { banker: 0, upgrade: 1, seatlist: [] },
bottom: { cards: [1,2,3,4,5,6,7,8], multiple: 2, grade1: 20, grade2: 40 },
account: [{ score: 12 }, { score: -6 }, { score: -6 }] }
});
t.eq('结算阶段', S.aset.step, 6);
t.eq('小局结算数据', S.result.aset.upgrade, 1);
// I-2:结算面板还开着时重连,抠底明细与大局结算必须一并恢复(与 jiesuan 推送同一份数据)。
// 漏接会让重连后抠底明细、末局大结算全部空白
t.eq('重连恢复抠底明细', S.result.bottom.grade2, 40);
t.eq('重连恢复大局结算', S.result.account.length, 3);
// 反面:非末局 / 投降局的 Balance 没有 bottom / account,缺字段保持 null,不兜底造分组
S.reset();
EQW_ResyncHandler.handleDeskinfo({
count: 6, idx: 2, PlayerInfo: [0,0,0], step: 6, MyCards: [],
Balance: { readystate: [0,0,0], aset: { banker: 0, upgrade: -99, seatlist: [] } }
});
t.eq('无抠底分组时保持 null', S.result.bottom, null);
t.eq('无大局结算时保持 null', S.result.account, null);
// ================= StartWar:差异化下发 =================
S.reset(); S.room.mySeat = 2; evts.length = 0;
EQW_ResyncHandler.handleStartWar({ data: { deskwar: { sendtype: 1, seatlist: [
{ seat: 0, data: { count: 6, idx: 1, PlayerInfo: [0,0,0], step: 1, MyCards: [90] } },
{ seat: 2, data: { count: 6, idx: 1, PlayerInfo: [0,0,0], step: 1, MyCards: [11,12] } }
] } } });
t.eq('按本座位取到自己那份', S.my.cards, [11,12]);
// 非差异化下发:直接用 data
S.reset(); S.room.mySeat = 0;
EQW_ResyncHandler.handleStartWar({ data: { count: 6, idx: 1, PlayerInfo: [0,0,0],
step: 1, MyCards: [3,4] } });
t.eq('非差异化下发', S.my.cards, [3,4]);
// 畸形入参不崩
let boom = false;
try { EQW_ResyncHandler.handleStartWar(null); EQW_ResyncHandler.handleDeskinfo(null); }
catch (e) { boom = true; }
t.eq('畸形入参不崩', boom, false);
process.exit(t.done('resync') ? 0 : 1);
+34
View File
@@ -0,0 +1,34 @@
// roomtype 位串解析(协议 §0.5),必须与服务端 class.config.js 的 parse() 同规则
const { load } = require('./_load');
const t = require('./_assert')();
load('client/js/01_SubGame/codes/state/RoomOptions.js');
const P = EQW_RoomOptions_Parse.parse;
// ---- 缺省 "00000" = 6局 / 房主扣卡 / 无傍王 / 常规算子 / 可查牌 ----
t.eq('缺省', P('00000'), { asetCount: 6, deductAA: 0, bangwang: 0, climb: 0, nocheck: 0 });
// ---- 协议 §0.5 的示例:10100 = 12局 / 房主扣卡 / 傍王开 / 常规算子 / 可查牌 ----
t.eq('示例 10100', P('10100'), { asetCount: 12, deductAA: 0, bangwang: 1, climb: 0, nocheck: 0 });
// ---- 逐位 ----
t.eq('位0 局数', P('10000').asetCount, 12);
t.eq('位1 AA扣卡', P('01000').deductAA, 1);
t.eq('位2 傍王', P('00100').bangwang, 1);
t.eq('位3 爬坡', P('00010').climb, 1);
t.eq('位4 不查牌', P('00001').nocheck, 1);
t.eq('全开', P('11111'), { asetCount: 12, deductAA: 1, bangwang: 1, climb: 1, nocheck: 1 });
// ---- 反面:缺失/非字符串/过短一律按 '0' ----
t.eq('undefined', P(undefined), { asetCount: 6, deductAA: 0, bangwang: 0, climb: 0, nocheck: 0 });
t.eq('null', P(null), { asetCount: 6, deductAA: 0, bangwang: 0, climb: 0, nocheck: 0 });
t.eq('空串', P(''), { asetCount: 6, deductAA: 0, bangwang: 0, climb: 0, nocheck: 0 });
t.eq('过短只有两位', P('11'), { asetCount: 12, deductAA: 1, bangwang: 0, climb: 0, nocheck: 0 });
t.eq('非字符串(数字)', P(11111), { asetCount: 6, deductAA: 0, bangwang: 0, climb: 0, nocheck: 0 });
t.eq('非字符串(数组)', P([]), { asetCount: 6, deductAA: 0, bangwang: 0, climb: 0, nocheck: 0 });
// ---- 超长不报错,只取前 5 位 ----
t.eq('超长', P('1111199999').bangwang, 1);
process.exit(t.done('roomoptions') ? 0 : 1);
+110
View File
@@ -0,0 +1,110 @@
// 语义化发包:route 正确、只带意图、参数形状校验
const { load, throws } = require('./_load');
const t = require('./_assert')();
load('client/js/01_SubGame/codes/state/GameState.js');
// 桩掉 RpcHelper,捕获发出的包
const sent = [];
global.RpcHelper = {
sendRpc: function (app, route, rpc, data) { sent.push({ app, route, rpc, data }); }
};
load('client/js/01_SubGame/codes/net/Rpc.js');
EQW_GameState.room.mySeat = 2;
// ---- route 必须是 erqiwang,不是 room ----
sent.length = 0;
EQW_Rpc.jiaofen(60);
t.eq('发了一个包', sent.length, 1);
t.eq('app', sent[0].app, 'youle');
t.eq('route 是 erqiwang', sent[0].route, 'erqiwang');
t.eq('rpc', sent[0].rpc, 'jiaofen');
t.eq('业务字段', sent[0].data, { seat: 2, call: 60 });
// ---- seat 自动从 GameState 带上 ----
EQW_GameState.room.mySeat = 0;
sent.length = 0;
EQW_Rpc.zhunbei();
t.eq('seat 自动带上', sent[0].data.seat, 0);
EQW_GameState.room.mySeat = 2;
// ---- 各方法的字段集合 ----
const call = (fn) => { sent.length = 0; fn(); return sent[0]; };
t.eq('不叫', call(() => EQW_Rpc.jiaofen(0)).data, { seat: 2, call: 0 });
t.eq('投降', call(() => EQW_Rpc.touxiang()).data, { seat: 2 });
t.eq('选主', call(() => EQW_Rpc.xuanzhu(3)).data, { seat: 2, flower: 3 });
t.eq('埋牌', call(() => EQW_Rpc.maipai([1,2,3,4,5,6,7,8])).data, { seat: 2, cards: [1,2,3,4,5,6,7,8] });
t.eq('出牌', call(() => EQW_Rpc.chupai([10,11])).data, { seat: 2, cards: [10,11] });
t.eq('明牌', call(() => EQW_Rpc.mingpai()).data, { seat: 2 });
t.eq('提示', call(() => EQW_Rpc.tishi(1)).data, { seat: 2, tip: 1 });
t.eq('准备', call(() => EQW_Rpc.zhunbei()).data, { seat: 2 });
// ---- 【核心】只带意图:所有发包的字段集合不含任何结论字段 ----
// FORBIDDEN 列表:协议中服务端→客户端方向的字段名。原则上都不该出现在客户端→服务端的包里。
// 作为回归网:新增发包方法时若引入了服务端字段名会被它拦下。
const FORBIDDEN = ['score', 'isWin', 'phase', 'nextSeat', 'nextseat', 'handCards',
'grade', 'multiple', 'result', 'banker', 'curmultiple', 'upgrade',
'cardsinhand', 'mustcard', 'seatlist', 'baozhu', 'liangpai', 'bottomcards', 'burycards',
'ancard3s', 'countdown', 'currcall', 'chongguan', 'wang', 'naward',
'grade_aw', 'grade_cg', 'grade_bw', 'grade_jf', 'account', 'readystate',
'shuai', 'shuaicuo', 'cardtype', 'maxseat', 'gradecards', 'others', 'zhucards',
'playproc', 'pushlist', 'touxiang'];
sent.length = 0;
EQW_Rpc.jiaofen(60); EQW_Rpc.touxiang(); EQW_Rpc.xuanzhu(1);
EQW_Rpc.maipai([1,2,3,4,5,6,7,8]); EQW_Rpc.chupai([9]);
EQW_Rpc.mingpai(); EQW_Rpc.tishi(2); EQW_Rpc.zhunbei();
const leaked = [];
sent.forEach(p => Object.keys(p.data).forEach(k => {
if (FORBIDDEN.indexOf(k) >= 0) { leaked.push(p.rpc + '.' + k); }
}));
t.eq('发包不含任何结论字段', leaked, []);
// ---- 参数形状校验:非法即抛错,不发残缺包 ----
sent.length = 0;
t.eq('牌 id 非数组抛错', throws(() => EQW_Rpc.chupai('5')), true);
t.eq('空数组抛错', throws(() => EQW_Rpc.chupai([])), true);
t.eq('字符串牌 id 抛错', throws(() => EQW_Rpc.chupai(['5'])), true);
t.eq('小数牌 id 抛错', throws(() => EQW_Rpc.chupai([1.5])), true);
t.eq('越界牌 id 抛错', throws(() => EQW_Rpc.chupai([108])), true);
t.eq('负数牌 id 抛错', throws(() => EQW_Rpc.chupai([-1])), true);
t.eq('重复牌 id 抛错', throws(() => EQW_Rpc.chupai([5, 5])), true);
t.eq('埋牌非 8 张抛错', throws(() => EQW_Rpc.maipai([1,2,3])), true);
t.eq('叫分越界抛错', throws(() => EQW_Rpc.jiaofen(71)), true);
t.eq('叫分非 5 的倍数抛错', throws(() => EQW_Rpc.jiaofen(7)), true);
t.eq('花色越界抛错', throws(() => EQW_Rpc.xuanzhu(5)), true);
t.eq('提示类型越界抛错', throws(() => EQW_Rpc.tishi(4)), true);
t.eq('校验失败时一个包都没发', sent.length, 0);
// ---- 合法边界值不抛 ----
t.eq('牌 id 0 合法', throws(() => EQW_Rpc.chupai([0])), false);
t.eq('牌 id 107 合法', throws(() => EQW_Rpc.chupai([107])), false);
t.eq('叫分 5 合法', throws(() => EQW_Rpc.jiaofen(5)), false);
t.eq('叫分 70 合法', throws(() => EQW_Rpc.jiaofen(70)), false);
// ---- 【C-1】座位校验:非 0/1/2 一律 fail-fast,绝不无声透传 ----
// mySeat 初始值是 -1(平台 appStart 早于 C_Player 创建,见 test_appstart_timing.js)。
// seat:-1 发出去,服务端 check_player 必然返回 falsy → 每个操作都回 ERR.PLAYER,
// 而前端一声不吭——整局玩不了却查不出原因。所以在最近处抛错。
const badSeats = [-1, 3, 1.5, undefined, null, '1', NaN];
badSeats.forEach(s => {
sent.length = 0;
EQW_GameState.room.mySeat = s;
t.eq('座位 ' + JSON.stringify(s) + ' 发包抛错', throws(() => EQW_Rpc.zhunbei()), true);
t.eq('座位 ' + JSON.stringify(s) + ' 时一个包都没发', sent.length, 0);
});
// 非法座位对每个发包方法都拦(校验在 _send 的公共入口,不是逐方法漏配)
EQW_GameState.room.mySeat = -1;
t.eq('非法座位时出牌也抛错', throws(() => EQW_Rpc.chupai([1, 2])), true);
t.eq('非法座位时叫分也抛错', throws(() => EQW_Rpc.jiaofen(5)), true);
t.eq('非法座位时埋牌也抛错', throws(() => EQW_Rpc.maipai([1,2,3,4,5,6,7,8])), true);
[0, 1, 2].forEach(s => {
EQW_GameState.room.mySeat = s;
t.eq('座位 ' + s + ' 合法不抛', throws(() => EQW_Rpc.zhunbei()), false);
});
process.exit(t.done('rpc_send') ? 0 : 1);
+65
View File
@@ -0,0 +1,65 @@
// 服务端座位 ↔ 显示位(清单 §0.1)
const { load, throws } = require('./_load');
const t = require('./_assert')();
load('client/js/01_SubGame/codes/core/SeatMap.js');
// ---- 3×3 全组合 ----
// mySeat=0: 自己0、下家1在右上、上家2在左上
t.eq('my0 seat0', EQW_SeatMap.toDisplay(0, 0), 'SELF');
t.eq('my0 seat1', EQW_SeatMap.toDisplay(1, 0), 'RIGHT');
t.eq('my0 seat2', EQW_SeatMap.toDisplay(2, 0), 'LEFT');
t.eq('my1 seat1', EQW_SeatMap.toDisplay(1, 1), 'SELF');
t.eq('my1 seat2', EQW_SeatMap.toDisplay(2, 1), 'RIGHT');
t.eq('my1 seat0', EQW_SeatMap.toDisplay(0, 1), 'LEFT');
t.eq('my2 seat2', EQW_SeatMap.toDisplay(2, 2), 'SELF');
t.eq('my2 seat0', EQW_SeatMap.toDisplay(0, 2), 'RIGHT');
t.eq('my2 seat1', EQW_SeatMap.toDisplay(1, 2), 'LEFT');
// ---- 反向 ----
t.eq('my1 -> RIGHT 是座位2', EQW_SeatMap.toSeat('RIGHT', 1), 2);
t.eq('my2 -> RIGHT 是座位0', EQW_SeatMap.toSeat('RIGHT', 2), 0);
t.eq('my2 -> LEFT 是座位1', EQW_SeatMap.toSeat('LEFT', 2), 1);
t.eq('my0 -> SELF 是座位0', EQW_SeatMap.toSeat('SELF', 0), 0);
// ---- 互逆:任意 mySeat/seat 组合 toSeat(toDisplay(s)) == s ----
let ok = true;
for (let my = 0; my < 3; my++) {
for (let s = 0; s < 3; s++) {
if (EQW_SeatMap.toSeat(EQW_SeatMap.toDisplay(s, my), my) !== s) { ok = false; }
}
}
t.eq('toDisplay/toSeat 互逆', ok, true);
// ---- 与服务端 get_nextseat 一致:下家恒在 RIGHT ----
let nextOk = true;
for (let my = 0; my < 3; my++) {
if (EQW_SeatMap.toDisplay((my + 1) % 3, my) !== 'RIGHT') { nextOk = false; }
}
t.eq('下家(seat+1)%3 恒在右上', nextOk, true);
// ---- 反面:非法显示位显式抛错,不静默返回 ----
t.eq('非法显示位抛错', throws(() => EQW_SeatMap.toSeat('TOP', 0)), true);
// ---- 反面:非法座位号显式抛错 ----
// toDisplay 曾不做校验:((NaN)%3+3)%3 = NaN,既不等于 0 也不等于 1,最后落到 return 'LEFT'
// ——一个看起来很合理的错误答案,比抛错难查得多
t.eq('toDisplay mySeat=undefined 抛错', throws(() => EQW_SeatMap.toDisplay(0, undefined)), true);
t.eq('toDisplay seat=undefined 抛错', throws(() => EQW_SeatMap.toDisplay(undefined, 0)), true);
t.eq('toDisplay seat=3 抛错', throws(() => EQW_SeatMap.toDisplay(3, 0)), true);
t.eq('toDisplay seat=-1 抛错', throws(() => EQW_SeatMap.toDisplay(-1, 0)), true);
t.eq('toDisplay seat=1.5 抛错', throws(() => EQW_SeatMap.toDisplay(1.5, 0)), true);
t.eq('toDisplay seat 是字符串 抛错', throws(() => EQW_SeatMap.toDisplay('1', 0)), true);
t.eq('toDisplay seat=null 抛错', throws(() => EQW_SeatMap.toDisplay(null, 0)), true);
t.eq('toSeat mySeat=undefined 抛错', throws(() => EQW_SeatMap.toSeat('SELF', undefined)), true);
t.eq('toSeat mySeat=3 抛错', throws(() => EQW_SeatMap.toSeat('LEFT', 3)), true);
// 正面:合法的 9 种组合都不抛错(上面已逐条断言过返回值,这里确认校验没误伤)
let legalOk = true;
for (let my = 0; my < 3; my++) {
for (let s = 0; s < 3; s++) {
if (throws(() => EQW_SeatMap.toDisplay(s, my))) { legalOk = false; }
}
}
t.eq('合法座位号不被误伤', legalOk, true);
process.exit(t.done('seatmap') ? 0 : 1);
+23
View File
@@ -0,0 +1,23 @@
// shared 同步守卫:服务端权威源与前端只读副本必须逐字节一致。
// 忘了跑 sync_shared.cmd、或有人手改了前端副本,这条立刻红。
const fs = require('fs');
const path = require('path');
const { ROOT } = require('./_load');
const t = require('./_assert')();
const srcDir = path.join(ROOT, 'server', 'games', 'erqiwang', 'shared');
const dstDir = path.join(ROOT, 'client', 'js', '01_SubGame', 'codes', 'shared');
const srcFiles = fs.readdirSync(srcDir).filter(f => f.endsWith('.js')).sort();
const dstFiles = fs.readdirSync(dstDir).filter(f => f.endsWith('.js')).sort();
t.eq('文件清单一致', dstFiles, srcFiles);
t.eq('源目录非空', srcFiles.length > 0, true);
srcFiles.forEach(f => {
const a = fs.readFileSync(path.join(srcDir, f));
const b = fs.existsSync(path.join(dstDir, f)) ? fs.readFileSync(path.join(dstDir, f)) : Buffer.alloc(0);
t.eq('字节一致: ' + f, a.equals(b), true);
});
process.exit(t.done('shared_sync') ? 0 : 1);
+88
View File
@@ -0,0 +1,88 @@
// View → group → 精灵 的常量树 → 扁平键名索引
const { load, throws } = require('./_load');
const t = require('./_assert')();
load('client/js/01_SubGame/codes/core/SpriteIndex.js');
// ---- 展平:Layer 与 group 的 id 都不进索引,精灵进 ----
const tree = {
TopInfoView: {
Layer: 101,
TopInfo: { id: 201, BTN_A: 1001, BTN_B: 1002 }
},
PlayerMarkView: {
Layer: 101,
LeftMark: { id: 202, P_LEFT_BANKER: 1100 },
RightMark: { id: 203, P_RIGHT_BANKER: 1110 }
}
};
t.eq('展平结果', EQW_SpriteIndex.build(tree),
{ BTN_A: 1001, BTN_B: 1002, P_LEFT_BANKER: 1100, P_RIGHT_BANKER: 1110 });
// ---- Layer 与 group 的 id 不进索引 ----
const flat = EQW_SpriteIndex.build(tree);
t.eq('Layer 不进索引', flat.Layer, undefined);
t.eq('group 的 id 不进索引', flat.id, undefined);
// ---- 群组映射:精灵 → 它所属的群组 ID ----
t.eq('群组映射', EQW_SpriteIndex.buildGroupMap(tree),
{ BTN_A: 201, BTN_B: 201, P_LEFT_BANKER: 202, P_RIGHT_BANKER: 203 });
// ---- 反面:一个 View 恰好一个 Layer;Layer1/Layer2 这种多图层写法要报错 ----
// (跨图层的界面应拆成两个 View,各带自己的 Layer——一个 View 将来对应一个 BaseComponent)
const multiLayer = {
OverlayView: { Layer1: 104, Layer2: 105, Bar: { id: 230, TIP_BG: 1750 } }
};
t.eq('多 Layer 写法报错', throws(() => EQW_SpriteIndex.build(multiLayer)), true);
// ---- 反面:View 缺 Layer 声明要报错 ----
const noLayer = { ViewA: { G1: { id: 201, BTN: 1001 } } };
t.eq('View 缺 Layer 报错', throws(() => EQW_SpriteIndex.build(noLayer)), true);
// ---- 反面:重复键报错,不静默覆盖 ----
const dupTree = {
ViewA: { Layer: 101, G1: { id: 201, SAME: 1001 } },
ViewB: { Layer: 102, G2: { id: 202, SAME: 1002 } }
};
t.eq('重复键报错', throws(() => EQW_SpriteIndex.build(dupTree)), true);
// ---- 反面:精灵值不是数字要报错 ----
const badValue = { ViewA: { Layer: 101, G1: { id: 201, NESTED: { x: 1 } } } };
t.eq('精灵值非数字报错', throws(() => EQW_SpriteIndex.build(badValue)), true);
// ---- 反面:View 下混入标量键(不是 group 容器)要报错 ----
// 例如把群组 ID 误写成 View 级的 `Group: 201`,而不是包进 group 容器
const strayScalar = { ViewA: { Layer: 101, Group: 201, G1: { id: 202, BTN: 1001 } } };
t.eq('View 下混入标量键报错', throws(() => EQW_SpriteIndex.build(strayScalar)), true);
// ---- 反面:group 容器缺 id 要报错 ----
const noGroupId = { ViewA: { Layer: 101, G1: { BTN: 1001 } } };
t.eq('group 缺 id 报错', throws(() => EQW_SpriteIndex.build(noGroupId)), true);
// ---- idOf / groupIdOf:查得到 / 查不到抛错 ----
EQW_SpriteIndex.init(tree);
t.eq('idOf 查到', EQW_SpriteIndex.idOf('BTN_A'), 1001);
t.eq('groupIdOf 查到', EQW_SpriteIndex.groupIdOf('P_RIGHT_BANKER'), 203);
t.eq('idOf 查不到抛错', throws(() => EQW_SpriteIndex.idOf('NOT_EXIST')), true);
t.eq('groupIdOf 查不到抛错', throws(() => EQW_SpriteIndex.groupIdOf('NOT_EXIST')), true);
// ---- 空树 ----
t.eq('空树', EQW_SpriteIndex.build({}), {});
// ---- 真实常量:EQW_Sprites 能完整建索引且无重复键 ----
load('client/js/01_SubGame/codes/config/Layers.js');
load('client/js/01_SubGame/codes/config/Groups.js');
load('client/js/01_SubGame/codes/config/Sprites_Table.js');
load('client/js/01_SubGame/codes/config/Sprites_Cards.js');
load('client/js/01_SubGame/codes/config/Sprites_Action.js');
load('client/js/01_SubGame/codes/config/Sprites_Result.js');
load('client/js/01_SubGame/codes/config/Sprites_CreateRoom.js');
let realOk = true;
let realIndex = {};
try { realIndex = EQW_SpriteIndex.build(EQW_Sprites); } catch (e) { realOk = false; console.log('建索引失败: ' + e.message); }
t.eq('真实精灵常量无重复键', realOk, true);
// 钉住精确数量(与 test_constants.js 同一个数):松下界删掉整个 View 都还能通过
t.eq('真实精灵数量', Object.keys(realIndex).length, 380);
process.exit(t.done('spriteindex') ? 0 : 1);
@@ -26,7 +26,10 @@ UI 组件 → SpriteManager(业务级 API:ID 范围校验 + 单位换算)
| `setPosition(id, x, y)` | 设置坐标 | |
| `setScale(id, scale)` | 缩放 | 业务用倍数(`1.2`),框架自动转引擎百分比 |
| `setOpacity(id, o)` | 透明度 | 业务用 `0.0–1.0`,框架自动转 `0–255` |
| `setText(id, text)` / `setTextWithWidth(...)` | 文字 | 文字精灵 |
| `setText(id, text)` | 文字 | 文字精灵,系统字体 |
| `setTextWithWidth(id, text, charWidth)` | 文字 + 自动定宽 | **文字精灵**:设文本并把宽度设为「字符数 × `charWidth`」,中文按 2 个字符计 |
| `setNumberImage(id, text, charWidth)` | **多帧图美术字数字** | **多帧图片精灵**:一个精灵显示**整串**(见下) |
| `drawImage(id, imgId, dx,dy,dw,dh)` | 在精灵上叠绘整张图 | **只能在绘制回调里调**(见 §5),用于标记叠加 |
| `showGroup/hideGroup(gid)` | 群组批量 | 整块 UI 显隐 |
| `showLayer/hideLayer(lid)` | 图层批量 | 整个界面显隐 |
| `exists(id)` | 存在性检查 | |
@@ -55,6 +58,49 @@ SpriteManager.show(sid); SpriteManager.setFrame(sid, card.code - 1);
ID 必须与编辑器中实际存在的精灵**完全一致**。超范围 → 校验失败返回 `false`;编辑器里不存在的 ID → 操作静默无效。**绝不随意编造 ID。**
### 三种显示机制:文字精灵 / 多帧图片精灵切帧 / 多帧图数字
同样是"在精灵上显示内容",`SpriteManager` 给了三条路,**能力边界完全不同,选错了会在后期变成返工**:
| 机制 | API | 一次能显示 | 字形 | 适用 |
|------|-----|-----------|------|------|
| **文字精灵** | `setText(id, text)`、`setTextWithWidth(id, text, charWidth)` | **任意长度**的文本 | **系统字体**(只能调字号/颜色,做不出描边、渐变、投影) | 纯数据型文本:昵称、局数、`N对`/`主N` 角标、`2子`、提示文案 |
| **多帧图片精灵切帧** | `setFrame(id, frame)` | **一帧**(整张精灵就是那一帧) | 美术出图 | **"选其一"的状态**:按钮可用/置灰、单选框选中/未选、花色图标、牌面(一张牌 = 一帧) |
| **多帧图数字** | `setNumberImage(id, text, charWidth)` | **整串**(多位数字 + 符号 + 后缀),都在**同一个**精灵里 | 美术出图 | **多位美术字数字**:倒计时、结算得分、叫分档位分数、张数、比分 |
**选用判据(按顺序问自己三个问题)**:
1. **要不要美术字形**(描边/渐变/投影)?不要 → **文字精灵**,到此为止,最省。需要按字符数定宽就用 `setTextWithWidth`(中文按 2 个字符计)。
2. 要美术字形,显示的是**"若干形态里选一个"**吗(一张牌、一个图标、一个按钮态)?是 → **`setFrame`**。
3. 要美术字形,显示的是**一串数字/符号**(可能两位、带正负号、带 `分`/`张` 后缀)?→ **`setNumberImage`**。
> ⚠️ **最容易踩的坑:显示多位数字时拆成多个精灵,或用 `setFrame` 逐位切。**
> `setFrame` 是"整个精灵显示第 N 帧",**一个精灵一次只显示一位**。有人据此把两位数拆成"十位精灵 + 个位精灵",三位数拆三个——精灵数随位数线性膨胀,布局、显隐、清理全要按位处理(十位为 0 还得单独隐藏),位数一变就得重排。
> **平台早就给了 `setNumberImage`,一个精灵显示整串,别再造这个轮子。**
> 真实事故:某次评审误判"一个精灵只能显示一位",先把选主张数拆成十位/个位 8 个精灵,随后又差点为此新造一套"图集叠绘"渲染器——直到发现 `core/SpriteManager.js` 的 `setNumberImage` 本来就有这个接口。
> **判据一句话:值可能超过一个字符,用 `setNumberImage`,既不拆精灵也不用 `setFrame`。**
**`setNumberImage` 怎么工作**(读实现,不要凭印象):它把文本里的符号编码后设给精灵的 TEXT 属性,由**引擎按字符逐帧渲染**(帧号由字符直接换算),并把精灵宽度设为 **`字符数 × charWidth`**。所以:
```js
// 资源须是 16 帧、帧序固定为 0123456789.+-x/p 的多帧图片
SpriteManager.setNumberImage(spriteId, '70', charWidth); // 叫分档位分数(两位数,一个精灵)
SpriteManager.setNumberImage(spriteId, '+120', charWidth); // 结算得分(帧12 = '+'、帧13 = '-')
SpriteManager.setNumberImage(spriteId, '26张', charWidth); // 带后缀('张' 落在第 16 帧)
// charWidth 来自常量文件,禁裸值;精灵宽度由该接口自动设,无需再 setSize
```
**资源规格(写进图片资源常量的注释里,供美术照做)**:
| 帧 | 1–10 | 11 | 12 | 13 | 14 | 15 | 16 |
|----|------|----|----|----|----|----|----|
| 内容 | `0`–`9` | `.` | `+` | `-` | `x` | `/` | **可变后缀**(每套资源自己的后缀字,只支持 `分` / `倍` / `张`) |
- **帧序不可重排、不可省略占位**:帧号由字符直接换算,用不到的帧位也要按顺序留出来,否则帧号对不上、画面串位。
- **16 帧等宽**(后缀字与数字同宽),整图宽 = `16 × 单字符宽`;单字符宽即代码里传的 `charWidth`。
- 第 16 帧的后缀只认 `分` / `倍` / `张` 这三个字(接口内部把它们统一编码到该帧),别的字写进去不会被识别。
- **复制精灵也能用**:`setNumberImage` 走的是属性设置、不依赖绘制回调,`SpriteCopyUtils` 复制出的字符串 ID 精灵同样适用。
---
## 2. 资源与布局常量三件套
@@ -173,6 +219,8 @@ SpriteEventController.registerMouseMove(sprites.HAND_AREA, function (event) { se
支持的事件类型:`mouseDown` / `mouseDownNoMove`(长按) / `mouseUp` / `mouseMove`(拖拽) / `drawBegin` / `draw`。
> ⚠️ **转发这一段必须真的接上,且参数顺序要逐个对齐 `handleXxx` 的签名**。转发缺失或参数错位不会报错,只会让**所有**精灵交互与绘制回调**静默失效**——按钮点了没反应、叠绘的标记一片空白,却查不到任何异常日志。接线时对着 `SpriteEventController` 里各 `handleXxx` 的形参表逐个核对,转发层**只转发、不写业务**。
对**每个**绘制精灵统一处理(不针对某个固定 ID)用 `registerGlobalDraw(fn)`——框架对每个 draw 事件都回调它、但**不认识**其业务含义,保持中立(“精牌标记”这类玩法叠绘即以此挂载,框架零感知,见 05「框架中立」)。
### 更高层:手势识别 `SpriteGestureRecognizer`
@@ -217,7 +265,8 @@ list.onClick = function (type, rowIndex, rowData) { /* ... */ };
| DO ✅ | DON'T ❌ |
|------|---------|
| UI 只调 `SpriteManager` | 直接调 `GameABCUtils`/引擎原生 API |
| 一精灵多帧、`setFrame` 切换 | 为每种牌面建一个精灵 |
| 一精灵多帧、`setFrame` 切换**状态** | 为每种牌面建一个精灵 |
| 多位美术字数字用 `setNumberImage`,**一个精灵显示整串** | 用 `setFrame` 逐位切 / 拆十位个位各建一个精灵 / 另造一套数字渲染器 |
| 精灵 ID 1001–3000、群组 ≥201、从编辑器查证 | 随意编造 ID / 超范围 ID |
| 检查 `SpriteManager` 返回值 | 忽略 `false` 返回 |
| 精灵/资源/坐标全进常量文件 | 在业务代码内联裸数字 |
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,395 @@
# 二七王前端 · 子项目 A:地基与逻辑层 · 设计
> 日期:2026-08-26 状态:待实施
> 权威源:玩法 `server/games/erqiwang/docs/design/design.md`;协议 `server/games/erqiwang/docs/protocol/packet_protocol.md`;
> 界面规格 `docs_dev/二七王-UI资源与精灵清单.md`(下称**清单**);前端规范 `docs/client/development-guide/`。
> 本文只做「规格 → 代码结构」的落地设计,**不重新定义规则、不修订清单结论**。
---
## 1. 背景与定位
服务端二七王已完成(`class.arith/paiju/desk/export/import/mod` + 17 个测试脚本(624 checks)),协议与玩法文档定稿,清单已把全流程翻译成可执行的界面规格。前端尚未开工:`client/js/01_SubGame/` 只有三个平台转发壳,`codes/` 目录不存在。
整个前端按依赖顺序拆为 6 个子项目,各自独立走「设计 → 计划 → 实现 → 提交」:
| # | 子项目 | 内容 |
|---|---|---|
| **A** | **地基与逻辑层(本文)** | 目录结构、常量层全量、纯逻辑核心、布局求解器、`shared/` 抽取与同步、单测框架、平台接入骨架 |
| B | 网络与数据镜像 | 发包封装、rpc 分发、`GameState` 服务端快照镜像、重连 `deskinfo` 复用同一条重画路径 |
| C | 牌桌主界面 | 顶部信息条、玩家位标记、底栏、手牌区、底牌区、三家已出牌区 |
| D | 阶段操作界面 | 叫分面板、选主/投降、埋牌条、出牌条、倒计时、提示气泡、状态提示条 |
| E | 弹窗与结算 | 小局/大局结算、冲关牌型叠加层、出牌历史、明牌、亮牌、底牌/扣底查看 |
| F | 建房与接入收尾 | 建房规则选项区、`SubGameHooks` 全接口、`PLAYER_INFO_LAYOUT` 同步、清单 §7.3 验收 18 条 |
### 1.1 前置条件的处理方式(已定)
清单 §7.4 把「美术出图 / 编辑器建精灵」列为开发前置条件。**本项目不采用等待策略**:
> **资源、精灵、图层、群组、布局、动画参数一律先在常量文件里定义**,代码只引用常量;后期按常量文件的注释在编辑器里手动创建对应资源与精灵。因此编辑器的建设进度**不阻塞任何编码工作**。
这与清单 §0 前言「代码里引用的 ID 必须与编辑器中实际建成的完全一致」并不冲突——次序倒过来了:**常量文件即建资源的规格书**,编辑器照它建。若建设过程中某个 ID 确需改动,改常量文件一处即可(业务代码零裸值,见 §4)。
清单 §0.3 已核实号段占用现状,规划的号段(精灵 1001–2999、图层 101–105/302–306、群组 201–250、图片 501+、声音 101+)**段内空闲**,可安全落地。
---
## 2. 本阶段范围
**交付**:
1. `codes/` 目录结构
2. 常量层全量落地(清单 §3 图片资源、§4 声音、§5 精灵结构、§6 布局配置、动画参数)
3. `core/` 纯逻辑模块(牌值编码、排序、标记推导、座位映射、精灵索引)
4. `codes/ui/LayoutSolver.js` 五型布局求解器
5. 服务端 `shared/cards.js` 抽取 + 前端只读同步副本 + 同步脚本
6. `client/tests/` 单测框架与全部用例
7. `codes/SubGameHooks.js` 空骨架 + `client/index.html` 加载段
**不在本阶段**:任何 UI 组件、任何 `SpriteManager` 渲染调用、任何收发包逻辑(B 起)。`LayoutSolver.apply()` 是唯一触碰 `SpriteManager` 的函数,且只有三行。
---
## 3. 目录结构
```
client/js/01_SubGame/codes/
config/ 纯数据:无函数、无副作用、不引用其他模块
Layers.js
Groups.js
ImageResources.js
SoundResources.js
Sprites_Table.js
Sprites_Cards.js
Sprites_Action.js
Sprites_Result.js
Sprites_CreateRoom.js
LayoutConstants.js
Layout_Table.js
Layout_Cards.js
Layout_Action.js
Layout_Result.js
Layout_CreateRoom.js
AnimConstants.js
core/ 纯逻辑:不碰精灵、无副作用、可 Node 单测
CardCodec.js
CardOrder.js
CardMark.js
SeatMap.js
SpriteIndex.js
shared/ 服务端 shared/ 的只读同步副本(禁止在前端修改)
cards.js
ui/
LayoutSolver.js
SubGameHooks.js 本阶段为空骨架
<仓库根>/
sync_shared.cmd / sync_shared.ps1 跨端同步脚本,放根目录(见 §7.4)
client/tests/
_load.js _assert.js run.js test_*.js
```
单向依赖:`config`(无依赖)← `core` ← `ui` ← 后续 `net`/组件层。`shared/` 无依赖,被 `core` 使用。
---
## 4. 常量层
遵循 client 02 §2「资源与布局常量三件套」与清单 §6.10 的文件组织。**多文件用保护性声明**(`var EQW_Layout = EQW_Layout || {};`),加载后合并为一份。
### 4.1 文件与内容来源
| 文件 | 全局名 | 内容来源 |
|---|---|---|
| `Layers.js` | `EQW_Layers` | 清单 §0.3 图层表:`TABLE_STATIC:101`、`HAND:102`、`TABLE_CARDS:103`、`ACTION:104`、`OVERLAY:105`、`POPUP_ASET_RESULT:302`、`POPUP_ACCOUNT:303`、`POPUP_HISTORY:304`、`POPUP_MINGPAI:305` |
| `Groups.js` | `EQW_Groups` | 清单 §0.3 群组表 201–250 |
| `ImageResources.js` | `EQW_Images` | 清单 §3 图片资源总表,**每条按 ImageResources.template 的要求注明用途 / 帧数 / 每帧含义 / 尺寸** |
| `SoundResources.js` | `EQW_Sounds` | 清单 §4——**整节 T-20 未定**,本阶段只建文件骨架与说明注释,不臆造条目(见 §11) |
| `Sprites_*.js` | `EQW_Sprites` | 清单 §5.1–§5.8。**以 UI View 为组织单位**(一个 View 将来对应一个 `BaseComponent`),View 下是一个个 **group 容器**:`{ Layer: 图层, GroupName: { id: 群组, 精灵键: id, … } }`。一个 View 恰好一个图层,跨图层的界面拆成两个 View。每个精灵注明类型 / 用途 / 资源键 / 帧说明 |
| `LayoutConstants.js` | `EQW_Layout` | 清单 §6.3 `textStyle` 预设、§6.4 `CARD_SIZE` 三档、座位键常量 `SELF/LEFT/RIGHT` |
| `Layout_Table.js` | `EQW_Layout` | 清单 §6.5 常驻区 + §6.6 玩家位(含气泡朝向 `arrowRight`) |
| `Layout_Cards.js` | `EQW_Layout` | 清单 §6.7 牌区(手牌单排/双排、底牌、埋牌底牌、已出牌 `bySeat`、冲关牌) |
| `Layout_Action.js` | `EQW_Layout` | 清单 §6.8 阶段操作区 |
| `Layout_Result.js` | `EQW_Layout` | 清单 §6.9 结算区 |
| `Layout_CreateRoom.js` | `EQW_Layout` | 清单 §6.9b 建房规则选项 + `EQW_RoomOptions` 选项配置(§1.1 的 categories 数据) |
| `AnimConstants.js` | `EQW_Anim` | 发牌铺开时长与每张延迟(§1.2)、`selectedOffsetY`(§6.7)、`floatRise`/`floatDuration`(§6.8)、`toastDuration`(§6.8)、`liangpaiAutoClose`(§1.6)、开底 3 秒(§1.4) |
### 4.2 硬约束
- **布局文件是纯数据**:不得出现函数、不得引用其他模块、不得有副作用(client 02 §2、清单 §6.10)。`fan` 的间距算法、`bySeat` 合并、`attach` 换算全在 `LayoutSolver`。
- **业务代码零裸值**:精灵 ID / 群组 ID / 图层 ID / 资源 ID / 坐标尺寸 / 动画时长 / 事件名一律来自常量(前端红线)。
- **注释即建资源的规格**:图片资源与精灵的注释必须写到「据此就能准确创建」的程度(client 02 §2)。
- 数值来自清单,均为参考图 1280×720 目视实测估值(±5px),设计稿到位后只改这一层。
---
## 5. `core/` 纯逻辑层
全部为无副作用纯函数,不引用 `SpriteManager`、不引用引擎、不读全局状态。
### 5.1 `CardCodec`
```js
EQW_CardCodec.cardIdToFrame(cardId) // 清单 §0.4 的唯一权威转换,全前端只此一处实现
EQW_CardCodec.CARD_BACK_FRAME // 55
```
实现照清单 §0.4:
```js
var n = cardId % 54;
if (n === 52) return 53; // 小王
if (n === 53) return 54; // 大王
return (3 - Math.floor(n / 13)) * 13 + (n % 13) + 1;
```
花色段序与美术帧相反(服务端 1=方块…4=黑桃,美术帧 黑桃→红桃→梅花→方块),是最易静默画错牌的一处,故用清单 §0.4 的 10 条校验样例逐条锁死。
牌 id 的花色/点数解码**不在此重复实现**,一律调 `shared/cards.js` 的 `id_to_flower` / `id_to_number`(§7)。
### 5.2 `CardOrder`
```js
EQW_CardOrder.sort(cards, mainflower) // 返回新数组,从大到小,与服务端同序
```
内部直接调 `shared/cards.js` 的 `order_cards`(同源,见 §7),**不在前端另写排序**(清单 T-5)。前端只负责:
- 传入 `mainflower`:选主前传 `0`(仅固定主牌成主)、选主后传 `flower`
- 不修改入参数组(服务端 `order_cards` 原地排序,前端包一层 `concat()`)
选主后必须整体重排(正 2 / 正 7 升格、主花色普通牌并入主牌段,design §3)——重排由调用方在收到 `xuanzhu` 时触发(B 阶段),本阶段只提供函数。
### 5.3 `CardMark`
清单 §1.6 T-8 的三种牌面标记,前端据 `flower` 本地推导,服务端不下发:
```js
EQW_CardMark.marksOfHand(cards, mainflower) // 批量,返回 { cardId: mark };无标记的牌不出现
EQW_CardMark.markOf(cardId, cards, mainflower) // 单张 → 'tractor'|'zheng'|'zhu'|null(互斥,至多一个)
EQW_CardMark.countByFlower(cards) // 选主面板:{ flower: {count, pairs} }
```
- `'tractor'`(红色 `拖` 圆标):该牌属于一组拖拉机 → 走 `shared` 的 `get_pairlist` + `get_tuolaji_list`
- `'zheng'`(橙色五角星):正 2 / 正 7,即选定花色的 2 和 7
- `'zhu'`(蓝色五角星):其余主牌(双王、副 2、副 7、主花色普通牌)
- 判定优先级:`tractor` > `zheng` > `zhu`(互斥且每张至多一个,见清单 §1.6)
`countByFlower` 供选主面板的中央大数字(该花色张数)与右上角标(对子数)——协议明确服务端不下发,前端据 `MyCards` 自算(清单 §1.5、协议 `ChooseMain` 注)。
### 5.4 `SeatMap`
清单 §0.1 的座位映射,**全前端唯一实现**:
```js
EQW_SeatMap.toDisplay(seat, mySeat) // → 'SELF' | 'RIGHT' | 'LEFT'
EQW_SeatMap.toSeat(displayKey, mySeat) // → 服务端座位号
```
规则:`SELF = mySeat`、`RIGHT = (mySeat+1)%3`(下家)、`LEFT = (mySeat+2)%3`(上家)。依据服务端 `class.paiju.js:264` `get_nextseat = (seat+1)%3`。
### 5.5 `SpriteIndex`
精灵常量的组织是 **View → group 容器 → 精灵**:
```js
EQW_Sprites.TopInfoView = {
Layer: EQW_Layers.TABLE_STATIC, // 一个 View 恰好一个图层
TopInfo: { // group 容器
id: EQW_Groups.TOP_INFO,
TOP_INFO_BG: 1001,
TOP_CALL_TEXT: 1003
}
};
```
而布局配置里 `attach.target` 写的是**键名**(清单 §6.1:「`target` 一律写键名而非数字 ID」),故需把这棵树展平:
```js
EQW_SpriteIndex.build(spriteTree) // → { 键名: 精灵ID }
EQW_SpriteIndex.buildGroupMap(spriteTree) // → { 键名: 群组ID }
EQW_SpriteIndex.init(spriteTree) // 默认用 EQW_Sprites 建好两张索引
EQW_SpriteIndex.idOf(key) // 查精灵 ID;查不到显式抛错
EQW_SpriteIndex.groupIdOf(key) // 查它所属的群组 ID;查不到显式抛错
```
`groupIdOf` 是这个结构带来的收益:group 容器把精灵与群组 ID 绑在一起,「某个精灵属于哪个群组」于是可以机械查出——显隐走群组时(前端红线:显隐由组件的 `showXxx`/`hideXxx` 控制)不必再手工对照。
**结构约定**:View 内 `Layer` 是 View 级声明、跳过;**一个 View 恰好一个图层**——跨图层的界面拆成两个 View(一个 View 将来对应一个 `BaseComponent`,把两个图层塞进一个 View 会让「哪个 group 属于哪个图层」只能靠注释隐含)。其余键的值必须是**对象**(group 容器)。group 容器内 `id` 是群组 ID、跳过;其余键一律是精灵 id。
**显式失败**五处(工程总则 §7):
- `idOf` / `groupIdOf` 键名拼错时立即报错,而不是把 `undefined` 传给 `SpriteManager` 静默无效;
- **重复键报错**而非后者覆盖前者——覆盖会让某个界面静默指向另一个界面的精灵。同名键在不同 View 下重复由清单 §5 的键名设计避免(`PLAY_SELF_*` / `PLAY_LEFT_*` / `PLAY_RIGHT_*`);
- **精灵值非数字报错**;
- **View 下混入标量键报错**(例如把群组 ID 误写成 View 级的 `Group: 201` 而没包进 group 容器);
- **group 容器缺 `id` 报错**;
- **View 缺 `Layer`(或写成 `Layer1`/`Layer2` 的多图层形式)报错**。
---
## 6. `ui/LayoutSolver.js` 布局求解器
### 6.1 接口
```js
EQW_LayoutSolver.solve(node, ctx) // → [{x, y, width, height}, ...],纯函数
EQW_LayoutSolver.apply(node, spriteIds, ctx) // solve 后逐个 SpriteManager.setPosition
```
- `node`:一份清单 §6.1 的布局配置(五型之一,可含 `bySeat`)
- `ctx`:
- `seat`:`'SELF' | 'LEFT' | 'RIGHT'`,用于 `bySeat` 合并(无 `bySeat` 时可省)
- `count`:运行时项数,供 `line` / `fan` / `grid`(清单 §6.1:`count` 由运行时数据给出,不进配置)
- `rects`:`{ 键名 → {x, y, width, height} }`,供 `attach` 查目标矩形
- 返回恒为**数组**(`point` / `attach` 长度为 1),调用方按索引摆精灵
`attach` 通过 `ctx.rects` 取目标矩形,而不是反查引擎——求解器因此保持纯函数、可在 Node 里完整单测。目标矩形由调用方提供(来自已解出的布局结果或常量里的固定 `x/y/w/h`)。
### 6.2 内部流程
```
solve(node, ctx)
1. mergeBySeat(node, ctx.seat) bySeat[seat] 覆盖外层 base,未列出的字段继承
2. 按 kind 分派:
point → 直接取 x/y/w/h
line → AlignmentUtils.distribute;有 items 时逐项取宽(itemWidth 失效)
fan → 先按 §6.1 公式算 spacing,再走同一个 distribute
grid → 逐行调 distributeHorizontally,fillOrder 决定填充次序
attach → corner 为空 → alignTo(目标内部对齐)
corner 有值 → 对应的 alignSpriteCorner*(贴目标外侧角)
3. 返回矩形数组
```
`fan` 的间距算法(清单 §6.1,**唯一实现,不散落**):
```
count <= 1 → spacing 不参与,按 anchor 摆一张
otherwise → raw = (maxWidth - itemWidth) / (count - 1) - itemWidth
spacing = clamp(raw, spacingMin, spacingMax)
```
`overlapFrom` 决定 z 序方向(`'left'` = 后面的牌盖住前面的;`'right'` 为左上家镜像),求解器**只输出坐标与 z 序次序**,实际 z 序由调用方按返回次序摆精灵。
### 6.3 参数命名
一律沿用框架 `AlignmentUtils` 的入参名(清单 §6.0),配置对象可原样喂进框架方法、中间零转换。对齐常量沿用 `AlignmentUtils.ALIGN`。精灵锚点恒在左上角,配置里的 `anchorX/anchorY` 是**对齐基准点**、不是精灵左上角坐标。
### 6.4 错误处理
未知 `kind`、缺必填参数、`attach` 的 `target` 在 `ctx.rects` 里查不到 → **显式抛错**(工程总则 §7),不返回 `{x:0,y:0}` 之类的兜底值。
---
## 7. `shared/` 抽取与同步
### 7.1 动机
前端排序(`CardOrder`)与手牌标记(`CardMark`)用的算法必须与服务端主牌序**完全一致**(清单 T-5:「对齐服务端主牌序,不另造一套」)。两处各写一份必然漂移,故按工程总则 §1(SSOT)抽为前后端同源的共享算法。
### 7.2 抽取边界
这些函数构成闭合依赖链,**不依赖对局状态、不依赖平台 API**:
```
order_cards ─→ id_to_code ─→ id_to_flower
└→ id_to_number
get_pairlist ─────→ id_to_code
get_tuolaji_list ─→ id_to_code
└→ is_continuous
trump_rank ───────→ id_to_code
```
共 8 个:`id_to_flower`、`id_to_number`、`id_to_code`、`order_cards`、`is_continuous`、`get_pairlist`、`get_tuolaji_list`、`trump_rank`。它们在 `class.arith.js` 里正好是**连续的一整段**(第 62–289 行),中间不夹杂其他函数,可整段搬运。
### 7.3 服务端改动
1. 新建 `server/games/erqiwang/shared/cards.js`,全局名 `youle_erqiwang_shared_cards`,形态照既有文件:`var X = X || { ... }` + 尾部 `if (typeof module !== "undefined"){ module.exports = X; }`。函数内部的相互调用改走 shared 自身引用。
2. `class.arith.js` 中这 7 项改为**挂载 shared 的引用**(`id_to_code: youle_erqiwang_shared_cards.id_to_code`),对外 API 与全部既有调用点**零改动**。
3. `mod.js` 的 `min_loadJsFile` 序列里 `shared/cards.js` 排在 `class.arith.js` **之前**。
4. `test/_shim.js` 或各测试脚本按需 `require('../shared/cards.js')`,保证 Node 侧全局可用。
**安全网**:现有 17 个测试脚本(624 checks)原样全绿,即为这次重构的验收依据——不新增业务行为,只搬家。
### 7.4 前端同步
**单向流水线,方向不可逆**:
```
server/games/erqiwang/shared/ ← 唯一可修改处(权威源)
│ sync_shared.cmd
▼
client/js/01_SubGame/codes/shared/ ← 只读副本,任何情况下都不在前端改
```
- **脚本位置:仓库根目录** `sync_shared.cmd` + `sync_shared.ps1`(`.cmd` 调 `.ps1`,仿既有 `client/scripts/build_spine_data.cmd` 的范式)。它是**跨端**的工程动作——源在 `server/`、目标在 `client/`,不属于任何一端,故不放 `client/scripts/`。
- 脚本行为:把 `server/games/erqiwang/shared/*.js` 覆盖拷贝到 `client/js/01_SubGame/codes/shared/`,并打印同步了哪些文件。**只有这一个方向**,脚本不提供反向同步。
- 副本**逐字节一致**——浏览器无 `module`,尾部 `module.exports` 守卫自动跳过,同一份文件两个运行时都能跑。
- 纪律(写进脚本头注释与 `codes/shared/` 目录说明):**改动一律落在服务端 `shared/`,改完跑一次根目录的 `sync_shared.cmd`**;前端副本的任何本地修改都会在下次同步时被覆盖。
- **防漂移守卫**:`client/tests/test_shared_sync.js` 断言两份文件内容逐字节相等——忘了同步、或有人手改了前端副本,测试立刻红。这条守卫比人工纪律可靠。
---
## 8. 测试
照搬服务端 `test/` 的现有范式(`run.js` spawn 各用例独立进程 + `_assert.js` + `_shim.js`),新建 `client/tests/`。
### 8.1 加载机制
前端正式代码是浏览器全局脚本(`var X = {...}`,无 `module.exports`)。测试侧用 `_load.js` 以 `vm.runInThisContext(fs.readFileSync(path))` 把它们喂进 Node global——**不改正式代码**,符合 client 05 §10「正式代码禁止为测试而加逻辑」。
测试代码可用现代语法(只跑 Node、不上线),且不受 `.githooks/pre-commit` 的严格 ES5 拦截。
### 8.2 用例
| 文件 | 覆盖 |
|---|---|
| `test_cardcodec.js` | 清单 §0.4 的 **10 条校验样例**逐条;两副牌同帧;牌背常量;越界 id 的行为 |
| `test_cardorder.js` | 与服务端 `order_cards` 同序(正例);选主前后(`mainflower=0` vs `flower`)顺序变化;不改入参数组 |
| `test_cardmark.js` | 三种标记互斥且每张至多一个;拖拉机分组正例/反例(不连续的对子不成拖);`countByFlower` 的张数与对数 |
| `test_seatmap.js` | 三个 `mySeat` × 三个 `seat` 全组合;`toDisplay`/`toSeat` 互逆 |
| `test_spriteindex.js` | 嵌套结构展平正确;键名查不到显式抛错;重复键报错 |
| `test_layoutsolver.js` | 五型各自的坐标正确;`bySeat` 合并(覆盖与继承);`fan` 的 clamp 上下边界与 `count<=1`;`line` 的 `items` 不等宽;`grid` 的 `fillOrder`;`attach` 四角与内部对齐;未知 kind / 缺参 / target 缺失均抛错 |
| `test_shared_sync.js` | 前后端 `shared/cards.js` 逐字节一致 |
| `test_constants.js` | 号段合规:精灵 ∈ 1001–2999、群组 ≥201、图层 ∈ 101–200/301–400、图片 ≥501、声音 ≥101;**全局无重复 ID**;布局配置里每个 `attach.target` 都能在精灵索引里查到 |
`test_constants.js` 是清单 §0.3 与前端红线的机械化守卫——常量层有上百个 ID,人工核对不可靠。
正面 / 反面 / 边界用例都要覆盖(client 05 §10)。测试失败先用证据裁定「业务缺陷 vs 脚本缺陷」,禁止 skip / 软化断言 / 吞异常。
---
## 9. 平台接入骨架
- `codes/SubGameHooks.js`:从 `gameabc-framework/templates/subgame-entry/SubGameHooks.template.js` 复制,本阶段**保持空骨架**(各 hook 留空函数 + 用途注释),B/F 阶段逐个填充。三个转发壳已就位、原样不动。
- `client/index.html`:按 client 06 §3 的顺序加入 codes 加载段——**依赖模块 → `SubGameHooks` → 三文件转发壳**。本阶段的加载次序:
```
config/*(纯数据,最先)→ shared/cards.js → core/* → ui/LayoutSolver.js
→ SubGameHooks.js → 00_/01_/02_SubGame_*.js(已在文件中,位置不变)
```
验证标准:浏览器打开 `client/index.html`(HTTP 方式)控制台**无报错**,全局对象 `EQW_*` 均已就绪。
---
## 10. 验收标准
1. `node client/tests/run.js` 全绿
2. `node server/games/erqiwang/test/run.js` 全绿(shared 抽取的安全网)
3. HTTP 打开 `client/index.html` 控制台无报错,`EQW_Layers/Groups/Images/Sprites/Layout/Anim/CardCodec/CardOrder/CardMark/SeatMap/SpriteIndex/LayoutSolver` 全部就绪
4. `git config core.hooksPath .githooks` 下提交通过机械红线校验(严格 ES5、可编辑范围)
5. 常量层与清单 §3 / §5 / §6 逐条对应,无遗漏、无臆造条目
6. 业务代码零裸值:`core/`、`ui/` 里不出现任何精灵 ID、坐标、时长的字面量
7. `CLAUDE.md`「常用命令」一节补上根目录的 `sync_shared.cmd`(该节现在写的是「仓库里唯一的脚本」,新增后须同步修订)
---
## 11. 遗留与依赖
| 项 | 状态 | 处理 |
|---|---|---|
| **T-20 音效** | 清单唯一未决规格项(整套音效清单未定) | `SoundResources.js` 只建骨架与说明注释,**不臆造条目**;定案后补 |
| **D-8 判定结果动画** | 已定形态(对局中 + 结算前播放,靠 `curmultiple` 符号分辨),缺动画设计稿 | 动画参数位在 `AnimConstants.js` 预留注释;实际动画在 E 阶段 |
| 编辑器建资源 | 未开始 | 不阻塞(§1.1)。常量文件即建资源规格书 |
| 布局数值 | 参考图目视实测估值(±5px) | 设计稿到位后只改 `config/Layout_*.js` 一层 |
| `Game_Modify.PLAYER_INFO_LAYOUT` 同步 | 清单 §6.11 / T-28:转发壳配置区不能引用 `codes`,只能手工同步 | F 阶段做,并列入验收检查 |
@@ -0,0 +1,370 @@
# 二七王前端 · 子项目 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 结构
```js
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` 是**纯路由表 + 分发器**,不写业务:
```js
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 不作身份依据):
```js
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 阶段的建房拼串复用同一份 |
Binary file not shown.

After

Width:  |  Height:  |  Size: 280 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 259 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 276 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 337 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 323 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 186 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 156 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 264 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 320 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 310 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 307 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 322 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

File diff suppressed because it is too large Load Diff
+49 -258
View File
@@ -1,6 +1,11 @@
///////////////////////////////////////////////////
////////// cls_youle_erqiwang_arith: 算法 /////////
///////////////////////////////////////////////////
//跨模块依赖:Node 走 require 守卫;友乐/浏览器无 require,由 min_loadJsFile 加载为同名全局(dev-guide 01 §1)
if (typeof require !== "undefined"){
var youle_erqiwang_shared_cards = require("./shared/cards.js");
}
var cls_youle_erqiwang_arith = cls_youle_erqiwang_arith || {
//根据叫分计算基础子数(design §7.1 常规算子 / §7.3.1 爬坡)
@@ -59,249 +64,32 @@ var cls_youle_erqiwang_arith = cls_youle_erqiwang_arith || {
return 40;
},
//牌id转花色
id_to_flower: function(cardid){
if (cardid == 52 || cardid == 106){
//小王
return 5;
}
if (cardid == 53 || cardid == 107){
//大王
return 5;
}
return parseInt((cardid % 54) / 13) + 1;
},
//牌id转牌面数值
id_to_number: function(cardid){
if (cardid == 52 || cardid == 106){
//小王
return 53;
}
if (cardid == 53 || cardid == 107){
//大王
return 54;
}
return (cardid % 54) % 13 + 1;
},
//牌id转牌编码
id_to_code: function(mainflower, cardid){
if (cardid == 52 || cardid == 106){
//小王
return 9553;
}
if (cardid == 53 || cardid == 107){
//大王
return 9554;
}
var flower = cls_youle_erqiwang_arith.id_to_flower(cardid);
var number = cls_youle_erqiwang_arith.id_to_number(cardid);
var code = flower * 100 + number;
//特殊处理
switch (number)
{
case 1:
code = code + 13;
break;
case 2:
code = code + 2000;
break;
case 7:
code = code + 7000;
break;
}
//主牌花色处理
if (flower == mainflower){
code = code + 1000;
}
return code;
},
//从大到小排序(冒泡排序)
order_cards: function(mainflower, aryCardIDs){
if (aryCardIDs){
for (var i = 0; i < aryCardIDs.length; i++) {
for (var j = i + 1; j < aryCardIDs.length; j++) {
var i_code = cls_youle_erqiwang_arith.id_to_code(mainflower, aryCardIDs[i]);
var j_code = cls_youle_erqiwang_arith.id_to_code(mainflower, aryCardIDs[j]);
if (i_code < j_code) {
var tmp = aryCardIDs[j];
aryCardIDs[j] = aryCardIDs[i];
aryCardIDs[i] = tmp;
}
}
}
}
return aryCardIDs;
},
//判断两个牌是否是连续的
is_continuous: function(bigcode, smallcode){
//是小王
var is_xiaowang = function(_code){
if (_code == 9553){
return true;
}
return false;
}
//是正7
var is_zheng7 = function(_code){
if (_code > 8000 && _code < 9000){
return true;
}
return false;
}
//是负7
var is_fu7 = function(_code){
if (_code > 7000 && _code < 8000){
return true;
}
return false;
}
//是正2
var is_zheng2 = function(_code){
if (_code > 3000 && _code < 4000){
return true;
}
return false;
}
//是负2
var is_fu2 = function(_code){
if (_code > 2000 && _code < 3000){
return true;
}
return false;
}
//是主A
var is_zhuA = function(_code){
if (_code > 1000 && _code < 2000 && _code % 100 == 14){
return true;
}
return false;
}
//是8
var is_8 = function(_code){
if (_code % 100 == 8){
return true;
}
return false;
}
//是6
var is_6 = function(_code){
if (_code % 100 == 6){
return true;
}
return false;
}
//普通的连牌
if (bigcode - smallcode == 1){
return true;
}
//小王、正7
if (is_xiaowang(bigcode) && is_zheng7(smallcode)){
return true;
}
//正7、负7
if (is_zheng7(bigcode) && is_fu7(smallcode)){
return true;
}
//负7、正2
if (is_fu7(bigcode) && is_zheng2(smallcode)){
return true;
}
//正2、负2
if (is_zheng2(bigcode) && is_fu2(smallcode)){
return true;
}
//负2、主A
if (is_fu2(bigcode) && is_zhuA(smallcode)){
return true;
}
//8、6(design §5.3:7 不与 6/8 相连,6 与 8 相连)
//必须是同一花色的 8 与 6:牌编码同花色相差 2(主花色 1408-1406=2、副牌 308-306=2),
//跨花色则相差 100 的倍数。只判 %100 会把「♥8 + ♣6」误判为连对,
//进而在 get_chongguan 扫描整手牌时让副牌的 6 对错误接续主 8、多算奖(design §8.1 连对链)
if (is_8(bigcode) && is_6(smallcode) && (bigcode - smallcode == 2)){
return true;
}
return false;
},
//获取对子列表,cards为同一花色的排过序的牌列表
get_pairlist: function(mainflower, flowercards){
var pairlist = [];
var i = 0;
while (i < flowercards.length - 1){
//牌编码,两张牌两张牌一取
var code_i = cls_youle_erqiwang_arith.id_to_code(mainflower, flowercards[i]);
var code_j = cls_youle_erqiwang_arith.id_to_code(mainflower, flowercards[i + 1]);
if (code_i == code_j){
pairlist.push([flowercards[i], flowercards[i + 1]]);
i = i + 2;
} else {
i++;
}
}
return pairlist;
},
//获取拖拉机列表,pairlist为同一花色的排过序的对子列表,cardtype为拖拉机牌型
get_tuolaji_list: function(mainflower, pairlist, cardtype){
var tuolaji = [];
var count = cardtype - 300; //拖拉机的对子数量
var i = 0;
for (var i = 0; i < pairlist.length - count + 1; i++){
//每次按拖拉机的对子数量取N对
var tmplist = pairlist.slice(i, i + count);
//判断是否是cardtype类型的拖拉机
var is_tlj = true;
for (var j = 0; j < tmplist.length - 1; j++) {
var code_j1 = cls_youle_erqiwang_arith.id_to_code(mainflower, tmplist[j][0]);
var code_j2 = cls_youle_erqiwang_arith.id_to_code(mainflower, tmplist[j+1][0]);
if (!cls_youle_erqiwang_arith.is_continuous(code_j1, code_j2)){
is_tlj = false;
break;
}
}
if (is_tlj){
var tlj = [];
for (var k = 0; k < tmplist.length; k++){
tlj = tlj.concat(tmplist[k]);
}
tuolaji.push(tlj);
}
}
return tuolaji;
},
//主牌归一化大小(副7 同级取 7000、副2 同级取 2000,其余用牌编码),仅用于甩牌分量比大小
trump_rank: function(mainflower, cardid){
var code = cls_youle_erqiwang_arith.id_to_code(mainflower, cardid);
if (code > 7000 && code < 8000){ //副7
return 7000;
}
if (code > 2000 && code < 3000){ //副2
return 2000;
}
return code;
},
//以下 9 项为前后端共享算法,权威实现在 shared/cards.js(经 sync_shared.cmd 同步到前端)。
//此处只挂引用:对外方法名与签名不变,全部既有调用点无需改动。
id_to_flower: youle_erqiwang_shared_cards.id_to_flower,
id_to_number: youle_erqiwang_shared_cards.id_to_number,
id_to_code: youle_erqiwang_shared_cards.id_to_code,
order_cards: youle_erqiwang_shared_cards.order_cards,
is_continuous: youle_erqiwang_shared_cards.is_continuous,
get_pairlist: youle_erqiwang_shared_cards.get_pairlist,
group_tractor_runs: youle_erqiwang_shared_cards.group_tractor_runs,
get_tuolaji_list: youle_erqiwang_shared_cards.get_tuolaji_list,
trump_rank: youle_erqiwang_shared_cards.trump_rank,
//把一组主牌分解为分量列表(design §5.4.3/§5.4.4)
//返回 [{type:'single'|'pair'|'tractor', len, value, cards:[]}...],value 为该分量最大牌的归一化 rank
decompose_trump: function(mainflower, cards){
var ordered = cls_youle_erqiwang_arith.order_cards(mainflower, cards.concat());
//先分出对子与单张
var pairs = []; //{code, cards:[id,id]}
var singles = []; //{card}
//先分出对子与单张(对子形状与 shared get_pairlist 一致:[id, id])
var pairs = []; //[[id,id], ...]
var singles = []; //[id, ...]
var i = 0;
while (i < ordered.length){
var code_i = cls_youle_erqiwang_arith.id_to_code(mainflower, ordered[i]);
if (i + 1 < ordered.length){
var code_n = cls_youle_erqiwang_arith.id_to_code(mainflower, ordered[i + 1]);
if (code_i == code_n){
pairs.push({code: code_i, cards: [ordered[i], ordered[i + 1]]});
pairs.push([ordered[i], ordered[i + 1]]);
i = i + 2;
continue;
}
@@ -309,26 +97,21 @@ var cls_youle_erqiwang_arith = cls_youle_erqiwang_arith || {
singles.push(ordered[i]);
i++;
}
//连续对子组成拖拉机,其余为单独对子
//连续对子组成拖拉机,其余为单独对子。极大连续段的扫描是前后端共享算法(shared/cards.js),
//此处只负责把段翻译成分量,不再自己实现「什么算连续、几对起算拖拉机」
var runs = youle_erqiwang_shared_cards.group_tractor_runs(mainflower, pairs);
var comps = [];
var j = 0;
while (j < pairs.length){
var run = [pairs[j]];
var k = j;
while (k + 1 < pairs.length && cls_youle_erqiwang_arith.is_continuous(pairs[k].code, pairs[k + 1].code)){
run.push(pairs[k + 1]);
k++;
for (var j = 0; j < runs.length; j++){
var run = runs[j];
var tcards = [];
for (var r = 0; r < run.length; r++){
tcards = tcards.concat(run[r]);
}
if (run.length >= 2){
var tcards = [];
for (var r = 0; r < run.length; r++){
tcards = tcards.concat(run[r].cards);
}
comps.push({type: "tractor", len: run.length, value: cls_youle_erqiwang_arith.trump_rank(mainflower, run[0].cards[0]), cards: tcards});
if (run.length >= youle_erqiwang_shared_cards.TRACTOR_MIN_PAIRS){
comps.push({type: "tractor", len: run.length, value: cls_youle_erqiwang_arith.trump_rank(mainflower, run[0][0]), cards: tcards});
} else {
comps.push({type: "pair", len: 1, value: cls_youle_erqiwang_arith.trump_rank(mainflower, run[0].cards[0]), cards: run[0].cards});
comps.push({type: "pair", len: 1, value: cls_youle_erqiwang_arith.trump_rank(mainflower, run[0][0]), cards: tcards});
}
j = k + 1;
}
for (var s = 0; s < singles.length; s++){
comps.push({type: "single", len: 1, value: cls_youle_erqiwang_arith.trump_rank(mainflower, singles[s]), cards: [singles[s]]});
@@ -1043,6 +826,14 @@ var cls_youle_erqiwang_arith = cls_youle_erqiwang_arith || {
_inhandcards = cls_youle_erqiwang_arith.order_cards(mainflower, _inhandcards);
_followcards = cls_youle_erqiwang_arith.order_cards(mainflower, _followcards);
//已排序跟牌的完整快照:下面的必出/可出校验会用 min_ary_deduct 削减 _followcards,
//而后半段的牌面值(cardvalue)与缺门/无对(noflower/nopair)判定必须基于「完整且已排序」的跟牌。
//这里绝不能改用入参 followcards——那是客户端提交的原始顺序,而 get_pairlist / get_tuolaji_list
//都按降序相邻取对:乱序提交会漏判对子/拖拉机,把本该压过的主拖拉机算成牌面 0(本轮胜者、
//捡分归属、扣底倍数全被客户端的数组顺序左右),也能抹掉缺门标志污染 §9 牌况表。
//(dev-guide server 04 §8:前端不是数据源,服务端不得依赖客户端可控的入参顺序)
var _sortfollow = _followcards.concat();
//根据第一个玩家的出牌获取后面玩家必出的和可出的牌
var get = cls_youle_erqiwang_arith.get_followcard(mainflower, _inhandcards, startcount, startflower, startcardtype);
@@ -1113,8 +904,8 @@ var cls_youle_erqiwang_arith = cls_youle_erqiwang_arith || {
/////////// 可以跟牌 ///////////
//根据跟牌情况获取是否有相同花色的牌,是否有对子
//如果第一张牌的花色与第一家的出牌花色不一样则认为没有了相同花色的牌
var _code = cls_youle_erqiwang_arith.id_to_code(mainflower, followcards[0]);
//如果最大的一张牌的花色与第一家的出牌花色不一样则认为没有了相同花色的牌
var _code = cls_youle_erqiwang_arith.id_to_code(mainflower, _sortfollow[0]);
var _flower = parseInt(_code / 100);
if (_code > 1000){
_flower = mainflower;
@@ -1123,8 +914,8 @@ var cls_youle_erqiwang_arith = cls_youle_erqiwang_arith || {
can.noflower = true;
can.nopair = true;
}
//如果最后一张牌的花色与第一家的出牌花色不一样则认为没有了相同花色的牌
var _code = cls_youle_erqiwang_arith.id_to_code(mainflower, followcards[followcards.length - 1]);
//如果最小的一张牌的花色与第一家的出牌花色不一样则认为没有了相同花色的牌
var _code = cls_youle_erqiwang_arith.id_to_code(mainflower, _sortfollow[_sortfollow.length - 1]);
var _flower = parseInt(_code / 100);
if (_code > 1000){
_flower = mainflower;
@@ -1135,7 +926,7 @@ var cls_youle_erqiwang_arith = cls_youle_erqiwang_arith || {
}
//如果第一家出的是对子或拖拉机牌型,而跟牌没有出对子则认为没有相同花色的对子
if (startcardtype > 200){
var pairlist = cls_youle_erqiwang_arith.get_pairlist(mainflower, followcards);
var pairlist = cls_youle_erqiwang_arith.get_pairlist(mainflower, _sortfollow);
if (pairlist.length != startcardtype % 100){
can.nopair = true;
}
@@ -1144,7 +935,7 @@ var cls_youle_erqiwang_arith = cls_youle_erqiwang_arith || {
//根据第一家的出牌计算牌值大小
//一张单张
if (startcardtype == 101){
var _code = cls_youle_erqiwang_arith.id_to_code(mainflower, followcards[0]);
var _code = cls_youle_erqiwang_arith.id_to_code(mainflower, _sortfollow[0]);
var _flower = parseInt(_code / 100);
if (_code > 1000){ //大于1000是主牌
_flower = mainflower;
@@ -1173,13 +964,13 @@ var cls_youle_erqiwang_arith = cls_youle_erqiwang_arith || {
//一对
if (startcardtype == 201){
var pairlist = cls_youle_erqiwang_arith.get_pairlist(mainflower, followcards);
var pairlist = cls_youle_erqiwang_arith.get_pairlist(mainflower, _sortfollow);
if (pairlist.length != 1){
can.cardvalue = 0;
return can;
}
var _code = cls_youle_erqiwang_arith.id_to_code(mainflower, followcards[0]);
var _code = cls_youle_erqiwang_arith.id_to_code(mainflower, _sortfollow[0]);
var _flower = parseInt(_code / 100);
if (_code > 1000){
_flower = mainflower;
@@ -1207,14 +998,14 @@ var cls_youle_erqiwang_arith = cls_youle_erqiwang_arith || {
//拖拉机
if (startcardtype > 300 && startcardtype < 400){
var pairlist = cls_youle_erqiwang_arith.get_pairlist(mainflower, followcards);
var pairlist = cls_youle_erqiwang_arith.get_pairlist(mainflower, _sortfollow);
var tuolaji_list = cls_youle_erqiwang_arith.get_tuolaji_list(mainflower, pairlist, startcardtype);
if (tuolaji_list.length != 1){
can.cardvalue = 0;
return can;
}
var _code = cls_youle_erqiwang_arith.id_to_code(mainflower, followcards[0]);
var _code = cls_youle_erqiwang_arith.id_to_code(mainflower, _sortfollow[0]);
var _flower = parseInt(_code / 100);
if (_code > 1000){
_flower = mainflower;
+25 -5
View File
@@ -19,10 +19,13 @@ var cls_youle_erqiwang_desk = cls_youle_erqiwang_desk || {
var desk = {};
desk.o_room = o_room; //桌所属的房间对象
desk.seatlist = []; //玩家得分
desk.seatlist.push([0, []]); //第一位为累积得分,第二位为每局得分
desk.seatlist.push([0, []]);
desk.seatlist.push([0, []]);
//玩家得分。每个元素:[累积总分, [每局总分...], 基础分累计, 冲关分累计, 傍王分累计]
//后三位是大局结算面板要分项展示的累计(design §7 捡分子数 / §8.1 冲关 / §8.3 傍王),
//三者之和恒等于累积总分——下发时组装成对象(见 get_desk_account),数组下标只在内部用
desk.seatlist = [];
desk.seatlist.push([0, [], 0, 0, 0]);
desk.seatlist.push([0, [], 0, 0, 0]);
desk.seatlist.push([0, [], 0, 0, 0]);
desk.prepare = [0,0,0]; //玩家准备状态
desk.paiju_list = []; //牌局列表
@@ -78,7 +81,10 @@ var cls_youle_erqiwang_desk = cls_youle_erqiwang_desk || {
o_desk.prepare = [0,0,0];
if (o_desk.paiju_list.length > 0){
//上一局的结算快照三件套一并清除(新局开始后重连不该再看到上一局的结算面板)
delete o_desk.paiju_list[o_desk.paiju_list.length - 1].tmp_jiesuan_aset;
delete o_desk.paiju_list[o_desk.paiju_list.length - 1].tmp_jiesuan_bottom;
delete o_desk.paiju_list[o_desk.paiju_list.length - 1].tmp_jiesuan_account;
}
//新开一局
@@ -92,6 +98,10 @@ var cls_youle_erqiwang_desk = cls_youle_erqiwang_desk || {
msg.data.success = true;
msg.data.asetidx = o_desk.paiju_list.length;
msg.data.asetcount = o_desk.o_room.asetcount;
//阶段:发完牌即 step1(叫分)。由服务端权威给出、不让前端按 rpc 推——
//没有包承载的状态变化等于逼前端去猜(server 03 §1.4 / 红线「状态机唯一在服务端」)
msg.data.step = o_desk.method.curr_paiju().step;
//控制权:当前等待叫分者。与重连包 CallRun.seat 同一个 get_callgrade_seat()(SSOT)
msg.data.seat = o_desk.method.curr_paiju().method.get_callgrade_seat();
msg.data.countdown = o_desk.method.get_countdown_jiaofen();
for (var i = 0; i < o_desk.o_room.seatlist.length; i++) {
@@ -186,7 +196,17 @@ var cls_youle_erqiwang_desk = cls_youle_erqiwang_desk || {
}
min_ontimeout(do_save_grade, 1000);
msg.data.account = o_desk.seatlist;
//大局结算下发:组装成对象数组,字段自解释(内部 o_desk.seatlist 是数组,下标语义不清,不直接外抛)
msg.data.account = [];
for (var s = 0; s < o_desk.seatlist.length; s++){
var _a = {};
_a.score = o_desk.seatlist[s][0]; //累积总分
_a.grades = o_desk.seatlist[s][1]; //每局总分列表
_a.grade_jf_total = o_desk.seatlist[s][2]; //基础分(捡分子数得分)累计
_a.grade_cg_total = o_desk.seatlist[s][3]; //冲关分累计(design §8.1,不含傍王)
_a.grade_bw_total = o_desk.seatlist[s][4]; //傍王分累计(design §8.3)
msg.data.account.push(_a);
}
return msg;
}
}
+79 -40
View File
@@ -6,6 +6,7 @@ if (typeof require !== "undefined"){
var cls_youle_erqiwang_config = require("./class.config.js");
var cls_youle_erqiwang_arith = require("./class.arith.js");
var cls_youle_erqiwang_desk = require("./class.desk.js");
var cls_youle_erqiwang_paiju = require("./class.paiju.js");
}
var cls_youle_erqiwang_export = cls_youle_erqiwang_export || {
@@ -94,6 +95,10 @@ var cls_youle_erqiwang_export = cls_youle_erqiwang_export || {
break;
case 2: //选主
deskinfo.ChooseMain = {};
//控制权:选主/投降都只由庄家做(mod.xuanzhu / mod.touxiang 的 SEAT 校验以 banker 为准)。
//与 shangzhuang 推送的 nextseat 同源同值——两条路径都由服务端算好给出,
//前端不再自己套「选主 = 庄家」这条规则(字段名与 CallRun.seat 一致:分组内的 seat = 轮到谁)
deskinfo.ChooseMain.seat = paiju.banker;
//庄家
deskinfo.ChooseMain.banker = paiju.banker;
//庄家叫分
@@ -102,7 +107,7 @@ var cls_youle_erqiwang_export = cls_youle_erqiwang_export || {
deskinfo.ChooseMain.multiple = cls_youle_erqiwang_arith.get_base_bycall(paiju.call, cfg.climb);
//选主倒计时
deskinfo.ChooseMain.countdown = o_room.o_desk.method.get_countdown_xuanzhu();
//底牌:暗牌只有庄家可见(70分的3秒亮牌是上庄时的一次性事件,重连不重放)
//底牌(发牌时没发给玩家、扣在桌面的 8 张):只有庄家可见(70分的3秒亮牌是上庄时的一次性事件,重连不重放)
if (seat == paiju.banker){
deskinfo.ChooseMain.bottomcards = paiju.method.get_bottomcards();
}
@@ -112,9 +117,16 @@ var cls_youle_erqiwang_export = cls_youle_erqiwang_export || {
} else {
deskinfo.ChooseMain.touxiang = 1;
}
//当前抓分倍数(design §7.2.0):与 shangzhuang 推送同源同值。
//选主阶段一张牌都还没出,捡分恒为 0 → 必为 +3(大光);
//漏发会让重连后顶部「抓分」角标掉回 0,与增量路径不一致
deskinfo.ChooseMain.curmultiple = paiju.method.get_curmultiple();
break;
case 3: //埋牌
deskinfo.BuryCards = {};
//控制权:埋牌只由庄家做(mod.maipai 的 SEAT 校验以 banker 为准)。
//与 xuanzhu 推送的 nextseat 同源同值
deskinfo.BuryCards.seat = paiju.banker;
//庄家
deskinfo.BuryCards.banker = paiju.banker;
//庄家叫分
@@ -123,12 +135,14 @@ var cls_youle_erqiwang_export = cls_youle_erqiwang_export || {
deskinfo.BuryCards.multiple = cls_youle_erqiwang_arith.get_base_bycall(paiju.call, cfg.climb);
//埋牌倒计时
deskinfo.BuryCards.countdown = o_room.o_desk.method.get_countdown_maipai();
//底牌:暗牌只有庄家可见
//底牌(发牌留桌的 8 张):只有庄家可见
if (seat == paiju.banker){
deskinfo.BuryCards.bottomcards = paiju.method.get_bottomcards();
}
//主牌花色
deskinfo.BuryCards.flower = paiju.flower;
//当前抓分倍数(design §7.2.0):埋牌阶段同样一张牌未出,捡分恒为 0 → 必为 +3
deskinfo.BuryCards.curmultiple = paiju.method.get_curmultiple();
//埋牌阶段无投降(投降是选主阶段与选主互斥的选择)
break;
case 5: //出牌
@@ -146,19 +160,39 @@ var cls_youle_erqiwang_export = cls_youle_erqiwang_export || {
//捡分
var jian_grade = paiju.method.get_jian_grade();
if (seat == paiju.banker){
//埋的牌
deskinfo.PushCards.bottomcards = paiju.method.get_burycard();
//埋牌底牌(庄家埋下的 8 张)。与「底牌」(发牌留桌 8 张,见 ChooseMain/BuryCards.bottomcards)
//是两批不同的牌,字段名刻意区分,勿混用
deskinfo.PushCards.burycards = paiju.method.get_burycard();
} else {
//闲家当前的捡分分牌
deskinfo.PushCards.gradecards = jian_grade.cards;
}
//闲家当前的捡分分数
deskinfo.PushCards.grade = jian_grade.grade;
//当前的出牌情况
deskinfo.PushCards.playproc = paiju.playproc;
//当前抓分倍数(design §7.2.0):与 chupai 包同源同值,重连后角标立即正确
deskinfo.PushCards.curmultiple = paiju.method.get_curmultiple();
//当前的出牌情况:与 chupai1/2/3 共用同一处快照函数(结构一致、且是深拷贝,
//不把活对象挂进下发包,见 class.paiju.js get_playproc)
deskinfo.PushCards.playproc = cls_youle_erqiwang_paiju.get_playproc(paiju);
//本轮跟牌的必出牌(design §5.2):只在【轮到本座位】且本轮已有人出牌时才有,
//算的也只是本座位自己的手牌,不涉及他家(server 红线:按可见性下发)
if (paiju.playproc && paiju.playproc.currseat == seat){
var _must = paiju.method.get_mustcard(seat);
if (_must){
deskinfo.PushCards.mustcard = _must;
}
}
//报无主:与 chupai1/2/3 的 baozhu 同源同门控(mod.chupai)。
//【必须带】它是「余主公示 + 明牌按钮」的开关(design §9,前端
//Sprites_Table.js 的明牌按钮据此显隐)。只在 chupai 包里给、重连包不给,
//等于重连后按钮凭空消失——状态变更没有包承载(server 红线:发全下发面)。
//不查牌模式恒 0(与 chupai 一致,协议 §11–§13 baozhu 条目)
deskinfo.PushCards.baozhu = (!cfg.nocheck && paiju.method.have_baofu()) ? 1 : 0;
//座位列表(报副统计)与亮牌:仅可查牌模式下展示(§9/§8.2)
if (!cfg.nocheck){
deskinfo.PushCards.seatlist = paiju.seatlist;
//【深拷贝】与 maipai / chupai1/2/3 共用同一处快照函数,且不把活表挂进下发包
//(paiju.seatlist 是 do_playcard 每手就地改写的活对象,见 class.paiju.js get_seatlist)
deskinfo.PushCards.seatlist = cls_youle_erqiwang_paiju.get_seatlist(paiju);
if (seat != paiju.banker){
var _lp = paiju.method.get_liangpai();
if (_lp){
@@ -170,45 +204,39 @@ var cls_youle_erqiwang_export = cls_youle_erqiwang_export || {
//不查牌模式下不带此属性——当前这一轮桌面上的牌由上面的 playproc 恢复,两种模式都有,
//否则后出的人无从跟牌(design §9 末尾的边界说明)
if (!cfg.nocheck){
deskinfo.PushCards.pushlist = [];
for (var i = 0; i < paiju.cards.length; i++) {
var pai = paiju.cards[i];
if (pai.playround > 0){
if (deskinfo.PushCards.pushlist.length < pai.playround){
deskinfo.PushCards.pushlist.length = pai.playround;
}
if (!deskinfo.PushCards.pushlist[pai.playround - 1]){
deskinfo.PushCards.pushlist[pai.playround - 1] = [[], [], []];
}
var owner = pai.dealowner;
if (owner == 0){
owner = paiju.banker + 1;
}
deskinfo.PushCards.pushlist[pai.playround - 1][owner - 1].push(i);
}
}
//按大小排序:外层下标=轮次、内层恒为 3 个座位;排序须按本局主牌花色
//(内层曾误用轮次数作上界,会把内层数组撑成轮次长度并填入 undefined;
// 且曾误用上一个循环泄漏出来的 pai.flower——那是最后一张牌大王的花色 5,
// 等于按「无主牌」排序)
for (var i = 0; i < deskinfo.PushCards.pushlist.length; i++) {
if (!deskinfo.PushCards.pushlist[i]){
continue;
}
for (var j = 0; j < 3; j++) {
deskinfo.PushCards.pushlist[i][j] = cls_youle_erqiwang_arith.order_cards(paiju.flower, deskinfo.PushCards.pushlist[i][j]);
}
}
//【直读归档、不重建】pushlist 的唯一来源是 do_playcard 出牌当场写下的
//paiju.playhistory(见 class.paiju.js)。旧实现是从 paiju.cards 的 playround/dealowner
//反查再自己排一次序——那是同一份数据「这一手打出的牌」的第二个写入处:
//一手多张(甩牌/对子/拖拉机)时它与 chupai1/2/3 的 data.cards 会给出不同顺序,
//前端增量回放与重连重建因此对不上(同编码的一对牌尤其:排序无法区分它们)。
deskinfo.PushCards.pushlist = paiju.method.get_pushlist();
}
break;
case 6: //结算
deskinfo.Balance = {};
//玩家的准备状态
deskinfo.Balance.readystate = o_room.o_desk.prepare;
//【返回副本】o_room.o_desk.prepare 是一张全程就地改写的活数组
//(do_prepare 每次准备就地把某一格置 1,do_new_paiju 又就地重建),
//把它本体挂进下发包会让「已发出的包」随后续准备操作被回改。
//与 get_playproc / get_seatlist / get_pushlist / get_bottomcards 同一条标准:
//平台真实链路虽然立刻序列化看不出来,但这是隐患,不留。
deskinfo.Balance.readystate = o_room.o_desk.prepare.concat();
if (o_room.o_desk.prepare[seat] == 0){
//结算面板的三件套:与 jiesuan 推送【同源同值】(都取 get_paiju_account 末尾
//冻结的那份快照)。只给 aset 会让「结算面板还开着时断线重连/硬刷新」丢掉
//抠底明细与末局大结算——增量路径有、重连路径空白,正是「同一份数据两条路径
//给的不一样」(server 红线:发全下发面,漏发修在服务端发包处)。
//结算情况
deskinfo.Balance.aset = paiju.tmp_jiesuan_aset;
//抠底明细:仅正常出牌结算存在(投降/解散没有抠底这一步),与 jiesuan.bottom 同一份
if (paiju.tmp_jiesuan_bottom){
deskinfo.Balance.bottom = paiju.tmp_jiesuan_bottom;
}
//大局结算:仅末局或解散时存在,与 jiesuan.account 同一份。
//「有就带、没有就不带」与 jiesuan 推送的取舍完全一致,前端两条路径同一套解析
if (paiju.tmp_jiesuan_account){
deskinfo.Balance.account = paiju.tmp_jiesuan_account;
}
}
break;
}
@@ -217,10 +245,21 @@ var cls_youle_erqiwang_export = cls_youle_erqiwang_export || {
}
//解散房间
//平台在 battlestate==1 时即会调用(rpc.js 开战处 makewar 后立刻置 1),而本游戏的首个牌局
//是 makewar 里 min_ontimeout(...,1000) 延迟创建的——这段窗口内 o_desk 已存在但 paiju_list 为空。
//此时返回 null(平台对 null 的 _deskfree 有分支:照常发解散包、不带 deskfree),
//而不是让 curr_paiju() 的 undefined 解引用抛异常打断整条解散链路。
exp.get_disbandRoom = function(o_room){
if (!o_room.o_desk){
return null;
}
var paiju = o_room.o_desk.method.curr_paiju();
if (!paiju){
return null;
}
var msg = {};
return o_room.o_desk.method.curr_paiju().method.get_paiju_account(2, msg);
}
return paiju.method.get_paiju_account(2, msg);
}
return exp;
}
+293 -49
View File
@@ -116,6 +116,24 @@ var cls_youle_erqiwang_paiju = cls_youle_erqiwang_paiju || {
tmpdeal.length = tmpdeal.length - 1;
}
//冻结底牌快照(发牌留桌的 8 张)
//——内容在发牌结束时就已确定(dealowner==0 之后不再变),【显示顺序也必须一并冻结】。
//理由(SSOT):排序用的 order_cards 依赖主牌花色,而底牌是在【选主之前】的上庄时刻
//就翻给庄家看的——那时 paiju.flower 还是 -1,根本还没有主牌。若每次调用都按「当时的
//flower」重排,上庄推送(flower=-1)与重连 BuryCards(step3,flower 已定)会对同一份数据
//给出两种顺序(实测:主花色的那张 K 被排到队首),前端增量路径与重连路径因此对不上。
//故在发牌结束时按【翻底那一刻的口径】算一次并冻结,之后所有下发点只取这份快照的副本。
var init_bottomcards = function(){
var ary = [];
for (var i = 0; i < paiju.cards.length; i++){
if (paiju.cards[i].dealowner == 0){
ary.push(paiju.cards[i].id);
}
}
//此处 paiju.flower 必为 -1(选主在 step2 才发生),即「无主牌」序 = 翻底时的顺序
paiju.bottomcards = cls_youle_erqiwang_arith.order_cards(paiju.flower, ary);
}
//进入叫分阶段
var start_call = function(){
paiju.step = 1;
@@ -149,17 +167,35 @@ var cls_youle_erqiwang_paiju = cls_youle_erqiwang_paiju || {
paiju.call = -1;
//主牌花色
paiju.flower = -1;
//底牌快照(发牌留桌的 8 张,含显示顺序):发牌结束时冻结,见 init_bottomcards
paiju.bottomcards = [];
//当前出牌情况
paiju.playproc = {};
//出牌历史归档:外层下标 = 轮次-1,内层恒 3 个数组、下标 = 座位;每一项就是那一手
//【实际打出、且已归一成权威顺序】的牌。唯一写入处是 do_playcard 的 doplay()。
//【为什么要存】重连包 PushCards.pushlist 需要往轮的出牌历史,而牌一旦落地就只剩
//「第几轮 + 属于谁」(cards[i].playround/dealowner),一手之内的顺序无从恢复——
//从那里反查就必须自己再排一次序,那是同一份数据的第二个写入处(SSOT 违反),
//且遇到同编码的两张牌(一对)时两处必然分岔。改为出牌当场归档、下游只读。
paiju.playhistory = [];
//输赢结果 0:庄赢 1:闲赢 2:庄投降 3:中途解散
paiju.result = null;
//结算包,供断线重连时使用,新开一局时清除
paiju.tmp_jiesuan_aset = null;
//结算包快照,供断线重连时使用,新开一局时清除。三件套与 jiesuan 推送【同源同值】:
// tmp_jiesuan_aset 单局结算(三种结算来源恒有)
// tmp_jiesuan_bottom 抠底明细(仅正常出牌结算有;投降/解散为 null)
// tmp_jiesuan_account 大局结算(仅末局或解散有;其余为 null)
//只存 aset 会让「结算面板还开着时重连/硬刷新」丢掉抠底明细与大局结算(增量路径有、
//重连路径没有),即同一份数据两条路径给的不一样。唯一写入处是 get_paiju_account 末尾。
paiju.tmp_jiesuan_aset = null;
paiju.tmp_jiesuan_bottom = null;
paiju.tmp_jiesuan_account = null;
//初始化座位列表
init_seatlist();
//初始化牌列表并发牌
init_cards();
//冻结底牌快照(必须在发牌之后、选主之前)
init_bottomcards();
//进入叫分阶段
start_call();
@@ -256,6 +292,26 @@ var cls_youle_erqiwang_paiju = cls_youle_erqiwang_paiju || {
return cls_youle_erqiwang_paiju.get_liangpai(paiju);
}
//获取当前抓分倍数(design §7.2.0,实时预览)
paiju.method.get_curmultiple = function(){
return cls_youle_erqiwang_paiju.get_curmultiple(paiju);
}
//获取出牌历史快照(重连包 PushCards.pushlist)
paiju.method.get_pushlist = function(){
return cls_youle_erqiwang_paiju.get_pushlist(paiju);
}
//获取三家座位牌况快照(maipai / chupai1/2/3 / 重连包 PushCards.seatlist)
paiju.method.get_seatlist = function(){
return cls_youle_erqiwang_paiju.get_seatlist(paiju);
}
//获取某座位本轮跟牌的必出牌(design §5.2)
paiju.method.get_mustcard = function(seat){
return cls_youle_erqiwang_paiju.get_mustcard(paiju, seat);
}
return paiju;
},
@@ -435,15 +491,90 @@ var cls_youle_erqiwang_paiju = cls_youle_erqiwang_paiju || {
}
},
//获取底牌
//获取底牌(发牌留桌的 8 张):唯一来源是发牌时冻结的快照 o_paiju.bottomcards(见 init_bottomcards)。
//【返回副本】——order_cards 是原地排序,把快照本体交出去会被调用方就地重排,冻结即失效。
get_bottomcards: function(o_paiju){
var aryCardIDs = [];
for (var i = 0; i < o_paiju.cards.length; i++){
if (o_paiju.cards[i].dealowner == 0){
aryCardIDs.push(o_paiju.cards[i].id);
return o_paiju.bottomcards.concat();
},
//获取【供下发的】当前出牌情况快照:deskinfo.PushCards 与 chupai1/2/3 共用这一处,
//两条路径因此结构完全一致(前端增量回放与重连重建可复用同一份解析)。
//必须【深拷贝】:playproc 是一个全程复用的活对象(new_playround 就地重置同一个对象,
//do_playcard 也就地改 cards/maxseat/maxcard),直接把它挂进 msg 会让「已发出的包」
//随后续出牌被回改;平台真实链路虽然立刻序列化看不出来,但这是隐患,不留。
//structure:round/start/currseat/startcount/startflower/starttype/maxseat/maxcard/
// cards(定长3、下标=座位)/shuai_demand
//获取【供下发的】出牌历史快照(重连包 PushCards.pushlist)。
//唯一来源是 do_playcard 当场归档的 playhistory,这里只拷贝、不重算也不重排:
//它与 chupai1/2/3 的 data.cards、playproc.cards 是同一次 order_playcards 的产物,
//因此两条路径逐元素相等(前端增量回放与重连重建必定一致)。
//【返回深拷贝】不把活数组挂进下发包(同 get_playproc)。
get_pushlist: function(o_paiju){
var re = [];
for (var i = 0; i < o_paiju.playhistory.length; i++){
var _round = o_paiju.playhistory[i];
var _one = [];
for (var s = 0; s < 3; s++){
_one.push(_round[s].concat());
}
re.push(_one);
}
return re;
},
//获取【供下发的】三家座位牌况快照(maipai / chupai1/2/3 / 重连包 PushCards.seatlist)。
//结构:外层定长 3(下标=座位),每家 5 个二元数组——前 4 个是花色1~4 的 [无该花色, 无对],
//第 5 个是报副 [剩余主牌数, 剩余主对数]。
//【返回深拷贝】o_paiju.seatlist 是一张全程就地改写的活表(do_playcard 里按缺门/无对/报无主
//逐格赋值),把它本体挂进下发包会让「已发出的包」随后续出牌被回改;平台真实链路虽然立刻
//序列化看不出来,但这是隐患,与 get_playproc / get_pushlist / get_bottomcards 同一条标准。
get_seatlist: function(o_paiju){
var re = [];
for (var s = 0; s < o_paiju.seatlist.length; s++){
var _one = [];
for (var f = 0; f < o_paiju.seatlist[s].length; f++){
_one.push(o_paiju.seatlist[s][f].concat());
}
re.push(_one);
}
return re;
},
get_playproc: function(o_paiju){
var _p = o_paiju.playproc;
if (!_p){
return null;
}
var re = {};
re.round = _p.round;
re.start = _p.start;
re.currseat = _p.currseat;
re.startcount = _p.startcount;
re.startflower = _p.startflower;
re.starttype = _p.starttype;
re.maxseat = _p.maxseat;
re.maxcard = _p.maxcard;
//本轮各位置出的牌:定长 3、下标 = 座位序号;尚未出牌的位置留空(序列化后为 null)
re.cards = [];
re.cards.length = 3;
if (_p.cards){
for (var i = 0; i < 3; i++){
if (_p.cards[i]){
re.cards[i] = _p.cards[i].concat();
}
}
}
return cls_youle_erqiwang_arith.order_cards(o_paiju.flower, aryCardIDs);
//首家甩牌的分量需求(§5.4.4);tractors 是数组,同样要拷贝,非甩牌为 null
if (_p.shuai_demand){
var _sd = {};
_sd.tractors = _p.shuai_demand.tractors ? _p.shuai_demand.tractors.concat() : [];
_sd.pairs = _p.shuai_demand.pairs;
_sd.singles = _p.shuai_demand.singles;
re.shuai_demand = _sd;
} else {
re.shuai_demand = null;
}
return re;
},
//选主
@@ -531,6 +662,23 @@ var cls_youle_erqiwang_paiju = cls_youle_erqiwang_paiju || {
o_paiju.playproc.shuai_demand= null; //首家甩牌的分量需求(供跟牌逐分量匹配),非甩牌为null
},
//【一手出牌的顺序:唯一口径】把一手牌规整成本局的权威展示顺序
//(按主牌花色从大到小)。只在 do_playcard 落牌那一刻调一次,之后全体下游只读:
// · playproc.cards[座位] → chupai1/2/3 与重连包的 playproc
// · do_playcard 返回的 re.cards → chupai1/2/3 的 data.cards
// · playhistory[轮次][座位] → 重连包的 PushCards.pushlist
//三者同源同数组内容,所以前端“增量回放”与“重连重建”必定逐元素相等。
//
//为什么不能用客户端提交的原序:那是玩家的点击顺序,同一手牌每次都可能不同,
//服务端不得依赖、也不回显(server 04 §8:前端不是数据源)。
//【返回新数组】——order_cards 是原地排序,直接排入参会就地改写调用方(含 pack.data.cards)。
order_playcards: function(o_paiju, cards){
if (!cards){
return cards;
}
return cls_youle_erqiwang_arith.order_cards(o_paiju.flower, cards.concat());
},
//在牌列表中获取分牌和分值
get_grade_incard: function(o_paiju, cards){
var re = {};
@@ -547,6 +695,12 @@ var cls_youle_erqiwang_paiju = cls_youle_erqiwang_paiju || {
//检查选中的牌是否可出,并出牌
do_playcard: function(o_paiju, cards){
//【顺序归一:唯一写入处】入参是客户端提交的点击顺序,服务端不采信、不下发。
//在这里一次性转成权威顺序(order_playcards),此后 playproc.cards / 返回给下发包的
//re.cards / 重连包 pushlist 全都是同一个口径,不会再有第二处排序(SSOT)。
//顺带隔离了 can_playcard 的【原地排序】——否则它会就地改写 pack.data.cards。
cards = cls_youle_erqiwang_paiju.order_playcards(o_paiju, cards);
//出牌
var doplay = function(playindex){
for (var i = 0; i < cards.length; i++) {
@@ -554,6 +708,11 @@ var cls_youle_erqiwang_paiju = cls_youle_erqiwang_paiju || {
o_paiju.cards[cards[i]].playindex = playindex;
};
o_paiju.playproc.cards[o_paiju.playproc.currseat] = cards;
//归档这一手,供重连包 PushCards.pushlist 直读(唯一写入处,见 paiju.playhistory)
while (o_paiju.playhistory.length < o_paiju.playproc.round){
o_paiju.playhistory.push([[], [], []]);
}
o_paiju.playhistory[o_paiju.playproc.round - 1][o_paiju.playproc.currseat] = cards.concat();
//进入出牌阶段
o_paiju.step = 5;
@@ -605,7 +764,7 @@ var cls_youle_erqiwang_paiju = cls_youle_erqiwang_paiju || {
if (can.shuaicuo){
//甩错惩罚(§5.4.5):收回甩牌,本轮只强制打出最小一张
_shuaicuo = true;
cards = [can.smallest];
cards = cls_youle_erqiwang_paiju.order_playcards(o_paiju, [can.smallest]);
can = cls_youle_erqiwang_arith.can_playcard(o_paiju.flower, cards, o_paiju.playproc.currseat, o_paiju.seatlist, opp_zhulist);
if (!can.result){
var reErr = {};
@@ -636,7 +795,10 @@ var cls_youle_erqiwang_paiju = cls_youle_erqiwang_paiju || {
re1.count = cards.length;
re1.flower = can.flower;
re1.cardtype = can.cardtype;
re1.cards = cards; //实际打出的牌(甩错时为单张)
//实际打出的牌(甩错时为单张),已是权威顺序。
//【给副本】cards 本体已挂在 playproc.cards[座位] 上,直接交出去会让「已发出的包」
//随后续改动被回改(同 get_playproc 的深拷贝理由)
re1.cards = cards.concat();
re1.shuaicuo = _shuaicuo;
//合法甩牌的分量构成:cardtype 只能表达单一牌型,甩牌(单张/对子/拖拉机自由混搭,
//design §5.4.3)会被压平成 1xx/2xx,客户端无从据此还原分组,故另行下发分量需求
@@ -688,6 +850,7 @@ var cls_youle_erqiwang_paiju = cls_youle_erqiwang_paiju || {
var re2 = {};
re2.idx = 2;
re2.result = true;
re2.cards = cards.concat(); //实际打出的牌,权威顺序(理由同 re1.cards)
return re2;
}
@@ -705,6 +868,7 @@ var cls_youle_erqiwang_paiju = cls_youle_erqiwang_paiju || {
var re3 = {};
re3.result = true;
re3.idx = 3;
re3.cards = cards.concat(); //实际打出的牌,权威顺序(理由同 re1.cards)
re3.maxseat = o_paiju.playproc.maxseat;
if (o_paiju.playproc.maxseat != o_paiju.banker){
re3.grade = 0;
@@ -752,49 +916,42 @@ var cls_youle_erqiwang_paiju = cls_youle_erqiwang_paiju || {
//手牌来源必须是 get_seat_cards_award 的【静态快照】(埋牌后 28 张,含之后已打出的),
//不能用 get_seat_cards(只取未出的牌):出牌开始亮出的这份统计全局固定、不随出牌缩水,
//否则闲家断线重连后 get_deskinfo 会按庄家当前剩余手牌重算,亮牌信息缩水甚至变成 null
//亮牌(design §8.2):庄家埋牌后、出牌前,若手中【固定主牌】达到门槛,
//就向两个闲家亮出【自己全部固定主牌的具体牌面】。
// 固定主牌 = 双王 + 全部花色的 2 + 全部花色的 7,【不含】主花色的普通牌 A/K/Q/J/10/9/8/6/5
// 门槛(任一满足即亮):固定主牌总数 ≥10 / 王 ≥3 / 7 ≥6 / 2 ≥6
// 不限叫分;是否真的发给闲家由调用方按查牌模式门控(design §9)
//返回 { cards: [牌id...] }(按本局主牌序从大到小),不达标返回 null。
//注意:亮出的内容是固定的一份,不随"被哪一条门槛触发"而增减。
get_liangpai: function(o_paiju){
if (o_paiju.banker < 0){
return null;
}
//静态初始手牌快照(与 8.1 算奖同源):庄家埋牌后的 28 张
var cards = cls_youle_erqiwang_paiju.get_seat_cards_award(o_paiju, o_paiju.banker);
var wang = 0, qi = 0, er = 0;
var zhucards = [];
var gudingcards = []; //固定主牌
for (var i = 0; i < cards.length; i++){
var num = cls_youle_erqiwang_arith.id_to_number(cards[i]);
var flw = cls_youle_erqiwang_arith.id_to_flower(cards[i]);
if (num >= 53){ wang = wang + 1; }
if (num == 7){ qi = qi + 1; }
if (num == 2){ er = er + 1; }
//主牌 = 主花色普通牌 或 王 或 任意 2/7
if (flw == o_paiju.flower || flw == 5 || num == 2 || num == 7){
zhucards.push(cards[i]);
if (num >= 53){ //大王 54 / 小王 53
wang = wang + 1;
gudingcards.push(cards[i]);
} else if (num == 7){ //任意花色的 7
qi = qi + 1;
gudingcards.push(cards[i]);
} else if (num == 2){ //任意花色的 2
er = er + 1;
gudingcards.push(cards[i]);
}
}
var guding = wang + qi + er; //固定主牌(王 + 全部 2 + 全部 7)
var re = {};
var has = false;
if (guding >= 10){
zhucards = cls_youle_erqiwang_arith.order_cards(o_paiju.flower, zhucards);
var pairs = cls_youle_erqiwang_arith.get_pairlist(o_paiju.flower, zhucards);
var comps = cls_youle_erqiwang_arith.decompose_trump(o_paiju.flower, zhucards);
var tuo = 0;
for (var c = 0; c < comps.length; c++){
if (comps[c].type == "tractor"){ tuo = tuo + 1; }
}
re.zhu = zhucards.length; //主牌总数
re.zhupair = pairs.length; //主对子数
re.zhutuo = tuo; //主拖拉机组数
has = true;
}
if (wang >= 3){ re.wang = wang; has = true; }
if (qi >= 6){ re.qi = qi; has = true; }
if (er >= 6){ re.er = er; has = true; }
if (!has){
//门槛判定(design §8.2)
if (gudingcards.length < 10 && wang < 3 && qi < 6 && er < 6){
return null;
}
var re = {};
re.cards = cls_youle_erqiwang_arith.order_cards(o_paiju.flower, gudingcards);
return re;
},
//获取抠底包
get_bottom_account: function(o_paiju, msg){
msg.data.bottom = {};
@@ -827,6 +984,60 @@ var cls_youle_erqiwang_paiju = cls_youle_erqiwang_paiju || {
return re;
},
//当前抓分倍数(design §7.2.0):按【当前累计捡分】实时算出的判定倍率,【带符号】。
//供客户端顶部「抓分」列角标显示「若此刻结束是几倍」,以及对局中的判定动画(D-8)。
//与结算包 aset.upgrade 同源同算法(get_qvalue + get_upgrade),差别只在:
//这里的捡分【不含扣底】——扣底要到最后一轮打完才产生,出牌过程中尚不存在。
//叫分未定(call<=0)时返回 0,前端不显示角标。
get_curmultiple: function(o_paiju){
var _call = o_paiju.call;
if (!_call || _call <= 0){
return 0;
}
var _cfg = cls_youle_erqiwang_config.parse(o_paiju.o_desk.o_room.roomtype);
var _grade = cls_youle_erqiwang_paiju.get_jian_grade(o_paiju).grade;
var _q = cls_youle_erqiwang_arith.get_qvalue(_call, _cfg.climb);
//【带符号】返回,与结算包 aset.upgrade 完全同口径:
// 正数 3/2/1 = 庄家 大光/小光/过庄;负数 -N = 闲家升 N 级。
//符号就是「谁赢」的区分,不能取绝对值——否则 3(大光) 与 -3(升3级)、
//2(小光) 与 -2(升2级)、1(过庄) 与 -1(升1级) 三对会完全撞在一起,
//客户端的判定动画将无从分辨该播哪个(前端清单 S-8)。
//只关心倍数大小的用途(如顶部「抓分」角标)自行取绝对值即可。
return cls_youle_erqiwang_arith.get_upgrade(_call, _grade, _q);
},
//某座位本轮跟牌的「必出牌」(design §5.2),供客户端自动选中。
//与服务端出牌校验用的是同一个 get_followcard,故建议与校验结果天然一致。
//返回 null(不下发)的情形:
// 1. 非出牌阶段、或该座位就是本轮首家(首家自由出牌,无必出);
// 2. 本轮首家【甩牌】——甩牌跟牌走 flush_follow_ok 的逐分量匹配(design §5.4.4),
// 与 get_followcard 不是同一条路径,此时给建议会给错,宁可不发;
// 3. 算出来的必出牌为空(玩家可自由选)。
get_mustcard: function(o_paiju, seat){
if (o_paiju.step != 5){
return null;
}
var _proc = o_paiju.playproc;
//本轮还没人出牌 / 该座位是首家 → 无必出
if (!_proc || _proc.start == seat || _proc.startcount == null || _proc.startcount <= 0){
return null;
}
//甩牌不走这条路径,见上
if (_proc.shuai_demand){
return null;
}
var _inhand = cls_youle_erqiwang_paiju.get_seat_cards(o_paiju, seat);
if (!_inhand || _inhand.length <= 0){
return null;
}
var _get = cls_youle_erqiwang_arith.get_followcard(
o_paiju.flower, _inhand, _proc.startcount, _proc.startflower, _proc.starttype);
if (!_get || !_get.mustcard || _get.mustcard.length <= 0){
return null;
}
return _get.mustcard;
},
//获取小局结算包,type 0正常结算 1投降结算 2解散结算
get_paiju_account: function(o_paiju, type, msg){
o_paiju.endtime = min_now();
@@ -847,6 +1058,13 @@ var cls_youle_erqiwang_paiju = cls_youle_erqiwang_paiju || {
//主动推送的 data 必须自带成败标志(dev-guide server 03 §4:成败唯一是 data.success)
msg.data.success = true;
//【阶段唯一写入处】结算包一出,本局即进入 step6:
// 正常出牌结算 do_playcard 已置 6、投降结算 mod.touxiang 已置 6,此处是幂等重申;
// 解散结算此前没有任何地方推进过 step(可能停在 1/2/3/5),由这里落定。
//随后显式下发(见下方 msg.data.step):阶段变更必须有包承载,不让前端按 rpc 猜(server 03 §1.4)
o_paiju.step = 6;
msg.data.step = o_paiju.step;
//读取房间可选规则(缺省:常规算子、不傍王;见 class.config.js 位串约定)
var o_room = o_paiju.o_desk.o_room;
var _cfg = cls_youle_erqiwang_config.parse(o_room.roomtype);
@@ -926,11 +1144,26 @@ var cls_youle_erqiwang_paiju = cls_youle_erqiwang_paiju || {
];
//算奖并入结算(design §8.4):玩家i从另两家各收 X×Ni、各付 X×Nj,净得 X×(2Ni−Nj−Nk)
var _aw = [
_X * (2 * _nlist[0] - _nlist[1] - _nlist[2]),
_X * (2 * _nlist[1] - _nlist[0] - _nlist[2]),
_X * (2 * _nlist[2] - _nlist[0] - _nlist[1])
];
var award_pay = function(n){
return [
_X * (2 * n[0] - n[1] - n[2]),
_X * (2 * n[1] - n[0] - n[2]),
_X * (2 * n[2] - n[0] - n[1])
];
}
var _aw = award_pay(_nlist);
//冲关与傍王【分别】再各算一次,供大局结算分项展示(S-6)。
//必须在这里拆——N = 冲关 + 傍王 求和后才代入公式,事后无法从 grade_aw 反推各自占比。
//公式对 N 是线性的,故 _awcg[i] + _awbw[i] 恒等于 _aw[i](下方 _p 赋值处有断言性注释)。
var _ncg = [0, 0, 0]; //冲关分量:只计庄家(design §8.1)
var _nbw = [0, 0, 0]; //傍王分量:勾选时庄闲都算(design §8.3)
for (var q = 0; q < 3; q++){
if (q == o_paiju.banker){ _ncg[q] = _cglist[q].count; }
if (_bangwang){ _nbw[q] = _cglist[q].wang; }
}
var _awcg = award_pay(_ncg);
var _awbw = award_pay(_nbw);
//捡分子数:_win=1庄赢→庄+X×2/闲−X;_win=-1闲赢→庄−X×2/闲+X
var _jf = [0, 0, 0];
@@ -951,12 +1184,17 @@ var cls_youle_erqiwang_paiju = cls_youle_erqiwang_paiju || {
_p.chongguan = _cglist[i].count; //常规算奖奖数(仅庄家计入 N)
_p.wang = _cglist[i].wang; //王数
_p.naward = _nlist[i]; //该家总奖数 N
_p.grade_aw = _aw[i]; //算奖得分 X×(2Ni−Nj−Nk)
_p.grade_jf = _jf[i]; //捡分子数得分
_p.grade_aw = _aw[i]; //算奖得分 X×(2Ni−Nj−Nk),= grade_cg + grade_bw
_p.grade_cg = _awcg[i]; //其中的冲关分量(design §8.1,不含傍王)
_p.grade_bw = _awbw[i]; //其中的傍王分量(design §8.3)
_p.grade_jf = _jf[i]; //捡分子数得分(基础分)
_p.grade = _p.grade_aw + _p.grade_jf; //本局总得分
//大局累积得分
//大局累积:总分 + 三项分量(基础/冲关/傍王),供大局结算面板分项展示
o_paiju.o_desk.seatlist[i][0] = o_paiju.o_desk.seatlist[i][0] + _p.grade;
_p.score = o_paiju.o_desk.seatlist[i][0];
o_paiju.o_desk.seatlist[i][2] = o_paiju.o_desk.seatlist[i][2] + _p.grade_jf;
o_paiju.o_desk.seatlist[i][3] = o_paiju.o_desk.seatlist[i][3] + _p.grade_cg;
o_paiju.o_desk.seatlist[i][4] = o_paiju.o_desk.seatlist[i][4] + _p.grade_bw;
//保存每局得分
o_paiju.o_desk.seatlist[i][1].push(_p.grade);
msg.data.aset.seatlist.push(_p);
@@ -966,8 +1204,14 @@ var cls_youle_erqiwang_paiju = cls_youle_erqiwang_paiju || {
msg = o_paiju.o_desk.method.get_desk_account(msg);
}
//保存结算包,供断线重连时使用
o_paiju.tmp_jiesuan_aset = msg.data.aset;
//保存结算包,供断线重连时使用(get_deskinfo 的 case 6 直读这三份,不重算)。
//必须与本次下发的 jiesuan 推送【同源】——取的就是刚组好的同一份 msg.data.*,
//这样「增量收到 jiesuan」与「结算面板开着时重连」得到的结算数据逐字段相等。
//bottom 只有正常出牌结算才有(get_bottom_account 组的)、account 只有末局/解散才有,
//没有的情形显式置 null(不是漏发,是本来就不存在;下发侧据此决定带不带该分组)
o_paiju.tmp_jiesuan_aset = msg.data.aset;
o_paiju.tmp_jiesuan_bottom = msg.data.bottom ? msg.data.bottom : null;
o_paiju.tmp_jiesuan_account = msg.data.account ? msg.data.account : null;
return msg;
}
@@ -133,6 +133,165 @@
---
## 第五轮核对(design 复验 + 首次以 packet_protocol.md 为核对对象)
> 本轮做两件事:① 再次独立复核 design.md ↔ 代码;② **首次把 `docs/protocol/packet_protocol.md` 本身当作核对对象**(前四轮只在改代码时顺手同步文档,从未反向验证「文档写的 = 客户端实际会收到的」)。
>
> **design 维度**:§1~§12 逐条重核,含自行重算 §7 边界档(爬坡 10 分/5 分无小光、70 分打牌、常规 40 分无过庄)与 §8.1 连对链(正 7 起链、副 7 跨花色不成对、非主花色断链)——**规则层未发现新的不一致**,前四轮整改均真实落地。
>
> **新发现 4 项**(1 项严重缺陷 + 2 项协议文档不准确/不完整 + 1 项健壮性),已全部处置。
| # | 严重度 | 位置 | 问题 | 处置 |
| --- | --- | --- | --- | --- |
| F1 | 🟥 严重 | `class.arith.js` `can_followcard` 尾段 | 计算 `cardvalue` / `noflower` / `nopair` 时用的是**入参原始数组 `followcards`**(客户端提交顺序),而不是上面已排好序的副本;`get_pairlist` / `get_tuolaji_list` 都按**降序相邻**取对。端到端实测:庄家出红心 KKQQ、闲家用黑桃 KKQQ 主拖拉机毙牌且为末轮——降序提交 `[51,105,50,104]` → `maxseat=1`、闲家捡分 40、扣底 ×4;打乱成 `[51,50,105,104]` → 对子漏判、`cardvalue=0`、`maxseat=0`(庄家赢)、捡分 0(大光)、不扣底。**同一手合法牌,客户端只靠数组顺序就能翻转本轮胜负、捡分归属、扣底与最终结算**;同源问题还能抹掉 `noflower` 缺门标志、污染 §9 下发给全场的牌况表。踩 server 红线「前端不是数据源」「服务器权威」 | 在 `min_ary_deduct` 削减 `_followcards` **之前**另存完整排序快照 `_sortfollow`,尾段 8 处全部改用它(`_followcards` 会被削减、不能复用);函数内注释固化"绝不能用入参 followcards"的原因。补 6 条回归(`test_follow.js` 入参顺序无关性 5 条 + `test_endgame.js` 端到端 1 条),已验证**回退修复后这 6 条全红** |
| F2 | 🟧 中等 | `packet_protocol.md` §14 | 「解散结算」被写成 `route=erqiwang / rpc=jiesuan` 的独立包。实际平台(`server_room/rpc.js`、`server_room/class.room.js`)是把 `get_disbandRoom` 的**返回值整个对象**塞进**房间路由**的解散包:`route=room / rpc=free_room`,`data.deskfree = { rpc:"jiesuan", data:{ success, aset, account } }`——**多一层 `data` 包装**,真实路径是 `data.deskfree.data.aset`。前端照原文档写会取空。另外该包外层 `data.success` 由平台填写,子游戏的 `success` 在 `deskfree.data.success`,与 §0.1 的读法不同 | 新增 §14.1「解散结算的投递方式」:给出真实包结构 JSON、三条取值要点、两层 `success` 的归属,并说明 `deskfree` 可能缺失(见 F4);§14 开头标注原表头只适用于正常出牌结算与投降结算 |
| F3 | 🟨 轻微 | `packet_protocol.md` 全篇 | 文档在 `multiple` / `bangwang` / `climb` / `baozhu` / `seatlist` / `liangpai` / `pushlist` 等多处引用「roomtype 位2 / 位3」,但**全文从未给出 roomtype 位串定义**——它只存在于本 compliance 文档里。前端据协议文档无法拼出建房串 | 新增 §0.5「`roomtype` 房间选项位串」:位表(位0局数/位1扣卡/位2傍王/位3爬坡/位4查牌)+ 缺省与示例 + 缺失兜底规则 + 局数/扣卡 4 组合对照,并指明 `class.config.js` `parse()` 是唯一解析入口(SSOT) |
| F4 | 🟨 轻微 | `class.export.js` `get_disbandRoom` | 未像 `get_deskinfo` 那样判 `o_room.o_desk` / `curr_paiju()` 为空。平台在 `rpc.js` 开战处 `makewar` 后**立刻**置 `battlestate=1`,而本游戏首局是 `makewar` 里 `min_ontimeout(...,1000)` 延迟创建的——这段窗口内解散会在 `curr_paiju()` 的 `undefined` 上解引用抛异常,打断平台整条解散链路 | 两级空守卫,返回 `null`(平台对 `null` 的 `_deskfree` 已有「照常发解散包、不带 deskfree」分支);补 2 条回归用例 |
**本轮同时复核并确认无误(勿再重提)**:
- **协议文档字段面**:跑完整局(含 `mingpai`/`tishi`/失败包/投降局)与重连五个阶段,把**实际下发的 `data` 键集**与文档逐包对齐——`fapai / jiaofen / shangzhuang / xuanzhu / maipai / chupai1,2,3 / jiesuan / mingpai / tishi / zhunbei` 及 `deskinfo` 的 `CallRun / ChooseMain / BuryCards / PushCards / Balance`,**字段名、出现条件、按座位裁剪(暗牌仅庄家、`gradecards` 仅闲家、`liangpai` 仅闲家+可查牌、`cardsinhand` 仅出牌者)全部一致**;`errcode` 码表、§0.1 `success` 约定、§0.3「`countdown` 无定时器」说明亦准确。
- **`can_followcard` 首家/跟家牌面值的比较基准不一致(首家取拖拉机最小对、跟家取最大牌)**:因同花色同长拖拉机不可能共用点数(两副牌已被首家占满),两段区间必然不重叠,比较结果恒正确,**属既有写法差异、非缺陷**。
- **`get_chongguan` 的 `cards.length < 28` 早退**:属隐式兜底写法,但三个调用点的快照恒为 28(闲家)或 36(70 分投降的庄家),**当前不可触发**。
### 尚未处置
**无。** F1/F4 已改代码 + 补回归单测(并已用「回退修复→用例转红」反验有效性),F2/F3 已改协议文档。全套单测 **401 项**全绿。
### 第五轮·复验(design 维度的穷举/差分取证)
F1~F4 修复后,对 **design.md 维度**又做了一次独立取证复核。方法上比前几轮更进一步:不再逐条读代码,而是**按 design 原文另写一份参考实现**,再和代码**穷举对拍**——参考实现只依据 design 的文字与表格,不看 `class.arith.js`。**未发现任何不一致。**
| design 章节 | 取证方式 | 规模 / 结果 |
| --- | --- | --- |
| §2 牌局构成 · §6.1 分牌 | 直接清点 `init_cards` 产物 | 108 张牌表 / 去 3-4 共 16 张 / 发 92 张 = 三家 84 + 暗牌 8 / 11 种点数各 8 张 + 大小王各 2 张 / 分值 5-10-K / 全场总分 200 ✅ |
| §3 主牌顺序 · 对子判定 | 取全部主牌排序后归并成「等级序列」,与 §3 表逐级比对 | 大王-小王-正7-副7-正2-副2-主A…主5 共 15 级完全一致;副牌 A…5 顺序一致;♥7+♣7 / ♥2+♣2 / 大王+小王 均不成对,同花色两副成对 ✅ |
| §5.3 拖拉机相邻 | 15 级相邻链逐段验证 + 反例 | 链全通;7 不与 6/8 相连、6 与 8 同花色相连、♥8+♣6 跨花色不相连、副2 只接主A ✅ |
| §5.1 / §5.2 跟牌强制层级 | **穷举差分**:按 §5.2 原文写参考裁定(缺门/不足/够牌 × 单张/对子/N 连对 + 最长拖拉机优先的档案比较),随机手牌枚举**全部** C 张出牌组合逐个对拍 | 4000 手牌 / **86474 个出牌组合,失配 0** ✅ |
| §5.4.1-3、§5.4.5 甩牌 | **随机差分**:按 §5.4.2 原文写「对手是否持有能压过任一分量的主牌/主对/同长主拖」的参考判定 | **6000 例,失配 0**;副牌禁甩 4 种组合全拒、副牌单一牌型全放行 ✅<br>(首轮出现的 170 例「最小张不符」经查是**同一等级的两副牌选了不同那张**,非分歧——改按牌面等级比对即归零) |
| §5.4.4 强制跟牌分量拆解 | 按 §5.4.4 五条逐条造正反用例 | 同长主拖必出 / 无同长退化为主对 / 主对不够全出+主单补 / 主牌不够全出+垫副 / 无主牌随意垫 / 多组不同长拖拉机逐组匹配,12 条全符合 ✅ |
| §5.4.5 甩错惩罚落地 | 走 `do_playcard` 真实链路 | 甩错后 `shuaicuo=true`、实际只打出最小一张、其余收回手牌、本轮牌型退化为 101、不再带 `shuai_demand` ✅ |
| §6.2 捡分归属 | 造庄赢/闲赢两轮 | 庄家赢 → 台面分牌作废(闲家捡分 0);闲家赢 → **台面全部分牌(含庄家自己打出的)** 计入闲家 ✅ |
| §6.3 扣底倍数 | §6.3 表逐格 | 单张 1 / 主对 2 / 2-6 连对 4·6·8·10·12;混合甩牌取最高规格;末轮用副牌或混合牌赢 → 0 不扣底 ✅ |
| §7 算子(全部) | **全量对拍**:把 §7.2.1 / §7.3.3 的判定表转写成参考实现,两种算子模式 × 14 个叫分档 × grade 0~260 逐格比 base / Q / 判定 / 最终子数 | **14672 格失配 0**,另抽查 design 表格里写死的最终子数 **113 格失配 0** ✅ |
| §8.1 常规算奖 | 逐组合造手牌 | 三王 1 / 四王 3 / 六·七·八个 7 → 1·2·3 / 六·八个 2 → 1·3;连对链 正7→副7→正2→副2→主A→主K 逐级 2·3·4·5·6·7 奖;链必起于正7(无正7 断链)、缺副7 断于正7、副A 对(非主花色)不入链、无三王则无链奖、固定主 ≥10 不给奖 ✅ |
| §8.2 亮牌 | 四档阈值边界(含刚好差 1) | 王≥3 / 7≥6 / 2≥6 / 固定主≥10 分别触发,各自差 1 即不触发;输出只含数量与结构、无具体牌面 ✅ |
| §8 快照时间点 | 走真实埋牌/出牌/投降链路 | 庄家=埋牌后 28 张(已埋 8 张不在内、出牌后不缩水)、闲家=发牌后 28 张、投降局庄家 36 张且 `flower=-1` ✅ |
| §8.4 算奖并入结算 | §8.4 举例数值 | `X=6, N=[1,2,0]` → `aw=[0,18,-18]`,三条支付线零和;只庄家持奖 → 另两家各付 X ✅ |
| §4.2 叫分 | **穷举全部叫分序列**(DFS,叫分集 {70,50,25,10,5}+不叫) | **80 条终局路径**:受理判定、待叫者永不落在已「不叫」者身上、上庄者恒为最低叫分者、上庄条件恒为「叫 5」或「另两家都不叫」,**异常 0**;首家不能不叫、75/71/67/3/-5 全按 PARAM 拒 ✅ |
| §4.3 暗牌可见性 · §4.4 投降 | 非 70 分 / 70 分两条链路 + 重连 | 非 70 分暗牌只给庄家(闲家无 `bottomcards`、无 `ancard3s`、无 `cards`);70 分三家都收到 8 张 + `ancard3s=1`;重连一律只给庄家(3 秒亮牌不重放);`touxiang` 仅 70 分为 1;投降 base=1、庄 -2 闲各 +1 ✅ |
| §4.6 轮庄 | 三种 result | 庄赢连庄 / 闲赢顺延下家 / 投降顺延下家;首局暂定庄家为 0 号座位 ✅ |
| §9 查牌模式 · §10 房间设置 · §11 提示 | 可查牌与不查牌两套房间跑同一段对局 | 可查牌下 `chupai.seatlist` 整表三家、`baozhu` 有值、重连带 `pushlist`/`seatlist`;不查牌下三者全无、`baozhu` 恒 0、明牌回 RULE;**当前轮桌面牌(`playproc`)两种模式都下发**;局数 6/12、房主 2/4、AA 1/2 与加入者扣卡 4 组合正确;提示只转发给对家、庄家发 → RULE、非法 tip → PARAM;不操作则 `step`/`playproc` 不变(无超时托管)✅ |
> 一句话结论:**就 design.md 而言,服务端实现完整且准确**。本轮两次「疑似失配」(甩错最小张 170 例、闲家赢轮捡分 15 vs 10)经查**都是探针写错**,代码是对的;已在上表注明,后续核对不要再把它们当缺陷重提。
>
> **已固化两组差分测试入库**(其余取证为一次性探针):`test/test_calc.js`(§7 全量对拍,7308 格 + design 表 141 格)与 `test/test_followdiff.js`(§5.2 穷举差分,约 11 万组),全套单测由 401 项增至 **462 项**,总耗时仍 < 1.5s。
>
> 两组都做过**变异检验**:关掉 `follow_tractor_cover_ok` → `test_followdiff` 三种拖拉机首出全红;大光倍率 3→4 / 常规 55 档 base 4→5 / 爬坡 40·35 档 Q 20→40 → `test_calc` 精确指出档位与 grade。
>
> ⚠️ 固化过程中的一个教训(改这两个文件时别踩回去):**差分测试的强度取决于造牌器,不取决于对拍逻辑**。第一版用纯随机抽牌,跑 7.8 万组全绿,但关掉 `follow_tractor_cover_ok` **依然全绿**——因为「同花色三组互不相邻的两连对」(12 张)这种唯一能区分"只出零散对子"与"先凑最长拖拉机"的结构,随机抽根本抽不出来(少于 3 组时任取 3 对必含相邻对,测不出差别)。补上确定性的 `structHands()` 后才转红。同理,掺入的其他花色一度把这手撑到 15 张、超过枚举上限被**静默跳过**,表面照常全绿。**"跑了很多组且全绿"不等于"测到了",新增差分测试必须做变异检验。**
---
## 第六轮核对(多局大局 + 整局带牌型模糊)
> 前五轮的端到端**都只跑单局、且只出单张**。本轮补两个从未被驱动过的维度:① `design §12.1 步骤10 → §12.2` 的**多局大局**(跨局轮庄、累计分、大局结算、战绩);② **带牌型的整局链路**(对子/拖拉机/甩牌/甩错/毙牌/扣底与 `do_playcard`→`mod.chupai`→结算的集成)。**未发现任何不一致。**
| 维度 | 取证方式 | 结果 |
| --- | --- | --- |
| §12.1 步骤10 · §4.6 跨局轮庄 | 真实驱动 6 局(RPC 全链路,含每局 `zhunbei` 开新局) | 每局 `firstseat` 与「上局 banker + result」严格对应;另单独构造出 `result=0` 的**庄赢局**验证**连庄**(此前只有 `do_prepare` 的桩单测,端到端从未走过)✅ |
| §7 累计分 | 6 局 | 每局零和;`desk.seatlist[i][0]` == 逐局累加;`aset.seatlist[i].score` == 累计分 ✅ |
| §12.2 大局结算 | 6 局房与 12 局房各跑满 | `account` **只在末局**出现(6 局房第 6 局、12 局房第 12 局);打满后再 `zhunbei` 回 RULE 拒 ✅ |
| §10.1 扣房卡 | 6 局 | 全程只扣一次(第一局结算),`save_grade` 一次 ✅ |
| 战绩 | `save_grade` 载荷 | `gameinfo1.asetcount`/`players.score` 与累计分一致;`gameinfo2` 每局 `seatlist`/`banker`/`call`/`flower`/`result` 齐全 ✅ |
| §12.2 中途解散 | 打完 2 局后在第 3 局解散 | 解散局 `multiple/upgrade` 均 0、三家得分 `[0,0,0]`、`result=3`;`account` 累计分 == 解散前累计分("按当前累计分结算")✅ |
| 牌局隔离 | 每局开新 paiju | 新局重新发满 92 张;上一局的 `tmp_jiesuan_aset` 已清理 ✅ |
| §5/§6/§7/§8 整局带牌型 | 160 局 / 约 3800 墩(4 个种子 × 40 局),**每一墩**用独立参考实现重算胜者与服务端比对 | 多张首出、合法甩牌、甩错、毙牌墩、扣底均大量出现;牌张守恒 84/8/16、捡分守恒、结算零和、扣底触发条件、算奖分配公式**全部相符,0 异常** ✅ |
**本轮两次"疑似缺陷"经查都不是**(勿再重提):
- 某局结算 `[0,0,0]`:是 `jf=[4,-8,4]` 与 `aw=[-4,8,-4]` 恰好抵消,不是漏算。
- 首轮 40 次尝试跑不出庄赢局:是**用例设计反了**——叫 5 分是闲家最容易达标的档(`grade ≥ 5` 即赢),应叫 70 分才易守。改后立刻出现。
**固化产物**:新增 `test/test_fuzz.js`(整局模糊 + 每墩胜者差分,20 局约 480 墩,8 项聚合断言,0.3s)。多局大局部分为一次性探针未入库(跑满 12 局较慢,且其不变量已由 `test_desk`/`test_endgame`/本轮探针共同覆盖)。全套单测 **511 → 519 项**全绿。
---
## 第七轮核对(反方向:代码 → design)
> 前六轮都是「design → 代码」方向:拿规则去找代码有没有实现。本轮换**反方向**——从代码出发,把服务端每一个规则决策与常量拉出来,逐个追问「design 里有没有明文依据」。找的是两类此前查不到的东西:**代码做了 design 没授权的事**,以及 **design 有歧义时代码替规则做的选择**。
>
> **结论:没有发现与 design 冲突的行为。** 但列出 6 项「design 未明文规定、由代码自行决定」的项——它们不是缺陷,但也从未被规则设计者确认过,建议逐条拍板后写回 design。
| # | 代码的决定 | design 依据 | 性质 |
| --- | --- | --- | --- |
| R1 | 四个阶段的倒计时秒数 **15 / 20 / 25 / 30**(`class.desk.js`) | §11 只说「四个阶段都会给出一个倒计时秒数」,**未规定具体值** | 实现选择,需确认 |
| R2 | **§5.4.4 拖拉机分量的降级阶梯不要求「最长优先」**:甩牌里有 3 连对分量而闲家只有 2 连对时,代码允许出任意 3 对,不强制先凑出那个 2 连对 | §5.4.4 第 1 条明文写「没有同等连对数的拖拉机,退而求其次用**同等张数的主对子**顶替」——支持代码;但该节开头又说「沿用 §5.2 强制层级同一原则」,而 §5.2 层级 2 要求「能凑多长就必须先凑多长」。**两处措辞可作两种解读** | 歧义,代码按显式的第 1 条实现 |
| R3 | 服务端把提交的一组主牌按「**连续对子最大化合并成拖拉机**」分解(`decompose_trump`) | §5.4.3 只说甩牌可自由混搭,**未规定服务端如何分解**。该选择同时影响最大性判定、跟牌分量需求、扣底倍数(合并对甩牌方更有利:`AAKKQQ` 记 3 连对 ×6 而非 2 连对 ×4) | 实现选择,需确认 |
| R4 | 两个**同级**的副 7 对(♥7对 + ♣7对)**不构成连对** | §5.3 的链只列一次「副7」,同级即非相邻——代码按此。但 design 未直说 | 解读,合理 |
| R5 | `call` 用 `parseInt` 宽松解析:`"65"`(字符串)与 `65.5`(小数截断为 65)都会被接受;而 `cards` 是严格校验(字符串数字/小数一律拒) | design 不管入参类型;协议 §0.2 只对 `cards` 定了严格约束。**两者标准不一致**,但截断后语义正确、不改变规则结果 | 一致性瑕疵,非规则违反 |
| R6 | `get_chongguan` 对 `cards.length < 28` 静默返回 0 奖 | 隐式兜底(违反工程总则「显式失败优于隐式兜底」)。三个调用点的快照恒为 28 或 36,**当前不可触发** | 代码风格,非缺陷 |
### 本轮新增取证:下发面泄露审计
design §4(暗牌只有庄家可见 / 70 分亮 3 秒)、§9(查牌模式)、§11(结束亮底牌)与 server 红线「**发全 ≠ 发多,按可见性下发**」此前**没有任何测试**——各处门控是逐条手工核对的,从未系统验证过「有没有哪个包把不该看的牌送到了某个座位」。
本轮把它做成可执行审计(`test/test_leak.js`):跑完整局(可查牌 / 不查牌 × 叫 65 / 70 / 5 共 6 局),对每一个「服务器 → 某座位」的下发面(含 6 个 RPC 的逐座位包 + 各阶段重连快照 + 明牌应答)做深度扫描,取出其中出现的**所有牌 id**,逐个判定该座位此刻是否有权知道这张牌。
**结果:1814 个下发面 / 18706 次「座位×牌」可见性判定,0 泄露。**
变异检验(注入真实泄露确认审计有效):
| 注入的泄露 | 结果 |
| --- | --- |
| 非 70 分也把 8 张暗牌发给闲家(这正是第三轮修过的历史缺陷) | ✅ 抓住 |
| 重连时把庄家埋的底牌发给闲家 | ✅ 抓住 |
| 出牌包把出牌者剩余手牌发给所有人 | ✅ 抓住 |
---
## 第八轮核对(按 流程 / 牌型 / 玩法 / 算分 四个维度复核)
> 规则方复述了一条叫分规则:「开局庄家先叫分,不允许不叫分;第一局在没有庄家的情况下,默认座位号 0 玩家开始叫分」。据此按四个维度重新取证,**未发现不一致**。
**新增取证(此前从未做过):阶段机迁移矩阵。** 前几轮每个 handler 只测了 1~2 条代表性失败路径,从没系统验证过「某个请求在**别的**阶段会不会被误放行、会不会静默丢弃」。本轮把 8 个 RPC × 5 个阶段做成 **40 格矩阵**逐格驱动:
| | step1 叫分 | step2 选主/投降 | step3 埋牌 | step5 出牌 | step6 结算 |
| --- | --- | --- | --- | --- | --- |
| jiaofen | ✓ 受理 | × STEP | × STEP | × STEP | × STEP |
| xuanzhu | × STEP | ✓ 受理 | × STEP | × STEP | × STEP |
| touxiang | × STEP | ✓ 受理 | × STEP | × STEP | × STEP |
| maipai | × STEP | × STEP | ✓ 受理 | × STEP | × STEP |
| chupai | × STEP | × STEP | × STEP | ✓ 受理 | × STEP |
| mingpai | × STEP | × STEP | × STEP | ✓ 受理 | × STEP |
| tishi | × STEP | × STEP | × STEP | ✓ 受理 | × STEP |
| zhunbei | × STEP | × STEP | × STEP | × STEP | ✓ 受理 |
**40 格全部符合**,无误放行、无静默丢弃。(`mingpai` 在 step5 还需「已有人报无主」这一前置,首轮矩阵曾把它标红——是期望表漏了前置条件,不是缺陷。)
**叫分起始者规则逐条取证**(`design §4.2 + §4.6`,与规则方复述一致):
| 规则 | 取证 | 结果 |
| --- | --- | --- |
| 第一局默认座位 0 开始叫分 | `makewar → do_new_paiju(0)` → `firstseat=0`、待叫者=0 | ✅ |
| 之后每局由**暂定庄家**先叫 | 连打 6 局,每局 `firstseat` 与「上局 banker + result」逐局比对 | ✅ |
| 庄赢 → **连庄**(起始仍是该庄家) | 随机对局几乎打不出庄赢,用「叫 70 分 + 首家出最大牌」专门构造出 `result=0` 局 | ✅ |
| 庄输 / 投降 → 起始顺延到下家 | 6 局中逐局验证 | ✅ |
| **首家不允许"不叫"** | 第一局与其后每一局(含连庄局)的首家发 `call=0`,一律回 `RULE` 且叫分过程不变 | ✅ |
四个维度的现有取证汇总:
| 维度 | 取证手段 | 规模 |
| --- | --- | --- |
| **流程** | 阶段机 40 格矩阵、叫分序列 DFS 80 条终局、多局大局(6 局/12 局)、轮庄含连庄、中途解散、端到端整局 | 本轮新增矩阵 |
| **牌型** | §3 等级序列逐级、§5.3 相邻链(副牌同花色 / 主牌不限花色 / 同档位不相邻)、§5.2 跟牌穷举差分 11 万组 | `test_arith`/`test_followdiff` |
| **玩法** | §5.4 甩牌最大性 6000 例差分、甩错惩罚、§5.4.4 分量拆解、整局带牌型模糊每墩胜者差分、§9 查牌门控、下发面泄露审计 1.8 万次判定 | `test_fuzz`/`test_leak` |
| **算分** | §7 算子全量对拍 7308 格 + design 表 141 格、§6.3 扣底、§8.1 算奖、§8.4 分配公式、结算零和 | `test_calc`/`test_paiju` |
**固化产物**:新增 `test/test_flow.js`(7 项聚合断言,0.3s)。变异检验:去掉 `chupai`/`maipai` 的阶段校验、把 `jiaofen` 的阶段校验改成静默丢弃——三条全部转红。全套单测 **540 → 547 项**全绿。
---
## 结论摘要
> **注**:下表是**首轮核对**的初始发现快照(保留以记录起点)。其中 §4.4、§5.4、§6.3、§7、§8.1-8.4、§9、§10、§11 等标 🟥/🟧/🟨 的项**后续均已修复并验证**——当前逐节状态以本文档后面的分节详情及全套单测(270 项全绿)为准。
@@ -147,7 +147,7 @@
## 5. 当前状态小结 —— ✅ DoD 达成,P0/P1/P2 全部完成
**共 361 项断言全绿**(`test_arith` 106 / `test_callgrade` 6 / `test_config` 19 / `test_deal` 12 / `test_desk` 6 / `test_follow` 50 / `test_input` 34 / `test_paiju` 31 / `test_rpc` 68 / `test_success` 29)。
**共 511 项断言全绿**(`test_arith` 122 / `test_calc` 49 / `test_callgrade` 6 / `test_config` 19 / `test_deal` 12 / `test_desk` 6 / `test_endgame` 25 / `test_follow` 58 / `test_followdiff` 12 / `test_input` 34 / `test_paiju` 50 / `test_rpc` 89 / `test_success` 29)。其中 `test_calc`/`test_followdiff` 见 §7、G1~G9 见 §8。
design 全部服务端可验证章节均有正/反/边界单测:
- **§2 构成**:`test_deal` 92张/去3-4/三家28+底8/分值。
@@ -182,6 +182,55 @@ design 全部服务端可验证章节均有正/反/边界单测:
| §9 出牌历史按查牌模式门控 | `get_deskinfo` | 反:不查牌房无 `pushlist`;正:可查牌房有 `pushlist`;边界:两种模式下当前轮桌面牌 `playproc.cards` 都必须恢复 | `test_rpc` | ✅ 4 |
| §3 相邻链花色 | `is_continuous` | 正:同花色 8-6 连;反:三组跨花色 8-6 不连;反:差 2 但非 8/6 不连 | `test_arith` | ✅ 5 |
**关于超时**:`countdown` 只作展示、服务端不做任何超时动作,这是 design §11 确认的规则(并非缺口),因此**没有也不需要「超时自动操作」的用例**;反过来,若将来有人给服务端加了超时代打,那才是违反 design §11,应由核对拦下。
**关于超时**:`countdown` 只作展示、服务端不做任何超时动作,这是 design §11 确认的规则(并非缺口),因此**没有也不需要「超时自动操作」的用例**;反过来,若将来有人给服务端加了超时代打,那才是违反 design §11——现已由 `test_rpc` 的**守卫用例**拦下(见 §8 G9)。
---
## 7. 第五轮:按 design 原文另写参考实现的差分测试
> 前六节的用例都是**手写的场景枚举**——覆盖的是"我想到的情况"。第五轮补两组**差分测试**:把 design 的规则整条转写成参考实现,与代码大面积对拍,覆盖手写用例想不到的组合。
| 文件 | 覆盖 | 规模 |
| --- | --- | --- |
| `test_calc.js` | §7 算子全量对拍:参考实现逐字转写自 §7.1/§7.2/§7.3 判定表,比 base / Q / 判定倍率 / 最终子数 | 2 模式 × 14 档 × grade 0~260 = **7308 格**,另按 design 的 17 张表逐格抽查 **141 个写死的最终子数** |
| `test_followdiff.js` | §5.2 跟牌强制层级穷举差分:参考裁定按原文写,对每手牌**枚举全部 C 张出牌组合**逐个对拍 | 约 **11 万组** |
**差分测试的强度取决于造牌器,不取决于对拍逻辑。** 第一版 `test_followdiff` 用纯随机抽牌跑 7.8 万组全绿,但把 `follow_tractor_cover_ok` 整个关掉**依然全绿**——因为唯一能区分"只出零散对子"与"先凑最长拖拉机"的形状是「同花色三组互不相邻的两连对」(12 张),随机抽根本抽不出来。故:
- 随机源必须是**固定种子 PRNG**(不用 `Math.random`),保证用例集可复现;
- 关键结构必须用**确定性手牌**钉死(`structHands()`),不能指望随机覆盖到;
- 注意别让掺入的其他花色把手牌撑过枚举上限而被**静默跳过**(踩过一次)。
---
## 8. 第五轮:逐规则的正/反/边界补全
> 对 design §1~§12 重新做了一次"每条规则是否三类用例齐全"的审计。覆盖面本来就广,但仍有 9 处 design 明文规则缺用例(多是**规则本身**而非入参校验,前几轮的矩阵按"被测函数"组织,容易漏掉这类"跨函数才体现"的规则)。
| # | design | 规则 | 补的用例(正/反/边界) | 文件 |
| --- | --- | --- | --- | --- |
| G1 | §4.2 | 暂定庄家必须叫、其后才可"更低或不叫" | 边界:下限 5 接受;正:非首家 `call=0` 接受;反:负数 / 缺 `call`(NaN) / 步进外小值 1 / 与当前叫分相同 均拒 | `test_rpc` |
| G2 | §4.2 | **"不叫"即退出本局叫分,轮次跳过他** | 正:首家叫 65 后轮到 seat1;正:seat1 不叫后跳到 seat2;反:seat1 反悔再叫 → SEAT 拒、`callproc` 不变、仍轮到 seat2、未产生庄家 | `test_rpc` |
| G3 | §4.5 | 埋牌仅庄家、仅埋牌阶段、恰 8 张 | 反:非庄家 → SEAT;反:step2/step5 埋 → STEP;边界:7 张 / 9 张 / 空数组 → PARAM | `test_rpc` |
| G4 | §5.1 | **每轮由上一轮牌面最大的一方先出** | 正:闲 1 赢 → 下一轮 `start/currseat` 都是 1;边界:闲 2 赢 → 改由闲 2 先出 | `test_paiju` |
| G5 | §6.2 | 闲家赢 → 台面**全部**分牌计入;庄家赢 → 分牌作废 | 反:庄赢 → 下发面无 `grade`、闲家捡分 0;正:闲 1 赢 → 15 分(**含庄家自己打出的 ♥5**);边界:闲 2 赢结果与闲 1 赢一致("两个闲家谁大不影响") | `test_paiju` |
| G6 | §8 | 算奖快照的时间点与静态性 | 正:庄家 = 埋牌后 28 张、已埋 8 张不在内;正:闲家 = 发牌后 28 张;边界:投降局庄家 = 发牌+暗牌 36 张;边界:之后打出 10 张,快照仍是同样的 28 张 | `test_paiju` |
| G7 | §3 | **对子只认同花色同点数的两副** | 正:同花色两副 ♥7/♥2/大王成对;反:♥7+♣7、♥2+♣2、正7+副7、正2+副2、大王+小王 均不成对;边界:四张跨花色 7/2 组不出拖拉机,而正7对+副7对可以 | `test_arith` |
| G8 | §6.3 | 赢末轮的牌不全是主牌就不扣底 | 反:副牌两连对赢 → 0;反:混合出牌(主K+副K)→ 0;边界:主副提交顺序颠倒仍判 0 | `test_arith` |
| G9 | §11 | **无超时托管**(守卫) | 守卫:叫分→选主→埋牌→出牌全程 `min_ontimeout` 调用次数为 0。将来若有人加"超时自动出牌/代打",必然要注册定时器,这条即转红 | `test_rpc` |
**每条都做了变异检验**(在副本上注入违反该规则的改动,确认对应断言转红):
| 注入的缺陷 | 转红的断言 |
| --- | --- |
| 去掉 `mod.jiaofen` 的"非当前叫分位"校验 | G2 三条 |
| 埋牌张数 `!= 8` 放宽为 `< 1` | G3 两条边界 |
| §6.2 改成庄家赢也累计闲家捡分 | G5 一条 |
| 下一轮改为固定庄家先出 | G4 两条 |
| 算奖快照不排除已埋的牌 | G6 两条 + 亮牌快照一条 |
| `get_pairlist` 放宽为"只看点数" | G7 五条 |
| 在 `mod.chupai` 里注册一个定时器 | G9 一条 |
> 过程中的一个教训:M2(埋牌张数)第一次跑出来是**整个文件崩溃**而不是干净的 FAIL——因为 `mkBury` 桩缺 `do_burycard`,校验一被改松就走到成功路径抛异常,后面的断言全不执行。**桩要完整到能走完成功路径**,否则"校验被改松"这类变异只会表现为崩溃,定位与信号都变差。已修。
> 原列在此处的两项「待外部输入」均已由规则设计者拍板:「不查牌模式是否屏蔽出牌历史」确认屏蔽,已改 design §9 + 代码 + 用例(见上表);「超时托管默认动作」确认维持现状、不做超时动作,已写入 design §11。至此测试计划无待外部输入项。

Some files were not shown because too many files have changed in this diff Show More