feat: integrate platform room entry, UI migration and erqiwang documentation

This commit is contained in:
2026-09-07 21:01:08 +08:00
parent 9c4510697c
commit 30a1b4114c
259 changed files with 84995 additions and 30201 deletions
+62
View File
@@ -0,0 +1,62 @@
# 二七王服务端单元测试
## 运行
```bash
node server/games/erqiwang/test/run.js # 跑全部单测
node server/games/erqiwang/test/test_arith.js # 单独跑某一组
```
退出码 0 表示全过,非 0 表示有失败;`run.js` 逐个以独立进程 spawn 各 `test_*.js` 并汇总。
## 原理:一套代码,两个运行时(dev-guide 01 §1)
正式代码(`class.*.js`)遵循平台的双运行时规范:
- **友乐 / 浏览器**:无 `require`,由 `mod.js` 用 `min_loadJsFile` 加载为**全局对象**;文件顶部的 `if (typeof require !== 'undefined')` 守卫被跳过,跨模块按**同名全局**引用。
- **Node(本地 / 单元测试)**:有 `require`,守卫块 `var X = require('./class.X.js')` 取到依赖;文件底部 `if (typeof module !== 'undefined') module.exports = ...` 导出。
因此这两处守卫是**纯增量**:友乐运行时行为完全不变,只在 Node 下额外启用 require/exports。
## `_shim.js`:平台全局垫片(仅测试用,不进正式代码)
正式代码按全局名引用少量平台工具(`min_ary_include/min_ary_deduct/min_random/min_now/min_ontimeout`)与模块入口 `youle_erqiwang.import`。友乐由平台提供,Node 单测由 `test/_shim.js` 提供等价实现(数组/随机工具忠实复刻 `server/minhttp.js`,`youle_erqiwang.import` 用 mock)。测试文件顶部先 `require('./_shim')` 再 `require('../class.*.js')`。
## 测试计划
完整的"规则 → 用例"覆盖矩阵、缺口清单与优先级见 [`../docs/compliance/02-测试计划.md`](../docs/compliance/02-测试计划.md)。
## 覆盖
- `test_arith.js`:主牌排序/相邻链(§3)、扣底倍数(§6.3)、算子常规+爬坡逐档+分界+升3级(§7)、算奖 get_chongguan(§8.1)、甩牌分解/最大性/合法性(§5.4)、跟甩牌强制分量拆解(§5.4.4)。
- `test_follow.js`:正常跟牌/毙牌/垫牌,单张/对子/拖拉机的必出与缺门毙牌数量对应(§5.1/5.2)。
- `test_callgrade.js`:叫分坐庄 do_callgrade(叫5立即/两家不叫/后叫更低)(§4.2)。
- `test_config.js`:roomtype 位串解析、局数/扣卡映射(§10.1)。
- `test_paiju.js`:亮牌 get_liangpai(§8.2,含「埋牌后静态快照、出牌后重连不缩水」回归)、结算 get_paiju_account 集成(§7/§8,大光/过庄/升级/爬坡/傍王/投降)、扣底触发 get_bottom_account(§6.3)。
- `test_deal.js`:发牌构成——92张/去3-4/三家28+底8/分值(§2/§6.1)。
- `test_desk.js`:坐庄轮换 do_prepare(庄赢连庄/闲赢·投降下家)(§4.6)。
- `test_endgame.js`(L4 端到端,用真实发牌跑完整一局):叫分→上庄→选主→埋牌→28 轮出牌→小局结算的流程闭合(§12.1),以及与牌面无关的不变量(牌张守恒 84/8/16、捡分=闲家赢得分牌+扣底分、`X` 分配庄±2X/闲∓X、算奖 `X×(2Ni−Nj−Nk)`、结算零和、`result` 与判定一致);另覆盖 §4.4/§7.1 投降局与 §12.2 中途解散两条支线。
- `test_rpc.js`(L3,用 `_rpc.js` 装配 mod.js + 捕获下发包):明牌 mingpai、出牌 chupai 的 seatlist/baozhu 查牌门控(§9,含「seatlist 整表带三家」)、投降 touxiang 条件、jiaofen 入参校验、70分上庄暗牌亮牌下发(§4)。
### 两组差分测试(按 design 原文另写参考实现,与代码对拍)
下面两组与上面的手写用例互补:它们不枚举"我想到的场景",而是**把 design 的规则整条转写成参考实现**,再和代码大面积比对,专治手写用例覆盖不到的欠约束分支。
- `test_calc.js`:**§7 算子全量对拍**。参考实现逐字转写自 §7.1/§7.2/§7.3 的判定表,与 `get_base_bycall` / `get_qvalue` / `get_upgrade` 比对 **2 种算子模式 × 14 个叫分档 × grade 0~260 = 7308 格**(每格比 base/Q/判定倍率/最终子数四项),另按 design 的 17 张表逐格抽查 141 个"写死的最终子数"。断言按(模式,叫分档)与表名分组,失败能直接定位到档位。
- `test_followdiff.js`:**§5.2 跟牌强制层级穷举差分**。参考裁定按 §5.2 原文写(缺门/不足/够牌 × 单张/对子/N 连对 + 最长拖拉机优先的档案比较),对每手牌**枚举全部 C 张出牌组合**逐个比对"参考允许 vs `can_followcard` 允许",任何一侧多放行或多拒绝都会被抓到(约 11 万组)。
- `test_flow.js`:**阶段机迁移矩阵 + 叫分起始者**(design §4.2/§4.6/§12.1)。① 8 个 RPC × 5 个阶段 = **40 格**逐格验证「该受理的受理、不该受理的一律回 `STEP` 失败包」——此前每个 handler 只测了 1~2 条代表性失败路径,没系统覆盖「某请求在别的阶段会不会被误放行 / 会不会静默丢弃」;断言里带 `cells === 40`,防止驱动失败导致整行"跳过"而假绿。② 第一局起始叫分者 = 座位 0、之后每局 = 暂定庄家(**庄赢连庄 / 庄输下家**)、每局首家都不能"不叫";连庄分支(`result=0`)随机打不出来,用「叫 70 分 + 首家出最大牌」专门构造。
- `test_leak.js`:**下发面泄露审计**(server 红线「发全 ≠ 发多,按可见性下发」+ design §4/§9/§11)。跑 6 局(可查牌/不查牌 × 叫 65/70/5),对每一个「服务器 → 某座位」的下发面——含各 RPC 的逐座位包、各阶段重连快照、明牌应答——深度扫描出其中所有牌 id,逐个判定该座位此刻**是否有权知道这张牌**(自己的牌 / 已打出 / 庄家的暗牌与底牌 / 70 分亮 3 秒 / 结束亮底牌 / §9 明牌)。约 1800 个下发面、1.8 万次判定。此前各处门控只有逐条手工核对,没有系统验证。
- `test_fuzz.js`:**整局模糊 + 每墩胜者差分**。`test_endgame.js` 的驱动器只会出单张,所以对子/拖拉机/甩牌/甩错在「整局链路」里从未跑过——单测逐个覆盖了 `can_playcard`/`can_followcard`,但它们与 `do_playcard`/`mod.chupai`/结算的**集成**是空白。本文件用带牌型的对局补上(20 局约 480 墩,四种房间选项组合轮换),并对**每一墩**用独立参考实现重算"谁最大"与服务端比对(不是抽查,每墩都比),另独立重算闲家捡分、扣底倍数、算奖与捡分子数的分配。提交时还会把牌按**最坏顺序**摆(`[K,K,Q,Q]→[K,Q,K,Q]`,把每个对子拆开),压服务端判定的入参顺序无关性。
三组都做过**变异检验**(在副本上注入缺陷确认会转红):关掉 `follow_tractor_cover_ok` → `test_followdiff` 三种拖拉机首出全红;大光倍率改 3→4 / 常规 55 档 base 改 4→5 / 爬坡 40·35 档 Q 改 20→40 → `test_calc` 精确指出档位与 grade;牌的归属写给非胜者、单张毙牌不再压过副牌、算奖分配去掉 `×X`、跟牌牌面值改回未排序入参 → `test_fuzz` 全部转红。
改这三个文件时别破坏的写法约定:
- **随机源必须是固定种子的 PRNG**,不用 `Math.random`——同一份代码每次跑的用例集完全相同。`test_fuzz.js` 连**发牌**也接到同一个种子上:它末尾的覆盖下限(甩牌/毙牌/扣底各出现多少次)是发牌相关的,用随机发牌时稀有事件会在某些运行里一次都不出现而让断言随机转红(实测 60 次里有 1 次 0 次扣底)。随机发牌的整局覆盖由 `test_endgame.js` 承担,两者互补。
- **关键结构要用确定性手牌钉死**,不能指望随机造牌覆盖到。例如「同花色三组互不相邻的两连对」(12 张)是唯一能区分"只出零散对子"与"先凑最长拖拉机"的形状(少于 3 组时任取 3 对必含相邻对,测不出差别);纯随机造牌造不出它,关掉覆盖度校验也全绿,差分就形同虚设。`structHands()` 就是为此存在的,另注意别让掺入的其他花色把手牌撑过枚举上限而被静默跳过。
- **不变量不能拿被测字段自证**。`test_fuzz` 里踩过两次:① 闲家捡分若按 `playowner` 累加再与 `aset.grade` 比,`playowner` 写错时两边一起错、断言照样通过——改成按**独立算出的墩胜者**累计;② 扣底倍数若再调 `get_bottom_multiple` 去比,倍数表被改成恒返回 2 也通过——改成按 design §6.3 独立重算。
## 约定(dev-guide §10 测试纪律)
- 测试代码可用现代 JS(不受严格 ES5 限制);**正式代码不得为测试放宽/加后门**。
- 正反面 + 边界都要覆盖;失败先裁根因(业务缺陷改业务、脚本缺陷改脚本),不软化断言。
+16
View File
@@ -0,0 +1,16 @@
// 极简断言助手(测试代码不受严格 ES5 限制,dev-guide 05/04 §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 };
};
+56
View File
@@ -0,0 +1,56 @@
// L3 RPC 测试脚手架:装配 mod.js(含 RPC 方法),捕获下发包
require('./_shim');
// mod.js 的 RPC handler 按全局名引用这些类,先挂到 global
global.cls_youle_erqiwang_config = require('../class.config.js');
global.cls_youle_erqiwang_pai = require('../class.pai.js');
global.cls_youle_erqiwang_arith = require('../class.arith.js');
global.cls_youle_erqiwang_paiju = require('../class.paiju.js');
global.cls_youle_erqiwang_desk = require('../class.desk.js');
// mod.js 顶部依赖:cls_mod.new 造模块对象、min_loadJsFile 置为同步 no-op(类已单独 require)
global.cls_mod = { new: (name, route, app) => ({ modname: name, routename: route, app }) };
global.youle_app = {};
global.min_loadJsFile = (p, cb) => { if (cb) cb(); };
const mod = require('../mod.js'); // = youle_erqiwang,含 jiaofen/xuanzhu/.../mingpai/zhunbei
const clone = m => JSON.parse(JSON.stringify(m));
// 装配一局:给定 roomtype 与 o_paiju,返回 { sent, o_room },并把 check_player/SendPack/sendpack_toother
// 全部指向同一个 sent 数组(快照每个下发包,因 mod 会跨座位改删字段)
function setup(roomtype, o_paiju, seatlist) {
const sent = [];
const o_desk = {
paiju_list: [o_paiju],
o_room: null,
seatlist: [[0, [], 0, 0, 0], [0, [], 0, 0, 0], [0, [], 0, 0, 0]],
prepare: [0, 0, 0],
method: {
curr_paiju: () => o_paiju,
get_countdown_jiaofen: () => 15, get_countdown_xuanzhu: () => 20,
get_countdown_maipai: () => 25, get_countdown_chupai: () => 30,
do_prepare: () => { }, get_desk_account: m => m
}
};
const o_room = {
roomtype, asetcount: 6,
seatlist: seatlist || [{ conmode: 0, fromid: 0 }, { conmode: 0, fromid: 1 }, { conmode: 0, fromid: 2 }],
o_desk,
method: { sendpack_toother: m => sent.push(clone(m)) }
};
o_desk.o_room = o_room;
if (o_paiju) o_paiju.o_desk = o_desk;
mod.import = { check_player: () => o_room, deduct_roomcard: () => { }, save_grade: () => { } };
mod.app = { SendPack: m => sent.push(clone(m)) };
return { sent, o_room, o_desk };
}
// 构造 108 张牌(带 flower/number/dealowner/playround),供 get_seat_zhucards / get_seat_cards 用
function make108(deal) {
const A = global.cls_youle_erqiwang_arith;
const cards = [];
for (let i = 0; i < 108; i++) cards[i] = { id: i, dealowner: 1, playround: -1, playowner: -1, score: 0, flower: A.id_to_flower(i), number: A.id_to_number(i) };
if (deal) for (const [cid, owner] of deal) cards[cid].dealowner = owner;
return cards;
}
module.exports = { mod, setup, make108, id: (d, f, n) => (d - 1) * 54 + (f - 1) * 13 + (n - 1) };
+26
View File
@@ -0,0 +1,26 @@
// Node 单测环境:提供正式代码按全局名引用的平台工具(不进正式代码,dev-guide 01 §1)
global.min_ary_include = function(aryparent, arychild){
for (var i = 0; i < arychild.length; i++){
var found = false;
for (var j = 0; j < aryparent.length; j++){ if (aryparent[j] == arychild[i]){ found = true; break; } }
if (!found){ return false; }
}
return true;
};
global.min_ary_deduct = function(aryparent, arychild){
var re = [];
for (var i = 0; i < aryparent.length; i++){
var found = false;
for (var j = 0; j < arychild.length; j++){ if (aryparent[i] == arychild[j]){ found = true; break; } }
if (!found){ re.push(aryparent[i]); }
}
return re;
};
global.min_random = function(min, max){ return min + Math.round(Math.random() * (max - min)); };
global.min_now = function(ts){ return ts ? ts : Date.now(); };
global.min_ontimeout = function(func, time){ /* 单测不真正异步执行 */ };
global.youle_erqiwang = global.youle_erqiwang || {
import: { check_player: function(){ return null; }, deduct_roomcard: function(){}, save_grade: function(){} },
app: { SendPack: function(){} }
};
module.exports = {};
+16
View File
@@ -0,0 +1,16 @@
// 二七王服务端单测总运行器:逐个 spawn test_*.js(各自独立进程/全局),汇总结果
// 用法:node server/games/erqiwang/test/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);
+287
View File
@@ -0,0 +1,287 @@
// arith 纯算法单测:扣底(§6.3) / 算子(§7) / 算奖(§8.1) / 甩牌(§5.4) / 跟甩牌(§5.4.4)
require('./_shim');
const A = require('../class.arith.js');
const t = require('./_assert')();
// 牌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; // 小王
const MF = 1; // 主花色=方块(1)
// ============ §6.3 扣底倍数 get_bottom_multiple ============
const mult = cards => A.get_bottom_multiple(MF, cards);
t.eq('扣底 单张主A', mult([id(1, 1, 1)]), 1);
t.eq('扣底 单张正7', mult([id(1, 1, 7)]), 1);
t.eq('扣底 主对K', mult([id(1, 1, 13), id(2, 1, 13)]), 2);
t.eq('扣底 两连对KQ', mult([id(1, 1, 13), id(2, 1, 13), id(1, 1, 12), id(2, 1, 12)]), 4);
t.eq('扣底 三连对KQJ', mult([id(1, 1, 13), id(2, 1, 13), id(1, 1, 12), id(2, 1, 12), id(1, 1, 11), id(2, 1, 11)]), 6);
t.eq('扣底 单张副A(非主)', mult([id(1, 2, 1)]), 0);
t.eq('扣底 小王对', mult([small(1), small(2)]), 2);
t.eq('扣底 8-6连对', mult([id(1, 1, 8), id(2, 1, 8), id(1, 1, 6), id(2, 1, 6)]), 4);
t.eq('扣底 两连对+单(取最高规格)', mult([id(1, 1, 13), id(2, 1, 13), id(1, 1, 12), id(2, 1, 12), id(1, 1, 9)]), 4);
// ============ §7 算子:判定标签 + 每对子子数 X ============
function judge(call, grade, climb) {
const base = A.get_base_bycall(call, climb);
const q = A.get_qvalue(call, climb);
const upg = A.get_upgrade(call, grade, q);
const X = Math.abs(upg) * base;
let label = upg === 3 ? '大光' : upg === 2 ? '小光' : upg === 1 ? '过庄' : upg < 0 ? '升' + (-upg) + '级' : '?';
return label + '/' + X;
}
// 常规算子 (design §7.2.1)
t.eq('常规65 大光', judge(65, 0, false), '大光/6');
t.eq('常规65 小光', judge(65, 20, false), '小光/4');
t.eq('常规65 过庄', judge(65, 50, false), '过庄/2');
t.eq('常规65 升1', judge(65, 65, false), '升1级/2');
t.eq('常规65 升2', judge(65, 105, false), '升2级/4');
t.eq('常规60 大光', judge(60, 0, false), '大光/9');
t.eq('常规55 大光', judge(55, 0, false), '大光/12');
t.eq('常规50 大光', judge(50, 0, false), '大光/18');
t.eq('常规45 过庄', judge(45, 44, false), '过庄/6');
t.eq('常规40 grade20 小光(无过庄)', judge(40, 20, false), '小光/12');
t.eq('常规40 升1', judge(40, 40, false), '升1级/6');
t.eq('常规10 grade5 小光', judge(10, 5, false), '小光/12');
t.eq('常规10 升1', judge(10, 10, false), '升1级/6');
t.eq('常规5 大光', judge(5, 0, false), '大光/18');
t.eq('常规5 升1(无小光)', judge(5, 5, false), '升1级/6');
t.eq('常规70打牌 大光', judge(70, 0, false), '大光/6');
t.eq('常规70打牌 过庄', judge(70, 69, false), '过庄/2');
// 爬坡 (design §7.3.3)
t.eq('爬坡40 大光', judge(40, 0, true), '大光/24');
t.eq('爬坡40 小光', judge(40, 19, true), '小光/16');
t.eq('爬坡40 过庄', judge(40, 20, true), '过庄/8');
t.eq('爬坡40 升1', judge(40, 40, true), '升1级/8');
t.eq('爬坡40 升2', judge(40, 60, true), '升2级/16');
t.eq('爬坡30 过庄', judge(30, 15, true), '过庄/10');
t.eq('爬坡20 升1', judge(20, 20, true), '升1级/12');
t.eq('爬坡10 过庄(无小光)', judge(10, 5, true), '过庄/14');
t.eq('爬坡10 升1', judge(10, 10, true), '升1级/14');
t.eq('爬坡5 大光', judge(5, 0, true), '大光/45');
t.eq('爬坡5 升1(无小光过庄)', judge(5, 5, true), '升1级/15');
t.eq('爬坡45 过庄', judge(45, 44, true), '过庄/7');
// ============ §8.1 算奖 get_chongguan ============
function fill28(req) {
const used = new Set(req), out = req.slice(), NN = [1, 5, 6, 8, 9, 10, 11, 12, 13];
for (const f of [1, 2, 3, 4]) for (const n of NN) {
if (out.length >= 28) break;
const c = id(1, f, n);
if (!used.has(c)) { used.add(c); out.push(c); }
}
return out;
}
const cg = (req, key) => A.get_chongguan(MF, fill28(req))[key];
t.eq('算奖 3王 count', cg([big(1), small(1), small(2)], 'count'), 1);
t.eq('算奖 3王 wang', cg([big(1), small(1), small(2)], 'wang'), 3);
t.eq('算奖 4王 count', cg([big(1), big(2), small(1), small(2)], 'count'), 3);
t.eq('算奖 6个7(无王)', cg([id(1, 1, 7), id(2, 1, 7), id(1, 2, 7), id(2, 2, 7), id(1, 3, 7), id(2, 3, 7)], 'count'), 1);
t.eq('算奖 8个2(无王)', cg([id(1, 1, 2), id(2, 1, 2), id(1, 2, 2), id(2, 2, 2), id(1, 3, 2), id(2, 3, 2), id(1, 4, 2), id(2, 4, 2)], 'count'), 3);
t.eq('算奖 3王+正7对+副7对(连对链)', cg([big(1), small(1), small(2), id(1, 1, 7), id(2, 1, 7), id(1, 2, 7), id(2, 2, 7)], 'count'), 3);
// ≥10固定主但互不成对 => 只3王(旧代码会误+1)
t.eq('算奖 10固定主无对无链', cg([big(1), small(1), small(2), id(1, 1, 7), id(1, 2, 7), id(1, 3, 7), id(1, 4, 7), id(1, 1, 2), id(1, 2, 2), id(1, 3, 2)], 'count'), 1);
// 连对链延伸到主A / 副花色A不算链
t.eq('算奖 连对链到主A对', cg([big(1), small(1), small(2), id(1, 1, 7), id(2, 1, 7), id(1, 2, 7), id(2, 2, 7), id(1, 1, 2), id(2, 1, 2), id(1, 2, 2), id(2, 2, 2), id(1, 1, 1), id(2, 1, 1)], 'count'), 6);
t.eq('算奖 副花色A对不续链', cg([big(1), small(1), small(2), id(1, 1, 7), id(2, 1, 7), id(1, 2, 7), id(2, 2, 7), id(1, 1, 2), id(2, 1, 2), id(1, 2, 2), id(2, 2, 2), id(1, 2, 1), id(2, 2, 1)], 'count'), 5);
// ============ shared/cards.js:牌编码区间常量 + 极大连续段扫描 ============
const S = require('../shared/cards.js');
// 常量必须与 id_to_code 的实际产物一致(常量是区间判定的 SSOT,写歪了这里就红)
const inOpen = (code, lo, hi) => code > lo && code < hi;
t.eq('常量 正7 区间', inOpen(S.id_to_code(MF, id(1, 1, 7)), S.CODE_ZHENG7_MIN, S.CODE_ZHENG7_MAX), true);
t.eq('常量 副7 区间', inOpen(S.id_to_code(MF, id(1, 2, 7)), S.CODE_FU7_MIN, S.CODE_FU7_MAX), true);
t.eq('常量 正2 区间', inOpen(S.id_to_code(MF, id(1, 1, 2)), S.CODE_ZHENG2_MIN, S.CODE_ZHENG2_MAX), true);
t.eq('常量 副2 区间', inOpen(S.id_to_code(MF, id(1, 2, 2)), S.CODE_FU2_MIN, S.CODE_FU2_MAX), true);
t.eq('常量 主花色普通牌区间', inOpen(S.id_to_code(MF, id(1, 1, 5)), S.CODE_ZHU_MIN, S.CODE_ZHU_MAX), true);
t.eq('常量 主牌判据 code>CODE_ZHU_MIN 对主花色牌成立', S.id_to_code(MF, id(1, 1, 5)) > S.CODE_ZHU_MIN, true);
t.eq('常量 主牌判据 对副牌不成立', S.id_to_code(MF, id(1, 2, 5)) > S.CODE_ZHU_MIN, false);
t.eq('常量 小王编码', S.id_to_code(MF, small(1)), S.CODE_XIAOWANG);
t.eq('常量 大王编码', S.id_to_code(MF, big(1)), S.CODE_DAWANG);
t.eq('常量 主A 的 code%100', S.id_to_code(MF, id(1, 1, 1)) % 100, S.CODE_A_IN_ZHU);
t.eq('常量 拖拉机起算对数', S.TRACTOR_MIN_PAIRS, 2);
// group_tractor_runs:极大连续段(服务端 decompose_trump 与前端手牌「拖」标记共用)
const pr = (n) => [id(1, 1, n), id(2, 1, n)]; // 主花色 n 的对子
const runLens = (pairs) => S.group_tractor_runs(MF, pairs).map(r => r.length);
t.eq('连续段 空表', S.group_tractor_runs(MF, []), []);
t.eq('连续段 单个对子是长度1的段', runLens([pr(13)]), [1]);
t.eq('连续段 KQ 连续合成一段', runLens([pr(13), pr(12)]), [2]);
t.eq('连续段 KQJ 三连对是一段(极大)', runLens([pr(13), pr(12), pr(11)]), [3]);
t.eq('连续段 K 与 9 不连 → 两段', runLens([pr(13), pr(9)]), [1, 1]);
t.eq('连续段 KQ + 9 → 2+1', runLens([pr(13), pr(12), pr(9)]), [2, 1]);
t.eq('连续段 KQ + 98 → 2+2', runLens([pr(13), pr(12), pr(9), pr(8)]), [2, 2]);
// 8-6 相连(design §5.3:7 不与 6/8 相连),且必须同花色
t.eq('连续段 主8-主6 相连', runLens([pr(8), pr(6)]), [2]);
t.eq('连续段 跨花色 主8+副6 不相连', runLens([pr(8), [id(1, 2, 6), id(2, 2, 6)]]), [1, 1]);
// 段的拼接必须恰好还原入参(不丢对子、不改顺序)
const runsIn = [pr(13), pr(12), pr(9)];
t.eq('连续段 拼接还原入参', [].concat.apply([], S.group_tractor_runs(MF, runsIn)), runsIn);
// arith 上挂的引用与 shared 同一实现
t.eq('arith 挂了 group_tractor_runs 引用', A.group_tractor_runs === S.group_tractor_runs, true);
// ============ §5.4 甩牌 decompose / 最大性 / can_playcard ============
const types = cards => A.decompose_trump(MF, cards).map(c => c.type + (c.len > 1 ? c.len : '')).sort().join(',');
t.eq('分解 大王对+主K单', types([big(1), big(2), id(1, 1, 13)]), 'pair,single');
t.eq('分解 主K主Q两连对', types([id(1, 1, 13), id(2, 1, 13), id(1, 1, 12), id(2, 1, 12)]), 'tractor2');
t.eq('分解 主K对+主9对(不连)', types([id(1, 1, 13), id(2, 1, 13), id(1, 1, 9), id(2, 1, 9)]), 'pair,pair');
// 拖拉机段与孤立对子、单张混排:分量顺序与 cards 归属都不变(改走 shared 扫描后的行为一致性)
const mixComp = A.decompose_trump(MF, [id(1, 1, 13), id(2, 1, 13), id(1, 1, 12), id(2, 1, 12), id(1, 1, 9), id(2, 1, 9), id(1, 1, 5)]);
t.eq('分解 KQ拖+9对+5单 的分量序', mixComp.map(c => c.type + '/' + c.len), ['tractor/2', 'pair/1', 'single/1']);
t.eq('分解 拖拉机分量含4张', mixComp[0].cards.length, 4);
t.eq('分解 对子分量含2张', mixComp[1].cards.length, 2);
const play = (cards, opps) => A.can_playcard(MF, cards, 0, null, opps);
const K1 = id(1, 1, 13), K2 = id(2, 1, 13), A1 = id(1, 1, 1), P5 = id(1, 1, 5), P6 = id(1, 1, 6), fK1 = id(1, 2, 13), f9 = id(1, 2, 9), fK2 = id(2, 2, 13), Q1 = id(1, 1, 12), Q2 = id(2, 1, 12);
t.eq('甩牌 合法(大王对+主K单)', play([big(1), big(2), K1], [[P5, P6], []]).result, true);
let r = play([big(1), big(2), K1], [[A1, P5], []]);
t.eq('甩牌 甩错(对手有主A>主K) shuaicuo', r.shuaicuo, true);
t.eq('甩牌 甩错 smallest=主K', r.smallest, K1);
r = play([fK1, f9], [[], []]);
t.eq('甩牌 副牌禁甩 result', r.result, false);
t.eq('甩牌 副牌禁甩 无shuaicuo', r.shuaicuo, undefined);
t.eq('甩牌 主单张(非甩)可出', play([big(1)], [[A1], [K1]]).result, true);
t.eq('甩牌 副对子(非甩)可出', play([fK1, fK2], [[], []]).result, true);
t.eq('甩牌 主拖拉机(单分量不受最大性约束)', play([K1, K2, Q1, Q2], [[big(1), big(2)], []]).result, true);
// —— 甩牌首出补全:各分量被压导致甩错 + 各牌型组合合法(design §5.4.2/§5.4.3)——
r = play([big(1), K1, K2], [[A1, id(2, 1, 1)], []]); // 大王单+主K对;对手主A对
t.eq('甩牌 甩错·主对被更大主对压', r.shuaicuo, true);
r = play([big(1), Q1, Q2, id(1, 1, 11), id(2, 1, 11)], [[A1, id(2, 1, 1), K1, K2], []]); // 大王+主QJ两连对;对手主AK两连对
t.eq('甩牌 甩错·拖拉机被同长更大主拖压', r.shuaicuo, true);
t.eq('甩牌 合法·纯双单张(大王+主K)', play([big(1), K1], [[P5], []]).result, true);
t.eq('甩牌 合法·纯双对不连(主K对+主9对)', play([K1, K2, id(1, 1, 9), id(2, 1, 9)], [[P5, P6], []]).result, true);
r = play([K1, K2, id(1, 1, 9), id(2, 1, 9)], [[id(1, 1, 11), id(2, 1, 11)], []]); // 主9对被主J对压
t.eq('甩牌 甩错·双对之一被压', r.shuaicuo, true);
t.eq('甩牌 合法·单张+拖拉机', play([big(1), K1, K2, Q1, Q2], [[P5, P6], []]).result, true);
t.eq('甩牌 合法·单+对+拖三混', play([big(1), id(1, 1, 10), id(2, 1, 10), K1, K2, Q1, Q2], [[P5, P6], []]).result, true);
t.eq('甩牌 副禁甩·对手非缺门仍禁', play([fK1, f9], [[fK2, id(1, 2, 10)], []]).result, false);
// design §5.4.3「无组合数量限制」:曾有「一次最多14张」的规则外硬上限,会把 15 张以上的
// 合法甩牌整个拒掉。16 张全固定主牌(4王 + 8个7 + 正2对 + 副2对)、两对手无主牌 → 必须可甩
const SHUAI16 = [big(1), big(2), small(1), small(2),
id(1, 1, 7), id(2, 1, 7), id(1, 2, 7), id(2, 2, 7), id(1, 3, 7), id(2, 3, 7), id(1, 4, 7), id(2, 4, 7),
id(1, 1, 2), id(2, 1, 2), id(1, 4, 2), id(2, 4, 2)];
t.eq('甩牌 16张(超14张上限)仍可甩', play(SHUAI16, [[], []]).result, true);
t.eq('甩牌 16张 有分量需求下发', !!play(SHUAI16, [[], []]).shuai_demand, true);
// ============ §5.4.4 跟甩牌强制分量拆解 flush_follow_ok ============
const J1 = id(1, 1, 11), N9a = id(1, 1, 9), N9b = id(2, 1, 9), P8 = id(1, 1, 8);
const fA = id(1, 2, 1), fB = id(1, 2, 5), fC = id(1, 2, 6), fD = id(1, 2, 8);
const ffo = (hand, follow, demand) => A.flush_follow_ok(MF, hand, follow, demand);
t.eq('跟牌 含主对KK', ffo([K1, K2, J1, N9a], [K1, K2, J1], { tractors: [], pairs: 1, singles: 1 }), true);
t.eq('跟牌 拆散主对(3散)→拒', ffo([K1, K2, J1, N9a], [J1, N9a, K1], { tractors: [], pairs: 1, singles: 1 }), false);
t.eq('跟牌 无主对散张可跟', ffo([J1, N9a, P6], [J1, N9a, P6], { tractors: [], pairs: 1, singles: 1 }), true);
const hand5 = [K1, K2, Q1, Q2, P6, J1, N9a];
t.eq('跟牌 打2连拖+单', ffo(hand5, [K1, K2, Q1, Q2, P6], { tractors: [2], pairs: 0, singles: 1 }), true);
t.eq('跟牌 有2连拖却打5散→拒', ffo(hand5, [K1, Q1, P6, J1, N9a], { tractors: [2], pairs: 0, singles: 1 }), false);
t.eq('跟牌 有2连拖却只打一对→拒', ffo(hand5, [K1, K2, P6, J1, N9a], { tractors: [2], pairs: 0, singles: 1 }), false);
const hand6 = [K1, K2, N9a, N9b, P6, J1, P8];
t.eq('跟牌 无同长拖两对退化', ffo(hand6, [K1, K2, N9a, N9b, P6], { tractors: [2], pairs: 0, singles: 1 }), true);
t.eq('跟牌 有两对却打5散→拒', ffo(hand6, [K1, N9a, P6, J1, P8], { tractors: [2], pairs: 0, singles: 1 }), false);
t.eq('跟牌 主牌最大化(有主用尽)', ffo([K1, Q1, fA, fB], [K1, Q1, fA], { tractors: [], pairs: 1, singles: 1 }), true);
t.eq('跟牌 留主牌垫副→拒', ffo([K1, Q1, fA, fB], [K1, fA, fB], { tractors: [], pairs: 1, singles: 1 }), false);
t.eq('跟牌 无主全垫副', ffo([fA, fB, fC, fD], [fA, fB, fC], { tractors: [], pairs: 1, singles: 1 }), true);
// —— 跟甩牌补全:拖分量退化到单张 / 多组拖拉机 / 混合demand(design §5.4.4)——
t.eq('跟牌 拖退化·1对不够必打+其余单', ffo([K1, K2, N9a, P8, P6, id(1, 1, 5)], [K1, K2, N9a, P8, P6], { tractors: [2], pairs: 0, singles: 1 }), true);
t.eq('跟牌 拖退化·有1对却拆散→拒', ffo([K1, K2, N9a, P8, P6, id(1, 1, 5)], [K1, N9a, P8, P6, id(1, 1, 5)], { tractors: [2], pairs: 0, singles: 1 }), false);
t.eq('跟牌 双拖·两组2连拖必打', ffo([K1, K2, Q1, Q2, id(1, 1, 10), id(2, 1, 10), N9a, N9b], [K1, K2, Q1, Q2, id(1, 1, 10), id(2, 1, 10), N9a, N9b], { tractors: [2, 2], pairs: 0, singles: 0 }), true);
t.eq('跟牌 混合·拖+对+单各对位', ffo([K1, K2, Q1, Q2, N9a, N9b, P8, id(1, 1, 5)], [K1, K2, Q1, Q2, N9a, N9b, P8], { tractors: [2], pairs: 1, singles: 1 }), true);
t.eq('跟牌 混合·有对却拆散→拒', ffo([K1, K2, Q1, Q2, N9a, N9b, P8, id(1, 1, 5)], [K1, K2, Q1, Q2, N9a, P8, id(1, 1, 5)], { tractors: [2], pairs: 1, singles: 1 }), false);
// ============ §3 主牌顺序 / 相邻链 ============
// order_cards 结果应为 design §3 从大到小:大王>小王>正7>副7>正2>副2>主A>主K…主5>副牌
const big1 = big(1), small1 = small(1), Z7 = id(1, 1, 7), F7 = id(1, 2, 7), Z2 = id(1, 1, 2), F2 = id(1, 2, 2), zA = id(1, 1, 1), zK = id(1, 1, 13), z5 = id(1, 1, 5);
t.eq('排序 主牌降序=设计顺序', A.order_cards(MF, [z5, F2, Z2, big1, F7, Z7, small1, zA, zK]), [big1, small1, Z7, F7, Z2, F2, zA, zK, z5]);
// is_continuous 相邻链逐段(design §3/§5.3)
const cont = (a, b) => A.is_continuous(A.id_to_code(MF, a), A.id_to_code(MF, b));
t.eq('相邻 大王-小王', cont(big(1), small(1)), true);
t.eq('相邻 小王-正7', cont(small(1), Z7), true);
t.eq('相邻 正7-副7', cont(Z7, F7), true);
t.eq('相邻 副7-正2', cont(F7, Z2), true);
t.eq('相邻 正2-副2', cont(Z2, F2), true);
t.eq('相邻 副2-主A', cont(F2, zA), true);
t.eq('相邻 主A-主K', cont(zA, zK), true);
t.eq('相邻 主8-主6(6/8可连)', cont(id(1, 1, 8), id(1, 1, 6)), true);
t.eq('相邻 主6-主5', cont(id(1, 1, 6), z5), true);
t.eq('相邻 正7-主6 不连(7不入普通序)', cont(Z7, id(1, 1, 6)), false);
t.eq('相邻 主8-正7 不连', cont(id(1, 1, 8), Z7), false);
// 8/6 相邻必须同花色:跨花色的 8 与 6 不成连对(否则 get_chongguan 连对链会被副牌 6 对错误接续)
t.eq('相邻 副♥8-副♥6 同花色 连', cont(id(1, 3, 8), id(1, 3, 6)), true);
t.eq('相邻 副♥8-副♣6 跨花色 不连', cont(id(1, 3, 8), id(1, 2, 6)), false);
t.eq('相邻 主8-副♣6 跨花色 不连', cont(id(1, 1, 8), id(1, 2, 6)), false);
t.eq('相邻 副♠8-副♦6 跨花色 不连', cont(id(1, 4, 8), id(1, 1, 6)), false);
// 顺带确认非 8/6 的等差2组合仍不连(防守卫写宽)
t.eq('相邻 主A-主Q 不连(差2但非8/6)', cont(zA, id(1, 1, 12)), false);
// ============ §3「关于对子的判定」:对子只认同一张具体牌的两副 ============
// design §3 注:正/副7、正/副2 虽各占一个大小等级,但**成对只认同花色同点数的两副**;
// 两张不同花色的副7(如♠7+♥7)不构成一对,副2 同理;大王与小王点数不同也不能混成一对。
// 拖拉机的连对判定以此为基础(每节连对里的每个对子都须是同花色同点数两副)。
const pl = cards => A.get_pairlist(MF, A.order_cards(MF, cards.slice())).length;
t.eq('对子 正 同花色两副♥7 成对', pl([id(1, 3, 7), id(2, 3, 7)]), 1);
t.eq('对子 反 ♥7+♣7 跨花色副7 不成对', pl([id(1, 3, 7), id(1, 2, 7)]), 0);
t.eq('对子 反 正7+副7 不成对', pl([id(1, 1, 7), id(1, 2, 7)]), 0);
t.eq('对子 正 同花色两副♥2 成对', pl([id(1, 3, 2), id(2, 3, 2)]), 1);
t.eq('对子 反 ♥2+♣2 跨花色副2 不成对', pl([id(1, 3, 2), id(1, 2, 2)]), 0);
t.eq('对子 反 正2+副2 不成对', pl([id(1, 1, 2), id(1, 2, 2)]), 0);
t.eq('对子 正 大王两副 成对', pl([big(1), big(2)]), 1);
t.eq('对子 反 大王+小王 不成对', pl([big(1), small(1)]), 0);
// 连对以「同花色同点数两副」为基础:四张跨花色的副7/副2 一对都凑不出,自然也没有拖拉机
t.eq('对子 边界 ♥7♣7♥2♣2 四张 → 0 对', pl([id(1, 3, 7), id(1, 2, 7), id(1, 3, 2), id(1, 2, 2)]), 0);
t.eq('连对 边界 上述四张组不出拖拉机',
A.get_tuolaji_list(MF, A.get_pairlist(MF, A.order_cards(MF, [id(1, 3, 7), id(1, 2, 7), id(1, 3, 2), id(1, 2, 2)])), 302).length, 0);
// 对照:正7对 + 副7对(各自同花色两副)才是合法两连对
t.eq('连对 正 正7对+副7对 构成两连对',
A.get_tuolaji_list(MF, A.get_pairlist(MF, A.order_cards(MF, [id(1, 1, 7), id(2, 1, 7), id(1, 2, 7), id(2, 2, 7)])), 302).length, 1);
// ============ §5.3 拖拉机:副牌须同花色 / 主牌按链相邻·不限花色 ============
// 这一层此前只测到 is_continuous(两张牌相不相邻),没测到「这几个对子能不能真的组成拖拉机」。
// design §5.3 改写后明确:副牌**必须同一花色**;主牌只要在主牌序列里**位置相邻**即可、**不限花色**;
// 同一档位的两个对子(如 ♥7对 + ♣7对)平级、不相邻。
const tljOf = cards => {
const s = A.order_cards(MF, cards.slice());
const ps = A.get_pairlist(MF, s);
return ps.length >= 2 && A.get_tuolaji_list(MF, ps, 300 + ps.length).length > 0;
};
// —— 主牌链:跨花色也成连对 ——
t.eq('§5.3 主链 正7对+副7对(跨花色) 成连对', tljOf([id(1, 1, 7), id(2, 1, 7), id(1, 2, 7), id(2, 2, 7)]), true);
t.eq('§5.3 主链 副7对+正2对(跨花色) 成连对', tljOf([id(1, 2, 7), id(2, 2, 7), id(1, 1, 2), id(2, 1, 2)]), true);
t.eq('§5.3 主链 正2对+副2对(跨花色) 成连对', tljOf([id(1, 1, 2), id(2, 1, 2), id(1, 3, 2), id(2, 3, 2)]), true);
t.eq('§5.3 主链 副2对+主A对(跨花色) 成连对', tljOf([id(1, 2, 2), id(2, 2, 2), id(1, 1, 1), id(2, 1, 1)]), true);
t.eq('§5.3 主链 大王对+小王对 成连对', tljOf([big(1), big(2), small(1), small(2)]), true);
t.eq('§5.3 主链 小王对+正7对 成连对', tljOf([small(1), small(2), id(1, 1, 7), id(2, 1, 7)]), true);
t.eq('§5.3 主链 三连(副7对+正2对+副2对) 成立', tljOf([id(1, 2, 7), id(2, 2, 7), id(1, 1, 2), id(2, 1, 2), id(1, 3, 2), id(2, 3, 2)]), true);
t.eq('§5.3 主链 主8对+主6对 成连对(7被抽走)', tljOf([id(1, 1, 8), id(2, 1, 8), id(1, 1, 6), id(2, 1, 6)]), true);
// —— 同一档位的两个对子平级、不相邻 ——
t.eq('§5.3 同档 ♥7对+♣7对 不成连对', tljOf([id(1, 3, 7), id(2, 3, 7), id(1, 2, 7), id(2, 2, 7)]), false);
t.eq('§5.3 同档 ♥2对+♣2对 不成连对', tljOf([id(1, 3, 2), id(2, 3, 2), id(1, 2, 2), id(2, 2, 2)]), false);
// —— 副牌:必须同花色 ——
t.eq('§5.3 副牌 ♥K对+♥Q对 同花色 成连对', tljOf([id(1, 3, 13), id(2, 3, 13), id(1, 3, 12), id(2, 3, 12)]), true);
t.eq('§5.3 副牌 ♥K对+♣Q对 跨花色 不成连对', tljOf([id(1, 3, 13), id(2, 3, 13), id(1, 2, 12), id(2, 2, 12)]), false);
t.eq('§5.3 副牌 ♥8对+♥6对 成连对(7是主牌被抽走)', tljOf([id(1, 3, 8), id(2, 3, 8), id(1, 3, 6), id(2, 3, 6)]), true);
t.eq('§5.3 副牌 ♥8对+♣6对 跨花色 不成连对', tljOf([id(1, 3, 8), id(2, 3, 8), id(1, 2, 6), id(2, 2, 6)]), false);
t.eq('§5.3 副牌 ♥6对+♥5对 成连对(5为最小档)', tljOf([id(1, 3, 6), id(2, 3, 6), id(1, 3, 5), id(2, 3, 5)]), true);
t.eq('§5.3 副牌 ♥9对+♥K对 不相邻 不成连对', tljOf([id(1, 3, 9), id(2, 3, 9), id(1, 3, 13), id(2, 3, 13)]), false);
// —— §2 牌堆里没有 3 和 4,所以副牌序列的最小一档就是 5 ——
// (规则复核时有人把副牌链写成 "…-6-5-3",那需要牌堆保留 3;但 92 = 28×3 + 8 只有在
// 3、4 都剔除时才成立。这里就地钉住"牌堆里 3 和 4 各 0 张",避免链的末端再被写错。)
const _P = require('../class.paiju.js');
const _deck = _P.new({ paiju_list: [] }, 0).cards.filter(c => c.dealowner !== -1);
t.eq('§2 参与发牌的牌里 3 和 4 各 0 张',
[3, 4].map(n => _deck.filter(c => c.number === n).length), [0, 0]);
t.eq('§2 参与发牌共 92 张 = 28×3 + 暗牌8', [_deck.length, 28 * 3 + 8], [92, 92]);
// ============ §6.3 扣底触发的反面:赢末轮的牌不全是主牌就不扣底 ============
t.eq('扣底 反 副牌两连对赢末轮 → 0', mult([id(1, 2, 13), id(2, 2, 13), id(1, 2, 12), id(2, 2, 12)]), 0);
t.eq('扣底 反 混合出牌(主K+副K)赢末轮 → 0', mult([id(1, 1, 13), id(1, 2, 13)]), 0);
t.eq('扣底 反 主牌在前副牌在后也判 0(与提交顺序无关)', mult([id(1, 2, 13), id(1, 1, 13)]), 0);
// ============ §7 算子:每档小光/过庄分界 + 升3级(补强)============
t.eq('常规65 g39小光', judge(65, 39, false), '小光/4');
t.eq('常规65 g40过庄(分界)', judge(65, 40, false), '过庄/2');
t.eq('常规65 g64过庄', judge(65, 64, false), '过庄/2');
t.eq('常规65 g65升1', judge(65, 65, false), '升1级/2');
t.eq('常规65 g104升1', judge(65, 104, false), '升1级/2');
t.eq('常规65 g105升2', judge(65, 105, false), '升2级/4');
t.eq('常规65 g145升3', judge(65, 145, false), '升3级/6');
process.exit(t.done('arith') ? 0 : 1);
+102
View File
@@ -0,0 +1,102 @@
// §7 算子全量对拍:把 design §7.1/§7.2/§7.3 的判定表**按原文另写一份参考实现**,
// 再与 class.arith 的 get_base_bycall / get_qvalue / get_upgrade 逐格比对。
// 参考实现只依据 design 的文字与表格,不看 class.arith 的实现,属差分测试而非同义反复。
// 覆盖:2 种算子模式 × 14 个叫分档 × grade 0~260,比 base / Q / 判定倍率 / 最终子数 X 四项。
require('./_shim');
const A = require('../class.arith.js');
const t = require('./_assert')();
// ============ 参考实现(逐字转写自 design)============
// §7.1 常规算子:65→2 60→3 55→4 50及以下统一6;70 打牌→2
// §7.3.1 爬坡:65 起每低 5 分加 1 个子,50→6 后继续延伸到 5 分→15;70 仍为 2
const CLIMB_BASE = { 65: 2, 60: 3, 55: 4, 50: 6, 45: 7, 40: 8, 35: 9, 30: 10, 25: 11, 20: 12, 15: 13, 10: 14, 5: 15 };
function refBase(climb, call) {
if (call === 70) return 2; // §7.1 末尾:70 分打牌基础子数 2
if (!climb) return (call === 65) ? 2 : (call === 60) ? 3 : (call === 55) ? 4 : 6;
return CLIMB_BASE[call];
}
// §7.2.0 常规算子:小光/过庄分界固定 40,升级级距同为 40
// §7.3.2 爬坡:≥45→40、40/35→20、30/25→15、20/15→10、10→5、5→5;70 分不适用分段,仍 40
function refQ(climb, call) {
if (!climb) return 40;
if (call === 70 || call >= 45) return 40;
if (call === 40 || call === 35) return 20;
if (call === 30 || call === 25) return 15;
if (call === 20 || call === 15) return 10;
return 5;
}
// §7.2.0 判定顺序:**必须先判是否达标(升级),再判大光/小光/过庄**
// 返回带符号倍率:大光 +3 / 小光 +2 / 过庄 +1 / 升 N 级 −N
function refUpgrade(call, grade, Q) {
if (grade >= call) return -(Math.floor((grade - call) / Q) + 1);
if (grade === 0) return 3;
if (grade < Q) return 2;
return 1;
}
// ============ 全量对拍 ============
const CALLS = [70, 65, 60, 55, 50, 45, 40, 35, 30, 25, 20, 15, 10, 5];
let totalCells = 0;
for (const climb of [false, true]) {
const tag = climb ? '爬坡' : '常规';
for (const call of CALLS) {
const eb = refBase(climb, call), eq = refQ(climb, call);
const gb = A.get_base_bycall(call, climb), gq = A.get_qvalue(call, climb);
const bad = [];
if (gb !== eb) bad.push(['base', gb, eb]);
if (gq !== eq) bad.push(['Q', gq, eq]);
for (let g = 0; g <= 260; g++) {
const exp = refUpgrade(call, g, eq);
const got = A.get_upgrade(call, g, gq);
totalCells++;
if (got !== exp) { bad.push(['判定 grade=' + g, got, exp]); continue; }
// §7.0 第3层:X = 基础子数 × |判定倍率|
if (gb * Math.abs(got) !== eb * Math.abs(exp)) bad.push(['X grade=' + g, gb * Math.abs(got), eb * Math.abs(exp)]);
}
t.eq('§7 全量对拍 ' + tag + ' 叫' + call + ' 分(base/Q/grade0~260)失配', bad.slice(0, 3), []);
}
}
t.eq('§7 全量对拍覆盖格数', totalCells, 2 * CALLS.length * 261);
// ============ design 判定表里「写死的最终子数」逐格抽查 ============
// [climb, call, grade, 期望最终子数 X, 期望判定(G大光/X小光/P过庄/Un升n级)]
const TABLES = {
'§7.2.1 常规 65 分坐庄': [[0, 65, 0, 6, 'G'], [0, 65, 1, 4, 'X'], [0, 65, 39, 4, 'X'], [0, 65, 40, 2, 'P'], [0, 65, 64, 2, 'P'], [0, 65, 65, 2, 'U1'], [0, 65, 104, 2, 'U1'], [0, 65, 105, 4, 'U2'], [0, 65, 144, 4, 'U2'], [0, 65, 145, 6, 'U3']],
'§7.2.1 常规 60 分坐庄': [[0, 60, 0, 9, 'G'], [0, 60, 39, 6, 'X'], [0, 60, 40, 3, 'P'], [0, 60, 59, 3, 'P'], [0, 60, 60, 3, 'U1'], [0, 60, 99, 3, 'U1'], [0, 60, 100, 6, 'U2'], [0, 60, 139, 6, 'U2'], [0, 60, 140, 9, 'U3']],
'§7.2.1 常规 55 分坐庄': [[0, 55, 0, 12, 'G'], [0, 55, 39, 8, 'X'], [0, 55, 40, 4, 'P'], [0, 55, 54, 4, 'P'], [0, 55, 55, 4, 'U1'], [0, 55, 94, 4, 'U1'], [0, 55, 95, 8, 'U2'], [0, 55, 135, 12, 'U3']],
'§7.2.1 常规 50 分坐庄': [[0, 50, 0, 18, 'G'], [0, 50, 39, 12, 'X'], [0, 50, 40, 6, 'P'], [0, 50, 49, 6, 'P'], [0, 50, 50, 6, 'U1'], [0, 50, 89, 6, 'U1'], [0, 50, 90, 12, 'U2'], [0, 50, 130, 18, 'U3']],
'§7.2.1 常规 45 分坐庄': [[0, 45, 0, 18, 'G'], [0, 45, 39, 12, 'X'], [0, 45, 40, 6, 'P'], [0, 45, 44, 6, 'P'], [0, 45, 45, 6, 'U1'], [0, 45, 84, 6, 'U1'], [0, 45, 85, 12, 'U2'], [0, 45, 125, 18, 'U3']],
'§7.2.1 常规 40 分及以下(无过庄档)': [[0, 40, 0, 18, 'G'], [0, 40, 39, 12, 'X'], [0, 40, 40, 6, 'U1'], [0, 40, 79, 6, 'U1'], [0, 40, 80, 12, 'U2'], [0, 10, 0, 18, 'G'], [0, 10, 5, 12, 'X'], [0, 10, 9, 12, 'X'], [0, 10, 10, 6, 'U1'], [0, 10, 49, 6, 'U1'], [0, 10, 50, 12, 'U2']],
'§7.2.1 常规 5 分坐庄(无小光)': [[0, 5, 0, 18, 'G'], [0, 5, 5, 6, 'U1'], [0, 5, 44, 6, 'U1'], [0, 5, 45, 12, 'U2'], [0, 5, 85, 18, 'U3']],
'§7.2.1 70 分坐庄·打牌': [[0, 70, 0, 6, 'G'], [0, 70, 39, 4, 'X'], [0, 70, 40, 2, 'P'], [0, 70, 69, 2, 'P'], [0, 70, 70, 2, 'U1'], [0, 70, 109, 2, 'U1'], [0, 70, 110, 4, 'U2'], [0, 70, 150, 6, 'U3'], [1, 70, 0, 6, 'G'], [1, 70, 70, 2, 'U1']],
'§7.3.3 爬坡 40 分坐庄(Q=20)': [[1, 40, 0, 24, 'G'], [1, 40, 1, 16, 'X'], [1, 40, 19, 16, 'X'], [1, 40, 20, 8, 'P'], [1, 40, 39, 8, 'P'], [1, 40, 40, 8, 'U1'], [1, 40, 59, 8, 'U1'], [1, 40, 60, 16, 'U2'], [1, 40, 79, 16, 'U2'], [1, 40, 80, 24, 'U3']],
'§7.3.3 爬坡 35 分坐庄(Q=20)': [[1, 35, 0, 27, 'G'], [1, 35, 19, 18, 'X'], [1, 35, 20, 9, 'P'], [1, 35, 34, 9, 'P'], [1, 35, 35, 9, 'U1'], [1, 35, 54, 9, 'U1'], [1, 35, 55, 18, 'U2'], [1, 35, 75, 27, 'U3']],
'§7.3.3 爬坡 30 分坐庄(Q=15)': [[1, 30, 0, 30, 'G'], [1, 30, 14, 20, 'X'], [1, 30, 15, 10, 'P'], [1, 30, 29, 10, 'P'], [1, 30, 30, 10, 'U1'], [1, 30, 44, 10, 'U1'], [1, 30, 45, 20, 'U2'], [1, 30, 60, 30, 'U3']],
'§7.3.3 爬坡 25 分坐庄(Q=15)': [[1, 25, 0, 33, 'G'], [1, 25, 14, 22, 'X'], [1, 25, 15, 11, 'P'], [1, 25, 24, 11, 'P'], [1, 25, 25, 11, 'U1'], [1, 25, 39, 11, 'U1'], [1, 25, 40, 22, 'U2'], [1, 25, 55, 33, 'U3']],
'§7.3.3 爬坡 20 分坐庄(Q=10)': [[1, 20, 0, 36, 'G'], [1, 20, 9, 24, 'X'], [1, 20, 10, 12, 'P'], [1, 20, 19, 12, 'P'], [1, 20, 20, 12, 'U1'], [1, 20, 29, 12, 'U1'], [1, 20, 30, 24, 'U2'], [1, 20, 40, 36, 'U3']],
'§7.3.3 爬坡 15 分坐庄(Q=10)': [[1, 15, 0, 39, 'G'], [1, 15, 9, 26, 'X'], [1, 15, 10, 13, 'P'], [1, 15, 14, 13, 'P'], [1, 15, 15, 13, 'U1'], [1, 15, 24, 13, 'U1'], [1, 15, 25, 26, 'U2'], [1, 15, 35, 39, 'U3']],
'§7.3.3 爬坡 10 分坐庄(Q=5,无小光)': [[1, 10, 0, 42, 'G'], [1, 10, 5, 14, 'P'], [1, 10, 9, 14, 'P'], [1, 10, 10, 14, 'U1'], [1, 10, 14, 14, 'U1'], [1, 10, 15, 28, 'U2'], [1, 10, 20, 42, 'U3']],
'§7.3.3 爬坡 5 分坐庄(无小光、无过庄)': [[1, 5, 0, 45, 'G'], [1, 5, 5, 15, 'U1'], [1, 5, 9, 15, 'U1'], [1, 5, 10, 30, 'U2'], [1, 5, 15, 45, 'U3']],
'§7.3.1/§7.3.3 爬坡 ≥45 档(45 为爬坡独有)': [[1, 45, 0, 21, 'G'], [1, 45, 39, 14, 'X'], [1, 45, 40, 7, 'P'], [1, 45, 44, 7, 'P'], [1, 45, 45, 7, 'U1'], [1, 45, 85, 14, 'U2'], [1, 65, 0, 6, 'G'], [1, 60, 0, 9, 'G'], [1, 55, 0, 12, 'G'], [1, 50, 0, 18, 'G']]
};
let cellCount = 0;
for (const name in TABLES) {
const bad = [];
for (const [c, call, grade, expX, expKind] of TABLES[name]) {
cellCount++;
const base = A.get_base_bycall(call, !!c), q = A.get_qvalue(call, !!c);
const up = A.get_upgrade(call, grade, q);
const kind = up === 3 ? 'G' : up === 2 ? 'X' : up === 1 ? 'P' : ('U' + (-up));
const X = base * Math.abs(up);
if (X !== expX || kind !== expKind) bad.push(['grade=' + grade, X + '/' + kind, expX + '/' + expKind]);
}
t.eq(name + ' 逐格失配', bad, []);
}
t.eq('design 判定表抽查格数', cellCount, 141);
// ============ 投降 / 解散:不走 7.1~7.3 模型(§7.0 例外、§12.2)============
// 数值由 get_paiju_account 直接固定(投降 base=1、解散 0),此处只钉住 get_base_bycall 对 70 分的取值
t.eq('§7.1 70 分打牌基础子数 2(两种模式一致)', [A.get_base_bycall(70, false), A.get_base_bycall(70, true)], [2, 2]);
t.eq('§7.1 无叫分 → 0', [A.get_base_bycall(0, false), A.get_base_bycall(0, true)], [0, 0]);
process.exit(t.done('calc') ? 0 : 1);
@@ -0,0 +1,28 @@
// §4.2 叫分坐庄 do_callgrade(纯函数,operate on 最小 o_paiju)。期望取自 design §4.2
require('./_shim');
const P = require('../class.paiju.js');
const t = require('./_assert')();
const mkCall = fs => ({ callproc: [{ seat: fs, call: null }], banker: -1, call: -1, step: 1 });
// 依次叫分序列(calls 里每个值 = 当前轮到者的叫分,0=不叫)
function seq(firstseat, calls) {
const p = mkCall(firstseat);
for (const c of calls) P.do_callgrade(p, c);
return p;
}
const bcs = p => [p.banker, p.call, p.step];
// 叫5立即上庄
t.eq('叫5立即上庄', bcs(seq(0, [5])), [0, 5, 2]);
// 暂定庄叫65,另两家都不叫 → 暂定庄(0)上庄
t.eq('0叫65 两家不叫→0上庄', bcs(seq(0, [65, 0, 0])), [0, 65, 2]);
// 0叫65、1叫更低60、2不叫、0不叫 → 60 最低者(1)上庄
t.eq('0叫65 1叫60 余不叫→1上庄', bcs(seq(0, [65, 60, 0, 0])), [1, 60, 2]);
// 首家叫5路径:0叫5 → 0直接上庄(其余无需叫)
t.eq('首家叫5直接坐庄', bcs(seq(0, [5])), [0, 5, 2]);
// 递减到5:0叫65,1叫60,2叫5 → 2上庄(叫5立即)
t.eq('2叫5立即上庄', bcs(seq(0, [65, 60, 5])), [2, 5, 2]);
// 不同首家:1起叫,1叫50,2不叫,0不叫 → 1上庄
t.eq('首家1叫50 余不叫→1上庄', bcs(seq(1, [50, 0, 0])), [1, 50, 2]);
process.exit(t.done('callgrade') ? 0 : 1);
+74
View File
@@ -0,0 +1,74 @@
// roomtype 位串解析 + 局数/扣卡映射(design §10.1)
require('./_shim');
const C = require('../class.config.js');
const t = require('./_assert')();
// 复刻 export 的两个映射(现为2行)做端到端验证
const needroomcard = rt => { const c = C.parse(rt); return c.deductAA ? (c.aset12 ? 2 : 1) : (c.aset12 ? 4 : 2); };
const asetcount = rt => { const c = C.parse(rt); return c.aset12 ? 12 : 6; };
// --- parse 位串 ---
t.eq('"00000"', C.parse("00000"), { aset12: false, deductAA: false, bangwang: false, climb: false, nocheck: false });
t.eq('"10100"', C.parse("10100"), { aset12: true, deductAA: false, bangwang: true, climb: false, nocheck: false });
t.eq('"01011"', C.parse("01011"), { aset12: false, deductAA: true, bangwang: false, climb: true, nocheck: true });
t.eq('"" 兜底', C.parse(""), { aset12: false, deductAA: false, bangwang: false, climb: false, nocheck: false });
t.eq('undefined 兜底', C.parse(undefined), { aset12: false, deductAA: false, bangwang: false, climb: false, nocheck: false });
t.eq('"1" 短串(位长校验后按缺省)', C.parse("1"), { aset12: false, deductAA: false, bangwang: false, climb: false, nocheck: false });
// --- 【位长协议 · 2026-09-01 升级 · 第 2 版】扩展位在末尾 ---
// 新版:N+5 位 = 前 5 位协议位(IDX_ASET..IDX_NOCHECK,对应 charAt 0..4)+ 后 N 位扩展位
// 字串示例:'11011' + '000000' = '11011000000'(默认 N=6 扩展位全 '0')
// 协议位的位索引(IDX_* = 0..4)永远不变,扩展位在协议位之后。
// 兼容性:旧版 5 位字符串直接走 charAt(0..4);新版 11 位也走 charAt(0..4)(自动兼容)。
// 前端发 11 位 = 5 位 buildRoomType() 右补 6 位扩展位,服务端按 charAt(0..4) 读协议位。
t.eq('新版 11 位 "10100000000"(前 5 位 10100 = 12局+房主+傍王)',
C.parse("10100000000"), { aset12: true, deductAA: false, bangwang: true, climb: false, nocheck: false });
t.eq('新版 11 位 "11011000000"(前 5 位 11011 = 12局+AA+不查+爬坡)',
C.parse("11011000000"), { aset12: true, deductAA: true, bangwang: false, climb: true, nocheck: true });
t.eq('新版 11 位 "00000000000"(前 5 位 00000 = 全默认)',
C.parse("00000000000"), { aset12: false, deductAA: false, bangwang: false, climb: false, nocheck: false });
// 兼容性:旧版 5 位 roomtype
t.eq('旧版 5 位 "10100"(无扩展位)直接解析',
C.parse("10100"), { aset12: true, deductAA: false, bangwang: true, climb: false, nocheck: false });
// 兼容性:未来位长增加(追加更多扩展位)→ 前 5 位协议位不变
t.eq('17 位 "11011000000000000"(前 5 位 11011)',
C.parse("11011000000000000"), { aset12: true, deductAA: true, bangwang: false, climb: true, nocheck: true });
// 位长异常 → 兜底(位长 < 5 时整体按 '00000')
t.eq('位长 6("101010",前 5 位 = "10101" → 12局+房主+傍王+不查)',
C.parse("101010"), { aset12: true, deductAA: false, bangwang: true, climb: false, nocheck: true });
t.eq('位长 4("0000",位长不足)→ 兜底',
C.parse("0000"), { aset12: false, deductAA: false, bangwang: false, climb: false, nocheck: false });
t.eq('位长 0(空串)→ 兜底', C.parse(""),
{ aset12: false, deductAA: false, bangwang: false, climb: false, nocheck: false });
// 扩展位允许非 '0'(服务端不校验扩展位内容,由专门读扩展位的代码解释)
t.eq('扩展位含 1 不影响协议位解析',
C.parse("11011111111"), { aset12: true, deductAA: true, bangwang: false, climb: true, nocheck: true });
// RESERVE_LEN = 6(前后端 SSOT,仅作 SSOT 标记与未来断言使用)
t.eq('RESERVE_LEN = 6(前后端 SSOT)', C.RESERVE_LEN, 6);
// --- 局数 ---
t.eq('6局 "0"', asetcount("00000"), 6);
t.eq('12局 "1"', asetcount("10000"), 12);
// --- 开房房卡 (design §10.1) ---
t.eq('房主6局', needroomcard("00000"), 2);
t.eq('房主12局', needroomcard("10000"), 4);
t.eq('AA6局', needroomcard("01000"), 1);
t.eq('AA12局', needroomcard("11000"), 2);
// --- 直接调用 export 的接口(替代复刻映射) ---
const exp = require('../class.export.js').new();
t.eq('export 局数6', exp.get_asetcount("00000"), 6);
t.eq('export 局数12', exp.get_asetcount("10000"), 12);
t.eq('export 房主6局2张', exp.get_needroomcard("00000"), 2);
t.eq('export 房主12局4张', exp.get_needroomcard("10000"), 4);
t.eq('export AA6局1张', exp.get_needroomcard("01000"), 1);
t.eq('export 加入·房主0张', exp.get_needroomcard_joinroom("00000"), 0);
t.eq('export 加入·AA6局1张', exp.get_needroomcard_joinroom("01000"), 1);
process.exit(t.done('config') ? 0 : 1);
+28
View File
@@ -0,0 +1,28 @@
// §2 牌局构成:两副去3/4共92张、三家各28+底8、分值。期望取自 design §2/§6.1
require('./_shim');
const P = require('../class.paiju.js');
const t = require('./_assert')();
const pj = P.new({ paiju_list: [] }, 0); // 真实发牌(min_random 由 shim 提供)
// dealowner 统计:-1 去掉的3/4;0 底牌;1/2/3 三家
const cnt = {};
for (const c of pj.cards) cnt[c.dealowner] = (cnt[c.dealowner] || 0) + 1;
t.eq('发牌 三家各28', [P.get_seat_cards_deal(pj, 0).length, P.get_seat_cards_deal(pj, 1).length, P.get_seat_cards_deal(pj, 2).length], [28, 28, 28]);
t.eq('发牌 底牌8', P.get_bottomcards(pj).length, 8);
t.eq('发牌 总发出92(28×3+8)', (cnt[0] || 0) + (cnt[1] || 0) + (cnt[2] || 0) + (cnt[3] || 0), 92);
t.eq('去掉的3/4共16', cnt[-1], 16);
t.eq('无3/4被发出', pj.cards.filter(c => (c.number === 3 || c.number === 4) && c.dealowner !== -1).length, 0);
t.eq('牌对象总数108', pj.cards.length, 108);
// 分值 (§6.1):5→5、10→10、K(13)→10、其余0
const scoreOf = n => pj.cards.find(x => x.number === n).score;
t.eq('分值 5→5', scoreOf(5), 5);
t.eq('分值 10→10', scoreOf(10), 10);
t.eq('分值 K(13)→10', scoreOf(13), 10);
t.eq('分值 A(1)→0', scoreOf(1), 0);
t.eq('分值 6→0', scoreOf(6), 0);
t.eq('分值 大王→0', scoreOf(54), 0);
process.exit(t.done('deal') ? 0 : 1);
+46
View File
@@ -0,0 +1,46 @@
// §4.6 坐庄轮换 do_prepare(庄赢连庄 / 闲赢·投降下家)。期望取自 design §4.6
require('./_shim');
const D = require('../class.desk.js');
const t = require('./_assert')();
// 造一个牌桌,压入一局"已结束"的牌局(banker/result),三家都准备后开新局,取新局 firstseat
function nextFirstseat(banker, result) {
const o_room = { seatlist: [{ conmode: 0, fromid: 0 }, { conmode: 0, fromid: 1 }, { conmode: 0, fromid: 2 }], asetcount: 6, method: { sendpack_toother: () => { } } };
const desk = D.new(o_room);
desk.paiju_list.push({ banker, result, tmp_jiesuan_aset: {} });
D.do_prepare(desk, 0); D.do_prepare(desk, 1); D.do_prepare(desk, 2);
return desk.paiju_list[desk.paiju_list.length - 1].firstseat;
}
t.eq('轮庄 庄1赢(result0)连庄', nextFirstseat(1, 0), 1);
t.eq('轮庄 庄1闲赢(result1)下家2', nextFirstseat(1, 1), 2);
t.eq('轮庄 庄1投降(result2)下家2', nextFirstseat(1, 2), 2);
t.eq('轮庄 庄2赢连庄', nextFirstseat(2, 0), 2);
t.eq('轮庄 庄2闲赢下家(回0)', nextFirstseat(2, 1), 0);
t.eq('轮庄 庄0闲赢下家1', nextFirstseat(0, 1), 1);
// ============ I-3 发牌包必须自带阶段 step 与控制权 seat ============
// 红线「状态机唯一在服务端」:阶段与「轮到谁」必须由服务端在包里显式给出。
// 曾经 fapai 只有 seat 没有 step,前端只能按 rpc 名硬编码 step=1——那就是把阶段推导
// 搬到了前端。反向验证:把 class.desk.js 里的 msg.data.step 删掉,本组第一条必转红。
function firstFapai() {
const sent = [];
const o_room = {
seatlist: [{ conmode: 0, fromid: 0 }, { conmode: 0, fromid: 1 }, { conmode: 0, fromid: 2 }],
asetcount: 6, method: { sendpack_toother: () => { } }
};
const prevApp = global.youle_erqiwang.app;
global.youle_erqiwang.app = { SendPack: m => sent.push(JSON.parse(JSON.stringify(m))) };
const desk = D.new(o_room);
D.do_new_paiju(desk, 1); // 首个叫分者 = 1 号位(刻意不用 0,避免"恒等于 0"的假绿)
global.youle_erqiwang.app = prevApp;
return { desk, pj: desk.paiju_list[desk.paiju_list.length - 1], fapai: sent.filter(m => m.rpc === 'fapai') };
}
const fp = firstFapai();
t.eq('发牌 fapai 带 step,且 = 牌局的真实阶段(1 叫分)', fp.fapai.map(m => m.data.step), [1, 1, 1]);
t.eq('发牌 fapai.step 与 paiju.step 同源(不是写死的常量)', fp.fapai[0].data.step, fp.pj.step);
t.eq('发牌 fapai 控制权 seat = 当前等待叫分者(firstseat=1)',
fp.fapai.map(m => m.data.seat), [1, 1, 1]);
t.eq('发牌 fapai.seat 与 get_callgrade_seat() 同源',
fp.fapai[0].data.seat, fp.pj.method.get_callgrade_seat());
process.exit(t.done('desk') ? 0 : 1);
+182
View File
@@ -0,0 +1,182 @@
// L4 端到端:用真实发牌跑完整一局(叫分→上庄→选主→埋牌→28轮出牌→小局结算),
// 校验 design §12.1 的流程闭合与一批与牌面无关的不变量(牌张守恒/捡分一致/结算零和/分配公式)。
// 另覆盖 §4.4 投降局与 §12.2 中途解散两条支线。
// 说明:牌是随机发的,但断言的都是**与具体牌面无关**的不变量,不会因发牌不同而抖动。
const { mod, setup } = require('./_rpc.js');
const t = require('./_assert')();
const P = global.cls_youle_erqiwang_paiju;
const AR = global.cls_youle_erqiwang_arith;
const pk = (seat, d) => ({ conmode: 0, fromid: seat, data: Object.assign({ agentid: 1, playerid: seat, gameid: 1, roomcode: 1, seat: seat }, d || {}) });
// 开局并让 0 号位以 call 分坐庄(另两家不叫)
function seatBanker(roomtype, call) {
const pj = P.new({ paiju_list: [] }, 0);
const ctx = setup(roomtype, pj);
mod.jiaofen(pk(pj.method.get_callgrade_seat(), { call: call }));
mod.jiaofen(pk(pj.method.get_callgrade_seat(), { call: 0 }));
mod.jiaofen(pk(pj.method.get_callgrade_seat(), { call: 0 }));
return { pj, ctx };
}
// 出牌驱动:首家出手上最小的单张,跟家取第一张能通过 can_followcard 的牌
function playAll(pj) {
let guard = 0;
while (pj.step === 5 && ++guard < 200) {
const seat = pj.playproc.currseat, hand = P.get_seat_cards(pj, seat);
let pick = null;
if (seat === pj.playproc.start) {
pick = [hand[hand.length - 1]];
} else {
for (const c of hand) {
if (AR.can_followcard(pj.flower, hand, [c], pj.playproc.startcount, pj.playproc.startflower, pj.playproc.starttype).result) { pick = [c]; break; }
}
}
if (!pick) { return false; }
mod.chupai(pk(seat, { cards: pick }));
}
return pj.step === 6;
}
// ============ §12.1 正常局全流程 ============
const { pj, ctx } = seatBanker("00000", 65);
t.eq('§4.2 坐庄(banker/call/step)', [pj.banker, pj.call, pj.step], [0, 65, 2]);
t.eq('§4.3 庄家摸暗牌后36张 / 闲家28张', [P.get_seat_cards(pj, 0).length, P.get_seat_cards(pj, 1).length], [36, 28]);
mod.xuanzhu(pk(0, { flower: 1 }));
t.eq('§4.4 选主后进埋牌阶段', [pj.flower, pj.step], [1, 3]);
mod.maipai(pk(0, { cards: P.get_seat_cards(pj, 0).slice(-8) }));
t.eq('§4.5 埋牌后庄家28张、进出牌阶段', [P.get_seat_cards(pj, 0).length, pj.step], [28, 5]);
t.eq('§5.1 第一轮固定由庄家先出', [pj.playproc.round, pj.playproc.start], [1, 0]);
t.eq('§5/§6 28轮打完进入结算', playAll(pj) && pj.playproc.round, 28);
t.eq('§2 牌张守恒(出84 / 埋8 / 去掉3-4共16)',
[pj.cards.filter(c => c.playround > 0).length, pj.cards.filter(c => c.playround === 0).length, pj.cards.filter(c => c.number === 3 || c.number === 4).length],
[84, 8, 16]);
const last = ctx.sent[ctx.sent.length - 1];
const aset = last.data.aset;
t.eq('结算包 rpc/success', [last.rpc, last.data.success], ['jiesuan', true]);
t.eq('§11 结束亮出底牌8张', last.data.bottom.cards.length, 8);
// §6.2/§6.3:闲家捡分 = 闲家赢下的分牌 + 扣底(可能翻倍)分
let jian = 0;
for (const c of pj.cards) { if (c.playowner > -1 && c.playowner !== pj.banker) { jian += c.score; } }
const bonus = last.data.bottom.grade2 || 0;
t.eq('§6.2/§6.3 捡分 = 闲家赢得分牌 + 扣底分', aset.grade, jian + bonus);
// §6.3:扣底只在「闲家用主牌赢下末轮」时发生
const lastWinIsXian = pj.playproc.maxseat !== pj.banker;
const lastMult = lastWinIsXian ? AR.get_bottom_multiple(pj.flower, pj.playproc.cards[pj.playproc.maxseat]) : 0;
t.eq('§6.3 扣底触发条件(闲家 + 主牌)', lastMult > 0, !!last.data.bottom.multiple);
// §7 第3层:X = 基础子数 × |判定倍率|,庄家 ±2X、闲家各 ∓X
const X = aset.multiple * Math.abs(aset.upgrade);
const win = aset.upgrade > 0 ? 1 : -1;
t.eq('§7 捡分子数分配 庄±2X / 闲各∓X', aset.seatlist.map(p => p.grade_jf), [win * X * 2, -win * X, -win * X]);
// §8.4 第4层:算奖线 X×(2Ni−Nj−Nk)
const N = aset.seatlist.map(p => p.naward);
t.eq('§8.4 算奖分配 X×(2Ni−Nj−Nk)', aset.seatlist.map(p => p.grade_aw),
[X * (2 * N[0] - N[1] - N[2]), X * (2 * N[1] - N[0] - N[2]), X * (2 * N[2] - N[0] - N[1])]);
// S-6:算奖得分按【冲关 / 傍王】两个分量分别再算一次,供大局结算分项展示。
// 公式对 N 线性,故两分量之和必须恒等于 grade_aw——这是拆分正确性的核心不变量。
t.eq('S-6 grade_cg + grade_bw == grade_aw',
aset.seatlist.map(p => p.grade_cg + p.grade_bw), aset.seatlist.map(p => p.grade_aw));
// 分量口径:冲关只计庄家;傍王未勾选(roomtype "00000" 位2=0)时应恒为 0
t.eq('S-6 未勾傍王 → grade_bw 全 0', aset.seatlist.map(p => p.grade_bw), [0, 0, 0]);
t.eq('S-6 未勾傍王 → grade_cg == grade_aw', aset.seatlist.map(p => p.grade_cg), aset.seatlist.map(p => p.grade_aw));
t.eq('本局结算零和', aset.seatlist.reduce((s, p) => s + p.grade, 0), 0);
t.eq('S-6 两个分量各自零和', [
aset.seatlist.reduce((s, p) => s + p.grade_cg, 0),
aset.seatlist.reduce((s, p) => s + p.grade_bw, 0)], [0, 0]);
t.eq('§4.6 result 与判定一致(0庄赢/1闲赢)', pj.result, aset.upgrade > 0 ? 0 : 1);
// ============ S-6:开傍王局的分量拆分 + 大局累计 ============
// 上面那局没开傍王,grade_bw 恒 0,只验到平凡情形。这里开傍王(roomtype 位2=1)跑一整局,
// 让两个分量都非零,才算真的验证了「N 拆成冲关/傍王分别代入公式」这件事。
{
const bw = seatBanker("00100", 65); // 位2=1 → 傍王开
mod.xuanzhu(pk(0, { flower: 1 }));
mod.maipai(pk(0, { cards: P.get_seat_cards(bw.pj, 0).slice(-8) }));
t.eq('S-6 傍王局 打满28轮', playAll(bw.pj) && bw.pj.playproc.round, 28);
const a = bw.ctx.sent[bw.ctx.sent.length - 1].data.aset;
t.eq('S-6 傍王局 bangwang 标志', a.bangwang, 1);
// 核心不变量:两分量之和 == grade_aw
t.eq('S-6 傍王局 grade_cg + grade_bw == grade_aw',
a.seatlist.map(p => p.grade_cg + p.grade_bw), a.seatlist.map(p => p.grade_aw));
// 分量各自零和(每条支付线都是三家之间的转移,不产生也不消灭分数)
t.eq('S-6 傍王局 两分量各自零和', [
a.seatlist.reduce((s, p) => s + p.grade_cg, 0),
a.seatlist.reduce((s, p) => s + p.grade_bw, 0)], [0, 0]);
// 口径:naward = 冲关(仅庄家) + 王数(傍王开)
const X = a.multiple * Math.abs(a.upgrade);
t.eq('S-6 傍王局 naward 口径', a.seatlist.map((p, i) => p.naward),
a.seatlist.map((p, i) => (i === a.banker ? p.chongguan : 0) + p.wang));
// 傍王分量必须真的非零,否则这一整块又是平凡情形(三家王数全相等时净额才会是 0)
const wangs = a.seatlist.map(p => p.wang);
const wangAllSame = wangs[0] === wangs[1] && wangs[1] === wangs[2];
t.eq('S-6 傍王局 王数不全相等时 grade_bw 必有非零', wangAllSame || a.seatlist.some(p => p.grade_bw !== 0), true);
// 逐项复核傍王分量的公式
const NB = a.seatlist.map(p => p.wang);
t.eq('S-6 傍王分量 == X×(2Ni−Nj−Nk)', a.seatlist.map(p => p.grade_bw),
[X * (2 * NB[0] - NB[1] - NB[2]), X * (2 * NB[1] - NB[0] - NB[2]), X * (2 * NB[2] - NB[0] - NB[1])]);
// 冲关分量:只有庄家有 N_cg,闲家为 0
const NC = a.seatlist.map((p, i) => (i === a.banker ? p.chongguan : 0));
t.eq('S-6 冲关分量 == X×(2Ni−Nj−Nk)(仅庄家有 N)', a.seatlist.map(p => p.grade_cg),
[X * (2 * NC[0] - NC[1] - NC[2]), X * (2 * NC[1] - NC[0] - NC[2]), X * (2 * NC[2] - NC[0] - NC[1])]);
// 大局累计:desk.seatlist 后三位分别累计 基础/冲关/傍王,三者之和 == 累积总分
const dsk = bw.pj.o_desk.seatlist;
t.eq('S-6 大局累计 基础+冲关+傍王 == 累积总分',
dsk.map(s => s[2] + s[3] + s[4]), dsk.map(s => s[0]));
}
// ============ §4.4 / §7.1 投降局 ============
const tx = seatBanker("00000", 70);
t.eq('§4.3 70分坐庄:暗牌亮3秒标志 + 闲家也收到暗牌',
[tx.ctx.sent[tx.ctx.sent.length - 1].data.ancard3s, tx.ctx.sent[tx.ctx.sent.length - 2].data.bottomcards.length], [1, 8]);
mod.touxiang(pk(0));
const txa = tx.ctx.sent[tx.ctx.sent.length - 1].data.aset;
t.eq('§7.1 投降:base=1 / 庄-2、闲各+1', [txa.multiple, txa.seatlist.map(p => p.grade_jf)], [1, [-2, 1, 1]]);
t.eq('§4.4 投降不选主、不埋牌', [tx.pj.flower, tx.pj.cards.filter(c => c.playround === 0).length], [-1, 0]);
t.eq('§8.4 投降算奖按庄家36张快照', P.get_seat_cards_award(tx.pj, 0).length, 36);
t.eq('§4.6 投降 result=2(下家坐庄)', tx.pj.result, 2);
// ============ §12.2 中途解散 ============
const ds = seatBanker("00000", 65);
mod.xuanzhu(pk(0, { flower: 1 }));
const EXPORT = require('../class.export.js').new();
const dsmsg = EXPORT.get_disbandRoom(ds.ctx.o_room);
t.eq('§12.2 解散:不结算子数、result=3', [dsmsg.data.aset.multiple, dsmsg.data.aset.seatlist.map(p => p.grade), ds.pj.result], [0, [0, 0, 0], 3]);
// 平台在 makewar 后立刻置 battlestate=1,而首局是延迟 1 秒创建的:这段窗口内解散必须返回 null,
// 而不是让 curr_paiju() 的 undefined 解引用抛异常打断平台的整条解散链路。
// 这里必须 try/catch:守卫一旦被去掉,被测函数会直接抛异常,未捕获时整个文件在此中断,
// 后面的断言全不执行——看到的是崩溃而不是某条断言转红(实测过)。
const noThrow = fn => { try { return fn(); } catch (e) { return '抛异常: ' + e.message; } };
t.eq('解散·牌局尚未创建 → 返回 null 不抛异常',
noThrow(() => EXPORT.get_disbandRoom({ o_desk: { method: { curr_paiju: () => undefined } } })), null);
t.eq('解散·牌桌尚未创建 → 返回 null 不抛异常', noThrow(() => EXPORT.get_disbandRoom({})), null);
// get_deskinfo 有同一个 o_desk 守卫(平台在开战前也可能回调),一并钉住
t.eq('重连·牌桌尚未创建 → 返回 null 不抛异常', noThrow(() => EXPORT.get_deskinfo({}, 0)), null);
// ============ 出牌入参顺序无关性(端到端)============
// 同一手完全合法的跟牌,仅客户端提交的数组顺序不同,本轮胜者/捡分/扣底/结算判定必须完全一致。
// 构造:庄家(0)首出 红心KKQQ(副牌两连对,含20分),闲家(1)用 黑桃KKQQ(主拖拉机,含20分)毙牌,
// 闲家(2)垫方块杂牌;这是本局最后一轮,故同时覆盖扣底判定。
function oneTrick(followOrder) {
const p = P.new({ paiju_list: [] }, 0);
const c = setup("00000", p);
p.banker = 0; p.call = 65; p.flower = 4; p.step = 5;
// 先把所有牌置为「第1轮已由庄家赢走」,再把参战的 12 张发回三家手上
for (const card of p.cards) { card.playround = 1; card.playowner = 0; }
const give = (ids, owner) => ids.forEach(i => Object.assign(p.cards[i], { dealowner: owner + 1, playround: -1, playowner: -1 }));
give([38, 92, 37, 91], 0); // 红心 K K Q Q
give([51, 105, 50, 104], 1); // 黑桃 K K Q Q(主)
give([5, 7, 8, 10], 2); // 方块杂牌
P.new_playround(p, 1, 0);
mod.chupai(pk(0, { cards: [38, 92, 37, 91] }));
mod.chupai(pk(1, { cards: followOrder }));
mod.chupai(pk(2, { cards: [5, 7, 8, 10] }));
const m = c.ctx ? c.ctx : c;
const j = m.sent[m.sent.length - 1];
return [p.playproc.maxseat, j.data.chupai.grade, j.data.aset.grade, j.data.bottom.multiple];
}
const ordered = oneTrick([51, 105, 50, 104]); // 降序
const shuffled = oneTrick([51, 50, 105, 104]); // 乱序(把两个对子拆开摆)
t.eq('序无关 端到端·有序提交:闲家毙牌赢下末轮并扣底×4', ordered, [1, 40, 40, 4]);
t.eq('序无关 端到端·乱序提交结果完全一致', shuffled, ordered);
process.exit(t.done('endgame') ? 0 : 1);
+195
View File
@@ -0,0 +1,195 @@
// 流程完整性(design §4.2 / §4.6 / §12.1 阶段机)
//
// ① **阶段机迁移矩阵**:8 个 RPC × 5 个阶段 = 40 格,逐格验证「该受理的受理、不该受理的
// 一律回 STEP 失败包」。此前每个 handler 只测了 1~2 条代表性失败路径,没有系统覆盖
// 「某个请求在别的阶段会不会被误放行 / 会不会静默丢弃」。
// ② **叫分起始者**:第一局默认座位 0;之后每局起始叫分者 = 暂定庄家(庄赢连庄 / 庄输下家);
// 每一局的首家都不能"不叫"。连庄分支单独构造(叫 70 分 + 首家出最大牌)才走得到。
//
// 随机源与发牌都接固定种子,结果可复现。
const R = require('./_rpc.js'); const mod = R.mod;
const P = global.cls_youle_erqiwang_paiju, A = global.cls_youle_erqiwang_arith;
const D = require('../class.desk.js');
const t = require('./_assert')();
let seed = 0x5A5A01;
const rnd = n => { seed ^= seed << 13; seed ^= seed >>> 17; seed ^= seed << 5; seed >>>= 0; return seed % n; };
global.min_ontimeout = fn => fn();
global.min_random = (a, b) => a + rnd(b - a + 1);
const pk = (s, d) => ({ conmode: 0, fromid: s, data: Object.assign({ agentid: 1, playerid: s, gameid: 1, roomcode: 1, seat: s }, d || {}) });
const ERR = mod.ERR;
function mkRoom(rt, asetcount) {
const sent = [];
const o_room = {
roomtype: rt, asetcount: asetcount || 6, roomcode: 1, createtime: 'T', makewartime: 'T',
seatlist: [0, 1, 2].map(i => ({ conmode: 0, fromid: i, playerid: 100 + i, nickname: 'P', avatar: '', gameinfo: {} })),
method: { sendpack_toother: m => sent.push(JSON.parse(JSON.stringify(m))) }
};
const desk = D.new(o_room); o_room.o_desk = desk;
global.youle_erqiwang.app = { SendPack: m => sent.push(JSON.parse(JSON.stringify(m))) };
global.youle_erqiwang.import = { check_player: () => o_room, deduct_roomcard: () => { }, save_grade: () => { } };
mod.import = global.youle_erqiwang.import; mod.app = global.youle_erqiwang.app;
return { o_room, desk, sent };
}
// 把牌局驱动到指定阶段。call=70 时保留投降资格
function driveTo(step, call) {
const c = mkRoom('00000');
D.do_new_paiju(c.desk, 0);
const pj = c.desk.method.curr_paiju();
if (step >= 2) {
mod.jiaofen(pk(pj.method.get_callgrade_seat(), { call: call || 65 }));
if (pj.step === 1) mod.jiaofen(pk(pj.method.get_callgrade_seat(), { call: 0 }));
if (pj.step === 1) mod.jiaofen(pk(pj.method.get_callgrade_seat(), { call: 0 }));
}
if (step >= 3) mod.xuanzhu(pk(pj.banker, { flower: 1 }));
if (step >= 5) mod.maipai(pk(pj.banker, { cards: P.get_seat_cards(pj, pj.banker).slice(-8) }));
if (step >= 6) {
let g = 0;
while (pj.step === 5 && ++g < 400) {
const s = pj.playproc.currseat, h = P.get_seat_cards(pj, s);
let p = null;
if (s === pj.playproc.start) p = [h[h.length - 1]];
else for (const x of h) if (A.can_followcard(pj.flower, h, [x], pj.playproc.startcount, pj.playproc.startflower, pj.playproc.starttype).result) { p = [x]; break; }
if (!p) break;
mod.chupai(pk(s, { cards: p }));
}
}
return { c, pj };
}
// 各 RPC 在「本阶段的正确发起者 + 正确入参」下发一次;返回是否被受理
const FIRE = {
jiaofen: (c, pj) => { const s = pj.step === 1 ? pj.method.get_callgrade_seat() : 0; mod.jiaofen(pk(s, { call: 65 })); },
xuanzhu: (c, pj) => mod.xuanzhu(pk(pj.banker >= 0 ? pj.banker : 0, { flower: 1 })),
touxiang: (c, pj) => mod.touxiang(pk(pj.banker >= 0 ? pj.banker : 0)),
maipai: (c, pj) => { const b = pj.banker >= 0 ? pj.banker : 0; const h = P.get_seat_cards(pj, b); mod.maipai(pk(b, { cards: h.slice(-8) })); },
chupai: (c, pj) => {
const s = (pj.playproc && pj.playproc.currseat >= 0) ? pj.playproc.currseat : (pj.banker >= 0 ? pj.banker : 0);
const h = P.get_seat_cards(pj, s);
mod.chupai(pk(s, { cards: h.length ? [h[h.length - 1]] : [0] }));
},
mingpai: (c, pj) => {
// design §9:明牌按钮在【有人报无主之后】才出现,故 step5 还要先满足这个前置
if (pj.step === 5) { const s2 = (pj.banker + 1) % 3; for (const cd of P.get_seat_zhucards(pj, s2)) pj.cards[cd].playround = 1; pj.seatlist[s2][4][0] = 0; }
mod.mingpai(pk(pj.banker >= 0 ? (pj.banker + 1) % 3 : 1));
},
tishi: (c, pj) => mod.tishi(pk(pj.banker >= 0 ? (pj.banker + 1) % 3 : 1, { tip: 1 })),
zhunbei: (c, pj) => mod.zhunbei(pk(0))
};
// design 允许的 (rpc, step):其余阶段一律应回 STEP
const ALLOWED = { jiaofen: [1], xuanzhu: [2], touxiang: [2], maipai: [3], chupai: [5], mingpai: [5], tishi: [5], zhunbei: [6] };
const STEPS = [1, 2, 3, 5, 6];
const bad = [];
let cells = 0; // 实际比对的格数,防止驱动失败导致整行"跳过"而假绿
console.log('阶段机迁移矩阵(行=RPC,列=阶段;✓受理 ×拒(STEP) ?拒(其他码))');
console.log(' step1 step2 step3 step5 step6');
for (const rpc in ALLOWED) {
const row = [];
for (const st of STEPS) {
// touxiang 需要 call=70 才有资格;其余用 65
const { c, pj } = driveTo(st, rpc === 'touxiang' ? 70 : 65);
if (pj.step !== st) { row.push(' 跳过'); continue; }
c.sent.length = 0;
FIRE[rpc](c, pj);
const fails = c.sent.filter(m => m.data && m.data.success === false);
const accepted = c.sent.length > 0 && fails.length === 0;
const stepRejected = fails.length > 0 && fails[0].data.errcode === ERR.STEP;
const should = ALLOWED[rpc].indexOf(st) >= 0;
let mark;
if (accepted) mark = ' ✓ ';
else if (stepRejected) mark = ' × ';
else if (fails.length) mark = ' ?' + fails[0].data.errcode + ' ';
else mark = ' 无包 ';
row.push(mark); cells++;
if (should && !accepted) bad.push(`${rpc} 在 step${st} 应受理却未受理(${mark.trim()})`);
if (!should && accepted) bad.push(`${rpc} 在 step${st} 不应受理却受理了`);
if (!should && !accepted && !stepRejected && fails.length) bad.push(`${rpc} 在 step${st} 被拒但错误码不是 STEP(${fails[0].data.errcode})`);
if (!should && !fails.length) bad.push(`${rpc} 在 step${st} 既没受理也没回失败包(静默丢弃)`);
}
console.log(rpc.padEnd(10) + row.join(''));
}
t.eq('§12.1 阶段机迁移矩阵(8 RPC × 5 阶段 = 40 格)无异常', bad.slice(0, 3), []);
t.eq('阶段机矩阵覆盖 40 格', cells, 40);
// ===================== 叫分起始者规则 =====================
console.log('\n叫分起始者规则(design §4.2 + §4.6)');
const errs2 = [];
const c2 = mkRoom('00000');
D.do_new_paiju(c2.desk, 0);
let pj = c2.desk.method.curr_paiju();
if (pj.firstseat !== 0) errs2.push('第一局起始叫分者应为座位0,实为 ' + pj.firstseat);
if (pj.method.get_callgrade_seat() !== 0) errs2.push('第一局待叫者应为座位0');
// 首家不能不叫
c2.sent.length = 0; mod.jiaofen(pk(0, { call: 0 }));
if (!(c2.sent.length === 1 && c2.sent[0].data.success === false && c2.sent[0].data.errcode === ERR.RULE)) errs2.push('首家"不叫"未被按 RULE 拒');
if (pj.method.get_callgrade_seat() !== 0) errs2.push('首家被拒后仍应轮到他自己');
t.eq('§4.6 第一局起始叫分者 = 座位 0', [pj.firstseat, 0], [0, 0]);
// 跨局:起始叫分者 = 暂定庄家(庄赢连庄 / 庄输下家)
const seen = { 0: 0, 1: 0, 2: 0 };
for (let r = 1; r <= 8; r++) {
const cur = c2.desk.method.curr_paiju();
if (cur.method.get_callgrade_seat() !== cur.firstseat) errs2.push('第' + r + '局待叫者≠firstseat');
// 该局首家不叫必须被拒
c2.sent.length = 0; mod.jiaofen(pk(cur.firstseat, { call: 0 }));
if (!(c2.sent.length === 1 && c2.sent[0].data.errcode === ERR.RULE)) errs2.push('第' + r + '局首家不叫未被拒');
// 正常打完这一局
mod.jiaofen(pk(cur.method.get_callgrade_seat(), { call: 65 }));
if (cur.step === 1) mod.jiaofen(pk(cur.method.get_callgrade_seat(), { call: 0 }));
if (cur.step === 1) mod.jiaofen(pk(cur.method.get_callgrade_seat(), { call: 0 }));
const b = cur.banker; seen[b]++;
mod.xuanzhu(pk(b, { flower: 1 }));
mod.maipai(pk(b, { cards: P.get_seat_cards(cur, b).slice(-8) }));
let g = 0;
while (cur.step === 5 && ++g < 400) {
const s = cur.playproc.currseat, h = P.get_seat_cards(cur, s);
let p = null;
if (s === cur.playproc.start) p = [h[h.length - 1]];
else for (const x of h) if (A.can_followcard(cur.flower, h, [x], cur.playproc.startcount, cur.playproc.startflower, cur.playproc.starttype).result) { p = [x]; break; }
if (!p) break;
mod.chupai(pk(s, { cards: p }));
}
const expNext = (cur.result === 0) ? b : (b + 1) % 3;
if (r < 6) {
mod.zhunbei(pk(0)); mod.zhunbei(pk(1)); mod.zhunbei(pk(2));
const nx = c2.desk.method.curr_paiju();
if (nx === cur) { errs2.push('第' + r + '局后未开新局'); break; }
if (nx.firstseat !== expNext) errs2.push('第' + (r + 1) + '局起始叫分者应为 ' + expNext + ',实为 ' + nx.firstseat);
} else break;
}
t.eq('§4.2/§4.6 叫分起始者与首家必叫(连打 6 局)无异常', errs2.slice(0, 3), []);
// ===================== 连庄分支(result=0):庄赢时起始叫分者不变 =====================
console.log('\n连庄分支(design §4.6 庄赢→暂定庄家仍为该玩家)');
let got = false;
for (let attempt = 0; attempt < 60 && !got; attempt++) {
const c3 = mkRoom('00000');
D.do_new_paiju(c3.desk, 0);
const p3 = c3.desk.method.curr_paiju();
// 叫 70:闲家要捡满 70 分才算达标,庄家最易守;首家出最大牌以多赢墩
mod.jiaofen(pk(p3.method.get_callgrade_seat(), { call: 70 }));
if (p3.step === 1) mod.jiaofen(pk(p3.method.get_callgrade_seat(), { call: 0 }));
if (p3.step === 1) mod.jiaofen(pk(p3.method.get_callgrade_seat(), { call: 0 }));
const b = p3.banker;
mod.xuanzhu(pk(b, { flower: 1 }));
mod.maipai(pk(b, { cards: P.get_seat_cards(p3, b).slice(-8) }));
let g = 0;
while (p3.step === 5 && ++g < 400) {
const s = p3.playproc.currseat, h = P.get_seat_cards(p3, s);
let p = null;
if (s === p3.playproc.start) p = [h[0]];
else for (let i = h.length - 1; i >= 0; i--) if (A.can_followcard(p3.flower, h, [h[i]], p3.playproc.startcount, p3.playproc.startflower, p3.playproc.starttype).result) { p = [h[i]]; break; }
if (!p) break;
mod.chupai(pk(s, { cards: p }));
}
if (p3.result !== 0) continue;
got = true;
mod.zhunbei(pk(0)); mod.zhunbei(pk(1)); mod.zhunbei(pk(2));
const nx = c3.desk.method.curr_paiju();
t.eq('§4.6 庄赢(result=0) → 下一局起始叫分者仍是该庄家(连庄)', nx.firstseat, b);
// 连庄局的首家同样不能不叫
c3.sent.length = 0; mod.jiaofen(pk(nx.firstseat, { call: 0 }));
t.eq('§4.2 连庄局的首家同样不能"不叫"', c3.sent.length === 1 && c3.sent[0].data.errcode === ERR.RULE, true);
}
t.eq('§4.6 连庄分支已覆盖到(构造出 result=0 的庄赢局)', got, true);
process.exit(t.done('flow') ? 0 : 1);
+139
View File
@@ -0,0 +1,139 @@
// §5.1/5.2 正常跟牌/毙牌/垫牌(can_followcard 纯函数)。期望值取自 design §5.2
require('./_shim');
const A = require('../class.arith.js');
const t = require('./_assert')();
const id = (d, f, n) => (d - 1) * 54 + (f - 1) * 13 + (n - 1);
const MF = 1; // 主花色=方块(1)
// 副花色2的牌(number 避开 2/7,否则是固定主)
const K = d => id(d, 2, 13), Q = d => id(d, 2, 12), J = d => id(d, 2, 11), T = d => id(d, 2, 10), N9 = d => id(d, 2, 9), N8 = d => id(d, 2, 8);
// 主牌(flower1)
const zK = d => id(d, 1, 13), zQ = d => id(d, 1, 12), zJ = d => id(d, 1, 11), z9 = d => id(d, 1, 9);
// 另一副花色3(用于垫牌)
const oth = n => id(1, 3, n);
// follow(inhand, cards, startcount, startflower, startcardtype) -> can_followcard 结果
const f = (inhand, cards, cnt, flw, typ) => A.can_followcard(MF, inhand, cards, cnt, flw, typ);
const res = r => r.result; // 能否跟
const beat = r => (r.cardvalue || 0) > 0; // 是否压过(cardvalue>0)
// ===================== 单张跟牌(首家 副K 单张,flower2, 101)=====================
// §5.2: 跟同花色任意单张;缺门则任意补(毙牌用主牌可压、垫副牌不压)
t.eq('单-跟同花色单张(合法)', res(f([N9(1), z9(1)], [N9(1)], 1, 2, 101)), true);
t.eq('单-有同花色却出主牌(拒)', res(f([N9(1), zK(1)], [zK(1)], 1, 2, 101)), false);
t.eq('单-缺门垫副牌(合法)', res(f([oth(9), zK(1)], [oth(9)], 1, 2, 101)), true);
t.eq('单-缺门垫副牌不压', beat(f([oth(9), zK(1)], [oth(9)], 1, 2, 101)), false);
t.eq('单-缺门毙牌主单(合法)', res(f([oth(9), zK(1)], [zK(1)], 1, 2, 101)), true);
t.eq('单-缺门毙牌主单压过', beat(f([oth(9), zK(1)], [zK(1)], 1, 2, 101)), true);
// ===================== 对子跟牌(首家 副K对,flower2, 201, 2张)=====================
// §2: 有同花色对必出对;没对用两张同花色单;不足两张任意补;缺门毙牌必须主对(不能任意两张)
t.eq('对-有同花色对必出对(合法)', res(f([N9(1), N9(2), N8(1)], [N9(1), N9(2)], 2, 2, 201)), true);
t.eq('对-有对却出两散张(拒)', res(f([N9(1), N9(2), N8(1)], [N9(1), N8(1)], 2, 2, 201)), false);
t.eq('对-无对出两同花色单(合法)', res(f([N9(1), N8(1), Q(1)], [N9(1), N8(1)], 2, 2, 201)), true);
t.eq('对-缺门毙主对(合法且压)', beat(f([zK(1), zK(2), oth(9)], [zK(1), zK(2)], 2, 2, 201)), true);
t.eq('对-缺门两散主顶对(可出但不压)', res(f([zK(1), zQ(1), oth(9)], [zK(1), zQ(1)], 2, 2, 201)), true);
t.eq('对-缺门两散主顶对 不压', beat(f([zK(1), zQ(1), oth(9)], [zK(1), zQ(1)], 2, 2, 201)), false);
// ===================== 拖拉机跟牌(首家 副KQ两连对,flower2, 302, 4张)=====================
// §2: 有同花色拖拉机必出拖;无拖但有两对不连也必出两对
t.eq('拖-有同长拖必出拖(合法)', res(f([J(1), J(2), T(1), T(2), N8(1)], [J(1), J(2), T(1), T(2)], 4, 2, 302)), true);
t.eq('拖-有拖却出4散(拒)', res(f([J(1), J(2), T(1), T(2), N8(1)], [J(1), T(1), N9(1), N8(1)], 4, 2, 302)), false);
t.eq('拖-无拖两对不连必出两对(合法)', res(f([J(1), J(2), N9(1), N9(2), N8(1)], [J(1), J(2), N9(1), N9(2)], 4, 2, 302)), true);
t.eq('拖-有两对却出散张(拒)', res(f([J(1), J(2), N9(1), N9(2), N8(1)], [J(1), N9(1), N8(1), Q(1)], 4, 2, 302)), false);
t.eq('拖-缺门毙同长主拖(合法且压)', beat(f([zK(1), zK(2), zQ(1), zQ(2), oth(9)], [zK(1), zK(2), zQ(1), zQ(2)], 4, 2, 302)), true);
const N6 = d => id(d, 2, 6);
// ===================== 主牌首出的跟牌(followers 必须跟主牌)=====================
t.eq('主单 有主必跟主', res(f([zQ(1), oth(9)], [zQ(1)], 1, 1, 101)), true);
t.eq('主单 有主却垫副→拒', res(f([zQ(1), oth(9)], [oth(9)], 1, 1, 101)), false);
t.eq('主单 缺主垫副(合法不压)', res(f([oth(9), oth(8)], [oth(9)], 1, 1, 101)), true);
t.eq('主单 缺主垫副 不压', beat(f([oth(9), oth(8)], [oth(9)], 1, 1, 101)), false);
t.eq('主对 有主对必出对', res(f([zJ(1), zJ(2), z9(1)], [zJ(1), zJ(2)], 2, 1, 201)), true);
t.eq('主对 有主对却拆散→拒', res(f([zJ(1), zJ(2), z9(1)], [zJ(1), z9(1)], 2, 1, 201)), false);
// ===================== 对子首出·同花色不足两张(必出仅有的1张+补)=====================
t.eq('对 同花1张必出它+补1', res(f([N9(1), oth(5), oth(6)], [N9(1), oth(5)], 2, 2, 201)), true);
t.eq('对 同花1张却不出它→拒', res(f([N9(1), oth(5), oth(6)], [oth(5), oth(6)], 2, 2, 201)), false);
// ===================== 拖拉机首出·跟牌方各手牌分支 =====================
t.eq('拖 对子不够·必出1对+补', res(f([J(1), J(2), N9(1), N8(1), N6(1)], [J(1), J(2), N9(1), N8(1)], 4, 2, 302)), true);
t.eq('拖 对子不够·不出对→拒', res(f([J(1), J(2), N9(1), N8(1), N6(1)], [N9(1), N8(1), N6(1), Q(1)], 4, 2, 302)), false);
t.eq('拖 同花无对·补同花4张', res(f([K(1), Q(1), J(1), T(1), N9(1)], [K(1), Q(1), J(1), T(1)], 4, 2, 302)), true);
// 多候选拖拉机(3连对里选1个2连拖)——回归 can_followcard 曾崩溃的 cantype=3xx 分支
t.eq('拖 多候选·出合法2连拖', res(f([K(1), K(2), Q(1), Q(2), J(1), J(2)], [K(1), K(2), Q(1), Q(2)], 4, 2, 302)), true);
t.eq('拖 多候选·出不连两对→拒(D1回归)', res(f([K(1), K(2), Q(1), Q(2), J(1), J(2)], [K(1), K(2), J(1), J(2)], 4, 2, 302)), false);
// 3连对首出(303)
t.eq('拖 3连对·有3连拖必出', res(f([K(1), K(2), Q(1), Q(2), J(1), J(2), N8(1)], [K(1), K(2), Q(1), Q(2), J(1), J(2)], 6, 2, 303)), true);
// 3连对首出、跟牌方有富余对子(4对)但含1个3连拖 → 必出该3连拖,拆散→拒
t.eq('拖 3连对·富余对子中出3连拖', res(f([K(1), K(2), Q(1), Q(2), J(1), J(2), N9(1), N9(2)], [K(1), K(2), Q(1), Q(2), J(1), J(2)], 6, 2, 303)), true);
t.eq('拖 3连对·拆散3连拖(改出99)→拒', res(f([K(1), K(2), Q(1), Q(2), J(1), J(2), N9(1), N9(2)], [K(1), K(2), Q(1), Q(2), N9(1), N9(2)], 6, 2, 303)), false);
// —— 最大覆盖度校验:修复 get_followcard 降级递归 else 分支欠约束 ——
// 跟牌方该副花色 = AA KK JJ TT 88 66(三组互不相邻的2连对:{A,K} {J,T} {8,6})
const sA = d => id(d, 2, 1); // 副A(flower2, number1;非固定主)
const H3 = [sA(1), sA(2), K(1), K(2), J(1), J(2), T(1), T(2), N8(1), N8(2), N6(1), N6(2)];
// 303:无3连拖、有多组2连拖 → 必出1个2连拖+1散对(档案[2,1]);出三孤立对(档案[1,1,1])应拒
t.eq('拖 303·三组2连·出2连+散(合法)', res(f(H3, [sA(1), sA(2), K(1), K(2), N8(1), N8(2)], 6, 2, 303)), true);
t.eq('拖 303·三组2连·出三孤立对AA/JJ/88→拒(else回归)', res(f(H3, [sA(1), sA(2), J(1), J(2), N8(1), N8(2)], 6, 2, 303)), false);
t.eq('拖 303·三组2连·另组KK/TT/66孤立→拒', res(f(H3, [K(1), K(2), T(1), T(2), N6(1), N6(2)], 6, 2, 303)), false);
// 304:无4连拖、有三组2连拖 → 必出两个2连拖(档案[2,2]);出1个2连+两散对(档案[2,1,1])应拒
t.eq('拖 304·三组2连·出两2连拖(合法)', res(f(H3, [sA(1), sA(2), K(1), K(2), J(1), J(2), T(1), T(2)], 8, 2, 304)), true);
t.eq('拖 304·三组2连·出1个2连+两散对→拒(else回归)', res(f(H3, [sA(1), sA(2), K(1), K(2), J(1), J(2), N8(1), N8(2)], 8, 2, 304)), false);
// ===================== 手牌数 == 首出张数:全部必出 =====================
t.eq('全出 手牌恰1张(单张首出)', res(f([oth(9)], [oth(9)], 1, 2, 101)), true);
t.eq('全出 手牌恰2张(对子首出)', res(f([oth(9), oth(8)], [oth(9), oth(8)], 2, 2, 201)), true);
// ===================== 垫牌:缺门首家副花色,用其他副牌补,不争夺出牌权 =====================
// (单张垫牌上文已覆盖;此处补对子/拖拉机首家时的副牌垫)
t.eq('垫 对子缺门出两副散(合法)', res(f([oth(9), oth(8), zK(1)], [oth(9), oth(8)], 2, 2, 201)), true);
t.eq('垫 对子缺门出两副散 不压', beat(f([oth(9), oth(8), zK(1)], [oth(9), oth(8)], 2, 2, 201)), false);
t.eq('垫 拖缺门出四副散(合法)', res(f([oth(9), oth(8), oth(6), oth(5), zK(1)], [oth(9), oth(8), oth(6), oth(5)], 4, 2, 302)), true);
t.eq('垫 拖缺门出四副散 不压', beat(f([oth(9), oth(8), oth(6), oth(5), zK(1)], [oth(9), oth(8), oth(6), oth(5)], 4, 2, 302)), false);
// ===================== 混合出牌:有该花色但不够,只能补主牌,等效垫牌不争权 =====================
// 首家副对子:手中仅1张该副花色 + 主牌(无其他副牌)→ 必出该副 + 补主,混合牌压不过
t.eq('混 对子·1副+补主(合法)', res(f([N9(1), zK(1), zQ(1)], [N9(1), zK(1)], 2, 2, 201)), true);
t.eq('混 对子·1副+补主 不压(等效垫)', beat(f([N9(1), zK(1), zQ(1)], [N9(1), zK(1)], 2, 2, 201)), false);
t.eq('混 对子·丢下必出的副牌只出两主→拒', res(f([N9(1), zK(1), zQ(1)], [zK(1), zQ(1)], 2, 2, 201)), false);
// 首家副拖拉机:手中2张该副花色散张 + 主牌 → 必出2副 + 补2主,混合压不过
t.eq('混 拖·2副散+补2主(合法)', res(f([N9(1), N8(1), zK(1), zQ(1), zJ(1)], [N9(1), N8(1), zK(1), zQ(1)], 4, 2, 302)), true);
t.eq('混 拖·2副散+补2主 不压', beat(f([N9(1), N8(1), zK(1), zQ(1), zJ(1)], [N9(1), N8(1), zK(1), zQ(1)], 4, 2, 302)), false);
// 首家副拖拉机:手中1对该副花色(数量仍不足) + 主牌 → 有对也压不过(混合非纯主拖)
t.eq('混 拖·副对(不足)+补2主 不压', beat(f([N9(1), N9(2), zK(1), zQ(1), zJ(1)], [N9(1), N9(2), zK(1), zQ(1)], 4, 2, 302)), false);
// ===================== 入参顺序无关性(服务端不得依赖客户端提交的数组顺序)=====================
// 回归:can_followcard 尾段曾用入参原始顺序的 followcards 算 cardvalue/noflower/nopair,
// 而 get_pairlist/get_tuolaji_list 按降序相邻取对——客户端只要打乱数组顺序,
// 就能把本该压过的主拖拉机算成牌面 0(改写本轮胜者/捡分/扣底),或抹掉缺门标志污染 §9 牌况表。
const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
// 主拖拉机毙牌:有序 vs 乱序,结果必须完全一致且都判为「压过」
const HT = [zK(1), zK(2), zQ(1), zQ(2), oth(9)];
const ht1 = f(HT, [zK(1), zK(2), zQ(1), zQ(2)], 4, 2, 302); // 降序
const ht2 = f(HT, [zK(1), zQ(1), zK(2), zQ(2)], 4, 2, 302); // 乱序(对子被拆开摆放)
t.eq('序无关 主拖毙牌·有序压过', beat(ht1), true);
t.eq('序无关 主拖毙牌·乱序同样压过', beat(ht2), true);
t.eq('序无关 主拖毙牌·两种顺序结果全等', same(ht1, ht2), true);
// 主对子毙牌
const HP = [zK(1), zK(2), oth(9)];
t.eq('序无关 主对毙牌·两种顺序结果全等',
same(f(HP, [zK(1), zK(2)], 2, 2, 201), f(HP, [zK(2), zK(1)], 2, 2, 201)), true);
// 同花色拖拉机跟牌(非毙牌)的牌面值也不得随顺序变
const HS = [J(1), J(2), T(1), T(2), N8(1)];
t.eq('序无关 同花色跟拖·两种顺序结果全等',
same(f(HS, [J(1), J(2), T(1), T(2)], 4, 2, 302), f(HS, [J(1), T(1), J(2), T(2)], 4, 2, 302)), true);
// 混合出牌:3 张该副花色 + 1 主牌。把副牌摆在首尾也必须记为「已缺该花色」
const HM = [K(1), Q(1), J(1), zK(1)];
const hm1 = f(HM, [zK(1), K(1), Q(1), J(1)], 4, 2, 302); // 降序:主牌在首
const hm2 = f(HM, [K(1), zK(1), Q(1), J(1)], 4, 2, 302); // 乱序:首尾都是副牌
t.eq('序无关 混合出牌·有序记缺门', hm1.noflower, true);
t.eq('序无关 混合出牌·乱序同样记缺门', hm2.noflower, true);
t.eq('序无关 混合出牌·两种顺序结果全等', same(hm1, hm2), true);
process.exit(t.done('follow') ? 0 : 1);
@@ -0,0 +1,236 @@
// §5.1/§5.2 跟牌强制层级 · 穷举差分测试
//
// 做法:把 design §5.2 的裁定规则**按原文另写一份参考实现**(本文件上半部分,只依据 design
// 的文字,不看 class.arith 的实现),再随机造手牌、**枚举该手牌的全部 C 张出牌组合**,
// 逐个比对「参考实现允许 vs can_followcard 允许」。任何一侧多放行或多拒绝都会被抓到。
//
// 这类差分比 test_follow.js 的手写正反例更能兜住实现里的欠约束分支
// (get_followcard 降级递归的 else 分支就曾在这类场景漏判)。
// 随机源用**固定种子的 PRNG**,不用 Math.random —— 同一份代码每次跑的用例集完全相同,不抖动。
require('./_shim');
const A = require('../class.arith.js');
const t = require('./_assert')();
const MF = 4; // 主花色 = 黑桃
const id = (d, f, n) => (d - 1) * 54 + (f - 1) * 13 + (n - 1);
const WG = (d, big) => (d - 1) * 54 + (big ? 53 : 52);
// ==================== 参考实现(逐条转写自 design §3/§5.2/§5.3)====================
const num = c => { const m = c % 54; return m === 52 ? 53 : m === 53 ? 54 : m % 13 + 1; };
const flw = c => { const m = c % 54; return m >= 52 ? 5 : Math.floor(m / 13) + 1; };
// §3:主牌 = 大小王 + 所有花色的 2 和 7 + 主花色普通牌
const isTrump = c => { const n = num(c); return n >= 53 || n === 2 || n === 7 || flw(c) === MF; };
// 跟牌时的「有效花色」:主牌一律归主花色
const eflw = c => isTrump(c) ? MF : flw(c);
// §3 主牌顺序 / §5.3 相邻链上的位置(越小越大);副7、副2 各自只占一个等级
const NORMAL = [1, 13, 12, 11, 10, 9, 8, 6, 5]; // A K Q J 10 9 8 6 5
function pos(c) {
const n = num(c), f = flw(c);
if (n === 54) return 0;
if (n === 53) return 1;
if (n === 7) return f === MF ? 2 : 3;
if (n === 2) return f === MF ? 4 : 5;
if (f === MF) return 6 + NORMAL.indexOf(n);
return 100 + NORMAL.indexOf(n); // 副牌:只在同花色内比较
}
// §3「对子指同一张具体牌的两副」= 同花色同点数
const samecard = (a, b) => num(a) === num(b) && flw(a) === flw(b);
// 取对子(返回每对的代表牌,按大到小)
function pairsOf(cs) {
const used = [], ps = [];
for (let i = 0; i < cs.length; i++) {
if (used[i]) continue;
for (let j = i + 1; j < cs.length; j++) {
if (!used[j] && samecard(cs[i], cs[j])) { used[i] = used[j] = 1; ps.push(cs[i]); break; }
}
}
ps.sort((a, b) => pos(a) - pos(b));
return ps;
}
// §5.3:相邻对子连成拖拉机 → 各连对段的长度,降序(孤立对子长度 1)
function runsOf(cs) {
const ps = pairsOf(cs), runs = [];
let cur = 1;
for (let i = 0; i + 1 < ps.length; i++) {
if (pos(ps[i + 1]) - pos(ps[i]) === 1) cur++;
else { runs.push(cur); cur = 1; }
}
if (ps.length) runs.push(cur);
runs.sort((a, b) => b - a);
return runs;
}
// §5.2 层级2「能凑多长的拖拉机,就必须先凑多长」→ 最长优先的贪心规格档案
function bestProfile(runs, k) {
const av = runs.concat().sort((a, b) => b - a), p = [];
let budget = k, i = 0;
while (budget > 0 && i < av.length) { const take = Math.min(av[i], budget); p.push(take); budget -= take; i++; }
return p;
}
function lessProfile(a, b) {
const n = Math.min(a.length, b.length);
for (let i = 0; i < n; i++) { if (a[i] < b[i]) return true; if (a[i] > b[i]) return false; }
return a.length < b.length;
}
// 参考裁定:这手 X 是不是 design §5.2 允许的跟牌
function refOk(hand, X, C, F, T) {
if (X.length !== C) return false;
const S = hand.filter(c => eflw(c) === F);
if (S.length === 0) return true; // §5.2 完全缺门:整手可任意出
if (S.length < C) return S.every(c => X.indexOf(c) >= 0); // §5.2 该花色不够:全出 + 缺口任意补
if (X.filter(c => eflw(c) === F).length !== C) return false; // 够:必须全打同花色
if (T > 100 && T < 200) return true; // 单张 / 多张单张
if (T === 201) return pairsOf(S).length === 0 ? true : pairsOf(X).length >= 1; // 有对必出对
if (T > 201 && T < 300) return true; // 多对(甩牌压平):分量约束在 flush_follow_ok
if (T > 300 && T < 400) { // N 连对拖拉机
const N = T - 300, k = Math.min(pairsOf(S).length, N); // 能凑几对就必须凑几对(层级 3/4/5)
if (pairsOf(X).length !== k) return false;
return !lessProfile(runsOf(X), bestProfile(runsOf(S), k)); // 且拖拉机规格不得劣于最优(层级 1/2)
}
return true;
}
// ==================== 穷举差分 ====================
// 固定种子的 xorshift32,保证用例集可复现
let _seed = 0x2717CAFE;
function rnd(n) { _seed ^= _seed << 13; _seed ^= _seed >>> 17; _seed ^= _seed << 5; _seed >>>= 0; return _seed % n; }
function combos(arr, k) {
const out = [];
(function rec(st, cur) {
if (cur.length === k) { out.push(cur.slice()); return; }
for (let i = st; i < arr.length; i++) { cur.push(arr[i]); rec(i + 1, cur); cur.pop(); }
})(0, []);
return out;
}
// 造牌器必须**按牌型结构**造手牌,不能纯随机抽:纯随机几乎抽不出「同花色 3 对以上、
// 且含多组不同长度拖拉机」的手,而 §5.2 层级 1/2 的强制约束恰恰只在那种手上才生效
// (实测纯随机版关掉 follow_tractor_cover_ok 也全绿 = 差分形同虚设)。
// 这里按「等级表」逐级选取 0/1/2 张,天然产出富对子、富连对的手牌。
// 副花色(梅花)的等级表:A K Q J 10 9 8 6 5(相邻即连对)
const SUB_RANKS = NORMAL.map(n => [id(1, 2, n), id(2, 2, n)]);
// 主牌的等级表 = design §5.3 的相邻链:大王-小王-正7-副7-正2-副2-主A…主5
const TRUMP_RANKS = [
[WG(1, true), WG(2, true)], // 大王
[WG(1, false), WG(2, false)], // 小王
[id(1, MF, 7), id(2, MF, 7)], // 正7
[id(1, 3, 7), id(2, 3, 7)], // 副7(红心)
[id(1, MF, 2), id(2, MF, 2)], // 正2
[id(1, 3, 2), id(2, 3, 2)] // 副2(红心)
].concat(NORMAL.map(n => [id(1, MF, n), id(2, MF, n)]));
// 同级但不同花色的另一张副7/副2(§3:跨花色不成对,用于压边界)
const SAME_LEVEL = [id(1, 2, 7), id(2, 2, 7), id(1, 2, 2), id(2, 2, 2)];
// 垫牌用的第三花色
const FILLER = [];
[1, 13, 12, 10, 8, 5].forEach(n => FILLER.push(id(1, 3, n), id(2, 3, n)));
function buildHand(F, C) {
const ranks = (F === MF) ? TRUMP_RANKS : SUB_RANKS;
// 覆盖 §5.2 的四类分支:完全缺门 / 该花色不够 / 恰好够 / 有富余
const mode = rnd(4);
let targetSuit;
if (mode === 0) targetSuit = 0;
else if (mode === 1) targetSuit = 1 + rnd(Math.max(1, C - 1));
else if (mode === 2) targetSuit = C;
else targetSuit = C + rnd(4);
const suit = [];
// 从随机起点顺链扫,每级取 0/1/2 张(偏向取 2 张以造出对子与连对)
let idx = rnd(ranks.length);
for (let step = 0; step < ranks.length && suit.length < targetSuit; step++) {
const r = ranks[(idx + step) % ranks.length];
const w = rnd(10);
const take = w < 5 ? 2 : w < 8 ? 1 : 0;
for (let q = 0; q < take && suit.length < targetSuit; q++) suit.push(r[q]);
}
// 顺链一圈还没凑够就再补(保证 targetSuit 尽量达成)
for (let step = 0; step < ranks.length && suit.length < targetSuit; step++) {
const r = ranks[step];
for (let q = 0; q < 2 && suit.length < targetSuit; q++) if (suit.indexOf(r[q]) < 0) suit.push(r[q]);
}
// 同级不同花色的副7/副2 偶尔掺一张(只在主牌为跟牌花色时才算同花色)
if (F === MF && rnd(6) === 0) suit.push(SAME_LEVEL[rnd(SAME_LEVEL.length)]);
// 非该花色的补充牌:垫牌花色 + (副牌首出时)主牌,用于毙牌/垫牌/混合出牌分支
const others = [];
const otherPool = (F === MF) ? FILLER.concat() : FILLER.concat(TRUMP_RANKS.reduce((a, r) => a.concat(r), []));
const oc = rnd(4);
for (let i = 0; i < oc && otherPool.length; i++) others.push(otherPool.splice(rnd(otherPool.length), 1)[0]);
// 去重后返回(同级不同花色那一张可能与 others 撞车)
const seen = {}, hand = [];
suit.concat(others).forEach(c => { if (!seen[c]) { seen[c] = 1; hand.push(c); } });
return hand;
}
// [张数, 首家花色, 首家牌型, 说明]
const LEADS = [
[1, 2, 101, '副牌单张'],
[2, 2, 201, '副牌对子'],
[4, 2, 302, '副牌两连对'],
[6, 2, 303, '副牌三连对'],
[8, 2, 304, '副牌四连对'],
[1, MF, 101, '主牌单张'],
[2, MF, 201, '主牌对子'],
[4, MF, 302, '主牌两连对'],
[6, MF, 303, '主牌三连对'],
[3, MF, 103, '主牌甩三单张(压平)'],
[4, MF, 202, '主牌甩两对(压平)']
];
// 结构化手牌(确定性):随机造牌打不到的关键结构在此显式钉住,
// 不让 PRNG 决定 §5.2 层级 1/2 这类强制约束到底有没有被覆盖。
// 例如「同花色三组互不相邻的两连对」共 12 张,是唯一能让「只出零散对子」与
// 「先凑最长拖拉机」产生不同结果的形状——少于 3 组时任取 3 对必含相邻对,测不出差别。
function structHands(F) {
const R = (F === MF) ? TRUMP_RANKS : SUB_RANKS;
const p = i => R[i]; // 第 i 级的一对
const one = i => [R[i][0]]; // 第 i 级的单张
const cat = (...xs) => [].concat(...xs);
return [
cat(p(0), p(1), p(3), p(4), p(6), p(7)), // 三组两连对(互不相邻)
cat(p(0), p(1), p(2), p(4), p(5), p(7)), // 一个三连对 + 一个两连对 + 一个孤立对
cat(p(0), p(2), p(4), p(6), p(8)), // 五个孤立对(无任何拖拉机)
cat(p(0), p(1), p(2), p(3), p(5)), // 一个四连对 + 一个孤立对
cat(p(0), p(1), p(3), one(5), one(7), one(8)), // 两连对 + 孤立对 + 三张单
cat(p(0), one(2), one(4), one(6), one(8)), // 一对 + 四张单(对子不够)
cat(one(0), one(1), one(2), one(3), one(4), one(5), one(6)), // 全单张(无对)
cat(p(0), p(1), p(2)), // 恰好一个三连对
cat(p(0), p(1)), // 恰好一个两连对
cat(one(0), one(1)), // 两张单
[], // 完全缺门
cat(one(0)), // 该花色仅 1 张(不够)
cat(p(0), one(2)) // 该花色 3 张(对子 + 单)
].map(suit => {
// 掺几张别的花色,制造垫牌 / 毙牌 / 混合出牌分支;
// 但同花色已经很多时不再掺——否则手牌被撑过枚举上限、整条用例会被静默跳过,
// 而「三组两连对」这类 12 张的关键结构恰恰就在上限边缘
const extra = (F === MF) ? FILLER : FILLER.concat([TRUMP_RANKS[0][0], TRUMP_RANKS[2][0], TRUMP_RANKS[6][0], TRUMP_RANKS[7][0]]);
return suit.concat(extra.slice(0, Math.max(0, Math.min(3, 12 - suit.length))));
});
}
const TRIALS_PER_LEAD = 320;
let grandCombos = 0;
for (const [C, F, T, desc] of LEADS) {
let combosCount = 0;
const bad = [];
const hands = structHands(F);
for (let k = 0; k < TRIALS_PER_LEAD; k++) hands.push(buildHand(F, C));
for (const hand of hands) {
if (hand.length < C || hand.length > 13) continue; // 组合数上界保护
for (const X of combos(hand, C)) {
combosCount++;
const got = A.can_followcard(MF, hand, X, C, F, T).result === true;
const exp = refOk(hand, X, C, F, T);
if (got !== exp && bad.length < 3) bad.push({ hand: hand.slice(), play: X.slice(), code: got, design: exp });
else if (got !== exp) bad.push('...');
}
}
grandCombos += combosCount;
t.eq('§5.2 穷举差分 ' + desc + '(' + combosCount + ' 组)失配', bad, []);
}
// 钉住覆盖规模:枚举数量若骤降说明造牌逻辑退化、差分形同虚设
t.eq('§5.2 差分覆盖组合数 ≥ 20000', grandCombos >= 20000, true);
process.exit(t.done('followdiff') ? 0 : 1);
+290
View File
@@ -0,0 +1,290 @@
// L4 整局模糊 + 每墩胜者差分(design §5/§6/§7/§8/§12)
//
// 为什么需要它:`test_endgame.js` 的驱动器**只会出单张**,所以对子 / 拖拉机 / 甩牌 / 甩错
// 这些牌型在「整局链路」里从未被跑过——单测虽然逐个覆盖了 can_playcard / can_followcard,
// 但它们与 do_playcard / mod.chupai / 结算的**集成**是空白。本文件用带牌型的随机对局补上,
// 并对**每一墩**用独立参考实现重算「谁最大」与服务端比对(不是抽查,是每墩都比)。
//
// 不抖动:随机源是固定种子的 xorshift32,同一份代码每次跑的对局序列完全相同。
// 末尾的「覆盖下限」断言防止造牌/选牌策略退化成全是单张而让本文件形同虚设。
const R = require('./_rpc.js'); const mod = R.mod;
const P = global.cls_youle_erqiwang_paiju, A = global.cls_youle_erqiwang_arith;
const D = require('../class.desk.js');
const t = require('./_assert')();
const GAMES = 20;
let seed = 0x51EED17;
const rnd = n => { seed ^= seed << 13; seed ^= seed >>> 17; seed ^= seed << 5; seed >>>= 0; return seed % n; };
const clone = m => JSON.parse(JSON.stringify(m));
// 提交顺序随机化:真实客户端可以按任意顺序发 cards,服务端的判定不得依赖入参顺序
// (曾有缺陷:跟牌牌面值用未排序的入参算,乱序提交能把主拖拉机毙牌算成 0 而翻转本墩胜负)
// 一半随机打乱,一半用**最坏顺序**:按大小排好后取偶数位再取奇数位,把每个对子都拆开摆
// ([K,K,Q,Q] → [K,Q,K,Q])。这正是按相邻取对的算法会漏判的形状;纯随机打乱在某些牌局下
// 一次都碰不到,所以必须有确定性的最坏顺序兜底。
const shuffle = a => {
if (rnd(2) === 0) {
const s = a.concat().sort((x, y) => pos(x) - pos(y));
return s.filter((_, i) => i % 2 === 0).concat(s.filter((_, i) => i % 2 === 1));
}
const r = a.concat();
for (let i = r.length - 1; i > 0; i--) { const j = rnd(i + 1); const x = r[i]; r[i] = r[j]; r[j] = x; }
return r;
};
const pk = (s, d) => ({ conmode: 0, fromid: s, data: Object.assign({ agentid: 1, playerid: s, gameid: 1, roomcode: 1, seat: s }, d || {}) });
global.min_ontimeout = fn => fn();
// 发牌也接到同一个种子上:本文件末尾的「覆盖下限」断言(甩牌/毙牌/扣底各出现多少次)是
// 发牌相关的,若沿用 _shim 的 Math.random 发牌,稀有事件(尤其扣底)会在某些运行里一次都
// 不出现而让下限断言随机转红——实测 60 次里有 1 次 0 次扣底。这里固定发牌序列,把覆盖变成
// 确定事实。随机发牌的整局覆盖由 test_endgame.js 承担,两者互补。
global.min_random = (a, b) => a + rnd(b - a + 1);
// ==================== 独立参考实现:按 design §3/§5/§6 重算「本墩谁最大」====================
let MF;
const num = c => { const m = c % 54; return m === 52 ? 53 : m === 53 ? 54 : m % 13 + 1; };
const flw = c => { const m = c % 54; return m >= 52 ? 5 : Math.floor(m / 13) + 1; };
const isTrump = c => { const n = num(c); return n >= 53 || n === 2 || n === 7 || flw(c) === MF; };
const eflw = c => isTrump(c) ? MF : flw(c); // 主牌一律归主花色
const NORMAL = [1, 13, 12, 11, 10, 9, 8, 6, 5];
function pos(c) { // design §3 等级位置(越小越大)
const n = num(c), f = flw(c);
if (n === 54) return 0; if (n === 53) return 1;
if (n === 7) return f === MF ? 2 : 3; // 正7 / 副7(副7 同级)
if (n === 2) return f === MF ? 4 : 5; // 正2 / 副2
if (f === MF) return 6 + NORMAL.indexOf(n);
return 100 + NORMAL.indexOf(n); // 副牌只在同花色内比较
}
const samecard = (a, b) => num(a) === num(b) && flw(a) === flw(b); // §3 对子=同花色同点数两副
function pairsOf(cs) {
const used = [], ps = [], s = cs.concat().sort((a, b) => pos(a) - pos(b));
for (let i = 0; i < s.length; i++) {
if (used[i]) continue;
for (let j = i + 1; j < s.length; j++) if (!used[j] && samecard(s[i], s[j])) { used[i] = used[j] = 1; ps.push(s[i]); break; }
}
return ps;
}
function isTractor(cs, N) {
const ps = pairsOf(cs);
if (ps.length !== N || cs.length !== 2 * N) return false;
for (let i = 0; i + 1 < ps.length; i++) if (pos(ps[i + 1]) - pos(ps[i]) !== 1) return false;
return true;
}
// 必须取正值:0 是「不参与本墩争夺」的哨兵,rank 若取负数,哨兵 0 反而数值最大(踩过一次)
const topRank = cs => 1000 - Math.min(...cs.map(pos));
// 某家本墩出牌的牌面值。design §5.1/§5.2:垫牌与混合出牌一律 0(不争夺);
// §5.4.4:首家甩牌时跟牌不可能压过;缺门用纯主牌顶同牌型即「毙牌」,加权盖过任何副牌。
function valueOf(cs, F, T, C) {
const efs = {}; cs.forEach(c => efs[eflw(c)] = 1);
const ks = Object.keys(efs);
if (ks.length > 1) return 0; // 混合出牌
const ef = parseInt(ks[0]);
if (ef !== F && ef !== MF) return 0; // 垫别的副牌
let ok;
if (T > 100 && T < 200) ok = (T === 101 && C === 1);
else if (T === 201) ok = (cs.length === 2 && pairsOf(cs).length === 1);
else if (T > 201 && T < 300) ok = false;
else if (T > 300 && T < 400) ok = isTractor(cs, T - 300);
else ok = false;
if (!ok) return 0;
const r = topRank(cs);
return (ef === MF && F !== MF) ? (100000 + r) : r; // 毙牌
}
// design §6.3 扣底倍数:赢末墩的牌须全是主牌;单张 ×1、主对 ×2、N 连对 ×2N;
// 混合甩牌取其中最高规格。返回 0 表示不扣底。
function refBottomMultiple(cards) {
if (!cards.every(isTrump)) return 0;
const ps = pairsOf(cards);
if (ps.length === 0) return 1;
let best = 1, cur = 1;
for (let i = 0; i + 1 < ps.length; i++) {
if (pos(ps[i + 1]) - pos(ps[i]) === 1) { cur++; if (cur > best) best = cur; } else cur = 1;
}
return 2 * best;
}
// ==================== 对局装配与选牌策略 ====================
function mkRoom(rt) {
const sent = [];
const o_room = {
roomtype: rt, asetcount: 6, roomcode: 1, createtime: 'T0', makewartime: 'T1',
seatlist: [0, 1, 2].map(i => ({ conmode: 0, fromid: i, playerid: 100 + i, nickname: 'P' + i, avatar: '', gameinfo: {} })),
method: { sendpack_toother: m => sent.push(clone(m)) }
};
const desk = D.new(o_room); o_room.o_desk = desk;
// class.desk / class.paiju 走全局 youle_erqiwang,mod.js 走自己的模块对象,两处都要接
global.youle_erqiwang.app = { SendPack: m => sent.push(clone(m)) };
global.youle_erqiwang.import = { check_player: () => o_room, deduct_roomcard: () => { }, save_grade: () => { } };
mod.import = global.youle_erqiwang.import; mod.app = global.youle_erqiwang.app;
return { o_room, desk, sent };
}
// 首家候选:单张 / 对子 / N 连对 / 随机主牌组合(后者可能是合法甩牌,也可能是甩错)
function leadCandidates(hand) {
const out = hand.map(c => [c]);
const bySuit = {};
hand.forEach(c => (bySuit[eflw(c)] = bySuit[eflw(c)] || []).push(c));
for (const f in bySuit) {
const cs = bySuit[f], ps = pairsOf(cs);
const mate = p => cs.filter(x => x !== p && samecard(x, p))[0];
ps.forEach(p => { if (mate(p) !== undefined) out.push([p, mate(p)]); });
for (let n = 2; n <= 4; n++) for (let s = 0; s + n <= ps.length; s++) {
const run = ps.slice(s, s + n);
let okr = true;
for (let q = 0; q + 1 < run.length; q++) if (pos(run[q + 1]) - pos(run[q]) !== 1) okr = false;
if (!okr) continue;
const t2 = [];
run.forEach(p => { t2.push(p); t2.push(mate(p)); });
if (t2.every(x => x !== undefined)) out.push(t2);
}
}
const tr = hand.filter(isTrump);
if (tr.length >= 2) for (let k = 0; k < 6; k++) {
const n = 2 + rnd(3), sel = [], p2 = tr.concat();
while (sel.length < n && p2.length) sel.push(p2.splice(rnd(p2.length), 1)[0]);
if (sel.length >= 2) out.push(sel);
}
return out;
}
function pickLead(pj, seat) {
const hand = P.get_seat_cards(pj, seat);
const opp = [0, 1, 2].filter(o => o !== seat).map(o => P.get_seat_zhucards(pj, o));
// 甩错候选也保留:mod.chupai 会按 §5.4.5 收回整套、只强制打出最小一张,这条路径也要跑到
const cands = leadCandidates(hand).filter(cs => {
const r = A.can_playcard(pj.flower, cs.concat(), seat, pj.seatlist, opp);
return r.result === true || r.shuaicuo === true;
});
if (!cands.length) return [hand[hand.length - 1]];
cands.sort((a, b) => b.length - a.length);
return rnd(3) === 0 ? cands[cands.length - 1] : cands[rnd(Math.min(4, cands.length))];
}
function pickFollow(pj, seat) {
const hand = P.get_seat_cards(pj, seat);
const C = pj.playproc.startcount, F = pj.playproc.startflower, T = pj.playproc.starttype;
const g = A.get_followcard(pj.flower, hand, C, F, T);
const okPick = p => A.can_followcard(pj.flower, hand, p, C, F, T).result === true
&& (!pj.playproc.shuai_demand || A.flush_follow_ok(pj.flower, hand, p, pj.playproc.shuai_demand));
for (let i = 0; i < 400; i++) { // 先按「必出 + 可出」凑(真实客户端的做法)
const pick = (g.mustcard || []).concat(), pool = (g.cancard || []).concat();
while (pick.length < C && pool.length) pick.push(pool.splice(rnd(pool.length), 1)[0]);
if (pick.length === C && okPick(pick)) return pick;
}
for (let i = 0; i < 800; i++) { // 兜底:整手牌里随机取 C 张
const h = hand.concat(), p = [];
while (p.length < C && h.length) p.push(h.splice(rnd(h.length), 1)[0]);
if (p.length === C && okPick(p)) return p;
}
return null;
}
// ==================== 跑局 ====================
const stats = { rounds: 0, tricks: 0, multi: 0, shuai: 0, shuaicuo: 0, bi: 0, kodi: 0 };
const errs = [];
const chk = (c, m) => { if (!c) errs.push(m); };
const CALLS = [70, 65, 60, 55, 50, 45, 40, 35, 30, 25, 20, 15, 10, 5];
const ROOMS = ['00000', '00100', '00010', '00110']; // 常规 / 傍王 / 爬坡 / 傍王+爬坡
for (let game = 0; game < GAMES; game++) {
const c = mkRoom(ROOMS[game % ROOMS.length]);
D.do_new_paiju(c.desk, 0);
const pj = c.desk.method.curr_paiju();
MF = null;
mod.jiaofen(pk(pj.method.get_callgrade_seat(), { call: CALLS[rnd(CALLS.length)] }));
if (pj.step === 1) mod.jiaofen(pk(pj.method.get_callgrade_seat(), { call: 0 }));
if (pj.step === 1) mod.jiaofen(pk(pj.method.get_callgrade_seat(), { call: 0 }));
const b = pj.banker; MF = 1 + rnd(4);
mod.xuanzhu(pk(b, { flower: MF }));
mod.maipai(pk(b, { cards: P.get_seat_cards(pj, b).slice(-8) }));
chk(P.get_seat_cards(pj, b).length === 28, `g${game} §4.5 埋牌后庄家应剩 28 张`);
const allJiesuan = [];
let guard = 0, dead = false, refJian = 0; // refJian:按独立算出的墩胜者累计的闲家捡分
while (pj.step === 5 && ++guard < 400) {
const st = pj.playproc.start;
c.sent.length = 0;
mod.chupai(pk(st, { cards: shuffle(pickLead(pj, st)) }));
const lp = c.sent.filter(m => m.rpc === 'chupai1')[0];
chk(!!lp, `g${game} 首出被拒`);
if (!lp) { dead = true; break; }
if (lp.data.shuaicuo) stats.shuaicuo++;
if (lp.data.shuai) stats.shuai++;
if (lp.data.cards.length > 1) stats.multi++;
const C = pj.playproc.startcount, F = pj.playproc.startflower, T = pj.playproc.starttype;
const trick = [{ seat: st, cards: lp.data.cards }];
let maxseat;
for (let k = 0; k < 2; k++) {
const s2 = pj.playproc.currseat, f2 = pickFollow(pj, s2);
if (f2 === null) { chk(false, `g${game} 找不到合法跟牌 seat${s2} T=${T} C=${C} F=${F}`); dead = true; break; }
chk(f2.length === C, `g${game} §5.2 跟牌张数应等于首出张数`);
const before = c.sent.length;
mod.chupai(pk(s2, { cards: shuffle(f2) }));
const out = c.sent.slice(before);
const ok = out.filter(m => ['chupai2', 'chupai3', 'jiesuan'].indexOf(m.rpc) >= 0);
chk(ok.length > 0 && ok[0].data.success === true, `g${game} 跟牌被拒`);
const fin = out.filter(m => m.rpc === 'jiesuan')[0], c3 = out.filter(m => m.rpc === 'chupai3')[0];
if (fin) { allJiesuan.push(fin); maxseat = fin.data.chupai.maxseat; }
else if (c3) { maxseat = c3.data.maxseat; }
trick.push({ seat: s2, cards: f2 });
}
if (dead) break;
stats.tricks++;
if (F !== MF && trick.slice(1).some(x => x.cards.every(isTrump))) stats.bi++;
// —— 每墩都独立重算胜者并比对 ——
const vals = trick.map(x => valueOf(x.cards, F, T, C));
let bi = 0; for (let v = 1; v < 3; v++) if (vals[v] > vals[bi]) bi = v;
const refWin = trick[bi].seat;
chk(maxseat === refWin,
`g${game} §6.2 墩胜者不符 服务端=${maxseat} 参考=${refWin} T=${T} C=${C} F=${F} MF=${MF} vals=${JSON.stringify(vals)} plays=${JSON.stringify(trick)}`);
// §6.2 本墩全部牌的归属必须是本墩胜者。
// 注意不能只用 aset.grade 去对「按 playowner 累加的捡分」——那是拿同一个字段自证,
// playowner 被写错时两边一起错、断言照样通过(实测过)。这里用**独立算出的胜者**来钉。
trick.forEach(x => x.cards.forEach(cid => {
chk(pj.cards[cid].playowner === refWin, `g${game} §6.2 牌 ${cid} 归属应为本墩胜者 ${refWin},实为 ${pj.cards[cid].playowner}`);
}));
if (refWin !== pj.banker) refJian += trick.reduce((a, x) => a + x.cards.reduce((b, cid) => b + pj.cards[cid].score, 0), 0);
}
if (dead) continue;
chk(pj.step === 6, `g${game} §12.1 未打到结算 step=${pj.step}`);
chk(pj.cards.filter(x => x.playround > 0).length === 84, `g${game} §2 出牌总数应为 84`);
chk(pj.cards.filter(x => x.playround === 0).length === 8, `g${game} §4.5 埋牌应为 8`);
chk(pj.cards.filter(x => x.playround > 0 && x.playowner < 0).length === 0, `g${game} §6.2 有出过的牌无归属`);
chk(allJiesuan.length === 1, `g${game} 结算包应恰好 1 个`);
if (allJiesuan.length === 1) {
const d = allJiesuan[0].data, aset = d.aset;
const bonus = (d.bottom && d.bottom.grade2) || 0;
chk(aset.grade === refJian + bonus, `g${game} §6.2/§6.3 捡分不符 aset=${aset.grade} 独立计=${refJian + bonus}`);
chk(aset.seatlist.reduce((a, p) => a + p.grade, 0) === 0, `g${game} §7/§8 结算不零和`);
// §6.3 扣底:触发条件与**倍数**都要独立算。倍数不能再调 get_bottom_multiple——
// 那是拿被测函数自证,倍数表被改错(比如恒返回 2)照样通过(实测过)。
const lastXian = pj.playproc.maxseat !== pj.banker;
const lastMult = lastXian ? refBottomMultiple(pj.playproc.cards[pj.playproc.maxseat]) : 0;
chk((lastMult > 0) === !!d.bottom.multiple, `g${game} §6.3 扣底触发条件不符`);
if (lastMult > 0) {
chk(d.bottom.multiple === lastMult, `g${game} §6.3 扣底倍数不符 服务端=${d.bottom.multiple} 参考=${lastMult}`);
chk(d.bottom.grade2 === d.bottom.multiple * d.bottom.grade1, `g${game} §6.3 扣底得分应为 倍数×底牌分`);
stats.kodi++;
}
const X = aset.multiple * Math.abs(aset.upgrade), N = aset.seatlist.map(p => p.naward);
chk(JSON.stringify(aset.seatlist.map(p => p.grade_aw)) ===
JSON.stringify([X * (2 * N[0] - N[1] - N[2]), X * (2 * N[1] - N[0] - N[2]), X * (2 * N[2] - N[0] - N[1])]),
`g${game} §8.4 算奖分配公式不符`);
chk(JSON.stringify(aset.seatlist.map(p => p.grade_jf)) ===
JSON.stringify([0, 1, 2].map(s => (aset.upgrade > 0 ? 1 : -1) * X * (s === pj.banker ? 2 : -1))),
`g${game} §7.0 捡分子数分配不符`);
}
stats.rounds++;
}
t.eq('整局模糊 无异常', errs.slice(0, 3), []);
t.eq('整局模糊 全部跑完', stats.rounds, GAMES);
// 覆盖下限:发牌与选牌都已定种子,这些数字是确定事实(实测 20 局:474 墩 / 多张 53 /
// 甩牌 11 / 甩错 178 / 毙牌 70 / 扣底 6)。阈值取略低于实测值——既能挡住「选牌策略退化成
// 全是单张」让本文件形同虚设,又不会因无害的重构而误报。
t.eq('覆盖 墩数 ≥ 400', stats.tricks >= 400, true);
t.eq('覆盖 多张首出 ≥ 40 次', stats.multi >= 40, true);
t.eq('覆盖 合法甩牌 ≥ 8 次', stats.shuai >= 8, true);
t.eq('覆盖 甩错 ≥ 100 次', stats.shuaicuo >= 100, true);
t.eq('覆盖 毙牌墩 ≥ 40 次', stats.bi >= 40, true);
t.eq('覆盖 扣底 ≥ 3 局', stats.kodi >= 3, true);
console.log(` [统计] ${stats.rounds} 局 / ${stats.tricks} 墩:多张首出 ${stats.multi}、合法甩牌 ${stats.shuai}、甩错 ${stats.shuaicuo}、毙牌墩 ${stats.bi}、扣底 ${stats.kodi} 局`);
process.exit(t.done('fuzz') ? 0 : 1);
+229
View File
@@ -0,0 +1,229 @@
// 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)——
// curmultiple 是【带符号】的(S-8):正数=庄家赢的三档,负数=闲家升 N 级。
// 符号就是「谁赢」的区分,客户端判定动画靠它分辨该播哪个。
const cm = (call, grade, climb) => {
const q = A.get_qvalue(call, climb ? 1 : 0);
return A.get_upgrade(call, grade, q);
};
t.eq('curmultiple 65分 捡0 → 大光 +3', cm(65, 0), 3);
t.eq('curmultiple 65分 捡35 → 小光 +2', cm(65, 35), 2);
t.eq('curmultiple 65分 捡40 → 过庄 +1', cm(65, 40), 1);
t.eq('curmultiple 65分 捡65 → 升1级 -1', cm(65, 65), -1);
t.eq('curmultiple 65分 捡105 → 升2级 -2', cm(65, 105), -2);
t.eq('curmultiple 65分 捡145 → 升3级 -3', cm(65, 145), -3);
// 爬坡:40分档 Q=20,分界线与常规不同(design §7.3.2)
t.eq('curmultiple 爬坡40分 捡15 → 小光 +2', cm(40, 15, true), 2);
t.eq('curmultiple 爬坡40分 捡20 → 过庄 +1', cm(40, 20, true), 1);
t.eq('curmultiple 爬坡40分 捡60 → 升2级 -2', cm(40, 60, true), -2);
// —— S-8 的核心:三对「绝对值相同、判定相反」的组合必须靠符号分开 ——
// 取绝对值的话这三对会完全撞在一起,判定动画就无从分辨了
t.eq('S-8 大光(+3) vs 升3级(-3) 可区分', [cm(65, 0), cm(65, 145)], [3, -3]);
t.eq('S-8 小光(+2) vs 升2级(-2) 可区分', [cm(65, 35), cm(65, 105)], [2, -2]);
t.eq('S-8 过庄(+1) vs 升1级(-1) 可区分', [cm(65, 40), cm(65, 65)], [1, -1]);
// 符号语义:>0 庄家赢、<0 闲家赢、==0 叫分未定
t.eq('S-8 符号语义 庄赢为正', [cm(65, 0), cm(65, 35), cm(65, 40)].every(v => v > 0), true);
t.eq('S-8 符号语义 闲赢为负', [cm(65, 65), cm(65, 105), cm(65, 145)].every(v => v < 0), true);
// —— 直接调 get_curmultiple 本身,覆盖【负数】分支 ——
// 必要性:上面的 cm() 走的是 A.get_upgrade,【绕过了 get_curmultiple】;
// 而下面端到端那局恰好是大光(正数),取不取绝对值结果一样。
// 若没有这一组,get_curmultiple 悄悄退回取绝对值时两处都发现不了。
function mkCur(call, xianGrade, roomtype) {
const cards = [];
for (let i = 0; i < 108; i++) {
cards.push({ id: i, number: 5, score: 0, playowner: -1, dealowner: 1, playround: -1 });
}
// 喂给闲家(seat1)足量分牌,凑出指定捡分(每张 5 分,故 xianGrade 须为 5 的倍数)
let need = xianGrade / 5;
for (let i = 0; i < 108 && need > 0; i++) { cards[i].score = 5; cards[i].playowner = 1; need--; }
return P.get_curmultiple({
call: call, banker: 0, flower: 1, cards: cards,
o_desk: { o_room: { roomtype: roomtype || '00000' } }
});
}
t.eq('S-8 get_curmultiple 大光 → +3', mkCur(65, 0), 3);
t.eq('S-8 get_curmultiple 小光 → +2', mkCur(65, 35), 2);
t.eq('S-8 get_curmultiple 过庄 → +1', mkCur(65, 40), 1);
t.eq('S-8 get_curmultiple 升1级 → -1', mkCur(65, 65), -1);
t.eq('S-8 get_curmultiple 升2级 → -2', mkCur(65, 105), -2);
t.eq('S-8 get_curmultiple 升3级 → -3', mkCur(65, 145), -3);
t.eq('S-8 get_curmultiple 叫分未定 → 0', mkCur(0, 0), 0);
// 爬坡走 roomtype 位3=1,Q 分段不同
t.eq('S-8 get_curmultiple 爬坡40分 捡60 → 升2级 -2', mkCur(40, 60, '00010'), -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);
}
// ============================================================================
// 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: {}, playhistory: [], 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),
get_pushlist: () => P.get_pushlist(pj)
};
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');
+117
View File
@@ -0,0 +1,117 @@
// 客户端入参校验:重复牌id / 越界id / 非数组 / 非整数(check_cards_valid + check_cards_inhand)
// 回归两个真实缺陷:① 重复id 被 can_playcard 当成对子/拖拉机;② 埋牌传重复id 只埋下 1 张
require('./_shim');
const { mod, setup, make108, id } = require('./_rpc.js');
const P = require('../class.paiju.js');
const t = require('./_assert')();
// 造一个最小可用牌局:108 张牌全发给指定座位,主花色=1
function newPaiju(step, banker, owner) {
const paiju = {
cards: make108(), step, banker, call: 65, flower: 1,
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: {}, playhistory: [], callproc: [], idx: 1
};
for (let i = 0; i < 108; i++) paiju.cards[i].dealowner = owner + 1;
paiju.method = {
check_cards_valid: c => P.check_cards_valid(paiju, c),
check_cards_inhand: (c, s) => P.check_cards_inhand(paiju, c, s),
do_burycard: c => P.do_burycard(paiju, c),
do_playcard: c => P.do_playcard(paiju, c),
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_curmultiple: () => P.get_curmultiple(paiju),
get_mustcard: s => P.get_mustcard(paiju, s)
};
return paiju;
}
// ============ L1 check_cards_valid:正 / 反 / 边界 ============
const pj = newPaiju(5, 0, 0);
const V = c => P.check_cards_valid(pj, c);
t.eq('valid 正 单张', V([0]), true);
t.eq('valid 正 多张不重复', V([0, 1, 2, 53]), true);
t.eq('valid 边界 最大牌id 107', V([107]), true);
t.eq('valid 边界 最小牌id 0', V([0]), true);
t.eq('valid 反 undefined', V(undefined), false);
t.eq('valid 反 null', V(null), false);
t.eq('valid 反 字符串', V('012'), false);
t.eq('valid 反 对象', V({ 0: 1, length: 1 }), false);
t.eq('valid 反 空数组', V([]), false);
t.eq('valid 反 重复id', V([5, 5]), false);
t.eq('valid 反 多张中含重复', V([1, 2, 3, 2]), false);
t.eq('valid 反 越界上界108', V([108]), false);
t.eq('valid 反 负数id', V([-1]), false);
t.eq('valid 反 小数id', V([1.5]), false);
t.eq('valid 反 数字字符串', V(['5']), false);
// ============ L1 check_cards_inhand ============
t.eq('inhand 正 在手上', P.check_cards_inhand(pj, [0, 1], 0), true);
t.eq('inhand 反 不在手上(别人的)', P.check_cards_inhand(pj, [0], 1), false);
t.eq('inhand 反 重复id(经 valid 拦截)', P.check_cards_inhand(pj, [0, 0], 0), false);
t.eq('inhand 反 越界id(经 valid 拦截)', P.check_cards_inhand(pj, [999], 0), false);
pj.cards[3].playround = 1; // 已打出
t.eq('inhand 反 已打出的牌', P.check_cards_inhand(pj, [3], 0), false);
pj.cards[4].playround = 0; // 已埋
t.eq('inhand 反 已埋下的牌', P.check_cards_inhand(pj, [4], 0), false);
// ============ L2 出牌:重复id 不得被当成一对 ============
const pj2 = newPaiju(5, 0, 0);
P.new_playround(pj2, 1, 0);
const cid = id(1, 1, 13); // 方块K
t.eq('出牌 反 重复id 被 mod 层拦截(valid)', P.check_cards_valid(pj2, [cid, cid]), false);
// ============ L3 mod.maipai:8 个重复id 必须被拒、不得进入出牌阶段 ============
const pj3 = newPaiju(3, 0, 0);
const s3 = setup('00000', pj3);
const dup = id(1, 1, 13);
mod.maipai({ data: { seat: 0, cards: [dup, dup, dup, dup, dup, dup, dup, dup] }, conmode: 0, fromid: 0 });
t.eq('埋牌 反 8个重复id 被拒(step仍为3)', pj3.step, 3);
t.eq('埋牌 反 8个重复id 未埋下任何牌', pj3.cards.filter(c => c.playround === 0).length, 0);
// 对照:8 张不同的牌可以正常埋下
const pj4 = newPaiju(3, 0, 0);
setup('00000', pj4);
mod.maipai({ data: { seat: 0, cards: [0, 1, 2, 3, 4, 5, 6, 7] }, conmode: 0, fromid: 0 });
t.eq('埋牌 正 8张不同牌 埋牌成功(step进5)', pj4.step, 5);
t.eq('埋牌 正 实际埋下8张', pj4.cards.filter(c => c.playround === 0).length, 8);
// ============ L3 mod.chupai:越界/非数组入参不得抛异常 ============
const pj5 = newPaiju(5, 0, 0);
P.new_playround(pj5, 1, 0);
const s5 = setup('00000', pj5);
let threw = null;
try {
mod.chupai({ data: { seat: 0, cards: [999] }, conmode: 0, fromid: 0 });
mod.chupai({ data: { seat: 0, cards: undefined }, conmode: 0, fromid: 0 });
mod.chupai({ data: { seat: 0, cards: 'x' }, conmode: 0, fromid: 0 });
mod.chupai({ data: { seat: 0, cards: [dup, dup] }, conmode: 0, fromid: 0 });
} catch (e) { threw = String(e); }
t.eq('出牌 反 非法入参不抛异常', threw, null);
t.eq('出牌 反 非法入参不产生任何出牌', pj5.cards.filter(c => c.playround > 0).length, 0);
// ============ design §9:报无主后立即为全体三人刷新主牌统计 ============
// 造 step5 牌局:seat0 手上只剩 1 张主牌(打掉即报无主),seat1/seat2 各持若干主牌
const pj6 = newPaiju(5, 0, 0);
for (let i = 0; i < 108; i++) pj6.cards[i].dealowner = -1; // 先全部清空
const give = (cid, seat) => { pj6.cards[cid].dealowner = seat + 1; pj6.cards[cid].playround = -1; };
give(id(1, 1, 5), 0); // seat0:主♦5(唯一主牌)
give(id(1, 3, 9), 0); // seat0:副♥9(保证还有牌)
give(id(1, 1, 13), 1); give(id(2, 1, 13), 1); give(id(1, 1, 12), 1); // seat1:主♦K对 + 主♦Q
give(id(1, 1, 7), 2); // seat2:正7(固定主牌)
P.new_playround(pj6, 1, 0);
t.eq('报无主前 三家统计均为初始 [-1,-1]', pj6.seatlist.map(s => s[4]), [[-1, -1], [-1, -1], [-1, -1]]);
P.do_playcard(pj6, [id(1, 1, 5)]); // seat0 打出唯一主牌 → 报无主
t.eq('报无主 seat0 主牌清空 [0,0]', pj6.seatlist[0][4], [0, 0]);
t.eq('报无主 立即刷新 seat1 [3张,1对]', pj6.seatlist[1][4], [3, 1]);
t.eq('报无主 立即刷新 seat2 [1张,0对]', pj6.seatlist[2][4], [1, 0]);
t.eq('报无主 seat0 主花色标志置1', pj6.seatlist[0][pj6.flower - 1], [1, 1]);
t.eq('报无主 seat2 无主对→无对标志置1', pj6.seatlist[2][pj6.flower - 1][1], 1);
process.exit(t.done('input') ? 0 : 1);
+540
View File
@@ -0,0 +1,540 @@
// 下发面泄露审计(server 红线「发全 ≠ 发多,按可见性下发」+ design §4/§9/§11)
// 跑完整局,对每一个「服务器 → 某座位」的包做两层扫描:
// ① 牌 id 层:取出包里出现的所有牌 id,逐个判定「此刻该座位是否有权知道这张牌」。
// 依据 design §4(底牌只有庄家可见 / 70分亮3秒)、§9(查牌模式)、§11(结束亮埋牌底牌)。
// ② 统计量层:牌 id 之外,包里还有【由手牌算出来的数】——seatlist 的缺门/无对表、
// playproc 的 maxseat/maxcard、curmultiple、捡分。它们不含牌 id,①层一个都扫不到,
// 却同样能泄露("某家某花色还剩几张"这种统计,功能正常、玩家看不出、只有抓包的人知道)。
// 故本层用【公开账本】独立重算这些数:只喂三家都看得见的信息(已广播的 chupai*.cards、
// chupai1 的 count/flower/cardtype、庄家/叫分/主花色),算出来必须与包内值逐项相等。
// 能被公开信息算出来的数 = 不含私密信息;算不出来 = 它一定读了别人的手牌。
// 同时钉死这些结构的【字段集/项数】,防止有人往里加一项新统计而门禁照样全绿。
const R = require('./_rpc.js'); const mod = R.mod;
const P = global.cls_youle_erqiwang_paiju, A = global.cls_youle_erqiwang_arith;
const CFG = global.cls_youle_erqiwang_config;
const D = require('../class.desk.js'), EX = require('../class.export.js');
const t = require('./_assert')();
let seed = 0xBEEF01;
const rnd = n => { seed ^= seed << 13; seed ^= seed >>> 17; seed ^= seed << 5; seed >>>= 0; return seed % n; };
global.min_ontimeout = fn => fn();
global.min_random = (a, b) => a + rnd(b - a + 1);
const pk = (s, d) => ({ conmode: 0, fromid: s, data: Object.assign({ agentid: 1, playerid: s, gameid: 1, roomcode: 1, seat: s }, d || {}) });
const JS = x => JSON.stringify(x);
// ==========================================================================
// ①层:牌 id 的可见性
// ==========================================================================
// 深度收集一个对象里所有「看起来是牌 id」的整数(0~107),并记下它【出现在哪个字段路径上】。
// 为避免把 seat/count/grade 之类误当牌 id,只扫描已知承载牌 id 的字段名。
// 【为什么要记路径】豁免必须按「哪一个字段」给,而不是按「哪一个 rpc」给:
// 同一个包里既有该公开的分组(结算面板的抠底明细/算奖明细),也有绝不该公开的分组,
// 按 rpc 整包放行会把后者一并放掉。路径只由对象键组成、忽略数组下标(如
// aset.seatlist[1].cards → "aset.seatlist.cards")。
const CARD_FIELDS = ['cards', 'bottomcards', 'burycards', 'cardsinhand', 'zhucards', 'gradecards', 'pushlist', 'MyCards', 'mustcard'];
function collectCards(node, path, key, out) {
if (node === null || node === undefined) return;
if (Array.isArray(node)) { node.forEach(x => collectCards(x, path, key, out)); return; }
if (typeof node === 'number') { if (CARD_FIELDS.indexOf(key) >= 0 && node >= 0 && node <= 107) out.add(node + '@' + path); return; }
if (typeof node === 'object') {
for (const k in node) {
collectCards(node[k], path ? path + '.' + k : k, CARD_FIELDS.indexOf(k) >= 0 ? k : key, out);
}
}
}
const cidOf = e => parseInt(e.slice(0, e.indexOf('@')), 10);
const pathOf = e => e.slice(e.indexOf('@') + 1);
// 路径尾匹配:deskinfo 会多一层分组前缀(Balance.aset.seatlist.cards vs aset.seatlist.cards)
const pathIs = (path, suffix) => path === suffix || path.endsWith('.' + suffix);
// design §8.2 亮牌:庄家埋牌后手中【固定主牌】达门槛时,这些牌的牌面对两个闲家公开(仅可查牌模式)。
// 【独立于 get_liangpai 重算一遍】——若实现误把非固定主牌(主花色普通牌、副牌)塞进 liangpai,
// 下面的集合里不会有它,审计照样报泄露。这是审计的意义所在,不能图省事直接复用被测实现。
function bankerLiangpaiCards(pj) {
const out = [];
let wang = 0, qi = 0, er = 0;
for (const c of pj.cards) {
// 与 get_seat_cards_award 同口径:庄家手牌 = 发到自己的 + 摸起的底牌,且排除已埋的
if (!(c.dealowner === pj.banker + 1 || c.dealowner === 0)) continue;
if (c.playround === 0) continue;
if (c.number >= 53) { wang++; out.push(c.id); }
else if (c.number === 7) { qi++; out.push(c.id); }
else if (c.number === 2) { er++; out.push(c.id); }
}
if (out.length < 10 && wang < 3 && qi < 6 && er < 6) return new Set(); // 不达标:一张都不该下发
return new Set(out);
}
let curLiangpai = new Set(); // 本局亮牌应公开的牌(埋牌后确定,全程固定)
let curNoCheck = false; // 本局是否「不查牌」
let liangpaiRounds = 0; // 可查牌 + 庄家达标的局数(亮牌真的下发过)
let liangpaiRoundsNoCheck = 0; // 不查牌 + 庄家达标的局数(此时一张都不该发)
let surrenderRuns = 0; // 走过投降结算的局数
let disbandRuns = 0; // 走过中途解散结算的局数
let awardExempt = 0; // 「算奖明细」这条显式豁免被真正用到的次数
const leaks = [];
let pkts = 0, scanned = 0;
// 判定:seat 此刻是否有权知道 cid(path 见 collectCards 的说明)
function mayKnow(pj, seat, cid, rpc, path) {
const c = pj.cards[cid];
if (c.dealowner === -1) return true; // 规则去除的 3/4,不可能出现
if (c.playround > 0) return true; // 已打出,全场可见
if (c.dealowner === seat + 1) return true; // 自己的牌
if (seat === pj.banker && c.dealowner === 0) return true; // 庄家可见底牌(含摸起后又埋下的)
// §4 70分坐庄:上庄推送把 8 张底牌向三家亮 3 秒——只放行 shangzhuang 的 bottomcards 这一处
if (rpc === 'shangzhuang' && pj.call === 70 && c.dealowner === 0 && pathIs(path, 'bottomcards')) return true;
// §11 结束亮埋牌底牌:本局【正常结算】后,埋牌底牌随抠底明细对全场公开。
// jiesuan 广播(bottom.cards,sendpack_toother(msg,-1) 三家都发)与结算面板还开着时的
// 重连包(Balance.bottom.cards)是【同一份数据的两条投递路径】,可见性必须同判。
// 门控写成「step===6 且 result!==3」而不是「rpc 是 jiesuan/deskinfo」:
// · 只认字段路径 bottom.cards,不整包放行(同一个包里的其他分组仍严格判定);
// · result 3 = 中途解散(class.paiju.js get_paiju_account 的 case 2,o_paiju.result = 3)。解散是【没打完】,
// 埋牌底牌不该亮。当前解散路径的 tmp_jiesuan_bottom 恒为 null、包里本就没有 bottom,
// 所以这一条现在不改变任何判定;它挡的是【将来】有人给解散路径也带上 bottom——
// 那时门禁必须转红,而不是因为「step 也是 6」被放行。
// 【措辞更正】上一轮把「给重连包补上这条豁免」记成了"收紧"——其实那一步的净效果是
// 【放宽】(在原豁免上加了一个 OR 分支,旧放行面 ⊂ 新放行面),只是放行面被限定在
// step===6 的同一份公开数据上。真正的收紧是这一轮:从「按 rpc 整包放行」改成
// 「按字段路径 bottom.cards + step6 + result!==3 放行」。
if (c.playround === 0 && pathIs(path, 'bottom.cards') && pj.step === 6 && pj.result !== 3) return true;
// 【显式豁免 · 结算算奖明细】aset.seatlist[i].cards(get_paiju_account 的 _cglist[i].cards)
// 是 design §8.1 冲关 / §8.3 傍王的算奖依据牌,三种结算来源都带、且三家都收到。
// · 正常打完:这些牌早已打出(playround>0),本来就公开,本豁免对它无影响。
// · 投降(step2 直接结算)与中途解散:牌【一张没打】(playround===-1),于是他家的
// 王/冲关牌会随算奖明细发给三家。这是既有行为——算奖要向三家自证,牌局此刻已终止、
// 不再有后续出牌,公开这几张牌不影响任何未决的对局信息。
// 写成一条【带路径限定的显式豁免】而不是让它落在扫不到的盲区里:
// 若哪天有人把「未打出的手牌」塞到别的字段上,它不在这条路径下,照样报泄露。
if (pathIs(path, 'aset.seatlist.cards') && pj.step === 6) { awardExempt++; return true; }
if (rpc === 'mingpai') return true; // §9 明牌:单独在下面按内容校验
// §8.2 亮牌:可查牌模式下,庄家的固定主牌牌面对闲家公开(只在 maipai / 重连包的 liangpai 分组里下发)
if (!curNoCheck && (rpc === 'maipai' || rpc === 'deskinfo') && pathIs(path, 'liangpai.cards') && curLiangpai.has(cid)) return true;
return false;
}
// ==========================================================================
// ②层:统计量必须能由「公开账本」独立重算出来
// ==========================================================================
// 账本里只有【三家都看得见】的东西:
// 庄家 / 庄家叫分(shangzhuang 广播)、主花色(xuanzhu 广播)、
// 每一手已摊在桌上的牌(chupai1/2/3 的 data.cards,三家同收)、
// 首家这一手的 张数/花色/牌型(chupai1 的 count/flower/cardtype —— 它就是那几张明牌的描述)。
// 账本【没有】任何人的手牌。凡是能从账本算出来的数,就不可能夹带手牌信息。
let LG = null;
let statChecks = 0;
const statBad = [];
const flowerOf = (fl, id) => { const c = A.id_to_code(fl, id); return c > 1000 ? fl : Math.floor(c / 100); };
// 牌面分值:5→5、10/K→10,其余 0。只由牌 id 推出(牌已摊在桌上,牌面是公开的)
const scoreOf = id => { const n = A.id_to_number(id); return n === 5 ? 5 : ((n === 10 || n === 13) ? 10 : 0); };
// 首家这一手的牌型值 = 这一手【最小一张】的牌编码。
// 【独立推导,不复用 can_playcard】:那段逐张状态机无论走 单张/对子/拖拉机/甩牌 哪个分支,
// cardvalue 最终都停在「最后处理到的那张牌」上;牌已按编码降序排列,最后一张即最小一张。
// 换个式子算出同一个数,才谈得上"独立重算"。
function leadValue(fl, cards) {
const s = A.order_cards(fl, cards.concat());
return A.id_to_code(fl, s[s.length - 1]);
}
// 跟家这一手的牌型值:照 design 的规则从【摊在桌上的牌】重算(can_followcard 里这段同样
// 只吃 _sortfollow / startflower / startcardtype,不碰手牌——本函数是它的独立复述)。
function followValue(fl, cards, startflower, starttype) {
const s = A.order_cards(fl, cards.concat());
const top = A.id_to_code(fl, s[0]);
const flTop = top > 1000 ? fl : Math.floor(top / 100);
const sameLine = (flTop === startflower || flTop === fl);
// 副7 / 副2 同级归一(四门的 7 一样大、四门的 2 一样大)
const norm = c => (c > 7000 && c < 8000) ? 7000 : ((c > 2000 && c < 3000) ? 2000 : c);
if (starttype === 101) { return sameLine ? norm(top) : 0; }
if (starttype > 101 && starttype < 200) { return 0; } // 首家甩单张:跟牌一律压不过
if (starttype === 201) {
if (A.get_pairlist(fl, s).length !== 1) return 0;
return sameLine ? norm(top) : 0;
}
if (starttype > 201 && starttype < 300) { return 0; } // 首家甩多对:同上
if (starttype > 300 && starttype < 400) {
if (A.get_tuolaji_list(fl, A.get_pairlist(fl, s), starttype).length !== 1) return 0;
return sameLine ? top : 0;
}
return 0;
}
// 跟家这一手暴露出的「缺门 / 无对」:完全由摊在桌上的牌与首家牌型推出(design §9 的牌况表)。
// 最大或最小的一张不是首家花色 → 这门没牌了(顺带也没对子)
// 首家出的是对子/拖拉机、跟牌没凑够同样多的对子 → 这门没对子了
function followFlags(fl, cards, startflower, starttype) {
const s = A.order_cards(fl, cards.concat());
let noflower = false, nopair = false;
if (flowerOf(fl, s[0]) !== startflower) { noflower = true; nopair = true; }
if (flowerOf(fl, s[s.length - 1]) !== startflower) { noflower = true; nopair = true; }
if (starttype > 200 && A.get_pairlist(fl, s).length !== starttype % 100) { nopair = true; }
return { noflower, nopair };
}
// 首家甩牌的分量需求:把桌面上那一手直接分解即可(decompose_trump 只吃牌 id),非甩牌为 null
function expectShuaiDemand() {
if (LG.start < 0 || !LG.cards[LG.start]) return null;
const comps = A.decompose_trump(LG.flower, LG.cards[LG.start].concat());
if (comps.length <= 1) return null;
const re = { tractors: [], pairs: 0, singles: 0 };
for (const c of comps) {
if (c.type === 'tractor') { re.tractors.push(c.len); }
else if (c.type === 'pair') { re.pairs++; }
else { re.singles++; }
}
return re;
}
function ledgerNewRound(round, start) {
LG.round = round; LG.start = start; LG.currseat = start;
LG.startcount = -1; LG.startflower = -1; LG.starttype = -1;
LG.maxseat = -1; LG.maxcard = -1;
LG.cards = [null, null, null];
}
function ledgerApplyFollow(seat, cards) {
const fl = LG.flower;
const s = A.order_cards(fl, cards.concat());
LG.cards[seat] = s;
const v = followValue(fl, s, LG.startflower, LG.starttype);
if (v > LG.maxcard) { LG.maxseat = seat; LG.maxcard = v; }
const fg = followFlags(fl, s, LG.startflower, LG.starttype);
const cell = LG.table[seat][LG.startflower - 1];
if (fg.nopair) { cell[1] = 1; }
if (fg.noflower) { cell[0] = 1; cell[1] = 1; }
}
function ledgerEndRound() {
// 本墩被闲家收走 → 墩里的分归闲家(庄家收走则不计入闲家捡分)
let g = 0;
if (LG.maxseat !== LG.banker) {
for (let i = 0; i < 3; i++) { for (const id of (LG.cards[i] || [])) { g += scoreOf(id); } }
}
LG.grade += g;
LG.lastRoundGrade = g;
ledgerNewRound(LG.round + 1, LG.maxseat);
}
// 当前抓分倍数:算法本身只吃 (叫分, 闲家捡分, 级距) 三个数——叫分与捡分都在账本里
function ledgerCurmultiple() {
if (!LG.call || LG.call <= 0) return 0;
return A.get_upgrade(LG.call, LG.grade, A.get_qvalue(LG.call, LG.climb));
}
// 把一个【逻辑包】喂进公开账本(同一个包会逐座位发 3 份,只喂第一份)
function ledgerFeed(rpc, d) {
if (!LG) return;
switch (rpc) {
case 'shangzhuang': LG.banker = d.banker; LG.call = d.grade; break;
case 'xuanzhu': LG.flower = d.flower; break;
case 'maipai': ledgerNewRound(1, d.seat); break;
case 'chupai1': {
const s = A.order_cards(LG.flower, d.cards.concat());
LG.startcount = s.length;
LG.startflower = flowerOf(LG.flower, s[0]);
LG.starttype = d.cardtype;
LG.maxseat = d.seat;
LG.maxcard = leadValue(LG.flower, s);
LG.cards[d.seat] = s;
LG.currseat = (LG.start + 1) % 3;
// 首家自报的 张数/花色 必须与桌面上那几张牌一致(同一份公开数据两处口径)
statChecks++;
if (d.count !== s.length || d.flower !== LG.startflower) {
statBad.push(`chupai1 自报 count/flower=(${d.count},${d.flower}) 与桌面牌重算=(${s.length},${LG.startflower}) 不一致`);
}
// 账本自检:首出者必须就是本轮首家(账本若跟丢了轮次,下面所有比对都不再可信)
statChecks++;
if (d.seat !== LG.start) { statBad.push(`chupai1 首出者=${d.seat} 公开账本认为本轮首家=${LG.start}`); }
break;
}
case 'chupai2': ledgerApplyFollow(d.seat, d.cards); LG.currseat = (LG.start + 2) % 3; break;
case 'chupai3': ledgerApplyFollow(d.seat, d.cards); ledgerEndRound(); break;
case 'jiesuan':
// 收尾墩不发 chupai3,整包换成 jiesuan、把这一手放进 data.chupai(协议 §13/§14)
if (d.chupai && d.chupai.cards) { ledgerApplyFollow(d.chupai.seat, d.chupai.cards); ledgerEndRound(); }
break;
}
}
// playproc 的字段集是【契约】:多一个键就是多一份下发信息,必须显式暴露出来
const PLAYPROC_KEYS = ['cards', 'currseat', 'maxcard', 'maxseat', 'round', 'shuai_demand', 'start', 'startcount', 'startflower', 'starttype'];
function checkPlayproc(tag, pp) {
statChecks++;
const keys = Object.keys(pp).sort();
if (JS(keys) !== JS(PLAYPROC_KEYS)) { statBad.push(`${tag} playproc 字段集变了:${JS(keys)}`); }
const exp = {
round: LG.round, start: LG.start, currseat: LG.currseat,
startcount: LG.startcount, startflower: LG.startflower, starttype: LG.starttype,
maxseat: LG.maxseat, maxcard: LG.maxcard
};
for (const k in exp) {
statChecks++;
if (pp[k] !== exp[k]) { statBad.push(`${tag} playproc.${k}=${pp[k]} 公开账本重算=${exp[k]}`); }
}
for (let s = 0; s < 3; s++) {
statChecks++;
const got = (pp.cards[s] === undefined || pp.cards[s] === null) ? null : pp.cards[s];
if (JS(got) !== JS(LG.cards[s])) { statBad.push(`${tag} playproc.cards[${s}]=${JS(got)} 公开账本重算=${JS(LG.cards[s])}`); }
}
statChecks++;
if (JS(pp.shuai_demand) !== JS(expectShuaiDemand())) {
statBad.push(`${tag} playproc.shuai_demand=${JS(pp.shuai_demand)} 桌面牌重算=${JS(expectShuaiDemand())}`);
}
}
function checkSeatlist(tag, sl) {
statChecks++;
if (!Array.isArray(sl) || sl.length !== 3) { statBad.push(`${tag} seatlist 外层不是定长 3:${JS(sl)}`); return; }
// 报副格(第 5 项)是 design §9 的【有意公开】:一旦有人把主牌打空(报无主),
// 系统就向全体三人公示三家各自的余主数量与余主对数。反过来说——【没人报无主之前
// 一格都不许透露】,必须还是初值 [-1,-1]。这条门控若被放宽,等于提前泄露三家主牌结构。
const anyEmpty = sl.some(one => Array.isArray(one[4]) && one[4][0] === 0);
for (let s = 0; s < 3; s++) {
const one = sl[s];
statChecks++;
if (!Array.isArray(one) || one.length !== 5) {
// 约定恒 5 项:花色 1~4 的 [缺门, 无对] + 报副 [余主数, 余主对数]。
// 多出来的一项必定是一份【新的统计】——而新统计几乎注定是从真实手牌算出来的,
// 公开账本重算不出来。这里直接判死,不给它蒙混过关的机会。
statBad.push(`${tag} seat${s} seatlist 项数不是 5(新增统计?):${JS(one)}`);
continue;
}
for (let f = 0; f < 5; f++) {
statChecks++;
const cell = one[f];
if (!Array.isArray(cell) || cell.length !== 2 || typeof cell[0] !== 'number' || typeof cell[1] !== 'number') {
statBad.push(`${tag} seat${s}[${f}] 形状不是二元数值组:${JS(cell)}`);
}
}
// 前 4 格:只能是公开账本推得出的缺门/无对
for (let f = 0; f < 4; f++) {
const exp = [LG.table[s][f][0], LG.table[s][f][1]];
// 主花色那一格另有一条来源:报无主公示之后,「余主数=0」等价于主花色缺门+无对、
// 「余主对数=0」等价于主花色无对(class.paiju.js do_playcard 的报副刷新)。
// 它推导自【本包自己给出的】报副格,仍属公开信息,不是额外泄露。
if (LG.flower > 0 && f === LG.flower - 1 && Array.isArray(one[4])) {
if (one[4][0] === 0) { exp[0] = 1; exp[1] = 1; }
else if (one[4][0] > 0 && one[4][1] === 0) { exp[1] = 1; }
}
statChecks++;
if (one[f][0] !== exp[0] || one[f][1] !== exp[1]) {
statBad.push(`${tag} seatlist[${s}][${f}]=${JS(one[f])} 公开账本重算=${JS(exp)}`);
}
}
statChecks++;
if (!anyEmpty) {
if (JS(one[4]) !== JS([-1, -1])) { statBad.push(`${tag} 无人报无主却下发了 seat${s} 的余主统计:${JS(one[4])}`); }
} else if (one[4][0] < 0 || one[4][1] < 0) {
statBad.push(`${tag} 已报无主但 seat${s} 的余主统计仍是初值:${JS(one[4])}`);
}
}
}
// 对一个「分组对象」(包的 data,或 deskinfo 的 ChooseMain / BuryCards / PushCards)做统计量审计
function statAuditGroup(tag, g, opts) {
if (!g || typeof g !== 'object' || !LG) return;
opts = opts || {};
if (g.hasOwnProperty('curmultiple')) {
statChecks++;
const exp = ledgerCurmultiple();
if (g.curmultiple !== exp) { statBad.push(`${tag} curmultiple=${g.curmultiple} 公开账本重算=${exp}`); }
}
if (opts.cumulativeGrade && g.hasOwnProperty('grade')) {
statChecks++;
if (g.grade !== LG.grade) { statBad.push(`${tag} grade=${g.grade} 公开账本重算=${LG.grade}`); }
}
// 分组这一层的 seatlist 恒指 design §9 的【牌况表】(maipai / chupai1/2/3 / PushCards);
// 结算包里的 aset.seatlist 是另一回事,嵌在 aset 下、不会走到这里。
// 【不加形状守卫】形状变了就该由 checkSeatlist 判红,而不是被守卫悄悄跳过
if (g.hasOwnProperty('seatlist')) { checkSeatlist(tag, g.seatlist); }
if (g.playproc) { checkPlayproc(tag, g.playproc); }
}
// ==========================================================================
function run(roomtype, call, opts) {
opts = opts || {};
const sent = [];
const o_room = {
roomtype, asetcount: 6, roomcode: 1, createtime: 'T', makewartime: 'T',
seatlist: [0, 1, 2].map(i => ({ conmode: 0, fromid: i, playerid: 100 + i, nickname: 'P', avatar: '', gameinfo: {} })),
method: { sendpack_toother: m => sent.push({ to: 'ALL', m: JSON.parse(JSON.stringify(m)) }) }
};
curNoCheck = (String(roomtype).charAt(4) === '1'); // roomtype 位4:1=不查牌
curLiangpai = new Set();
LG = {
climb: CFG.parse(roomtype).climb, banker: -1, call: -1, flower: -1,
round: 0, start: -1, currseat: -1, startcount: -1, startflower: -1, starttype: -1,
maxseat: -1, maxcard: -1, cards: [null, null, null], grade: 0, lastRoundGrade: 0,
table: [0, 1, 2].map(() => [[0, 0], [0, 0], [0, 0], [0, 0]])
};
const desk = D.new(o_room); o_room.o_desk = desk;
global.youle_erqiwang.app = { SendPack: m => sent.push({ to: m.fromid, m: JSON.parse(JSON.stringify(m)) }) };
global.youle_erqiwang.import = { check_player: () => o_room, deduct_roomcard: () => { }, save_grade: () => { } };
mod.import = global.youle_erqiwang.import; mod.app = global.youle_erqiwang.app;
D.do_new_paiju(desk, 0);
const pj = desk.method.curr_paiju();
const audit = () => {
while (sent.length) {
const { to, m } = sent.shift();
pkts++;
// 同一个逻辑包逐座位发 3 份(i=0,1,2),只用第一份推进公开账本;
// 但【每一份都要审计】,这样三家收到的统计量若有差异也会被抓住
if (to === 0 || to === 'ALL') { ledgerFeed(m.rpc, m.data); }
const targets = to === 'ALL' ? [0, 1, 2] : [to];
statAuditGroup(`rpc=${m.rpc}→seat${to}`, m.data);
// chupai3 的 data.grade 是【这一墩】闲家收到的分(>0 才带),与账本的墩内分值同源
if (m.rpc === 'chupai3') {
statChecks++;
const got = m.data.hasOwnProperty('grade') ? m.data.grade : 0;
if (got !== LG.lastRoundGrade) { statBad.push(`chupai3 本墩 grade=${got} 公开账本重算=${LG.lastRoundGrade}`); }
}
// 结算包的闲家总捡分 = 账本累计捡分 + 抠底(扣底只在最后一墩产生)
if (m.rpc === 'jiesuan' && m.data.aset) {
statChecks++;
const bg = (m.data.bottom && m.data.bottom.grade2) ? m.data.bottom.grade2 : 0;
if (m.data.aset.grade !== LG.grade + bg) {
statBad.push(`jiesuan aset.grade=${m.data.aset.grade} 公开账本重算=${LG.grade}+抠底${bg}`);
}
}
const out = new Set();
collectCards(m.data, '', null, out);
for (const seat of targets) for (const e of out) {
scanned++;
const cid = cidOf(e);
if (!mayKnow(pj, seat, cid, m.rpc, pathOf(e))) {
leaks.push(`rpc=${m.rpc} @${pathOf(e)} → seat${seat} 泄露牌 ${cid}(dealowner=${pj.cards[cid].dealowner} playround=${pj.cards[cid].playround})`);
}
}
}
};
// 重连快照的审计(每个座位各取一次)。阶段取【实际的】pj.step:最后一手打完后 step 已转 6,
// 这里拿到的是结算快照,写死 "step5" 会让报错信息指错阶段(曾据此误判过)
const auditDeskinfo = () => {
for (let s = 0; s < 3; s++) {
const di = EX.new().get_deskinfo(o_room, s);
pkts++;
statAuditGroup(`deskinfo(step${pj.step})→seat${s}.ChooseMain`, di.ChooseMain);
statAuditGroup(`deskinfo(step${pj.step})→seat${s}.BuryCards`, di.BuryCards);
statAuditGroup(`deskinfo(step${pj.step})→seat${s}.PushCards`, di.PushCards, { cumulativeGrade: true });
const out = new Set(); collectCards(di, '', null, out);
for (const e of out) {
scanned++;
const cid = cidOf(e);
if (!mayKnow(pj, s, cid, 'deskinfo', pathOf(e))) {
leaks.push(`deskinfo(step${pj.step}) @${pathOf(e)} → seat${s} 泄露牌 ${cid}(dealowner=${pj.cards[cid].dealowner} playround=${pj.cards[cid].playround})`);
}
}
}
};
audit();
mod.jiaofen(pk(pj.method.get_callgrade_seat(), { call })); audit();
if (pj.step === 1) { mod.jiaofen(pk(pj.method.get_callgrade_seat(), { call: 0 })); audit(); }
if (pj.step === 1) { mod.jiaofen(pk(pj.method.get_callgrade_seat(), { call: 0 })); audit(); }
const b = pj.banker;
// ---- 投降结算路径(design §4:70 分坐庄可在选主阶段直接投降)----
// 这条路径此前从未被审计过:9 局全部「正常打完」,mod.touxiang 一次都没走到。
if (opts.surrender) {
if (pj.call !== 70) { leaks.push(`投降用例前置不成立:庄家叫分=${pj.call},非 70 分坐庄`); return pj; }
mod.touxiang(pk(b)); audit();
if (pj.step !== 6 || pj.result !== 2) { leaks.push(`投降用例未真正结算:step=${pj.step} result=${pj.result}`); return pj; }
auditDeskinfo();
surrenderRuns++;
return pj;
}
mod.xuanzhu(pk(b, { flower: 1 + rnd(4) })); audit();
// 亮牌快照在埋牌后才固定;先算出期望集合,再审计这一包(maipai 正是亮牌的下发时机)
mod.maipai(pk(b, { cards: P.get_seat_cards(pj, b).slice(-8) }));
curLiangpai = bankerLiangpaiCards(pj);
if (curLiangpai.size > 0) { if (curNoCheck) { liangpaiRoundsNoCheck++; } else { liangpaiRounds++; } }
audit();
auditDeskinfo();
let guard = 0;
while (pj.step === 5 && ++guard < 400) {
if (opts.disbandAfter && guard > opts.disbandAfter) { break; }
const seat = pj.playproc.currseat, hand = P.get_seat_cards(pj, seat);
let pick = null;
if (seat === pj.playproc.start) pick = [hand[hand.length - 1]];
else for (const c of hand) if (A.can_followcard(pj.flower, hand, [c], pj.playproc.startcount, pj.playproc.startflower, pj.playproc.starttype).result) { pick = [c]; break; }
if (!pick) break;
mod.chupai(pk(seat, { cards: pick })); audit();
if (guard % 7 === 0) { // 中途穿插重连与明牌
auditDeskinfo();
if (pj.method.have_baofu()) {
sent.length = 0;
mod.mingpai(pk(0));
// §9 明牌:只允许返回「另外两家未出的主牌」,多一张都算越权
for (const { m } of sent) {
if (m.rpc !== 'mingpai' || !m.data.others) continue;
for (const o of m.data.others) {
for (const cid of o.zhucards) {
scanned++;
const c = pj.cards[cid];
const isTrumpCard = c.flower === pj.flower || c.flower === 5 || c.number === 2 || c.number === 7;
if (c.dealowner !== o.seat + 1 && !(o.seat === pj.banker && c.dealowner === 0)) leaks.push(`mingpai 返回了非该座位的牌 ${cid}`);
if (c.playround !== -1) leaks.push(`mingpai 返回了已出/已埋的牌 ${cid}`);
if (!isTrumpCard) leaks.push(`mingpai 返回了非主牌 ${cid}`);
}
}
}
sent.length = 0;
}
}
}
audit();
// ---- 中途解散结算路径(get_disbandRoom)----
// 同样此前零覆盖。平台在解散时取这份 deskfree 下发给三家(前端 Game_Modify.Free),
// 故按「广播给三家」审计;随后的 step6 重连快照(result===3)也一并过一遍。
if (opts.disbandAfter) {
const msg = EX.new().get_disbandRoom(o_room);
if (!msg) { leaks.push('解散用例前置不成立:get_disbandRoom 返回 null'); return pj; }
sent.push({ to: 'ALL', m: JSON.parse(JSON.stringify(msg)) });
audit();
if (pj.step !== 6 || pj.result !== 3) { leaks.push(`解散用例未真正结算:step=${pj.step} result=${pj.result}`); return pj; }
auditDeskinfo();
disbandRuns++;
}
return pj;
}
for (const rt of ['00000', '00001']) for (const call of [65, 70, 5]) run(rt, call);
// 上面 6 局按 0xBEEF01 的牌序,庄家一次都没达到亮牌门槛(§8.2)。亮牌是【有意的信息公开】,
// 必须真的被审计走过,否则「无越权泄露」对这条路径只是碰巧没触发。故换牌序再补几局:
// 可查牌局验「该发的发了且不越权」,不查牌局验「达标也一张都不发」。
seed = 0x12345;
for (const call of [65, 5]) run('00000', call);
seed = 0x777001;
run('00000', 65);
run('00001', 65);
// 投降与解散:两条结算路径此前从未进过本门禁(9 局全是「正常打完」)
seed = 0x24680;
run('00000', 70, { surrender: true });
run('00001', 70, { surrender: true });
run('00000', 65, { disbandAfter: 11 });
run('00001', 60, { disbandAfter: 5 });
t.eq('下发面无越权泄露', [...new Set(leaks)].slice(0, 5), []);
t.eq('统计量均可由公开账本重算', [...new Set(statBad)].slice(0, 5), []);
// 覆盖下限:若收集器失效(扫不到牌),上面的断言会假绿,这里钉住实际扫描量
t.eq('审计覆盖 下发面 ≥ 1000 个', pkts >= 1000, true);
t.eq('审计覆盖 可见性判定 ≥ 10000 次', scanned >= 10000, true);
t.eq('审计覆盖 统计量重算 ≥ 10000 次', statChecks >= 10000, true);
// 覆盖下限(续):随机手牌下庄家未必达到亮牌门槛。若一局都没触发,
// 上面「无越权泄露」对亮牌这条路径就是【碰巧没走到】而不是【验证过安全】——必须钉住。
t.eq('审计覆盖 亮牌路径(可查牌) 至少 1 局', liangpaiRounds >= 1, true);
t.eq('审计覆盖 亮牌路径(不查牌) 至少 1 局', liangpaiRoundsNoCheck >= 1, true);
t.eq('审计覆盖 投降结算路径 至少 1 局', surrenderRuns >= 1, true);
t.eq('审计覆盖 中途解散结算路径 至少 1 局', disbandRuns >= 1, true);
// 「算奖明细」那条显式豁免必须真的被用到,否则它是一条无人走过的死规则,
// 早晚会在无人察觉时开始放行别的东西
t.eq('审计覆盖 算奖明细豁免确实被走到', awardExempt >= 1, true);
console.log(` [统计] 扫描 ${pkts} 个下发面 / ${scanned} 次「座位×牌」可见性判定 / ${statChecks} 次统计量重算比对`);
console.log(` [统计] 亮牌达标 可查牌${liangpaiRounds}局·不查牌${liangpaiRoundsNoCheck}局 / 投降${surrenderRuns}局 / 解散${disbandRuns}局 / 算奖明细豁免命中 ${awardExempt} 次`);
process.exit(t.done('leak') ? 0 : 1);
+262
View File
@@ -0,0 +1,262 @@
// paiju 单测:亮牌(§8.2) + 结算 get_paiju_account(§7/§8) 集成
require('./_shim');
const P = require('../class.paiju.js');
const t = require('./_assert')();
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;
// ============ §8.2 亮牌 get_liangpai ============
// 构造庄家(seat0)手牌 o_paiju;副花色中性单张填充到28
function fill(req, n) {
const u = new Set(req), o = req.slice();
for (const f of [2, 3, 4]) for (const x of [1, 5, 6, 8, 9, 10, 11, 12, 13]) {
if (o.length >= n) break;
const c = id(1, f, x);
if (!u.has(c)) { u.add(c); o.push(c); }
}
return o;
}
const mkHand = ids => ({ flower: 1, banker: 0, cards: ids.map(x => ({ id: x, playround: -1, dealowner: 1 })) });
const lp = ids => P.get_liangpai(mkHand(fill(ids, 28)));
// 亮牌返回 { cards: [固定主牌id...] }(按主牌序排好);不达标返回 null。
// 断言用「排序后的 id 集合」比较,避免把排序细节写死进用例。
const sortIds = a => a.slice().sort((x, y) => x - y);
const lpCards = ids => { const r = lp(ids); return r === null ? null : sortIds(r.cards); };
// —— 四条门槛各自能触发,且亮出的都是【全部固定主牌】——
{
const h = [big(1), small(1), small(2)]; // 3 王
t.eq('亮牌 3王 → 亮出这3张王', lpCards(h), sortIds(h));
}
{
const h = [id(1, 1, 2), id(2, 1, 2), id(1, 2, 2), id(2, 2, 2), id(1, 3, 2), id(2, 3, 2)]; // 6 个 2
t.eq('亮牌 6个2 → 亮出这6张2', lpCards(h), sortIds(h));
}
{
const h = [id(1, 1, 7), id(2, 1, 7), id(1, 2, 7), id(2, 2, 7), id(1, 3, 7), id(2, 3, 7)]; // 6 个 7
t.eq('亮牌 6个7 → 亮出这6张7', lpCards(h), sortIds(h));
}
{
const h = [big(1), small(1), id(1, 1, 7), id(2, 1, 7), id(1, 2, 7), id(2, 2, 7),
id(1, 1, 2), id(2, 1, 2), id(1, 2, 2), id(2, 2, 2)]; // 固定主 10(各档单看都不达标)
t.eq('亮牌 固定主10边界 → 亮出全部10张', lpCards(h), sortIds(h));
}
// —— 不达标 → null ——
t.eq('亮牌 2王2个7 不达标→null', lp([big(1), small(1), id(1, 1, 7), id(2, 1, 7)]), null);
t.eq('亮牌 固定主9 不达标→null',
lp([big(1), small(1), id(1, 1, 7), id(2, 1, 7), id(1, 2, 7), id(2, 2, 7), id(1, 1, 2), id(2, 1, 2), id(1, 2, 2)]), null);
// —— 关键:亮出的只有【固定主牌】,主花色普通牌【不能】混进来 ——
// 手牌里塞满主花色(♦)普通牌 A/K/Q/J/10/9/8/6/5,它们是主牌但不是固定主牌
{
const guding = [big(1), small(1), small(2)]; // 3 王 → 达标
const zhuNormal = [id(1, 1, 1), id(1, 1, 13), id(1, 1, 12), id(1, 1, 11), id(1, 1, 10)]; // ♦A/K/Q/J/10
const r = lp(guding.concat(zhuNormal));
t.eq('亮牌 只含固定主牌 张数=3', r.cards.length, 3);
t.eq('亮牌 主花色普通牌未混入', sortIds(r.cards), sortIds(guding));
}
// —— 亮出内容与「被哪条门槛触发」无关:4王+6个7 应亮出全部 10 张固定主 ——
{
const h = [big(1), big(2), small(1), small(2), id(1, 1, 7), id(2, 1, 7), id(1, 2, 7), id(2, 2, 7), id(1, 3, 7), id(2, 3, 7)];
t.eq('亮牌 同时满足多档 → 仍只亮同一份固定主牌', lpCards(h), sortIds(h));
}
// —— 排序:按本局主牌序从大到小(大王 > 小王 > 正7 > 副7 > 正2 > 副2)——
{
const A = require('../class.arith.js');
const h = [big(1), small(1), id(1, 1, 7), id(1, 2, 7), id(1, 1, 2), id(1, 2, 2),
id(2, 1, 7), id(2, 2, 7), id(2, 1, 2), id(2, 2, 2)]; // 固定主 10,主花色=♦(1)
const r = lp(h);
t.eq('亮牌 按主牌序排好', r.cards, A.order_cards(1, r.cards.slice()));
t.eq('亮牌 首张为大王', r.cards[0], big(1));
}
// design §8.2:亮牌依据是【庄家埋牌后】的静态手牌快照,全程固定、不随出牌缩水
// (闲家断线重连时 get_deskinfo 会再取一次;若按当前剩余手牌重算,亮牌会缩水甚至变 null)
const LIANG28 = fill([big(1), big(2), small(1), small(2), id(1, 1, 7), id(2, 1, 7), id(1, 2, 7), id(2, 2, 7), id(1, 3, 7), id(2, 3, 7)], 28);
const EXPECT_LIANG = sortIds([big(1), big(2), small(1), small(2), id(1, 1, 7), id(2, 1, 7), id(1, 2, 7), id(2, 2, 7), id(1, 3, 7), id(2, 3, 7)]);
t.eq('亮牌 埋牌后快照(出牌开始)', sortIds(P.get_liangpai(mkHand(LIANG28)).cards), EXPECT_LIANG);
const played = mkHand(LIANG28); // 同一手牌,已打出2大王+1对♦7
for (const c of played.cards){
if (c.id === big(1) || c.id === big(2) || c.id === id(1, 1, 7) || c.id === id(2, 1, 7)){ c.playround = 1; }
}
t.eq('亮牌 打出4张后重连仍按快照', sortIds(P.get_liangpai(played).cards), EXPECT_LIANG);
// 埋下的牌(playround==0)不算在快照里
const buried = mkHand(LIANG28);
for (const c of buried.cards){ if (c.id === big(1) || c.id === big(2)){ c.playround = 0; } }
t.eq('亮牌 已埋的牌不计入快照', sortIds(P.get_liangpai(buried).cards),
sortIds([small(1), small(2), id(1, 1, 7), id(2, 1, 7), id(1, 2, 7), id(2, 2, 7), id(1, 3, 7), id(2, 3, 7)]));
// ============ §7/§8 结算 get_paiju_account 集成 ============
// 受控 o_paiju/o_desk/o_room:控制庄家算奖手牌(dealowner=1)与闲1捡分(playowner=1,score)
const mkDesk = roomtype => ({ seatlist: [[0, [], 0, 0, 0], [0, [], 0, 0, 0], [0, [], 0, 0, 0]], o_room: { roomtype, asetcount: 6 }, method: { get_desk_account: m => m } });
const paiju = (call, flower, cards, roomtype) => ({ call, banker: 0, flower, cards, idx: 2, o_desk: mkDesk(roomtype), endtime: null, result: null });
// bankerIds→庄家算奖手牌(dealowner=1);xian1grade→闲1捡分(每张≤10分)
function mkCards(bankerIds, xian1grade) {
const cs = bankerIds.map(x => ({ id: x, dealowner: 1, playround: -1, playowner: -1, score: 0 }));
let g = xian1grade, i = 0;
while (g > 0) { const s = g >= 10 ? 10 : g; cs.push({ id: 200 + i, dealowner: 2, playround: 1, playowner: 1, score: s }); g -= s; i++; }
return cs;
}
const settle = (call, flower, cards, roomtype, type = 0) => P.get_paiju_account(paiju(call, flower, cards, roomtype), type, {});
const grades = msg => msg.data.aset.seatlist.map(p => p.grade);
const B0 = [id(1, 1, 1)]; // 庄家仅1张手牌(不足28→无算奖),用于纯算子用例
// 常规算子:庄赢/闲赢各档(无算奖)
t.eq('结算 65小光(grade20) X=4', grades(settle(65, 1, mkCards(B0, 20), "00000")), [8, -4, -4]);
t.eq('结算 65 upgrade/multiple', [settle(65, 1, mkCards(B0, 20), "00000").data.aset.upgrade, settle(65, 1, mkCards(B0, 20), "00000").data.aset.multiple], [2, 2]);
t.eq('结算 65大光(grade0)', grades(settle(65, 1, mkCards(B0, 0), "00000")), [12, -6, -6]);
t.eq('结算 65过庄(grade50)', grades(settle(65, 1, mkCards(B0, 50), "00000")), [4, -2, -2]);
t.eq('结算 65升2级(grade110)', grades(settle(65, 1, mkCards(B0, 110), "00000")), [-8, 4, 4]);
// 爬坡局(位3=1):40分大光 base8 → X=24
t.eq('结算 爬坡40大光', grades(settle(40, 1, mkCards(B0, 0), "00010")), [48, -24, -24]);
// 投降(type1):基础1、X=1 → 庄-2、闲各+1
t.eq('结算 投降 grade', grades(settle(70, -1, mkCards(B0, 0), "00000", 1)), [-2, 1, 1]);
t.eq('结算 投降 upgrade标识', settle(70, -1, mkCards(B0, 0), "00000", 1).data.aset.upgrade, -99);
// 傍王(位2=1) + 庄家3王:N_庄=1(常规算奖)+3(傍王)=4;65小光 X=4
// jf 庄+8/闲-4,aw 庄+32/闲-16 → 庄40、闲各-20(零和)
const B3wang = fill([big(1), small(1), small(2)], 28);
t.eq('结算 傍王65小光+庄3王', grades(settle(65, 1, mkCards(B3wang, 20), "00100")), [40, -20, -20]);
// 对照:同局不勾傍王 → N_庄=1(仅常规算奖):aw 庄+8/闲-4,jf 庄+8/闲-4 → 庄16、闲各-8
t.eq('结算 不傍王65小光+庄3王(仅常规算奖)', grades(settle(65, 1, mkCards(B3wang, 20), "00000")), [16, -8, -8]);
// ============ §6.3 扣底触发 get_bottom_account ============
// 仅当闲家(maxseat!=banker)用主牌赢末轮才扣底/翻倍
function mkBottomPaiju(maxseat, winCards, bottomScoreCards, flower) {
const cards = [];
for (let i = 0; i < 108; i++) cards[i] = { id: i, playround: -1, score: 0, dealowner: 1, playowner: -1 };
for (const [cid, sc] of bottomScoreCards) { cards[cid].playround = 0; cards[cid].score = sc; }
const pc = [[], [], []]; pc[maxseat] = winCards;
const o = { banker: 0, flower, cards, playproc: { maxseat, cards: pc }, playhistory: [] };
o.method = { get_burycard: () => P.get_burycard(o), get_grade_incard: c => P.get_grade_incard(o, c) };
return o;
}
// 闲1用主对(方块Q对)赢末轮,底2张K=20分 → 主对×2、grade2=40
const bo = P.get_bottom_account(mkBottomPaiju(1, [id(1, 1, 12), id(2, 1, 12)], [[id(1, 1, 13), 10], [id(2, 1, 13), 10]], 1), { data: {} });
t.eq('扣底 闲家主对赢 multiple', bo.data.bottom.multiple, 2);
t.eq('扣底 grade1/grade2', [bo.data.bottom.grade1, bo.data.bottom.grade2], [20, 40]);
// 闲1用副牌(副K)赢末轮 → 非主牌不扣底
const bo2 = P.get_bottom_account(mkBottomPaiju(1, [id(1, 2, 13)], [[id(1, 1, 13), 10]], 1), { data: {} });
t.eq('扣底 副牌赢不扣底', bo2.data.bottom.multiple, undefined);
// 庄家(maxseat==banker)赢末轮 → 不扣底
const bo3 = P.get_bottom_account(mkBottomPaiju(0, [id(1, 1, 12), id(2, 1, 12)], [[id(1, 1, 13), 10]], 1), { data: {} });
t.eq('扣底 庄家赢不扣底', bo3.data.bottom.multiple, undefined);
// 闲1用两连对拖拉机赢 → ×4
const bo4 = P.get_bottom_account(mkBottomPaiju(1, [id(1, 1, 13), id(2, 1, 13), id(1, 1, 12), id(2, 1, 12)], [[id(1, 4, 13), 10]], 1), { data: {} });
t.eq('扣底 两连对拖赢 ×4', bo4.data.bottom.multiple, 4);
// ============ §5.4.5 甩错惩罚执行 do_playcard ============
const A = require('../class.arith.js');
// 造首出场景:庄家(0)手牌与两闲家主牌,step5 本轮首出
function mkFirstPlay(flower, dealMap) {
const cards = [];
for (let i = 0; i < 108; i++) cards[i] = { id: i, dealowner: 99, playround: -1, playowner: -1, score: 0, flower: A.id_to_flower(i), number: A.id_to_number(i) };
for (const [cid, owner] of dealMap) cards[cid].dealowner = owner;
const sl = () => [[0, 0], [0, 0], [0, 0], [0, 0], [-1, -1]];
const o = { step: 5, banker: 0, flower, cards, seatlist: [sl(), sl(), sl()], playproc: {}, playhistory: [] };
o.method = { have_baofu: () => o.seatlist.some(s => s[4][0] == 0) };
P.new_playround(o, 1, 0);
return o;
}
// 庄家甩 大王+主K对;seat1 持主A对(能压主K对) → 甩错:收回甩牌、只强制打出最小一张(主K)、其余留手
const fp = mkFirstPlay(1, [[big(1), 1], [id(1, 1, 13), 1], [id(2, 1, 13), 1], [id(1, 1, 1), 2], [id(2, 1, 1), 2]]);
const re = P.do_playcard(fp, [big(1), id(1, 1, 13), id(2, 1, 13)]);
t.eq('甩错执行 shuaicuo=true', re.shuaicuo, true);
t.eq('甩错执行 只打1张且为主K', re.cards.length === 1 && A.id_to_number(re.cards[0]) === 13, true);
t.eq('甩错执行 大王未出(留手)', fp.cards[big(1)].playround, -1);
t.eq('甩错执行 打出的主K已出', fp.cards[re.cards[0]].playround > 0, true);
// 对照:合法甩牌 do_playcard 正常打出整套、不甩错
const fp2 = mkFirstPlay(1, [[big(1), 1], [big(2), 1], [id(1, 1, 13), 1], [id(1, 1, 5), 2], [id(1, 1, 6), 3]]); // 大王对+主K单;对手仅小主牌
const re2 = P.do_playcard(fp2, [big(1), big(2), id(1, 1, 13)]);
t.eq('合法甩牌 do_playcard 非甩错', !!re2.shuaicuo, false);
t.eq('合法甩牌 整套3张打出', re2.cards.length, 3);
// §5.4.4 集成:首出合法甩牌 → 跟牌方违反分量拆解 → do_playcard 拒;正确对位 → 接受
const fpf = mkFirstPlay(1, [
[big(1), 1], [id(1, 1, 12), 1], [id(2, 1, 12), 1], // 庄家 大王+主Q对(合法甩牌)
[id(1, 1, 11), 2], [id(2, 1, 11), 2], [id(1, 1, 9), 2], [id(1, 1, 8), 2] // seat1 主J对+主9+主8
]);
const lead = P.do_playcard(fpf, [big(1), id(1, 1, 12), id(2, 1, 12)]);
t.eq('甩牌集成 首出合法(记录shuai_demand)', lead.result && !lead.shuaicuo, true);
const badF = P.do_playcard(fpf, [id(1, 1, 11), id(1, 1, 9), id(1, 1, 8)]); // 拆散主J对
t.eq('甩牌集成 跟牌拆散主对→拒', badF.result, false);
const goodF = P.do_playcard(fpf, [id(1, 1, 11), id(2, 1, 11), id(1, 1, 9)]); // 用主J对对位
t.eq('甩牌集成 跟牌用主对→接受', goodF.result, true);
// ============ §5.1 控制权 + §6.2 捡分归属 ============
// 造一轮三家各出 1 张同花色副牌的完整墩:主花色=♦(1),本轮花色=♥(3)。
// deal: [[牌id, 座位], ...];scores: [[牌id, 分值], ...]
function mkTrick(deal, scores) {
const cards = [];
for (let i = 0; i < 108; i++) cards[i] = { id: i, dealowner: 99, playround: -1, playowner: -1, score: 0, flower: A.id_to_flower(i), number: A.id_to_number(i) };
for (const [cid, seat] of deal) cards[cid].dealowner = seat + 1;
for (const [cid, s] of scores) cards[cid].score = s;
const sl = () => [[0, 0], [0, 0], [0, 0], [0, 0], [-1, -1]];
const o = { step: 5, banker: 0, flower: 1, cards, seatlist: [sl(), sl(), sl()], playproc: {}, playhistory: [] };
o.method = { have_baofu: () => o.seatlist.some(s => s[4][0] == 0) };
P.new_playround(o, 1, 0); // 第 1 轮由庄家(0)首出
return o;
}
const H5 = id(1, 3, 5), H8 = id(1, 3, 8), HK = id(1, 3, 13), HA = id(1, 3, 1); // ♥5 ♥8 ♥K ♥A(副牌)
const SC = [[H5, 5], [HK, 10]]; // §6.1:5→5分、K→10分;♥8/♥A 不计分
// —— 反:庄家赢下本轮 → 台面分牌全部作废,不计入闲家捡分 ——
const wb = mkTrick([[HA, 0], [HK, 1], [H5, 2]], SC);
P.do_playcard(wb, [HA]); P.do_playcard(wb, [HK]);
const wbRe = P.do_playcard(wb, [H5]);
// 注:第三家出完后 do_playcard 已开新一轮、playproc 被重置,胜者只能从返回值读
t.eq('§6.2 反 庄家赢本轮 maxseat=庄', wbRe.maxseat, 0);
t.eq('§6.2 反 庄家赢 → 下发面无本轮得分', wbRe.grade, undefined);
t.eq('§6.2 反 庄家赢 → 闲家捡分 0(分牌作废)', P.get_jian_grade(wb).grade, 0);
// —— 正:闲家1 赢下本轮 → 台面【全部】分牌(含庄家自己打出的♥5)计入闲家 ——
const w1 = mkTrick([[H5, 0], [HK, 1], [H8, 2]], SC);
P.do_playcard(w1, [H5]); P.do_playcard(w1, [HK]);
const w1Re = P.do_playcard(w1, [H8]);
t.eq('§6.2 正 闲1赢本轮 maxseat=1', w1Re.maxseat, 1);
t.eq('§6.2 正 本轮闲家得分=5+10(含庄家打出的♥5)', w1Re.grade, 15);
t.eq('§6.2 正 累计捡分=15', P.get_jian_grade(w1).grade, 15);
// §5.1:下一轮由本轮牌面最大的一方先出(不是固定庄家、也不是顺序下家)
t.eq('§5.1 正 下一轮由本轮最大者(闲1)先出', [w1.playproc.round, w1.playproc.start, w1.playproc.currseat], [2, 1, 1]);
// —— 边界:两个闲家谁赢都算「闲家捡到」,结果与闲1赢完全一致(design §6.2)——
const w2 = mkTrick([[H5, 0], [H8, 1], [HK, 2]], SC);
P.do_playcard(w2, [H5]); P.do_playcard(w2, [H8]);
const w2Re = P.do_playcard(w2, [HK]);
t.eq('§6.2 边界 闲2赢本轮 maxseat=2', w2Re.maxseat, 2);
t.eq('§6.2 边界 闲2赢与闲1赢同样计闲家捡分', [w2Re.grade, P.get_jian_grade(w2).grade], [15, 15]);
t.eq('§5.1 边界 下一轮改由闲2先出', [w2.playproc.round, w2.playproc.start], [2, 2]);
// ============ §8 算奖快照 get_seat_cards_award ============
// design §8:庄家按【埋牌完成后】的 28 张、闲家按【发牌后】的 28 张;快照全程固定,
// 不随之后的出牌改变。例外:70 分投降局庄家未埋牌,快照为「发牌 + 暗牌」共 36 张。
function mkAward() {
const cards = [];
for (let i = 0; i < 108; i++) cards[i] = { id: i, dealowner: -1, playround: -1, playowner: -1, score: 0 };
let n = 0;
const take = k => { const out = []; while (out.length < k) { out.push(n); n++; } return out; };
const bankerDeal = take(28), an = take(8), x1 = take(28), x2 = take(28);
bankerDeal.forEach(i => cards[i].dealowner = 1);
an.forEach(i => cards[i].dealowner = 0); // 暗牌
x1.forEach(i => cards[i].dealowner = 2);
x2.forEach(i => cards[i].dealowner = 3);
return { o: { banker: 0, flower: 1, cards }, bankerDeal, an, x1 };
}
// 投降局:未选主未埋牌 → 庄家快照 = 发牌 28 + 暗牌 8 = 36
const aw0 = mkAward();
t.eq('§8 边界 投降局 庄家快照=发牌+暗牌 36 张', P.get_seat_cards_award(aw0.o, 0).length, 36);
// 正常局:埋掉 8 张后 → 庄家快照 28 张,且已埋的不在内
const aw = mkAward();
const bury = aw.bankerDeal.slice(0, 5).concat(aw.an.slice(0, 3)); // 从发牌与暗牌里各埋几张
bury.forEach(i => aw.o.cards[i].playround = 0);
const snap = P.get_seat_cards_award(aw.o, 0);
t.eq('§8 正 庄家快照=埋牌后 28 张', snap.length, 28);
t.eq('§8 正 已埋的 8 张不在快照里', bury.some(i => snap.indexOf(i) >= 0), false);
t.eq('§8 正 闲家快照=发牌后 28 张', [P.get_seat_cards_award(aw.o, 1).length, P.get_seat_cards_award(aw.o, 2).length], [28, 28]);
// 快照是静态的:之后打出的牌仍算在内(否则闲家重连时算奖/亮牌会缩水)
snap.slice(0, 10).forEach(i => aw.o.cards[i].playround = 3);
t.eq('§8 边界 打出 10 张后 庄家快照仍是同样的 28 张', P.get_seat_cards_award(aw.o, 0), snap);
t.eq('§8 边界 打出后闲家快照也不缩水', P.get_seat_cards_award(aw.o, 1).length, 28);
process.exit(t.done('paiju') ? 0 : 1);
+788
View File
@@ -0,0 +1,788 @@
// L3 RPC 层:§9 明牌/查牌门控、§4 投降条件。期望取自 design §4/§9
const R = require('./_rpc');
const P = require('../class.paiju.js');
const { mod, setup, make108, id } = R;
const t = require('./_assert')();
const pack = (seat, extra) => Object.assign({ data: Object.assign({ agentid: 'a', playerid: 1, gameid: 'g', roomcode: 1, seat }, extra), conmode: 0, fromid: seat });
// 被拒 = 恰好回 1 个失败包给请求者(data.success=false + errcode),不再是"静默不发包"
const ERR = mod.ERR;
const rej = (c, errcode) => c.sent.length === 1 && c.sent[0].data.success === false && c.sent[0].data.errcode === errcode;
// ===================== §9 明牌 mingpai =====================
const dealMP = [[id(1, 1, 13), 2], [id(1, 1, 12), 3]]; // seat1 主K, seat2 主Q
const mkMP = (step, baofu) => ({ step, banker: 0, flower: 1, cards: make108(dealMP), method: { have_baofu: () => baofu } });
let ctx = setup("00000", mkMP(5, true)); // 可查牌 + 报无主 + 出牌阶段
mod.mingpai(pack(0));
t.eq('明牌 合法 下发1包', ctx.sent.length, 1);
t.eq('明牌 others=他家主牌', ctx.sent[0] && ctx.sent[0].data.others, [{ seat: 1, zhucards: [id(1, 1, 13)] }, { seat: 2, zhucards: [id(1, 1, 12)] }]);
ctx = setup("00001", mkMP(5, true)); mod.mingpai(pack(0)); // 不查牌(位4=1)
t.eq('明牌 不查牌 拒(回失败包RULE)', rej(ctx, ERR.RULE), true);
ctx = setup("00000", mkMP(5, false)); mod.mingpai(pack(0)); // 未报无主
t.eq('明牌 未报无主 拒(回失败包RULE)', rej(ctx, ERR.RULE), true);
ctx = setup("00000", mkMP(3, true)); mod.mingpai(pack(0)); // 非出牌阶段
t.eq('明牌 非出牌阶段 拒(回失败包STEP)', rej(ctx, ERR.STEP), true);
// ===================== §4 投降 touxiang 条件 =====================
const mkTX = (step, banker, call) => ({ step, banker, call, method: { get_paiju_account: (type, msg) => { msg.rpc = 'jiesuan'; msg.data = { type }; return msg; } } });
let p = mkTX(2, 0, 70); ctx = setup("00000", p); mod.touxiang(pack(0)); // 70分 step2 庄家
t.eq('投降 70分step2庄家 step→6', p.step, 6);
t.eq('投降 下发jiesuan', ctx.sent.length === 1 && ctx.sent[0].rpc, 'jiesuan');
p = mkTX(2, 0, 65); ctx = setup("00000", p); mod.touxiang(pack(0));
t.eq('投降 65分 拒(回失败包RULE)', rej(ctx, ERR.RULE), true);
p = mkTX(3, 0, 70); ctx = setup("00000", p); mod.touxiang(pack(0));
t.eq('投降 埋牌后step3 拒(回失败包STEP)', rej(ctx, ERR.STEP), true);
p = mkTX(2, 1, 70); ctx = setup("00000", p); mod.touxiang(pack(0));
t.eq('投降 非庄家 拒(回失败包SEAT)', rej(ctx, ERR.SEAT), true);
// ===================== §9 出牌 chupai 的查牌门控(seatlist/baozhu)=====================
// 造真实牌局 → 强制到首出(step5),庄家(0)出一张副牌单张;seat1 已报无主
function mkChupai(roomtype) {
const o_desk_stub = { paiju_list: [] };
const pj = P.new(o_desk_stub, 0);
pj.banker = 0; pj.call = 65; pj.flower = 1; pj.step = 5;
P.new_playround(pj, 1, 0); // 本轮庄家(0)首出
const lead = id(1, 3, 9); // 副9(flower3) 作单张首出
pj.cards[lead].dealowner = 1; // 归庄家
pj.cards[lead].playround = -1;
// seat1 报无主:必须让它【真的】打空主牌。do_playcard 会按各家实际手牌重算三家的主牌统计
// (design §9:一旦有人报无主,立即为全体三人刷新),只置标志位会被重算纠正回去
for (const c of P.get_seat_zhucards(pj, 1)) pj.cards[c].playround = 1;
pj.seatlist[1][4][0] = 0; // seat1 报无主 → have_baofu()=true
const c = setup(roomtype, pj);
mod.chupai(pack(0, { cards: [lead] }));
return c;
}
let cv = mkChupai("00000"); // 可查牌
t.eq('出牌 可查牌 有seatlist', cv.sent[0] && cv.sent[0].data.seatlist !== undefined, true);
// design §9:报无主后要为全体三人显示【另外两家】的主牌数/对子数 → 下发面必须整表带三家,
// 不能只带出牌者一家(否则另两家的统计要滞后到它们各自出牌才补上)
t.eq('出牌 可查牌 seatlist 带三家', cv.sent[0] && cv.sent[0].data.seatlist.length, 3);
t.eq('出牌 可查牌 seatlist 含非出牌者的报副统计', cv.sent[0] && cv.sent[0].data.seatlist[1][4][0], 0);
t.eq('出牌 可查牌 baozhu=1(有人报无主)', cv.sent[0] && cv.sent[0].data.baozhu, 1);
let cn = mkChupai("00001"); // 不查牌(位4=1)
t.eq('出牌 不查牌 无seatlist', cn.sent[0] && cn.sent[0].data.seatlist, undefined);
t.eq('出牌 不查牌 baozhu=0', cn.sent[0] && cn.sent[0].data.baozhu, 0);
// ===================== §4.2 mod.jiaofen 入参校验 =====================
// do_callgrade 被调用=接受;被 return 挡掉=拒绝。step 设 0 跳过后续广播
function mkJF(currcall) {
const st = { called: null };
const pj = {
step: 1, banker: -1, call: -1,
method: {
get_callgrade_seat: () => 0,
get_callgrade_value: () => currcall,
do_callgrade: c => { st.called = c; pj.step = 0; }
}
};
return { pj, st };
}
function jf(currcall, call) {
const { pj, st } = mkJF(currcall);
setup("00000", pj);
mod.jiaofen(pack(0, { call }));
return st.called;
}
t.eq('叫分 >70 拒', jf(0, 75), null);
t.eq('叫分 非5倍数 拒', jf(0, 63), null);
t.eq('叫分 首家不叫(currcall0,call0) 拒', jf(0, 0), null);
t.eq('叫分 后叫不更低(call>=currcall) 拒', jf(55, 60), null);
t.eq('叫分 首家合法65 接受', jf(0, 65), 65);
t.eq('叫分 后叫更低50 接受', jf(55, 50), 50);
t.eq('叫分 边界70 接受', jf(0, 70), 70);
// ===================== §4 70分上庄的暗牌亮牌 gating(真实 jiaofen 驱动到上庄)=====================
function driveUp(calls) {
const pj = P.new({ paiju_list: [] }, 0); // 真实牌局 step1 firstseat0
const c = setup("00000", pj);
for (const [seat, call] of calls) mod.jiaofen(pack(seat, { call }));
return c.sent.filter(m => m.rpc === 'shangzhuang');
}
// 0叫70、1不叫、2不叫 → 0上庄70
const sz70 = driveUp([[0, 70], [1, 0], [2, 0]]);
t.eq('上庄70 有shangzhuang下发', sz70.length > 0, true);
const bankerPk = sz70.find(m => m.data.cards !== undefined);
const xianPk = sz70.find(m => m.data.cards === undefined);
t.eq('上庄70 庄家包有手牌', !!bankerPk, true);
t.eq('上庄70 闲家包有暗牌', xianPk && xianPk.data.bottomcards !== undefined, true);
t.eq('上庄70 ancard3s=1', xianPk && xianPk.data.ancard3s, 1);
// 对照:叫5立即上庄(非70) → 闲家无暗牌、无ancard3s
const sz5 = driveUp([[0, 5]]);
const xian5 = sz5.find(m => m.data.cards === undefined);
t.eq('上庄5 闲家无暗牌', xian5 && xian5.data.bottomcards, undefined);
t.eq('上庄5 无ancard3s', xian5 && xian5.data.ancard3s, undefined);
// ===================== §12 端到端(叫分→上庄→选主→埋牌→出牌ready) + §9 maipai/deskinfo 门控 =====================
const E = require('../class.export.js').new();
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 })); // 0上庄65
mod.xuanzhu(pack(0, { flower: 1 })); // 选主 方块
mod.maipai(pack(0, { cards: P.get_seat_cards(pj, 0).slice(0, 8) })); // 埋任意8张
return { pj, ...c };
}
const e = driveToPlay("00000");
t.eq('端到端 叫分→选主→埋牌→step5', e.pj.step, 5);
t.eq('端到端 庄家首出(currseat=0)', e.pj.playproc.currseat, 0);
t.eq('端到端 选主flower=1', e.pj.flower, 1);
t.eq('端到端 埋牌后庄家保留28张', P.get_seat_cards(e.pj, 0).length, 28);
// §9 maipai 亮牌门控:不查牌 → 闲家包无 liangpai
const en = driveToPlay("00001");
const mpXian = en.sent.filter(m => m.rpc === 'maipai').find(m => m.data.cards === undefined);
t.eq('埋牌 不查牌 闲家无liangpai', mpXian && mpXian.data.liangpai, undefined);
// §9 deskinfo(重连) PushCards 门控:可查牌有 seatlist、不查牌无
t.eq('重连 可查牌 PushCards有seatlist', E.get_deskinfo(e.o_room, 1).PushCards.seatlist !== undefined, true);
t.eq('重连 不查牌 PushCards无seatlist', E.get_deskinfo(en.o_room, 1).PushCards.seatlist, undefined);
// ===================== 底牌 vs 埋牌底牌:字段名必须分开(S-4)=====================
// 术语:底牌 = 发牌时没发给玩家、扣在桌面的 8 张(字段 bottomcards)
// 埋牌底牌 = 庄家埋牌扣下的 8 张(字段 burycards)
// 两者曾共用 bottomcards、只靠所在包区分语义,已拆分。以下用例锁死拆分结果,防回归。
const mpAll = e.sent.filter(m => m.rpc === 'maipai');
const mpZhuang = mpAll.find(m => m.data.cards !== undefined); // 庄家包(带手牌)
const mpXian2 = mpAll.find(m => m.data.cards === undefined); // 闲家包
// 正面:庄家的 maipai 包带 burycards,且就是埋下的那 8 张
t.eq('maipai 庄家有burycards', mpZhuang && Array.isArray(mpZhuang.data.burycards), true);
t.eq('maipai burycards为8张', mpZhuang && mpZhuang.data.burycards.length, 8);
// 反面:maipai 包里不得再出现 bottomcards(改名漏改会被这条抓住)
t.eq('maipai 庄家无bottomcards', mpZhuang && mpZhuang.data.bottomcards, undefined);
// 可见性:闲家两个字段都不该有(埋牌底牌只有庄家可见)
t.eq('maipai 闲家无burycards', mpXian2 && mpXian2.data.burycards, undefined);
t.eq('maipai 闲家无bottomcards', mpXian2 && mpXian2.data.bottomcards, undefined);
// 重连 PushCards:庄家有 burycards、无 bottomcards;闲家两者皆无
const pcZhuang = E.get_deskinfo(e.o_room, 0).PushCards;
const pcXian = E.get_deskinfo(e.o_room, 1).PushCards;
t.eq('重连 PushCards 庄家有burycards', Array.isArray(pcZhuang.burycards), true);
t.eq('重连 PushCards 庄家burycards为8张', pcZhuang.burycards.length, 8);
t.eq('重连 PushCards 庄家无bottomcards', pcZhuang.bottomcards, undefined);
t.eq('重连 PushCards 闲家无burycards', pcXian.burycards, undefined);
t.eq('重连 PushCards 闲家无bottomcards', pcXian.bottomcards, undefined);
// 对照:底牌一侧字段名不受影响,仍叫 bottomcards(ChooseMain/BuryCards 为庄家专属)
const cmZhuang = E.get_deskinfo(e.o_room, 0);
t.eq('重连 ChooseMain/BuryCards 侧仍用bottomcards命名未被误改',
cmZhuang.PushCards.burycards !== undefined && cmZhuang.PushCards.bottomcards === undefined, true);
// ===================== 下发面一致性:增量路径(实时推送) 必须与 全量路径(重连 deskinfo) 给出同一份数据 =====================
// 背景:前端一致性测试(同一局真包,逐包增量回放 vs 一次性 deskinfo 重建)暴露出 4 处服务端下发面缺陷。
// 这些都不是"算错了",而是"同一份数据两条路径给的不一样 / 有一条根本没给",前端只能自己补——
// 而自己补就等于把对局推进逻辑搬到前端(前端红线「数据驱动、前端无对局状态机」)。以下逐条钉死。
const JS = x => JSON.stringify(x);
const AR = require('../class.arith.js'); // 本文件下方另有同模块的 A,此处在其声明之前,故另起名
// ---- ① bottomcards:上庄推送 / ChooseMain(step2) / BuryCards(step3) 三处必须【同序】----
// 曾经的缺陷:get_bottomcards 每次按"当时的 paiju.flower"重排,而底牌是在【选主之前】翻给庄家看的,
// 于是 step2(flower=-1) 与 step3(flower 已定) 给出两种顺序——主花色的那张牌被顶到队首。
// 现按发牌时冻结的快照下发。反面守卫:主牌花色刻意选成底牌里真实存在的花色,一旦退回"按当时 flower 排"必红。
function driveBottom(flowerPick) {
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 }));
const sz = c.sent.filter(m => m.rpc === 'shangzhuang').find(m => m.data.cards !== undefined).data; // 庄家包
const step2 = E.get_deskinfo(c.o_room, 0).ChooseMain;
mod.xuanzhu(pack(0, { flower: flowerPick(pj) }));
const step3 = E.get_deskinfo(c.o_room, 0).BuryCards;
return { pj, c, sz, step2, step3 };
}
// 主牌花色必须挑一个【会真的改变底牌排序】的:否则本组用例是空转的——
// 退回"按当时 flower 重排"也照样绿(实测:随手挑底牌里第一张的花色就是空转,它本来就排在队首)。
let btReorders = false;
const bt = driveBottom(pj => {
const snap = P.get_bottomcards(pj);
for (let f = 1; f <= 4; f++) {
if (JS(AR.order_cards(f, snap.concat())) !== JS(snap)) { btReorders = true; return f; }
}
return 1;
});
// 先钉住前提:所选花色确实会让"按主牌花色排"与"冻结快照"不同序,下面三条才是有效守卫
t.eq('底牌 用例前提:所选主牌花色会改变排序(否则本组用例空转)', btReorders, true);
t.eq('底牌 上庄推送 8 张', bt.sz.bottomcards.length, 8);
t.eq('底牌 上庄推送 == 重连 ChooseMain(step2) 同序', JS(bt.step2.bottomcards), JS(bt.sz.bottomcards));
t.eq('底牌 上庄推送 == 重连 BuryCards(step3) 同序(选主后不得重排)', JS(bt.step3.bottomcards), JS(bt.sz.bottomcards));
// 快照必须是【副本】:order_cards 是原地排序,调用方就地重排不得污染冻结的快照
const bcCopy = P.get_bottomcards(bt.pj); bcCopy.reverse();
t.eq('底牌 get_bottomcards 返回副本(调用方重排不污染快照)', JS(P.get_bottomcards(bt.pj)), JS(bt.sz.bottomcards));
// ---- ② curmultiple:ChooseMain(step2) / BuryCards(step3) 必须与 shangzhuang 同源同值 ----
// 此刻一张牌未出、捡分恒为 0 → 必为 +3(大光)。曾经 deskinfo 不带此字段,重连后前端角标掉回 0。
t.eq('抓分倍数 上庄推送 curmultiple=3(捡分恒0→大光)', bt.sz.curmultiple, 3);
t.eq('抓分倍数 重连 ChooseMain.curmultiple 与上庄同值', bt.step2.curmultiple, bt.sz.curmultiple);
t.eq('抓分倍数 重连 BuryCards.curmultiple 与上庄同值', bt.step3.curmultiple, bt.sz.curmultiple);
// ---- ③ maipai.seatlist:与 PushCards.seatlist 同表同门控 ----
// 曾经的缺陷:maipai 不带 seatlist,「埋牌完成 → 庄家首出」这段窗口增量路径为空、重连路径却有表。
const mpZhuang3 = mpAll.find(m => m.data.cards !== undefined);
const mpXian3 = mpAll.find(m => m.data.cards === undefined);
t.eq('埋牌 可查牌 maipai 带 seatlist(庄闲同表)',
JS(mpZhuang3.data.seatlist), JS(E.get_deskinfo(e.o_room, 0).PushCards.seatlist));
t.eq('埋牌 可查牌 maipai 闲家同一张表(seatlist 三家公开、不裁剪)',
JS(mpXian3.data.seatlist), JS(mpZhuang3.data.seatlist));
t.eq('埋牌 seatlist 埋牌完成时为初始化空表', JS(mpZhuang3.data.seatlist),
JS([0, 1, 2].map(() => [[0, 0], [0, 0], [0, 0], [0, 0], [-1, -1]])));
// 反面:不查牌模式一律不下发(与 chupai / PushCards 同一道门控,design §9)
t.eq('埋牌 不查牌 maipai 无 seatlist',
en.sent.filter(m => m.rpc === 'maipai').every(m => m.data.seatlist === undefined), true);
// ---- ③b maipai.playproc:与 PushCards.playproc 同一个快照函数、同结构,且是深拷贝 ----
// 曾经的缺陷:mod.js 组 maipai 包时没有放 playproc(do_burycard 内部已 new_playround 就地初始化好
// round-1 的进行态),「埋牌完成 → 庄家首出」这段窗口增量路径的 table.playproc 是 null,
// 重连路径却已有初值——与 seatlist 是同一个窗口、同一条理由,被漏在了同一处。
t.eq('埋牌 maipai 带 playproc(庄闲同值)',
JS(mpZhuang3.data.playproc), JS(E.get_deskinfo(e.o_room, 0).PushCards.playproc));
t.eq('埋牌 maipai 闲家同一份 playproc(三家公开、不裁剪)',
JS(mpXian3.data.playproc), JS(mpZhuang3.data.playproc));
t.eq('埋牌 playproc 埋牌完成时为 round-1 初值(庄家首出、桌面全空)', mpZhuang3.data.playproc, {
round: 1, start: 0, currseat: 0, startcount: -1, startflower: -1, starttype: -1,
maxseat: -1, maxcard: -1, cards: [null, null, null], shuai_demand: null
});
// 反面:playproc 不受查牌门控——不像 seatlist,它只是桌面公开信息,不查牌模式下也恒有
t.eq('埋牌 不查牌 maipai 仍带 playproc',
en.sent.filter(m => m.rpc === 'maipai').every(m => m.data.playproc !== undefined), true);
// 深拷贝守卫:playproc 是全程复用的活对象,new_playround 就地重置同一个对象。
// 若下发挂的是活引用,埋牌后紧跟着打一手牌,已发出的 maipai.playproc 会被回改成下一状态的样子。
const dcPj = P.new({ paiju_list: [] }, 0);
const dcC = setup("00000", dcPj);
mod.jiaofen(pack(0, { call: 65 })); mod.jiaofen(pack(1, { call: 0 })); mod.jiaofen(pack(2, { call: 0 }));
mod.xuanzhu(pack(0, { flower: 1 }));
const dcInner = mod.app.SendPack;
let dcLive = null;
mod.app.SendPack = m => { if (m.rpc === 'maipai' && m.data.cards !== undefined) dcLive = m.data.playproc; dcInner(m); };
mod.maipai(pack(0, { cards: P.get_seat_cards(dcPj, 0).slice(0, 8) }));
mod.app.SendPack = dcInner;
const dcSnapAtSend = JS(dcLive);
const dcHand = P.get_seat_cards(dcPj, dcPj.playproc.currseat);
mod.chupai(pack(dcPj.playproc.currseat, { cards: [dcHand[dcHand.length - 1]] })); // 推进出一手,制造"若为活引用会被回改"的条件
t.eq('埋牌 playproc 下发的是深拷贝快照(已发出的包不被后续出牌回改)', JS(dcLive), dcSnapAtSend);
// ---- ④ burycards:maipai 必须发服务端权威快照,不得回显客户端提交的 cards 原序 ----
// 曾经的缺陷:msg.data.burycards = cards(请求包原样回显),而 PushCards.burycards 走 get_burycard()
// 按主牌花色排序 —— 同一副埋牌底牌两条路径两种顺序(SSOT)。
const bu = (() => {
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 }));
// 刻意打乱提交顺序:手牌是从大到小的,取 8 张后【反转】成从小到大提交
const raw = P.get_seat_cards(pj, 0).slice(0, 8).reverse();
mod.maipai(pack(0, { cards: raw }));
const mp = c.sent.filter(m => m.rpc === 'maipai').find(m => m.data.burycards !== undefined);
return { pj, c, raw, mp };
})();
t.eq('埋牌底牌 maipai == 重连 PushCards 同序(SSOT)',
JS(bu.mp.data.burycards), JS(E.get_deskinfo(bu.c.o_room, 0).PushCards.burycards));
t.eq('埋牌底牌 maipai 不回显客户端提交的原序', JS(bu.mp.data.burycards) === JS(bu.raw), false);
t.eq('埋牌底牌 maipai 与提交的是同一批牌(只是顺序不同)',
JS(bu.mp.data.burycards.concat().sort((x, y) => x - y)), JS(bu.raw.concat().sort((x, y) => x - y)));
// ---- ⑤ chupai1/2/3.playproc:结构与取值必须与同一时刻的 PushCards.playproc 完全一致 ----
// 没有它,前端在非重连时拿不到「本轮谁先手/谁最大/三家各出了什么/第几轮」,只能自己累积推导。
// 用真实牌局打满 28 轮,每出一手就拿三家的下发包与三家的重连快照逐一比对。
const pp = (() => {
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(-8) }));
// 深拷贝守卫需要【活引用】:setup 的 SendPack 会 JSON 深拷贝每个包,用 c.sent 验不出活引用问题。
// 故在其之外再挂一层,原样留住服务端交出来的那个 playproc 对象。
const live = [];
const innerSend = mod.app.SendPack;
mod.app.SendPack = m => { if (/^chupai[123]$/.test(m.rpc)) live.push(m.data.playproc); innerSend(m); };
let cmp = 0, mism = 0, missing = 0, rounds = 0, guard = 0, jiesuanHasPlayproc = null, firstSnap = null;
while (pj.step === 5 && ++guard < 400) {
const seat = pj.playproc.currseat, hand = P.get_seat_cards(pj, seat);
let pick = null;
if (seat === pj.playproc.start) pick = [hand[hand.length - 1]];
else for (const x of hand) if (AR.can_followcard(pj.flower, hand, [x], pj.playproc.startcount, pj.playproc.startflower, pj.playproc.starttype).result) { pick = [x]; break; }
if (!pick) break;
c.sent.length = 0;
mod.chupai(pack(seat, { cards: pick }));
const cps = c.sent.filter(m => /^chupai[123]$/.test(m.rpc));
const js = c.sent.filter(m => m.rpc === 'jiesuan');
if (js.length) jiesuanHasPlayproc = js.some(m => m.data.playproc !== undefined);
if (!cps.length) continue;
rounds++;
if (cps.some(m => m.data.playproc === undefined)) missing++;
if (firstSnap === null) firstSnap = JS(live[0]); // 第 1 包发出【当时】的样子
if (pj.step !== 5) continue; // 已进结算,无"当前进行态"可比
for (let s = 0; s < 3; s++) {
const rec = E.get_deskinfo(c.o_room, s).PushCards.playproc;
cmp++;
if (JS(cps[s].data.playproc) !== JS(rec)) mism++;
}
}
mod.app.SendPack = innerSend;
return { pj, cmp, mism, missing, rounds, jiesuanHasPlayproc, firstSnap, firstRef: live[0] };
})();
t.eq('本轮进行态 28轮打完(用例真的走到底)', pp.pj.playproc.round, 28);
t.eq('本轮进行态 chupai1/2/3 恒带 playproc', [pp.rounds > 80, pp.missing], [true, 0]);
t.eq('本轮进行态 增量下发 == 同时刻重连快照(逐包逐座位)', [pp.cmp > 200, pp.mism], [true, 0]);
t.eq('本轮进行态 chupai3 转 jiesuan 时不带 playproc', pp.jiesuanHasPlayproc, false);
// 深拷贝守卫:playproc 是全程复用的活对象(new_playround 就地重置同一个对象)。
// 若下发挂的是活引用,打完整局后"第一包里的第 1 轮进行态"会变成最后一轮的样子。
t.eq('本轮进行态 下发的是深拷贝快照(已发出的包不被后续出牌回改)', JS(pp.firstRef), pp.firstSnap);
t.eq('本轮进行态 第1包就是第1轮、cards 定长3', [pp.firstRef.round, pp.firstRef.cards.length], [1, 3]);
// ===================== §5.4 甩牌:chupai1 需下发分量构成 shuai(cardtype 表达不了甩牌)=====================
// 主花色=♦(1)。庄家(0) 甩「主♦K对 + 主♦5单张」;两闲家手上一张主牌都没有 → 甩牌合法
function mkShuai(extraToSeat1) {
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: {}, playhistory: [], 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, 1, 13), 0); give(id(2, 1, 13), 0); give(id(1, 1, 5), 0); // 庄家:主♦K对 + 主♦5
give(id(1, 3, 9), 1); give(id(1, 3, 8), 1); give(id(2, 3, 9), 1); // 闲1:全副♥,无主牌
give(id(1, 2, 9), 2); give(id(1, 2, 8), 2); give(id(2, 2, 9), 2); // 闲2:全副♣,无主牌
if (extraToSeat1) give(extraToSeat1, 1); // 需要造甩错时给闲1一张大主牌
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),
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;
}
// 合法甩牌 → 带 shuai 分量
let sh = mkShuai(null); let shc = setup("00000", sh);
mod.chupai(pack(0, { cards: [id(1, 1, 13), id(2, 1, 13), id(1, 1, 5)] }));
let shPk = shc.sent.filter(m => m.rpc === 'chupai1')[0];
t.eq('甩牌 合法 下发 chupai1', !!shPk, true);
t.eq('甩牌 打出3张', shPk && shPk.data.cards.length, 3);
t.eq('甩牌 shuai=1对+1单张', shPk && shPk.data.shuai, { tractors: [], pairs: 1, singles: 1 });
t.eq('甩牌 无 shuaicuo', shPk && shPk.data.shuaicuo, undefined);
// 非甩牌(单一对子)→ 无 shuai
sh = mkShuai(null); shc = setup("00000", sh);
mod.chupai(pack(0, { cards: [id(1, 1, 13), id(2, 1, 13)] }));
shPk = shc.sent.filter(m => m.rpc === 'chupai1')[0];
t.eq('非甩牌(单一对子) 无 shuai', shPk && shPk.data.shuai, undefined);
// 甩错(闲1 持大王,能压过主K对里的单张分量)→ 收回、只打最小一张、无 shuai
sh = mkShuai((2 - 1) * 54 + 53); shc = setup("00000", sh); // 第二副大王给闲1
mod.chupai(pack(0, { cards: [id(1, 1, 13), id(2, 1, 13), id(1, 1, 5)] }));
shPk = shc.sent.filter(m => m.rpc === 'chupai1')[0];
t.eq('甩错 shuaicuo=1', shPk && shPk.data.shuaicuo, 1);
t.eq('甩错 只打出最小一张(主♦5)', shPk && shPk.data.cards, [id(1, 1, 5)]);
t.eq('甩错 无 shuai 分量', shPk && shPk.data.shuai, undefined);
// ===================== 重连 PushCards.pushlist:与 chupai 下发的 data.cards 同一份数据 =====================
// 【契约】「这一手打出的牌」只有一个顺序口径:do_playcard 用 order_playcards 归一一次,
// 之后 chupai1/2/3 的 data.cards、playproc.cards、playhistory→PushCards.pushlist 全都读它。
// 因此重连包里的每一手,必须与当时那个 chupai 包的 cards【逐元素完全相等】。
//
// 旧实现在这里分岔:pushlist 是从 cards[i].playround/dealowner 反查后自己再排一次序,
// 与 chupai 回显的「客户端提交原序」是两个写入处——一手多张(甩牌/对子)时必然不同,
// 同编码的一对牌更是排序都区分不了。故本用例必须【一手多张 + 乱序提交】才抓得住。
//
// 【乱序提交】asc() 把选好的一手反转成升序再发,模拟真实客户端的点击顺序;
// 服务端不得回显它,一律给权威降序。
function ascSubmit(pj, cards) { return AR.order_cards(pj.flower, cards.concat()).reverse(); }
function drivePushlist(roomtype, hands) {
const c = driveToPlay(roomtype);
const pj = c.pj;
const played = []; // [{round, seat, cards(下发的)}...]
for (let h = 0; h < hands; h++) {
if (pj.step !== 5) break;
const seat = pj.playproc.currseat;
const round = pj.playproc.round;
const hand = P.get_seat_cards(pj, seat);
let pick = null;
if (seat === pj.playproc.start) {
// 首家优先领一对(制造"一手多张";对子两张同编码,排序无法区分,正是分岔点)
const pairs = AR.get_pairlist(pj.flower, hand.concat());
for (const pr of pairs) {
if (AR.can_playcard(pj.flower, pr.concat(), seat, pj.seatlist,
[0, 1, 2].filter(s => s !== seat).map(s => P.get_seat_zhucards(pj, s))).result) {
pick = pr.concat(); break;
}
}
if (!pick) { pick = [hand[hand.length - 1]]; }
} else {
const n = pj.playproc.startcount;
const get = AR.get_followcard(pj.flower, hand.concat(), n, pj.playproc.startflower, pj.playproc.starttype);
const base = (get && get.mustcard) ? get.mustcard.concat() : [];
if (base.length === n) { pick = base; }
else {
const pool = (get && get.cancard && get.cancard.length ? get.cancard : hand).filter(x => base.indexOf(x) < 0);
const need = n - base.length;
const combo = [];
(function walk(st) {
if (pick) return;
if (combo.length === need) {
const cand = base.concat(combo);
if (AR.can_followcard(pj.flower, hand, cand, n, pj.playproc.startflower, pj.playproc.starttype).result
&& (!pj.playproc.shuai_demand || AR.flush_follow_ok(pj.flower, hand, cand, pj.playproc.shuai_demand))) {
pick = cand;
}
return;
}
for (let i = st; i < pool.length && !pick; i++) { combo.push(pool[i]); walk(i + 1); combo.pop(); }
})(0);
}
}
if (!pick) { break; }
const before = c.sent.length;
mod.chupai(pack(seat, { cards: ascSubmit(pj, pick) }));
const cp = c.sent.slice(before).filter(m => /^chupai[123]$/.test(m.rpc))[0];
if (cp) { played.push({ round, seat, cards: cp.data.cards, submitted: ascSubmit(pj, pick) }); }
}
return { c, pj, played };
}
const plv = drivePushlist("00000", 12);
t.eq('pushlist 用例真的出过一手多张', plv.played.some(x => x.cards.length > 1), true);
const di = E.get_deskinfo(plv.c.o_room, 1).PushCards.pushlist;
t.eq('重连 pushlist 每轮恒3个座位', di.every(r => r.length === 3), true);
t.eq('重连 pushlist 不含 undefined', di.some(r => r.some(x => x === undefined)), false);
// 核心:逐手比对「重连归档」与「当时的 chupai 包」——两条路径同一份数据,必须逐元素相等
let plMismatch = [];
for (const h of plv.played) {
const arc = di[h.round - 1] && di[h.round - 1][h.seat];
if (JS(arc) !== JS(h.cards)) { plMismatch.push({ round: h.round, seat: h.seat, arc, pkt: h.cards }); }
}
t.eq('重连 pushlist 每一手都与当时的 chupai.cards 逐元素相等', plMismatch, []);
// 反面:服务端不得回显客户端的提交顺序(提交是升序,下发必须是权威降序)
const plMulti = plv.played.filter(x => x.cards.length > 1);
t.eq('chupai.cards 不回显客户端提交原序(一手多张时)',
plMulti.some(x => JS(x.cards) === JS(x.submitted) && JS(x.cards) !== JS(AR.order_cards(plv.pj.flower, x.cards.concat()))), false);
t.eq('chupai.cards 是本局主牌花色的权威降序',
plMulti.every(x => JS(x.cards) === JS(AR.order_cards(plv.pj.flower, x.cards.concat()))), true);
// 客户端提交的数组不被就地改写(can_playcard 是原地排序,do_playcard 必须先拷贝)
const inplace = driveToPlay("00000");
const inHand = P.get_seat_cards(inplace.pj, inplace.pj.playproc.currseat);
const inArg = [inHand[inHand.length - 1], inHand[inHand.length - 2]];
const inArgCopy = inArg.concat();
mod.chupai(pack(inplace.pj.playproc.currseat, { cards: inArg }));
t.eq('chupai 不就地改写 pack.data.cards', inArg, inArgCopy);
// §9 不查牌模式:出牌历史属于「查牌」,一律不下发;但当前轮桌面上的牌必须照常恢复
const plv2 = drivePushlist("00001", 2);
const pc2 = E.get_deskinfo(plv2.c.o_room, 1).PushCards;
t.eq('重连 不查牌 无出牌历史 pushlist', pc2.pushlist, undefined);
t.eq('重连 不查牌 当前轮桌面牌仍恢复(playproc.cards)',
JS(pc2.playproc.cards[plv2.played[0].seat]), JS(plv2.played[0].cards));
const pc1 = E.get_deskinfo(plv.c.o_room, 1).PushCards;
t.eq('重连 可查牌 有出牌历史 pushlist', Array.isArray(pc1.pushlist), true);
// ===================== 重连 PushCards.baozhu:与 chupai 的 baozhu 同源同门控 =====================
// baozhu 是「余主公示 + 明牌按钮」的开关(design §9)。它只在 chupai 包里给、重连包不给的话,
// 重连后按钮凭空消失——状态变更没有包承载(server 红线:发全下发面)。
function mkBaozhuRoom(roomtype) {
const o_desk_stub = { paiju_list: [] };
const pj = P.new(o_desk_stub, 0);
pj.banker = 0; pj.call = 65; pj.flower = 1; pj.step = 5;
P.new_playround(pj, 1, 0);
const lead = id(1, 3, 9);
pj.cards[lead].dealowner = 1; pj.cards[lead].playround = -1;
for (const cc of P.get_seat_zhucards(pj, 1)) { pj.cards[cc].playround = 1; }
pj.seatlist[1][4][0] = 0; // seat1 报无主 → have_baofu()=true
const c = setup(roomtype, pj);
mod.chupai(pack(0, { cards: [lead] }));
return c;
}
const bzOn = mkBaozhuRoom("00000");
t.eq('重连 可查牌 PushCards 带 baozhu,且与 chupai 同值',
E.get_deskinfo(bzOn.o_room, 1).PushCards.baozhu,
bzOn.sent.filter(m => /^chupai[123]$/.test(m.rpc))[0].data.baozhu);
t.eq('重连 可查牌 已报无主 → baozhu=1', E.get_deskinfo(bzOn.o_room, 1).PushCards.baozhu, 1);
const bzOff = mkBaozhuRoom("00001");
t.eq('重连 不查牌 baozhu 恒 0', E.get_deskinfo(bzOff.o_room, 1).PushCards.baozhu, 0);
// ===================== §7.3 下发面 multiple 必须随「爬坡」开关取值(与结算 aset.multiple 同源)=====================
// 叫 45 分:常规算子基础子数 6(design §7.1「50 及以下统一 6」),爬坡 7(design §7.3.1)
const A = require('../class.arith.js');
function driveUp45(roomtype) {
const pj = P.new({ paiju_list: [] }, 0);
const c = setup(roomtype, pj);
mod.jiaofen(pack(0, { call: 45 })); mod.jiaofen(pack(1, { call: 0 })); mod.jiaofen(pack(2, { call: 0 }));
return { pj, c };
}
t.eq('算子 常规房 get_base_bycall(45,false)=6', A.get_base_bycall(45, false), 6);
t.eq('算子 爬坡房 get_base_bycall(45,true)=7', A.get_base_bycall(45, true), 7);
const n45 = driveUp45("00000"); // 常规算子
const p45 = driveUp45("00010"); // 位3=1 爬坡
const szm = c => c.sent.filter(m => m.rpc === 'shangzhuang')[0].data.multiple;
t.eq('上庄包 常规房 multiple=6', szm(n45.c), 6);
t.eq('上庄包 爬坡房 multiple=7', szm(p45.c), 7);
// 重连各阶段同源:选主阶段(step2) ChooseMain.multiple
t.eq('重连 常规房 ChooseMain.multiple=6', E.get_deskinfo(n45.c.o_room, 0).ChooseMain.multiple, 6);
t.eq('重连 爬坡房 ChooseMain.multiple=7', E.get_deskinfo(p45.c.o_room, 0).ChooseMain.multiple, 7);
// 与结算包 aset.multiple 一致(同一 call 同一房间规则)
const aset45 = P.get_paiju_account(
{ call: 45, banker: 0, flower: 1, cards: [], idx: 1, endtime: null, result: null, o_desk: { seatlist: [[0, [], 0, 0, 0], [0, [], 0, 0, 0], [0, [], 0, 0, 0]], o_room: { roomtype: "00010", asetcount: 6 }, method: { get_desk_account: m => m } } },
0, {}).data.aset.multiple;
t.eq('爬坡房 下发面 multiple 与结算 aset.multiple 一致', szm(p45.c), aset45);
// ===================== §11 闲家出牌提示 tishi(只转发给对家,不校验)=====================
const mkTip = (step, banker) => ({ step, banker });
// 闲家1 发"踩"(tip=1),banker=0 → 对家=2;默认 seatlist fromid==座位号
let tp = setup("00000", mkTip(5, 0)); mod.tishi(pack(1, { tip: 1 }));
t.eq('提示 合法 下发1包', tp.sent.length, 1);
t.eq('提示 只发给对家(fromid=2)', tp.sent[0] && tp.sent[0].fromid, 2);
t.eq('提示 rpc=tishi', tp.sent[0] && tp.sent[0].rpc, 'tishi');
t.eq('提示 data.seat=发出者1', tp.sent[0] && tp.sent[0].data.seat, 1);
t.eq('提示 data.tip=1(踩)', tp.sent[0] && tp.sent[0].data.tip, 1);
// 闲家2 发"有分"(tip=3) → 对家=1
tp = setup("00000", mkTip(5, 0)); mod.tishi(pack(2, { tip: 3 }));
t.eq('提示 闲2发→对家1(fromid=1)', tp.sent[0] && tp.sent[0].fromid, 1);
// banker=2 时,闲家0 发"没分"(tip=2) → 对家=1
tp = setup("00000", mkTip(5, 2)); mod.tishi(pack(0, { tip: 2 }));
t.eq('提示 banker=2·闲0发→对家1', tp.sent[0] && tp.sent[0].fromid, 1);
// 庄家发 → 拒(庄家无对家)
tp = setup("00000", mkTip(5, 0)); mod.tishi(pack(0, { tip: 1 }));
t.eq('提示 庄家发 拒(回失败包RULE)', rej(tp, ERR.RULE), true);
// 非出牌阶段 → 拒
tp = setup("00000", mkTip(3, 0)); mod.tishi(pack(1, { tip: 1 }));
t.eq('提示 非出牌阶段(step3) 拒(回失败包STEP)', rej(tp, ERR.STEP), true);
// 非法 tip / 缺 tip → 拒
tp = setup("00000", mkTip(5, 0)); mod.tishi(pack(1, { tip: 9 }));
t.eq('提示 非法tip=9 拒(回失败包PARAM)', rej(tp, ERR.PARAM), true);
tp = setup("00000", mkTip(5, 0)); mod.tishi(pack(1, {}));
t.eq('提示 缺tip 拒(回失败包PARAM)', rej(tp, ERR.PARAM), true);
// ===================== §4.2 叫分:边界与非法入参补全 =====================
// design §4.2:范围 5~70、步进 5;暂定庄家必须叫(不能"不叫"),其后才可以"叫更低"或"不叫"
t.eq('叫分 边界 下限5 接受', jf(0, 5), 5);
t.eq('叫分 正 非首家可以不叫(call=0) 接受', jf(65, 0), 0);
t.eq('叫分 反 负数 拒', jf(0, -5), null);
t.eq('叫分 反 缺 call 字段(NaN) 拒', jf(0, undefined), null);
t.eq('叫分 反 步进外的小值1 拒', jf(0, 1), null);
t.eq('叫分 反 与当前叫分相同(不更低) 拒', jf(55, 55), null);
// ===================== §4.2 已"不叫"的玩家不得再叫 =====================
// design §4.2:不叫即退出本局叫分,轮次直接跳过他;他再发 jiaofen 时已不是当前叫分位,应被拒。
const bd = P.new({ paiju_list: [] }, 0); // 真实牌局,暂定庄家 = 0 号座位
const bdc = setup("00000", bd);
mod.jiaofen(pack(0, { call: 65 }));
t.eq('叫分 正 首家叫65后轮到 seat1', bd.method.get_callgrade_seat(), 1);
mod.jiaofen(pack(1, { call: 0 })); // seat1 不叫
t.eq('叫分 正 seat1不叫后跳到 seat2', bd.method.get_callgrade_seat(), 2);
const bdLen = bd.callproc.length;
bdc.sent.length = 0;
mod.jiaofen(pack(1, { call: 60 })); // seat1 反悔想再叫
t.eq('叫分 反 已不叫者再叫 → 拒(SEAT)', rej(bdc, ERR.SEAT), true);
t.eq('叫分 反 已不叫者再叫 不改变叫分过程', bd.callproc.length, bdLen);
t.eq('叫分 反 已不叫者再叫 仍轮到 seat2', bd.method.get_callgrade_seat(), 2);
t.eq('叫分 反 已不叫者再叫 未产生庄家', bd.banker, -1);
// ===================== §4.5 埋牌 maipai:庄家 / 阶段 / 张数 =====================
// design §4.5:仅"选主/打牌"路径、由庄家从 36 张里选出 **8 张** 重新扣下
// 桩要"完整到能走完成功路径":否则一旦校验被改松,这里会**抛异常打断整个文件**,
// 后面的断言全不执行——看到的是崩溃而不是某条断言转红,定位与信号都变差。
const mkBury = (step, banker) => ({
step, banker, cards: make108(), playproc: { currseat: banker }, playhistory: [],
method: {
check_cards_valid: c => P.check_cards_valid({ cards: make108() }, c),
check_cards_inhand: () => true,
do_burycard: () => { },
get_seat_cards: () => [],
get_liangpai: () => null
}
});
let bc = setup("00000", mkBury(3, 1)); mod.maipai(pack(0, { cards: [0, 1, 2, 3, 4, 5, 6, 7] }));
t.eq('埋牌 反 非庄家 → 拒(SEAT)', rej(bc, ERR.SEAT), true);
bc = setup("00000", mkBury(2, 0)); mod.maipai(pack(0, { cards: [0, 1, 2, 3, 4, 5, 6, 7] }));
t.eq('埋牌 反 选主阶段(step2)就埋 → 拒(STEP)', rej(bc, ERR.STEP), true);
bc = setup("00000", mkBury(5, 0)); mod.maipai(pack(0, { cards: [0, 1, 2, 3, 4, 5, 6, 7] }));
t.eq('埋牌 反 出牌阶段(step5)再埋 → 拒(STEP)', rej(bc, ERR.STEP), true);
bc = setup("00000", mkBury(3, 0)); mod.maipai(pack(0, { cards: [0, 1, 2, 3, 4, 5, 6] }));
t.eq('埋牌 边界 只埋7张 → 拒(PARAM)', rej(bc, ERR.PARAM), true);
bc = setup("00000", mkBury(3, 0)); mod.maipai(pack(0, { cards: [0, 1, 2, 3, 4, 5, 6, 7, 8] }));
t.eq('埋牌 边界 埋9张 → 拒(PARAM)', rej(bc, ERR.PARAM), true);
bc = setup("00000", mkBury(3, 0)); mod.maipai(pack(0, { cards: [] }));
t.eq('埋牌 反 空数组 → 拒(PARAM)', rej(bc, ERR.PARAM), true);
// ===================== §11 无超时托管:对局阶段不得注册任何定时器 =====================
// design §11 已确认「倒计时只作展示,服务端不做任何超时动作」。这不是缺口,而是规则——
// 所以这里放的是**守卫用例**:若将来有人给服务端加「超时自动出牌 / AI 代打」,必然要注册
// 定时器来推进对局,这条就会转红,从而把违反 §11 的改动拦在提交前。
// (min_ontimeout 在**结算**阶段另有正当用途:延迟扣房卡与存战绩,属平台事务、不推进对局,
// 故本守卫只覆盖到出牌阶段为止。)
const realTO = global.min_ontimeout;
let timerCount = 0;
global.min_ontimeout = function () { timerCount++; };
const gd = driveToPlay("00000");
mod.chupai(pack(0, { cards: [P.get_seat_cards(gd.pj, 0)[0]] }));
global.min_ontimeout = realTO;
t.eq('§11 叫分→选主→埋牌→出牌 全程未注册任何定时器', timerCount, 0);
// ===================== I-3 阶段 step 与控制权 必须由服务端在包里显式给出 =====================
// server 红线:「阶段、控制权(轮到谁)… 必须由服务端唯一维护并在相关下发包里显式给出;
// 任何状态变更都要有包承载」。曾经的缺陷:增量推送里【一个 step 都没有】,选主/埋牌阶段
// 也没有「轮到谁」,前端只能按 rpc 名硬编码 step、并自己套「选主/埋牌 = 庄家」这条规则——
// 那就是前端持有了一小段对局状态机(服务端将来插一个中间阶段,前端不会红、只会静默画错)。
//
// 本组逐包钉死两件事:①每个包的 step == 同时刻 paiju.step(不是常量,是权威值);
// ②每个包的「轮到谁」== 服务端各 RPC 校验所用的同一个权威来源。
// 反向验证:删掉 mod.js/class.desk.js 里任意一处 msg.data.step / nextseat,本组必转红。
const sp = (() => {
const pj = P.new({ paiju_list: [] }, 0);
const c = setup("00000", pj);
const grab = rpc => c.sent.filter(m => m.rpc === rpc);
const out = {};
mod.jiaofen(pack(0, { call: 65 }));
out.jiaofen1 = grab('jiaofen')[0]; out.after_jf1 = { step: pj.step, next: pj.method.get_callgrade_seat() };
mod.jiaofen(pack(1, { call: 0 }));
out.jiaofen2 = grab('jiaofen')[1]; out.after_jf2 = { step: pj.step, next: pj.method.get_callgrade_seat() };
out.deskinfo1 = E.get_deskinfo(c.o_room, 1);
mod.jiaofen(pack(2, { call: 0 })); // → 0 号位上庄
out.shangzhuang = grab('shangzhuang')[0]; out.after_sz = { step: pj.step, banker: pj.banker };
out.deskinfo2 = E.get_deskinfo(c.o_room, 1); // 闲家视角(控制权与视角无关)
mod.xuanzhu(pack(0, { flower: 1 }));
out.xuanzhu = grab('xuanzhu')[0]; out.after_xz = { step: pj.step, banker: pj.banker };
out.deskinfo3 = E.get_deskinfo(c.o_room, 2);
mod.maipai(pack(0, { cards: P.get_seat_cards(pj, 0).slice(-8) }));
out.maipai = grab('maipai')[0]; out.after_mp = { step: pj.step, curr: pj.playproc.currseat };
out.deskinfo5 = E.get_deskinfo(c.o_room, 1);
return { pj, c, out };
})();
const O = sp.out;
// ① 阶段 step:每个包都有,且等于同时刻牌局的真实阶段
t.eq('阶段 jiaofen 带 step,且 == 当时的 paiju.step(仍在叫分=1)', [O.jiaofen1.data.step, O.after_jf1.step], [1, 1]);
t.eq('阶段 jiaofen(第二包) step 仍为 1', O.jiaofen2.data.step, O.after_jf2.step);
t.eq('阶段 shangzhuang 带 step,且 == paiju.step(选主/投降=2)', [O.shangzhuang.data.step, O.after_sz.step], [2, 2]);
t.eq('阶段 xuanzhu 带 step,且 == paiju.step(埋牌=3)', [O.xuanzhu.data.step, O.after_xz.step], [3, 3]);
t.eq('阶段 maipai 带 step,且 == paiju.step(出牌=5)', [O.maipai.data.step, O.after_mp.step], [5, 5]);
// 与重连包 deskinfo.step 同值:增量与全量两条路径必须给同一个阶段
t.eq('阶段 jiaofen.step == 同时刻 deskinfo.step', O.jiaofen2.data.step, O.deskinfo1.step);
t.eq('阶段 shangzhuang.step == 同时刻 deskinfo.step', O.shangzhuang.data.step, O.deskinfo2.step);
t.eq('阶段 xuanzhu.step == 同时刻 deskinfo.step', O.xuanzhu.data.step, O.deskinfo3.step);
t.eq('阶段 maipai.step == 同时刻 deskinfo.step', O.maipai.data.step, O.deskinfo5.step);
// ② 控制权:每个包的「轮到谁」== 服务端自己校验时用的权威来源
t.eq('控制权 jiaofen.nextseat == get_callgrade_seat()(下一个叫分者)', O.jiaofen1.data.nextseat, O.after_jf1.next);
t.eq('控制权 jiaofen 不叫后跳过该家', O.jiaofen2.data.nextseat, O.after_jf2.next);
t.eq('控制权 shangzhuang.nextseat == banker(选主/投降只由庄家做)', O.shangzhuang.data.nextseat, O.after_sz.banker);
t.eq('控制权 xuanzhu.nextseat == banker(埋牌只由庄家做)', O.xuanzhu.data.nextseat, O.after_xz.banker);
t.eq('控制权 maipai.seat == playproc.currseat(即将首出者)', O.maipai.data.seat, O.after_mp.curr);
// 反面:shangzhuang 的 data.seat 是【最后一个叫分者】,不是控制权——两者必须能区分开,
// 否则"直接读 seat 当控制权"的写法也能蒙混过关(本局:最后叫分者=2、庄家=0)
t.eq('控制权 shangzhuang.seat(最后叫分者) 与 nextseat(庄家) 确实不同 → 用例非空转',
O.shangzhuang.data.seat !== O.shangzhuang.data.nextseat, true);
// 重连包:ChooseMain / BuryCards 也必须显式给控制权,且与增量推送同值
t.eq('控制权 重连 ChooseMain.seat == shangzhuang.nextseat', O.deskinfo2.ChooseMain.seat, O.shangzhuang.data.nextseat);
t.eq('控制权 重连 BuryCards.seat == xuanzhu.nextseat', O.deskinfo3.BuryCards.seat, O.xuanzhu.data.nextseat);
t.eq('控制权 重连 PushCards 由 playproc.currseat 给出 == maipai.seat',
O.deskinfo5.PushCards.playproc.currseat, O.maipai.data.seat);
// ===================== I-2 结算阶段(step6)的重连包必须与 jiesuan 推送给同一份结算数据 =====================
// 曾经的缺陷:deskinfo 的 Balance 只给 readystate + aset,而 jiesuan 推送给的是
// chupai + bottom + aset(+ 末局 account)——结算面板还开着时断线重连/硬刷新,
// 抠底明细与末局大结算全部空白(同一份数据两条路径给的不一样)。
function driveToSettle(roomtype, hookDesk) {
const pj = P.new({ paiju_list: [] }, 0);
const c = setup(roomtype, pj);
if (hookDesk) { hookDesk(c.o_desk, 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(-8) }));
let guard = 0;
while (pj.step === 5 && ++guard < 400) {
const seat = pj.playproc.currseat, hand = P.get_seat_cards(pj, seat);
let pick = null;
if (seat === pj.playproc.start) { pick = [hand[hand.length - 1]]; }
else { for (const x of hand) { if (AR.can_followcard(pj.flower, hand, [x], pj.playproc.startcount, pj.playproc.startflower, pj.playproc.starttype).result) { pick = [x]; break; } } }
if (!pick) { break; }
mod.chupai(pack(seat, { cards: pick }));
}
return { pj, c, jiesuan: c.sent.filter(m => m.rpc === 'jiesuan')[0] };
}
const sv = driveToSettle("00000");
t.eq('结算 用例真的打到 step6', sv.pj.step, 6);
t.eq('结算 jiesuan 带 step=6(阶段同样由包承载)', sv.jiesuan.data.step, 6);
const bal = [0, 1, 2].map(s => E.get_deskinfo(sv.c.o_room, s).Balance);
t.eq('结算 重连 Balance.aset == jiesuan.aset(三家)',
bal.map(b => JS(b.aset)), [0, 1, 2].map(() => JS(sv.jiesuan.data.aset)));
t.eq('结算 重连 Balance.bottom == jiesuan.bottom(抠底明细,三家)',
bal.map(b => JS(b.bottom)), [0, 1, 2].map(() => JS(sv.jiesuan.data.bottom)));
// 用例非空转:抠底分组必须真的有内容(cards 恒 8 张),否则上面两边"同为 undefined"也会绿
t.eq('结算 抠底分组真的有内容(8张埋牌底牌) → 用例非空转', bal[0].bottom.cards.length, 8);
t.eq('结算 重连 deskinfo.step == 6', E.get_deskinfo(sv.c.o_room, 0).step, 6);
// 已按下准备的座位不再重发结算数据(原有门控不变)
sv.c.o_desk.prepare[1] = 1;
const balReady = E.get_deskinfo(sv.c.o_room, 1).Balance;
t.eq('结算 已准备的座位不带 aset/bottom/account(门控不变)',
[balReady.aset, balReady.bottom, balReady.account], [undefined, undefined, undefined]);
// 末局:account 也必须能从重连包拿到。用桩 get_desk_account 造出 account(真实实现要写战绩,
// 与本用例无关),验证的是「get_paiju_account 冻结了它 + get_deskinfo 原样给出」这条链路
const svLast = driveToSettle("00000", (o_desk) => {
o_desk.method.get_desk_account = m => { m.data.account = [{ score: 1 }, { score: 2 }, { score: 3 }]; return m; };
});
svLast.pj.idx = 99; // 末局判定:idx >= asetcount(6)
// idx 改在结算之前才有效,故这里重新走一遍结算路径:直接再调一次 get_paiju_account
const lastMsg = P.get_paiju_account(svLast.pj, 0, { data: {} });
t.eq('结算 末局 jiesuan 带 account', Array.isArray(lastMsg.data.account), true);
t.eq('结算 末局 重连 Balance.account == jiesuan.account',
JS(E.get_deskinfo(svLast.c.o_room, 0).Balance.account), JS(lastMsg.data.account));
// 反面:非末局不带 account(与 jiesuan 推送的取舍一致,不凭空造分组)
t.eq('结算 非末局 jiesuan 无 account', sv.jiesuan.data.account, undefined);
t.eq('结算 非末局 重连 Balance 无 account', bal[0].account, undefined);
// ===================== M-2 seatlist 必须以深拷贝下发(不把活对象挂进下发包)=====================
// paiju.seatlist 是一张全程就地改写的活表(do_playcard 每手都在改缺门/无对/报副)。
// 同一份数组曾同时挂在 maipai 的三个逐座位包、chupai1/2/3 与重连包 PushCards 上——
// 平台真实链路立刻序列化看不出来,但这是隐患,与 playproc / pushlist / bottomcards 同一条标准。
const EMPTY_TABLE = JS([0, 1, 2].map(() => [[0, 0], [0, 0], [0, 0], [0, 0], [-1, -1]]));
const sl = (() => {
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 }));
// 深拷贝守卫需要【活引用】:setup 的 SendPack 会 JSON 深拷贝每个包,用 c.sent 验不出问题
const inner = mod.app.SendPack;
let mpLive = null, cpLive = null;
mod.app.SendPack = m => {
if (m.rpc === 'maipai' && mpLive === null) { mpLive = m.data.seatlist; }
if (/^chupai[123]$/.test(m.rpc) && cpLive === null) { cpLive = m.data.seatlist; }
inner(m);
};
mod.maipai(pack(0, { cards: P.get_seat_cards(pj, 0).slice(-8) }));
// 重连快照也取一份活引用(此刻还是初始空表)
const diLive = E.get_deskinfo(c.o_room, 1).PushCards.seatlist;
let guard = 0;
while (pj.step === 5 && ++guard < 400) {
const seat = pj.playproc.currseat, hand = P.get_seat_cards(pj, seat);
let pick = null;
if (seat === pj.playproc.start) { pick = [hand[hand.length - 1]]; }
else { for (const x of hand) { if (AR.can_followcard(pj.flower, hand, [x], pj.playproc.startcount, pj.playproc.startflower, pj.playproc.starttype).result) { pick = [x]; break; } } }
if (!pick) { break; }
mod.chupai(pack(seat, { cards: pick }));
}
mod.app.SendPack = inner;
return { pj, c, mpLive, cpLive, diLive };
})();
// 用例非空转:整局打完后这张活表必须真的变过,否则"没被回改"是碰巧
t.eq('座位牌况 整局打完后 paiju.seatlist 确实变过 → 用例非空转', JS(sl.pj.seatlist) !== EMPTY_TABLE, true);
t.eq('座位牌况 maipai 下发的是深拷贝(已发出的包不被后续出牌回改)', JS(sl.mpLive), EMPTY_TABLE);
t.eq('座位牌况 chupai1 下发的是深拷贝(打完整局后仍是发出当时的样子)',
JS(sl.cpLive) !== JS(sl.pj.seatlist), true);
t.eq('座位牌况 重连 PushCards 下发的是深拷贝', JS(sl.diLive), EMPTY_TABLE);
// 正面:深拷贝的内容必须与当时的活表逐格相等(拷贝不能拷错/拷浅)
const slNow = P.get_seatlist(sl.pj);
t.eq('座位牌况 get_seatlist 内容与活表逐格相等', JS(slNow), JS(sl.pj.seatlist));
slNow[0][0][0] = 9;
t.eq('座位牌况 get_seatlist 是深拷贝(改副本不影响本体)', sl.pj.seatlist[0][0][0] !== 9, true);
// ===================== Balance.readystate 必须以深拷贝下发(不把活数组挂进下发包)=====================
// o_desk.prepare 是一张全程就地改写的活数组(do_prepare 把某一格置 1、do_new_paiju 就地重建)。
// 它此前被整根挂进 deskinfo.Balance.readystate,与 playproc / seatlist / pushlist 同级的隐患:
// 已发出的包会随后续准备操作被回改。标准与 get_playproc 一致——下发副本。
const rs = driveToSettle("00000");
const rsLive = E.get_deskinfo(rs.c.o_room, 0).Balance.readystate;
t.eq('准备状态 内容与活数组逐格相等', JS(rsLive), JS(rs.c.o_desk.prepare));
t.eq('准备状态 发包时刻三家皆未准备 → 用例起点明确', JS(rsLive), JS([0, 0, 0]));
rs.c.o_desk.prepare[1] = 1; //模拟「包已发出之后,1 号位按了准备」(do_prepare 的就地写)
t.eq('准备状态 活数组确实变过 → 用例非空转', JS(rs.c.o_desk.prepare), JS([0, 1, 0]));
t.eq('准备状态 下发的是深拷贝(已发出的包不被后续准备操作回改)', JS(rsLive), JS([0, 0, 0]));
// 反面:改副本不得影响本体
rsLive[2] = 9;
t.eq('准备状态 是深拷贝(改副本不影响本体)', rs.c.o_desk.prepare[2], 0);
process.exit(t.done('rpc') ? 0 : 1);
+135
View File
@@ -0,0 +1,135 @@
// 成败标志 data.success(dev-guide server 03 §4/§5 硬红线):
// ① 每个下发包(主动推送与应答)都必须自带 data.success;
// ② 每条失败路径都必须回一个 success=false + errcode 的包给请求者,不得静默丢弃。
require('./_shim');
const { mod, setup, make108, id } = require('./_rpc.js');
const P = require('../class.paiju.js');
const D = require('../class.desk.js');
const t = require('./_assert')();
const ERR = mod.ERR;
const clone = m => JSON.parse(JSON.stringify(m));
const pack = (seat, extra) => ({ data: Object.assign({ agentid: 'a', playerid: 1, gameid: 'g', roomcode: 1, seat }, extra), conmode: 0, fromid: seat });
// ============ ① 成功包一律带 success=true ============
// -- 牌桌层主动推送:fapai / zhunbei 走全局 youle_erqiwang.app.SendPack --
const gsent = [];
global.youle_erqiwang.app.SendPack = m => gsent.push(clone(m));
const o_room = {
roomtype: '00000', asetcount: 6,
seatlist: [{ conmode: 0, fromid: 0 }, { conmode: 0, fromid: 1 }, { conmode: 0, fromid: 2 }],
method: { sendpack_toother: m => gsent.push(clone(m)) }
};
const desk = D.new(o_room);
o_room.o_desk = desk;
D.do_new_paiju(desk, 0);
const fapai = gsent.filter(m => m.rpc === 'fapai');
t.eq('fapai 下发3包(每座位1)', fapai.length, 3);
t.eq('fapai 带 success=true', fapai.every(m => m.data.success === true), true);
gsent.length = 0;
desk.paiju_list.push({ banker: 0, result: 0, tmp_jiesuan_aset: {} });
D.do_prepare(desk, 0); // 只有1家准备 → 广播 zhunbei
const zb = gsent.filter(m => m.rpc === 'zhunbei');
t.eq('zhunbei 带 success=true', zb.length === 1 && zb[0].data.success === true, true);
// -- RPC 层:真实驱动 叫分→上庄→选主→埋牌→出牌 --
function driveToPlay() {
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 })); // 0 上庄 65
mod.xuanzhu(pack(0, { flower: 1 }));
mod.maipai(pack(0, { cards: P.get_seat_cards(pj, 0).slice(0, 8) }));
return { pj, c };
}
const e = driveToPlay();
const rpcs = e.c.sent.map(m => m.rpc);
t.eq('端到端 覆盖 jiaofen/shangzhuang/xuanzhu/maipai', ['jiaofen', 'shangzhuang', 'xuanzhu', 'maipai'].every(r => rpcs.indexOf(r) >= 0), true);
t.eq('端到端 所有下发包均 success=true', e.c.sent.every(m => m.data.success === true), true);
// 首出一张牌 → chupai1 也带 success
const lead = P.get_seat_cards(e.pj, 0)[0];
e.c.sent.length = 0;
mod.chupai(pack(0, { cards: [lead] }));
const cp1 = e.c.sent.filter(m => m.rpc === 'chupai1');
t.eq('chupai1 下发3包', cp1.length, 3);
t.eq('chupai1 带 success=true', cp1.every(m => m.data.success === true), true);
// -- 结算包 jiesuan:正常/投降/解散 三种 type 都带 success --
const mkDesk = () => ({ seatlist: [[0, [], 0, 0, 0], [0, [], 0, 0, 0], [0, [], 0, 0, 0]], o_room: { roomtype: '00000', asetcount: 6 }, method: { get_desk_account: m => m } });
const mkPaiju = () => ({ call: 65, banker: 0, flower: 1, cards: [], idx: 2, o_desk: mkDesk(), endtime: null, result: null });
t.eq('jiesuan 正常结算 success=true', P.get_paiju_account(mkPaiju(), 0, {}).data.success, true);
t.eq('jiesuan 投降结算 success=true', P.get_paiju_account(mkPaiju(), 1, {}).data.success, true);
t.eq('jiesuan 解散结算 success=true', P.get_paiju_account(mkPaiju(), 2, {}).data.success, true);
// mingpai / tishi 成功包
const mp = setup('00000', { step: 5, banker: 0, flower: 1, cards: make108(), method: { have_baofu: () => true } });
mod.mingpai(pack(0));
t.eq('mingpai 成功包 success=true', mp.sent.length === 1 && mp.sent[0].data.success === true, true);
const tp = setup('00000', { step: 5, banker: 0 });
mod.tishi(pack(1, { tip: 1 }));
t.eq('tishi 成功包 success=true', tp.sent.length === 1 && tp.sent[0].data.success === true, true);
// ============ ② 失败路径回包:success=false + errcode,且只回给请求者 ============
// 每个 handler 取一条代表性失败路径;断言"恰好1包 + success=false + errcode + 回到请求者"
function failCase(name, o_paiju, call, seat, errcode) {
const c = setup('00000', o_paiju);
call(seat);
const ok = c.sent.length === 1
&& c.sent[0].data.success === false
&& c.sent[0].data.errcode === errcode
&& c.sent[0].fromid === seat;
t.eq(name, ok, true);
}
const stubJF = { step: 9, method: { get_callgrade_seat: () => 0, get_callgrade_value: () => 0, do_callgrade: () => { } } };
failCase('jiaofen 阶段不对 → STEP', stubJF, s => mod.jiaofen(pack(s, { call: 65 })), 0, ERR.STEP);
failCase('jiaofen 非当前叫分位 → SEAT',
{ step: 1, method: { get_callgrade_seat: () => 1, get_callgrade_value: () => 0, do_callgrade: () => { } } },
s => mod.jiaofen(pack(s, { call: 65 })), 0, ERR.SEAT);
failCase('jiaofen 叫分>70 → PARAM',
{ step: 1, method: { get_callgrade_seat: () => 0, get_callgrade_value: () => 0, do_callgrade: () => { } } },
s => mod.jiaofen(pack(s, { call: 75 })), 0, ERR.PARAM);
failCase('jiaofen 后叫不更低 → RULE',
{ step: 1, method: { get_callgrade_seat: () => 0, get_callgrade_value: () => 55, do_callgrade: () => { } } },
s => mod.jiaofen(pack(s, { call: 60 })), 0, ERR.RULE);
failCase('xuanzhu 非庄家 → SEAT', { step: 2, banker: 1 }, s => mod.xuanzhu(pack(s, { flower: 1 })), 0, ERR.SEAT);
failCase('xuanzhu 花色非法 → PARAM', { step: 2, banker: 0 }, s => mod.xuanzhu(pack(s, { flower: 9 })), 0, ERR.PARAM);
const mkMP8 = () => ({ step: 3, banker: 0, cards: make108(), method: { check_cards_valid: c => P.check_cards_valid({ cards: make108() }, c), check_cards_inhand: () => true } });
failCase('maipai 张数不是8 → PARAM', mkMP8(), s => mod.maipai(pack(s, { cards: [0, 1, 2] })), 0, ERR.PARAM);
failCase('maipai 入参非数组 → PARAM', mkMP8(), s => mod.maipai(pack(s, { cards: 'x' })), 0, ERR.PARAM);
failCase('touxiang 非70分 → RULE', { step: 2, banker: 0, call: 65 }, s => mod.touxiang(pack(s)), 0, ERR.RULE);
const mkCP = () => {
const pj = P.new({ paiju_list: [] }, 0);
pj.banker = 0; pj.call = 65; pj.flower = 1; pj.step = 5;
P.new_playround(pj, 1, 0);
return pj;
};
failCase('chupai 重复牌id → PARAM', mkCP(), s => mod.chupai(pack(s, { cards: [0, 0] })), 0, ERR.PARAM);
failCase('chupai 牌不在手上 → PARAM', (function () { const pj = mkCP(); for (let i = 0; i < 108; i++) pj.cards[i].dealowner = 2; return pj; })(),
s => mod.chupai(pack(s, { cards: [0] })), 0, ERR.PARAM);
failCase('chupai 非当前出牌位 → SEAT', (function () { const pj = mkCP(); pj.playproc.currseat = 1; return pj; })(),
s => mod.chupai(pack(s, { cards: [0] })), 0, ERR.SEAT);
failCase('mingpai 未报无主 → RULE', { step: 5, banker: 0, method: { have_baofu: () => false } }, s => mod.mingpai(pack(s)), 0, ERR.RULE);
failCase('tishi 庄家发 → RULE', { step: 5, banker: 0 }, s => mod.tishi(pack(s, { tip: 1 })), 0, ERR.RULE);
failCase('zhunbei 非结算阶段 → STEP', { step: 5 }, s => mod.zhunbei(pack(s)), 0, ERR.STEP);
// 不查牌房的 mingpai 需要 roomtype '00001',单列
const mpn = setup('00001', { step: 5, banker: 0, method: { have_baofu: () => true } });
mod.mingpai(pack(0));
t.eq('mingpai 不查牌 → 回 RULE 失败包', mpn.sent.length === 1 && mpn.sent[0].data.errcode === ERR.RULE, true);
// check_player 不通过(房间/座位对不上)也要回包,不能让前端干等
const cp = setup('00000', { step: 1 });
mod.import.check_player = () => null;
mod.jiaofen(pack(0, { call: 65 }));
t.eq('check_player 失败 → 回 PLAYER 失败包', cp.sent.length === 1 && cp.sent[0].data.errcode === ERR.PLAYER, true);
process.exit(t.done('success') ? 0 : 1);