二七王:实现 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>
This commit is contained in:
+29
-41
@@ -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`,合法性最终由服务端校验。
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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(不在结算阶段无此属性)**
|
||||
|
||||
|
||||
@@ -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){
|
||||
|
||||
@@ -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');
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user