diff --git a/docs_dev/二七王-UI资源与精灵清单.md b/docs_dev/二七王-UI资源与精灵清单.md index 954ea57..24fff17 100644 --- a/docs_dev/二七王-UI资源与精灵清单.md +++ b/docs_dev/二七王-UI资源与精灵清单.md @@ -1562,8 +1562,8 @@ codes/config/ ## 7. 待补充清单 > **状态汇总**(2026-08-25):待确认规格 29 项**已全部有结论**(T-9 亮牌文案暂定 `x对 x主`;T-20 音效明确后续补充)。 -> **§7.5 服务端待补项**共 5 条:**S-4 / S-5 已完成**(字段拆分 + 三份文档术语统一),**S-3 已定论**(无需改服务端), -> 剩 **S-1**(抓分倍数字段 `curmultiple`)与 **S-2**(下发应选中的牌 `mustcard`)待实现。 +> **§7.5 服务端待补项 5 条已全部完成**:S-1 `curmultiple`、S-2 `mustcard`、S-3(按手册、无需改服务端)、S-4 字段拆分、S-5 术语同步。 +> **服务端侧无遗留项**;前端开工只等美术与编辑器资源(§7.4)。 ### 7.1 待出设计稿的界面(后续统一补充) @@ -1604,13 +1604,13 @@ codes/config/ | ~~T-6~~ | 叫分按钮**按档位位置固定三档配色**(浅黄/金黄/橙)+ 灰=已叫过;按钮底与角标底各 4 帧(§1.3) | | ~~T-7~~ | 选主按钮中央大数字 = 该花色**张数**,右上角标 = 该花色**对数**(§1.5) | | ~~T-8~~ | 手牌标记 3 帧:`拖`=拖拉机 / 橙星=**正2正7** / 蓝星=**其他主牌**;前端据 `flower` 本地标注(§1.6) | -| ~~T-10~~ | 出牌阶段**必有** `提示` 按钮;另需服务端下发「应选中的牌」以实现自动选中 → **S-2** | +| ~~T-10~~ | 出牌阶段**必有** `提示` 按钮;服务端已下发 `mustcard` 供自动选中(S-2 ✅) | | ~~T-11~~ | 牌型标签**只做「毙」一种**;数据层保留 毙/垫/混合出牌 三种标识备扩展,当前不出图 | | ~~T-12~~ | `底牌 20x0` 的 `x` 后面是**扣底倍数**(`bottom.multiple`);未扣底显示 0(§1.8) | | ~~T-13~~ | 结算两个小字 = `牌局分`(`grade_jf`) / `算奖分`(`grade_aw`) | | ~~T-14~~ | 算奖明细走独立的**算奖牌型叠加层** | | ~~T-15~~ | 准备标识**用平台的** | -| ~~T-16~~ | `抓分` 角标 = **当前捡分对应判定倍率的实时预览**(大光×3/小光×2/过庄×1/升N级×N);协议现无此字段,需服务端随现有包下发 → **S-1** | +| ~~T-16~~ | `抓分` 角标 = **当前捡分对应判定倍率的实时预览**;服务端已下发 `curmultiple`(S-1 ✅) | | ~~T-17~~ | 倒计时归零**停在 `0`** 不隐藏 | | ~~T-18~~ | 底栏 5 按钮:`上一轮`/`出牌历史`/`明牌`/`扣底`(看**埋牌底牌**)/`底牌`(看**底牌**);术语已统一见 §2.6;可见性按手册 → **S-3** | | ~~T-19~~ | 数字**一律用图片多帧精灵**(参考图数字带描边渐变,文字精灵做不出),资源 631–634 全部要出 | @@ -1675,56 +1675,44 @@ codes/config/ 以下 3 条来自本轮确认的需求,**当前协议/服务端不支持**,需在服务端补齐后前端才能实现。三条都要同步更新 `packet_protocol.md`。 -#### S-1 · 「当前抓分的倍数」字段(对应 T-16)—— 语义已确认,待服务端实现 +#### S-1 · 「当前抓分的倍数」字段 `curmultiple` —— ✅ 已完成(2026-08-25) -**需求**:顶部信息条 `抓分` 列的角标显示「当前抓分的倍数」(参考图 `45 [1倍]`),且**牌局过程中可见并正确更新**。 +**语义**:当前捡分对应判定倍率的**实时预览**——「若此刻结束是几倍」,取绝对值。 -**语义(已确认)**:**当前捡分对应的判定倍率的实时预览**——即「若此刻结束,是几倍」: - -| 当前捡分 `grade` 相对叫分 `call` | 判定 | 倍率 | +| 当前捡分 `grade` 相对叫分 `call` | 判定 | `curmultiple` | | --- | --- | --- | -| `grade == 0` | 庄家:大光 | **×3** | -| `0 < grade < Q` | 庄家:小光 | **×2** | -| `Q ≤ grade < call` | 庄家:过庄 | **×1** | -| `grade ≥ call` | 闲家:升 N 级 | **×N** | +| `grade == 0` | 庄家:大光 | **3** | +| `0 < grade < Q` | 庄家:小光 | **2** | +| `Q ≤ grade < call` | 庄家:过庄 | **1** | +| `grade ≥ call` | 闲家:升 N 级 | **N** | -(`Q` = 小光/过庄分界兼升级级距:常规算子固定 40,爬坡按叫分分段,见 design §7.2.0 / §7.3.2。) +(`Q` = 小光/过庄分界兼升级级距,常规算子固定 40、爬坡按叫分分段,design §7.2.0 / §7.3.2。) -**现状**:服务端**没有任何**下发此值的字段。`chupai1/2/3` 只带 `grade`(本轮闲家得分),倍数类字段(`aset.upgrade`、`bottom.multiple`)**都要到结算包才有**。 +**下发位置**(随现有包,**不另开推送**):`shangzhuang`、`chupai1`、`chupai2`、`chupai3`、`deskinfo.PushCards`。 -**实现要求**: +**要点**: +- 三家**同值**,整表下发不涉及泄露(`grade` 本就公开)。 +- 与结算包 `aset.upgrade` **同源同算法**(`get_qvalue` + `get_upgrade`),差别只在这里**不含扣底**——扣底要到末轮打完才产生。 +- 叫分未定时为 **0**,前端不显示角标。 +- 前端**不得自算**(服务端权威)。 -1. 算法**已存在**:`class.arith.js` 的 `get_upgrade(call, grade, q)` 返回带符号判定倍率(3 大光 / 2 小光 / 1 过庄 / −N 升 N 级),出牌过程中随时可算。 -2. **不新开推送包**——随现有包一起下发,避免多一条推送链路: +**实现**:`class.paiju.js` 新增 `get_curmultiple()`;`mod.js` / `class.export.js` 组包处下发。 -| 包 | 增加字段 | 类型 | 说明 | -| --- | --- | --- | --- | -| `chupai1` / `chupai2` / `chupai3` | `curmultiple` | 整数 | 按当前累计捡分实时算出的判定倍率**绝对值**(`Math.abs(get_upgrade(...))`) | -| `deskinfo.PushCards` | `curmultiple` | 整数 | 同上,供重连后立即正确显示 | -| `shangzhuang` | `curmultiple` | 整数 | 叫分刚定、`grade == 0` 时即为大光 ×3,需要一个初始值 | +#### S-2 · 下发「应当选中的牌」`mustcard` —— ✅ 已完成(2026-08-25) -3. 三家**同值**,可整表下发,不涉及泄露(`grade` 本来就是公开信息)。 -4. 前端**不得自算**——判定倍率属服务端权威裁定(红线:服务器权威、前端只显示不自算)。 +**语义**:下一个出牌者本轮跟牌的**必出牌**(design §5.2),前端收到后自动选中(上浮),玩家确认后点 `出牌`。 -#### S-2 · 下发「应当选中的牌」(对应 T-10) +**下发位置**:`chupai1` / `chupai2`(给 `nextseat`)、`deskinfo.PushCards`(给轮到的那一家)。 -**需求**:某些情况下(跟牌存在必出牌、或唯一合法出法)服务端下发应选中的牌,前端收到后自动选中。 +**可见性(关键)**:**只发给 `nextseat` 一家**。它是该玩家自己手牌的子集,发给他本人不泄露;**整表下发会把另两家的手牌结构透露出去**(server 红线:发全 ≠ 发多,按可见性下发)。已纳入 `test_leak.js` 的 `CARD_FIELDS` 审计白名单。 -**现状**:`chupai1/2/3` 下发包里没有此字段。 +**不下发的情形**: +1. 非出牌阶段; +2. 该座位是本轮**首家**(自由出牌,无必出); +3. 首家是**甩牌**——甩牌跟牌走 `flush_follow_ok` 的逐分量匹配(design §5.4.4),与 `get_followcard` 不是同一条路径,给建议会给错,宁可不发; +4. 算出的必出牌为空(玩家可自由选)。 -**好消息——算法已存在,只差下发**:`class.arith.js:737` `get_followcard(mainflower, inhandcards, startcount, startflower, startcardtype)` 已经返回 - -``` -{ mustcard: 必出的牌列表, cancard: 可出的牌列表, cantype: 可出牌需要的牌型 } -``` - -目前只在 `class.arith.js:1055` 用于内部合法性校验,**从未下发**。建议在 `chupai1/2/3` 里**按座位差异化**下发给「下一个出牌者」: - -| 字段 | 类型 | 说明 | -| --- | --- | --- | -| `mustcard` | 数组 | 该座位本轮的必出牌 id 列表;**只发给 `nextseat` 对应的那一家**,其他两家不带 | - -**下发面注意**(红线:发全 ≠ 发多):`mustcard` 是**该玩家自己手牌**的子集,只发给他本人不泄露任何信息;**绝不可整表下发三家的 `mustcard`**——那等于把另两家的手牌结构透露出去。 +**实现**:`class.paiju.js` 新增 `get_mustcard(seat)`,内部复用**与出牌校验完全相同**的 `get_followcard`,故建议与校验结果天然一致。 **不违反「请求包只带意图」**:方向是服务端 → 前端的建议,前端仍只回传 `cards`,合法性最终由服务端校验。 diff --git a/server/games/erqiwang/class.export.js b/server/games/erqiwang/class.export.js index b243979..6525528 100644 --- a/server/games/erqiwang/class.export.js +++ b/server/games/erqiwang/class.export.js @@ -155,8 +155,18 @@ var cls_youle_erqiwang_export = cls_youle_erqiwang_export || { } //闲家当前的捡分分数 deskinfo.PushCards.grade = jian_grade.grade; + //当前抓分倍数(design §7.2.0):与 chupai 包同源同值,重连后角标立即正确 + deskinfo.PushCards.curmultiple = paiju.method.get_curmultiple(); //当前的出牌情况 deskinfo.PushCards.playproc = paiju.playproc; + //本轮跟牌的必出牌(design §5.2):只在【轮到本座位】且本轮已有人出牌时才有, + //算的也只是本座位自己的手牌,不涉及他家(server 红线:按可见性下发) + if (paiju.playproc && paiju.playproc.currseat == seat){ + var _must = paiju.method.get_mustcard(seat); + if (_must){ + deskinfo.PushCards.mustcard = _must; + } + } //座位列表(报副统计)与亮牌:仅可查牌模式下展示(§9/§8.2) if (!cfg.nocheck){ deskinfo.PushCards.seatlist = paiju.seatlist; diff --git a/server/games/erqiwang/class.paiju.js b/server/games/erqiwang/class.paiju.js index 9074733..8b04bf3 100644 --- a/server/games/erqiwang/class.paiju.js +++ b/server/games/erqiwang/class.paiju.js @@ -256,6 +256,16 @@ 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); + } + + //获取某座位本轮跟牌的必出牌(design §5.2) + paiju.method.get_mustcard = function(seat){ + return cls_youle_erqiwang_paiju.get_mustcard(paiju, seat); + } + return paiju; }, @@ -827,6 +837,55 @@ var cls_youle_erqiwang_paiju = cls_youle_erqiwang_paiju || { return re; }, + //当前抓分倍数(design §7.2.0):按【当前累计捡分】实时算出的判定倍率绝对值, + //供客户端顶部「抓分」列角标显示「若此刻结束是几倍」。 + //与结算包 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); + var _u = cls_youle_erqiwang_arith.get_upgrade(_call, _grade, _q); + return (_u < 0) ? (0 - _u) : _u; + }, + + //某座位本轮跟牌的「必出牌」(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(); diff --git a/server/games/erqiwang/docs/protocol/packet_protocol.md b/server/games/erqiwang/docs/protocol/packet_protocol.md index b2efaf4..689a20a 100644 --- a/server/games/erqiwang/docs/protocol/packet_protocol.md +++ b/server/games/erqiwang/docs/protocol/packet_protocol.md @@ -161,6 +161,7 @@ | cards | 数组 | 拿了底牌后手上的牌(36 张,含底牌),庄家才有此属性,闲家没有该属性 | | countdown | 整数 | 选主倒计时 | | touxiang | 整数 | 是否允许投降 0:不允许 1:允许(仅 70 分坐庄为 1)。投降与选主互斥、同为选主阶段(step2)的决策,见 touxiang 包与 design §4 | +| curmultiple | 整数 | **当前抓分倍数**(design §7.2.0):按此刻累计捡分实时算出的判定倍率**绝对值**(3 大光 / 2 小光 / 1 过庄 / N 升 N 级)。上庄时捡分恒为 0,故必为 3。供客户端顶部「抓分」列角标显示。三家同值(捡分本就公开),叫分未定时为 0。**不含扣底**——扣底要到末轮才产生 | --- @@ -268,6 +269,8 @@ | countdown | 整数 | 下一个出牌者的出牌倒计时 | | baozhu | 整数 | 是否已有玩家报无主(主牌出空)0否 1是;对应服务端 `have_baofu()`(任一玩家 `seatlist[seat][4][0]==0`)。**仅可查牌模式**下才可能为 1,供客户端展示他家主牌数量/对子结构并出现"明牌"按钮;**不查牌模式恒为 0**(design §9)| | cardsinhand | 数组 | 出牌者出牌后手上剩下的牌id列表,只有出牌者才有此属性 | +| curmultiple | 整数 | **当前抓分倍数**(design §7.2.0):按当前累计捡分实时算出的判定倍率**绝对值**(3 大光 / 2 小光 / 1 过庄 / N 升 N 级)。与结算包 `aset.upgrade` 同源同算法,差别仅在此处**不含扣底**。三家同值,随本包下发、不另开推送 | +| mustcard | 数组 | **下一个出牌者本轮跟牌的必出牌**(design §5.2),供其客户端自动选中。**只发给 `nextseat` 那一家**,其余两家无此属性——它是该玩家自己手牌的子集,整表下发会泄露他家手牌结构。以下情形不下发:`nextseat` 是本轮首家、首家为**甩牌**(甩牌跟牌走逐分量匹配,见 design §5.4.4)、或算出的必出牌为空 | --- @@ -284,6 +287,7 @@ | countdown | 整数 | 出牌倒计时 | | baozhu | 整数 | 是否已有玩家报无主(主牌出空)0否 1是;对应服务端 `have_baofu()`(任一玩家 `seatlist[seat][4][0]==0`)。**仅可查牌模式**下才可能为 1,供客户端展示他家主牌数量/对子结构并出现"明牌"按钮;**不查牌模式恒为 0**(design §9)| | cardsinhand | 数组 | 出牌后出牌者手上剩下的牌id列表,只有出牌者才有此属性 | +| mustcard | 数组 | **下一个出牌者本轮跟牌的必出牌**(design §5.2),供其客户端自动选中。**只发给 `nextseat` 那一家**,其余两家无此属性——它是该玩家自己手牌的子集,整表下发会泄露他家手牌结构。以下情形不下发:`nextseat` 是本轮首家、首家为**甩牌**、或算出的必出牌为空 | --- @@ -302,6 +306,7 @@ | cardsinhand | 数组 | 出牌后出牌者手上剩下的牌id列表,只有出牌者才有此属性 | | maxseat | 整数 | 本轮出牌谁最大,只有本轮最后一个玩家出牌后才有此属性 | | grade | 整数 | 本轮闲家得分,只有本轮最后一个玩家出牌后且闲家有得分才有此属性 | +| curmultiple | 整数 | **当前抓分倍数**(design §7.2.0):按当前累计捡分实时算出的判定倍率**绝对值**。与结算包 `aset.upgrade` 同源同算法,差别仅在此处**不含扣底**。三家同值,随本包下发、不另开推送 | --- @@ -551,6 +556,8 @@ | seatlist | 数组 | 三家座位牌况,与上文 chupai 包的 `seatlist` 同名同结构(每个玩家一个 5 元素数组:4 个花色的 `[无该花色,无对]` + 报副 `[剩余主牌数,剩余主对数]`)。**仅可查牌模式下有此属性**(design §9)| | liangpai | json | 庄家亮牌信息,结构同 maipai 包的 `liangpai`;**仅可查牌模式、且请求者为闲家、且庄家达标时有**(供闲家重连后仍能看到亮牌,design §8.2)| | pushlist | 数组 | 出牌历史 `[[[], [], []], [[], [], []], ...]`,外层下标=轮次(从第 1 轮起,含进行中的当前轮),内层**恒为 3 个数组**、按位置序号存该轮各家出的牌id,各自已按**本局主牌花色**从大到小排序。**仅可查牌模式下有此属性**——往轮打出、已被收走的牌属于「查牌」范畴,不查牌模式一律不下发(design §9)。注意当前这一轮桌面上的牌由 `playproc.cards` 恢复,**两种模式下都有**,否则后出的人无从跟牌 | +| curmultiple | 整数 | **当前抓分倍数**(design §7.2.0):与 `chupai1/2/3` 同源同值,供重连后顶部「抓分」角标立即正确 | +| mustcard | 数组 | **本轮跟牌的必出牌**(design §5.2)。**仅当 `playproc.currseat == 请求者座位` 时才有**,且只算请求者自己的手牌;未轮到本家、本轮首家、甩牌局面、必出牌为空时均无此属性 | **Balance(不在结算阶段无此属性)** diff --git a/server/games/erqiwang/mod.js b/server/games/erqiwang/mod.js index 0d44f7d..1f7cd60 100644 --- a/server/games/erqiwang/mod.js +++ b/server/games/erqiwang/mod.js @@ -160,6 +160,8 @@ youle_erqiwang.jiaofen = function(pack){ msg.data.ancard3s = 1; } var _bottomcards = o_paiju.method.get_bottomcards(); + //当前抓分倍数(design §7.2.0):此刻捡分为 0,即大光×3;供客户端顶部「抓分」角标显示 + msg.data.curmultiple = o_paiju.method.get_curmultiple(); for (var i = 0; i < o_room.seatlist.length; i++) { msg.conmode = o_room.seatlist[i].conmode; @@ -467,6 +469,8 @@ youle_erqiwang.chupai = function(pack){ } msg.data.nextseat = o_paiju.playproc.currseat; msg.data.countdown = o_desk.method.get_countdown_chupai(); + //当前抓分倍数(design §7.2.0):随本包一起下发、不另开推送;三家同值,grade 本就公开 + msg.data.curmultiple = o_paiju.method.get_curmultiple(); if (re.shuaicuo){ msg.data.shuaicuo = 1; //甩错:本次甩牌被收回,只打出了最小一张(§5.4.5) } @@ -504,6 +508,11 @@ youle_erqiwang.chupai = function(pack){ } if (o_paiju.step == 5){ + //下一个出牌者本轮跟牌的必出牌(design §5.2),供其客户端自动选中。 + //【只发给 nextseat 一家】——mustcard 是该玩家自己手牌的子集,发给他本人不泄露; + //整表下发三家会把另两家的手牌结构透露出去(server 红线:发全 ≠ 发多,按可见性下发)。 + var _nextseat = o_paiju.playproc.currseat; + var _mustcard = o_paiju.method.get_mustcard(_nextseat); for (var i = 0; i < o_room.seatlist.length; i++){ msg.conmode = o_room.seatlist[i].conmode; msg.fromid = o_room.seatlist[i].fromid; @@ -512,6 +521,11 @@ youle_erqiwang.chupai = function(pack){ } else { delete msg.data.cardsinhand; } + if (i == _nextseat && _mustcard){ + msg.data.mustcard = _mustcard; + } else { + delete msg.data.mustcard; + } youle_erqiwang.app.SendPack(msg); } } else if (o_paiju.step == 6){ diff --git a/server/games/erqiwang/test/test_hint.js b/server/games/erqiwang/test/test_hint.js new file mode 100644 index 0000000..907ecba --- /dev/null +++ b/server/games/erqiwang/test/test_hint.js @@ -0,0 +1,191 @@ +// S-1 curmultiple(当前抓分倍数,design §7.2.0)与 S-2 mustcard(跟牌必出牌,design §5.2) +// 两者都是「随现有包下发、供客户端显示/自动选中」的辅助字段: +// curmultiple —— 三家同值(grade 本就公开),随 shangzhuang / chupai1-3 / PushCards 下发 +// mustcard —— 【只发给 nextseat 一家】,是该玩家自己手牌的子集;整表下发会泄露他家手牌结构 +// 除正面用例外,本文件重点覆盖:可见性(谁不该有)、边界(何时不该有)、与结算的一致性。 +const R = require('./_rpc.js'); const mod = R.mod, setup = R.setup, make108 = R.make108, id = R.id; +const P = global.cls_youle_erqiwang_paiju, A = global.cls_youle_erqiwang_arith; +const E = require('../class.export.js').new(); +const t = require('./_assert')(); +const pack = (s, d) => ({ conmode: 0, fromid: s, data: Object.assign({ agentid: 1, playerid: s, gameid: 1, roomcode: 1, seat: s }, d || {}) }); + +// ============================================================================ +// S-1 curmultiple +// ============================================================================ + +// —— 算法层:与 design §7.2.0 判定表逐档对齐(常规算子 Q=40)—— +const cm = (call, grade, climb) => { + const q = A.get_qvalue(call, climb ? 1 : 0); + const u = A.get_upgrade(call, grade, q); + return u < 0 ? -u : u; +}; +t.eq('curmultiple 65分 捡0 → 大光x3', cm(65, 0), 3); +t.eq('curmultiple 65分 捡35 → 小光x2', cm(65, 35), 2); +t.eq('curmultiple 65分 捡40 → 过庄x1', cm(65, 40), 1); +t.eq('curmultiple 65分 捡65 → 升1级x1', cm(65, 65), 1); +t.eq('curmultiple 65分 捡105 → 升2级x2', cm(65, 105), 2); +t.eq('curmultiple 65分 捡145 → 升3级x3', cm(65, 145), 3); +// 爬坡:40分档 Q=20,分界线与常规不同(design §7.3.2) +t.eq('curmultiple 爬坡40分 捡15 → 小光x2', cm(40, 15, true), 2); +t.eq('curmultiple 爬坡40分 捡20 → 过庄x1', cm(40, 20, true), 1); +t.eq('curmultiple 爬坡40分 捡60 → 升2级x2', cm(40, 60, true), 2); + +// —— 端到端:走完整流程,验证包里带且值正确 —— +function driveToPlay(roomtype) { + const pj = P.new({ paiju_list: [] }, 0); + const c = setup(roomtype, pj); + mod.jiaofen(pack(0, { call: 65 })); mod.jiaofen(pack(1, { call: 0 })); mod.jiaofen(pack(2, { call: 0 })); + mod.xuanzhu(pack(0, { flower: 1 })); + mod.maipai(pack(0, { cards: P.get_seat_cards(pj, 0).slice(0, 8) })); + return Object.assign({ pj }, c); +} +const e = driveToPlay("00000"); +// shangzhuang:叫分刚定、捡分为 0 → 大光 x3 +const szPk = e.sent.filter(m => m.rpc === 'shangzhuang')[0]; +t.eq('shangzhuang 带curmultiple', szPk && szPk.data.curmultiple !== undefined, true); +t.eq('shangzhuang curmultiple=3(此刻大光)', szPk && szPk.data.curmultiple, 3); +// 出牌包:三家同值 +e.sent.length = 0; +mod.chupai(pack(0, { cards: [P.get_seat_cards(e.pj, 0)[0]] })); +const cp1 = e.sent.filter(m => m.rpc === 'chupai1'); +t.eq('chupai1 下发给三家', cp1.length, 3); +t.eq('chupai1 都带curmultiple', cp1.every(m => m.data.curmultiple !== undefined), true); +t.eq('chupai1 三家curmultiple同值', new Set(cp1.map(m => m.data.curmultiple)).size, 1); +// 重连 PushCards 也带,且与 chupai 包同值 +const pcNow = E.get_deskinfo(e.o_room, 1).PushCards; +t.eq('重连 PushCards 带curmultiple', pcNow.curmultiple !== undefined, true); +t.eq('重连 PushCards curmultiple 与出牌包一致', pcNow.curmultiple, cp1[0].data.curmultiple); + +// —— 边界:叫分未定时为 0 —— +const pjNoCall = P.new({ paiju_list: [] }, 0); +setup("00000", pjNoCall); +t.eq('叫分未定 curmultiple=0', P.get_curmultiple(pjNoCall), 0); + +// —— 一致性:curmultiple 与结算 aset.upgrade 同源 —— +// 构造:庄家 0 叫 65、闲家一分未捡 → 局末应为大光,|upgrade| 须等于出牌过程中的 curmultiple +{ + const pj = P.new({ paiju_list: [] }, 0); + const c = setup("00000", pj); + mod.jiaofen(pack(0, { call: 65 })); mod.jiaofen(pack(1, { call: 0 })); mod.jiaofen(pack(2, { call: 0 })); + mod.xuanzhu(pack(0, { flower: 1 })); + mod.maipai(pack(0, { cards: P.get_seat_cards(pj, 0).slice(0, 8) })); + const before = pj.method.get_curmultiple(); + const q = A.get_qvalue(pj.call, 0); + const u = A.get_upgrade(pj.call, P.get_jian_grade(pj).grade, q); + t.eq('curmultiple 与 get_upgrade 绝对值一致', before, u < 0 ? -u : u); +} + +// ============================================================================ +// S-2 mustcard +// ============================================================================ + +// 构造一个「跟牌方有必出牌」的确定场景: +// 主花色 ♦(1)。庄家 0 首出 ♥ 对子;闲家 1 手上恰有一对 ♥ → 必须打出该对(design §5.2) +function mkFollow() { + const pj = { + idx: 1, step: 5, banker: 0, call: 65, flower: 1, callproc: [], + seatlist: [[[0, 0], [0, 0], [0, 0], [0, 0], [-1, -1]], [[0, 0], [0, 0], [0, 0], [0, 0], [-1, -1]], [[0, 0], [0, 0], [0, 0], [0, 0], [-1, -1]]], + playproc: {}, cards: make108() + }; + for (let i = 0; i < 108; i++) pj.cards[i].dealowner = -1; + const give = (cid, s) => { pj.cards[cid].dealowner = s + 1; pj.cards[cid].playround = -1; }; + give(id(1, 3, 9), 0); give(id(2, 3, 9), 0); // 庄家:♥9 对(首出) + give(id(1, 3, 10), 1); give(id(2, 3, 10), 1); // 闲1:♥10 对 ← 必出 + // 闲2:♣8 对 + ♣9 对共 4 张(缺♥)。必须给够 4 张——若只给 2 张,手牌数恰等于首家张数, + // get_followcard 会判「全是必出」(这是对的),就测不出「缺门方可自由垫」这个点了 + give(id(1, 2, 8), 2); give(id(2, 2, 8), 2); give(id(1, 2, 9), 2); give(id(2, 2, 9), 2); + pj.method = { + check_cards_valid: c => P.check_cards_valid(pj, c), + check_cards_inhand: (c, s) => P.check_cards_inhand(pj, c, s), + do_playcard: c => P.do_playcard(pj, c), + get_seat_cards: s => P.get_seat_cards(pj, s), + get_jian_grade: () => P.get_jian_grade(pj), + get_burycard: () => P.get_burycard(pj), + get_liangpai: () => null, + have_baofu: () => false, + get_curmultiple: () => P.get_curmultiple(pj), + get_mustcard: s => P.get_mustcard(pj, s) + }; + P.new_playround(pj, 1, 0); + return pj; +} +const f = mkFollow(); const fc = setup("00000", f); +// 首家出牌前:本轮无人出牌 → 谁都没有必出牌 +t.eq('本轮未开始 首家无mustcard', P.get_mustcard(f, 0), null); +mod.chupai(pack(0, { cards: [id(1, 3, 9), id(2, 3, 9)] })); // 庄家首出 ♥9 对 +const fp = fc.sent.filter(m => m.rpc === 'chupai1'); +t.eq('chupai1 下发给三家', fp.length, 3); +const toNext = fp.find(m => m.fromid === 1); // nextseat = 1 +const toOther = fp.filter(m => m.fromid !== 1); +// 正面:nextseat 收到 mustcard,且正是那对 ♥10 +t.eq('mustcard 只有nextseat收到', Array.isArray(toNext.data.mustcard), true); +t.eq('mustcard = 闲1的♥10对', toNext.data.mustcard.slice().sort((a, b) => a - b), + [id(1, 3, 10), id(2, 3, 10)].sort((a, b) => a - b)); +// 可见性(防泄露):另外两家一律没有该字段 +t.eq('mustcard 其他两家都没有', toOther.every(m => m.data.mustcard === undefined), true); +// 反面:mustcard 必须是收包者自己的牌,不得含他家手牌 +t.eq('mustcard 全部属于nextseat自己', toNext.data.mustcard.every(cid => f.cards[cid].dealowner === 2), true); +// 首家自己那一包也不该有(他已经出完了) +t.eq('mustcard 出牌者本人无', fp.find(m => m.fromid === 0).data.mustcard, undefined); + +// 边界:缺门且手牌有富余(闲2 有 4 张 ♣、首家出 2 张)→ 可自由垫,无必出牌 +t.eq('缺门且手牌有富余 无mustcard', P.get_mustcard(f, 2), null); +// 把桩推进到「庄家已首出 ♥9 对、轮到跟牌」的状态。 +// 直接设 playproc 而不走 mod.chupai,是为了让下面每个边界用例【只变动一个因素】—— +// 否则 startcount 仍是 -1,前置守卫就会让所有用例都返回 null 而「假通过」,测不到被测因素。 +function asFollowing(s, currseat) { + s.playproc.start = 0; + s.playproc.currseat = currseat; + s.playproc.startcount = 2; + s.playproc.startflower = 3; // ♥ + s.playproc.starttype = 201; // 一对 + return s; +} +// 先自证:推进后确实能算出必出牌(否则下面的 null 断言仍可能是假通过) +{ + const s = asFollowing(mkFollow(), 1); + setup("00000", s); + t.eq('自证:推进到跟牌态后 闲1有mustcard', Array.isArray(P.get_mustcard(s, 1)), true); +} +// 对照:手牌数恰等于首家张数时,整手都是必出(没有任何选择余地) +{ + const s = asFollowing(mkFollow(), 2); + setup("00000", s); + s.cards[id(1, 2, 9)].dealowner = -1; s.cards[id(2, 2, 9)].dealowner = -1; // 把闲2 削回 2 张 + t.eq('手牌数恰等于首家张数 整手必出', P.get_mustcard(s, 2).slice().sort((a, b) => a - b), + [id(1, 2, 8), id(2, 2, 8)].sort((a, b) => a - b)); +} + +// 边界:甩牌不下发 mustcard(甩牌跟牌走 flush_follow_ok 的逐分量匹配,见 design §5.4.4) +// 与上面的「自证」用例只差 shuai_demand 一个因素 +{ + const s = asFollowing(mkFollow(), 1); + setup("00000", s); + s.playproc.shuai_demand = { tractors: [], pairs: 1, singles: 1 }; + t.eq('甩牌局面 不下发mustcard', P.get_mustcard(s, 1), null); +} + +// 边界:非出牌阶段不下发(与「自证」用例只差 step 一个因素) +{ + const s = asFollowing(mkFollow(), 1); + setup("00000", s); + s.step = 3; + t.eq('非出牌阶段 不下发mustcard', P.get_mustcard(s, 1), null); +} + +// 边界:首家自己没有必出牌(与「自证」用例只差 seat 一个因素) +{ + const s = asFollowing(mkFollow(), 0); + setup("00000", s); + t.eq('本轮首家 无mustcard', P.get_mustcard(s, 0), null); +} + +// 重连 PushCards:轮到本座位才有 mustcard,其他座位没有 +{ + const pcNext = E.get_deskinfo(fc.o_room, 1).PushCards; // 当前轮到 1 + const pcIdle = E.get_deskinfo(fc.o_room, 2).PushCards; + t.eq('重连 轮到本家 PushCards有mustcard', Array.isArray(pcNext.mustcard), true); + t.eq('重连 未轮到 PushCards无mustcard', pcIdle.mustcard, undefined); +} + +t.done('hint'); diff --git a/server/games/erqiwang/test/test_input.js b/server/games/erqiwang/test/test_input.js index c490edb..0745ff1 100644 --- a/server/games/erqiwang/test/test_input.js +++ b/server/games/erqiwang/test/test_input.js @@ -21,7 +21,9 @@ function newPaiju(step, banker, owner) { get_seat_cards: s => P.get_seat_cards(paiju, s), get_seat_zhucards: s => P.get_seat_zhucards(paiju, s), have_baofu: () => (paiju.seatlist[0][4][0] == 0 || paiju.seatlist[1][4][0] == 0 || paiju.seatlist[2][4][0] == 0), - get_liangpai: () => null + get_liangpai: () => null, + get_curmultiple: () => P.get_curmultiple(paiju), + get_mustcard: s => P.get_mustcard(paiju, s) }; return paiju; } diff --git a/server/games/erqiwang/test/test_leak.js b/server/games/erqiwang/test/test_leak.js index 9dac2b3..5282314 100644 --- a/server/games/erqiwang/test/test_leak.js +++ b/server/games/erqiwang/test/test_leak.js @@ -14,7 +14,7 @@ const pk = (s, d) => ({ conmode: 0, fromid: s, data: Object.assign({ agentid: 1, // 深度收集一个对象里所有「看起来是牌 id」的整数(0~107)。 // 为避免把 seat/count/grade 之类误当牌 id,只扫描已知承载牌 id 的字段名。 -const CARD_FIELDS = ['cards', 'bottomcards', 'burycards', 'cardsinhand', 'zhucards', 'gradecards', 'pushlist', 'MyCards']; +const CARD_FIELDS = ['cards', 'bottomcards', 'burycards', 'cardsinhand', 'zhucards', 'gradecards', 'pushlist', 'MyCards', 'mustcard']; function collectCards(node, key, out) { if (node === null || node === undefined) return; if (Array.isArray(node)) { node.forEach(x => collectCards(x, key, out)); return; } diff --git a/server/games/erqiwang/test/test_rpc.js b/server/games/erqiwang/test/test_rpc.js index fa5ccbc..521a3a7 100644 --- a/server/games/erqiwang/test/test_rpc.js +++ b/server/games/erqiwang/test/test_rpc.js @@ -184,7 +184,9 @@ function mkShuai(extraToSeat1) { check_cards_inhand: (c, s) => P.check_cards_inhand(pj, c, s), do_playcard: c => P.do_playcard(pj, c), get_seat_cards: s => P.get_seat_cards(pj, s), - have_baofu: () => (pj.seatlist[0][4][0] == 0 || pj.seatlist[1][4][0] == 0 || pj.seatlist[2][4][0] == 0) + have_baofu: () => (pj.seatlist[0][4][0] == 0 || pj.seatlist[1][4][0] == 0 || pj.seatlist[2][4][0] == 0), + get_curmultiple: () => P.get_curmultiple(pj), + get_mustcard: s => P.get_mustcard(pj, s) }; P.new_playround(pj, 1, 0); return pj; @@ -238,7 +240,9 @@ function mkPushlist() { get_seat_cards: s => P.get_seat_cards(pj, s), get_jian_grade: () => P.get_jian_grade(pj), get_burycard: () => P.get_burycard(pj), - get_liangpai: () => null + get_liangpai: () => null, + get_curmultiple: () => P.get_curmultiple(pj), + get_mustcard: s => P.get_mustcard(pj, s) }; return { pj, zhu5, fuA, cur }; }