feat: integrate platform room entry, UI migration and erqiwang documentation
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,77 @@
|
||||
///////////////////////////////////////////////////
|
||||
////// cls_youle_erqiwang_config: 房间配置解析 //////
|
||||
///////////////////////////////////////////////////
|
||||
//
|
||||
// roomtype 采用定长数字位串(字符串),每一位对应一个开关,'0'/'1',方便前端逐项勾选:
|
||||
// 位0 局数: '0'→6局 '1'→12局
|
||||
// 位1 扣卡方式: '0'→房主扣卡 '1'→AA每人扣卡
|
||||
// 位2 傍王: '0'→关 '1'→开
|
||||
// 位3 爬坡: '0'→常规算子 '1'→爬坡
|
||||
// 位4 查牌: '0'→可查牌 '1'→不查牌
|
||||
// 示例 "10100" = 12局 / 房主扣卡 / 傍王开 / 常规算子 / 可查牌
|
||||
// 缺省 "00000" = 6局 / 房主扣卡 / 无傍王 / 常规算子 / 可查牌(对齐 design 缺省)
|
||||
//
|
||||
// 【位长协议 · 2026-09-01 升级 · 第 2 版】
|
||||
// 字串格式 = 「5 位协议位」+「N 位扩展位(默认 N=6,当前全 '0')」
|
||||
// 例:'11011' + '000000' = '11011000000'
|
||||
// 协议位的位索引(IDX_* = 0..4)永远对应字串前 5 位 charAt(0..4),
|
||||
// 在所有版本中不变——这是服务端与子游戏的 SSOT(沿用未升级前的约定)。
|
||||
// 扩展位在协议位之后(字串末尾),用于将来加新协议项时向后追加,
|
||||
// 旧客户端/旧服务端都按 charAt(0..4) 读协议位(向后兼容)。
|
||||
//
|
||||
// 兼容策略:parse() / bit() 接受任意长度:
|
||||
// - 长度 ≥ 5:bit(idx) 读 charAt(idx),charAt 越界返回 false(等同缺省)
|
||||
// - 长度 < 5 / 非字符串:parse() 整体按 '00000' 兜底
|
||||
// 不再校验扩展位内容:当前扩展位全 '0' 是惯例,将来版本可能填非 0,
|
||||
// 服务端只读协议位 0..4,扩展位由专门读扩展位的代码(如果有)去解释。
|
||||
//
|
||||
// 本模块是 roomtype 位串格式的唯一解析入口(SSOT);游戏逻辑一律经 parse() 读取,
|
||||
// 不得在别处按下标硬解析。
|
||||
//
|
||||
var cls_youle_erqiwang_config = cls_youle_erqiwang_config || {
|
||||
|
||||
//位索引常量
|
||||
IDX_ASET: 0, //局数
|
||||
IDX_DEDUCT: 1, //扣卡方式
|
||||
IDX_BANGWANG:2, //傍王
|
||||
IDX_CLIMB: 3, //爬坡
|
||||
IDX_NOCHECK: 4, //查牌
|
||||
|
||||
//预留扩展位长度(字串末尾,当前全 '0')。
|
||||
//将来加新协议项时,扩展位长不变、协议位索引不变(永远 0..4),
|
||||
//扩展位长可以动态增加(任何位置 [5, 5+N) 都行),与旧版兼容。
|
||||
//本常量仅作 SSOT 标记与未来断言使用,不影响 parse() / bit() 行为。
|
||||
RESERVE_LEN: 6,
|
||||
|
||||
//取第 idx 位是否为 '1';roomtype 缺失/过短一律按 '0'(缺省)处理
|
||||
bit: function(roomtype, idx){
|
||||
if (!roomtype || typeof roomtype != "string"){
|
||||
return false;
|
||||
}
|
||||
if (idx < 0 || idx >= roomtype.length){
|
||||
return false;
|
||||
}
|
||||
return roomtype.charAt(idx) == "1";
|
||||
},
|
||||
|
||||
//解析 roomtype 位串为具名配置对象
|
||||
//协议位 charAt(0..4) = IDX_ASET..IDX_NOCHECK;扩展位(如果有)由调用方另读
|
||||
parse: function(roomtype){
|
||||
// 防御式:非字符串 / 过短 → 整体按全 '0' 处理
|
||||
if (!roomtype || typeof roomtype != "string" || roomtype.length < 5){
|
||||
roomtype = "00000";
|
||||
}
|
||||
var cfg = {};
|
||||
cfg.aset12 = cls_youle_erqiwang_config.bit(roomtype, cls_youle_erqiwang_config.IDX_ASET); //true=12局 false=6局
|
||||
cfg.deductAA = cls_youle_erqiwang_config.bit(roomtype, cls_youle_erqiwang_config.IDX_DEDUCT); //true=AA每人 false=房主
|
||||
cfg.bangwang = cls_youle_erqiwang_config.bit(roomtype, cls_youle_erqiwang_config.IDX_BANGWANG);//true=开傍王
|
||||
cfg.climb = cls_youle_erqiwang_config.bit(roomtype, cls_youle_erqiwang_config.IDX_CLIMB); //true=常规算子
|
||||
cfg.nocheck = cls_youle_erqiwang_config.bit(roomtype, cls_youle_erqiwang_config.IDX_NOCHECK); //true=不查牌
|
||||
return cfg;
|
||||
}
|
||||
}
|
||||
|
||||
//Node(本地/单元测试)导出;友乐/浏览器无 module 时跳过,按全局名引用(dev-guide 01 §1)
|
||||
if (typeof module !== "undefined"){
|
||||
module.exports = cls_youle_erqiwang_config;
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
///////////////////////////////////////////////////
|
||||
////////// cls_youle_erqiwang_desk: 牌桌 //////////
|
||||
///////////////////////////////////////////////////
|
||||
//跨模块依赖:Node 走 require 守卫;友乐/浏览器由 min_loadJsFile 加载为同名全局(dev-guide 01 §1)
|
||||
if (typeof require !== "undefined"){
|
||||
var cls_youle_erqiwang_paiju = require("./class.paiju.js");
|
||||
}
|
||||
|
||||
var cls_youle_erqiwang_desk = cls_youle_erqiwang_desk || {
|
||||
|
||||
countdown_jiaofen: 15, //叫分倒计时
|
||||
countdown_xuanzhu: 20, //选主倒计时
|
||||
countdown_maipai: 25, //埋牌倒计时
|
||||
countdown_chupai: 30, //出牌倒计时
|
||||
|
||||
//创建牌桌对象
|
||||
new: function(o_room){
|
||||
|
||||
var desk = {};
|
||||
|
||||
desk.o_room = o_room; //桌所属的房间对象
|
||||
//玩家得分。每个元素:[累积总分, [每局总分...], 基础分累计, 冲关分累计, 傍王分累计]
|
||||
//后三位是大局结算面板要分项展示的累计(design §7 捡分子数 / §8.1 冲关 / §8.3 傍王),
|
||||
//三者之和恒等于累积总分——下发时组装成对象(见 get_desk_account),数组下标只在内部用
|
||||
desk.seatlist = [];
|
||||
desk.seatlist.push([0, [], 0, 0, 0]);
|
||||
desk.seatlist.push([0, [], 0, 0, 0]);
|
||||
desk.seatlist.push([0, [], 0, 0, 0]);
|
||||
desk.prepare = [0,0,0]; //玩家准备状态
|
||||
desk.paiju_list = []; //牌局列表
|
||||
|
||||
//方法
|
||||
desk.method = {};
|
||||
|
||||
//当前牌局
|
||||
desk.method.curr_paiju = function(){
|
||||
return desk.paiju_list[desk.paiju_list.length - 1];
|
||||
}
|
||||
|
||||
//新牌局
|
||||
desk.method.do_new_paiju = function(firstseat){
|
||||
cls_youle_erqiwang_desk.do_new_paiju(desk, firstseat);
|
||||
}
|
||||
|
||||
//叫分倒计时
|
||||
desk.method.get_countdown_jiaofen = function(){
|
||||
return cls_youle_erqiwang_desk.countdown_jiaofen;
|
||||
}
|
||||
|
||||
//叫主倒计时
|
||||
desk.method.get_countdown_xuanzhu = function(){
|
||||
return cls_youle_erqiwang_desk.countdown_xuanzhu;
|
||||
}
|
||||
|
||||
//埋牌倒计时
|
||||
desk.method.get_countdown_maipai = function(){
|
||||
return cls_youle_erqiwang_desk.countdown_maipai;
|
||||
}
|
||||
|
||||
//出牌倒计时
|
||||
desk.method.get_countdown_chupai = function(){
|
||||
return cls_youle_erqiwang_desk.countdown_chupai;
|
||||
}
|
||||
|
||||
//准备
|
||||
desk.method.do_prepare = function(seat){
|
||||
return cls_youle_erqiwang_desk.do_prepare(desk, seat);
|
||||
}
|
||||
|
||||
//获取大局结算包
|
||||
desk.method.get_desk_account = function(msg){
|
||||
return cls_youle_erqiwang_desk.get_desk_account(desk, msg);
|
||||
}
|
||||
|
||||
return desk;
|
||||
},
|
||||
|
||||
//新开一局
|
||||
do_new_paiju: function(o_desk, firstseat){
|
||||
//恢复玩家准备状态
|
||||
o_desk.prepare = [0,0,0];
|
||||
|
||||
if (o_desk.paiju_list.length > 0){
|
||||
//上一局的结算快照三件套一并清除(新局开始后重连不该再看到上一局的结算面板)
|
||||
delete o_desk.paiju_list[o_desk.paiju_list.length - 1].tmp_jiesuan_aset;
|
||||
delete o_desk.paiju_list[o_desk.paiju_list.length - 1].tmp_jiesuan_bottom;
|
||||
delete o_desk.paiju_list[o_desk.paiju_list.length - 1].tmp_jiesuan_account;
|
||||
}
|
||||
|
||||
//新开一局
|
||||
cls_youle_erqiwang_paiju.new(o_desk, firstseat);
|
||||
|
||||
var msg = {};
|
||||
msg.app = "youle";
|
||||
msg.route = "erqiwang";
|
||||
msg.rpc = "fapai";
|
||||
msg.data = {};
|
||||
msg.data.success = true;
|
||||
msg.data.asetidx = o_desk.paiju_list.length;
|
||||
msg.data.asetcount = o_desk.o_room.asetcount;
|
||||
//阶段:发完牌即 step1(叫分)。由服务端权威给出、不让前端按 rpc 推——
|
||||
//没有包承载的状态变化等于逼前端去猜(server 03 §1.4 / 红线「状态机唯一在服务端」)
|
||||
msg.data.step = o_desk.method.curr_paiju().step;
|
||||
//控制权:当前等待叫分者。与重连包 CallRun.seat 同一个 get_callgrade_seat()(SSOT)
|
||||
msg.data.seat = o_desk.method.curr_paiju().method.get_callgrade_seat();
|
||||
msg.data.countdown = o_desk.method.get_countdown_jiaofen();
|
||||
for (var i = 0; i < o_desk.o_room.seatlist.length; i++) {
|
||||
msg.conmode = o_desk.o_room.seatlist[i].conmode;
|
||||
msg.fromid = o_desk.o_room.seatlist[i].fromid;
|
||||
msg.data.cards = o_desk.method.curr_paiju().method.get_seat_cards(i);
|
||||
youle_erqiwang.app.SendPack(msg);
|
||||
}
|
||||
},
|
||||
|
||||
//准备
|
||||
do_prepare: function(o_desk, seat){
|
||||
o_desk.prepare[seat] = 1;
|
||||
if (o_desk.prepare[0] && o_desk.prepare[1] && o_desk.prepare[2]){
|
||||
//新开一局
|
||||
var paiju = o_desk.method.curr_paiju();
|
||||
var firstseat = paiju.banker;
|
||||
if (paiju.result == 1 || paiju.result == 2){
|
||||
firstseat = (firstseat + 1) % 3;
|
||||
}
|
||||
cls_youle_erqiwang_desk.do_new_paiju(o_desk, firstseat);
|
||||
} else {
|
||||
var msg = {};
|
||||
msg.app = "youle";
|
||||
msg.route = "erqiwang";
|
||||
msg.rpc = "zhunbei";
|
||||
msg.data = {};
|
||||
msg.data.success = true;
|
||||
msg.data.seat = seat;
|
||||
o_desk.o_room.method.sendpack_toother(msg, -1);
|
||||
}
|
||||
},
|
||||
|
||||
//大局结算包
|
||||
get_desk_account: function(o_desk, msg){
|
||||
//保存战绩
|
||||
var do_save_grade = function(){
|
||||
var o_gameinfo1 = {};
|
||||
o_gameinfo1.roomcode = o_desk.o_room.roomcode;
|
||||
o_gameinfo1.asetcount = o_desk.paiju_list.length;
|
||||
o_gameinfo1.createtime = o_desk.o_room.createtime;
|
||||
o_gameinfo1.makewartime = o_desk.o_room.makewartime;
|
||||
o_gameinfo1.players = [];
|
||||
for (var i = 0; i < o_desk.o_room.seatlist.length; i++){
|
||||
var _player = {};
|
||||
_player.seat = i;
|
||||
_player.playerid = o_desk.o_room.seatlist[i].playerid;
|
||||
_player.name = o_desk.o_room.seatlist[i].nickname;
|
||||
_player.avatar = o_desk.o_room.seatlist[i].avatar;
|
||||
_player.score = o_desk.seatlist[i][0];
|
||||
o_gameinfo1.players.push(_player);
|
||||
}
|
||||
|
||||
var o_gameinfo2 = [];
|
||||
for (var i = 0; i < o_desk.paiju_list.length; i++) {
|
||||
var _paiju = {};
|
||||
_paiju.starttime = o_desk.paiju_list[i].starttime;
|
||||
_paiju.endtime = o_desk.paiju_list[i].endtime;
|
||||
_paiju.seatlist = [0, 0, 0];
|
||||
_paiju.seatlist[0] = o_desk.seatlist[0][1][i];
|
||||
_paiju.seatlist[1] = o_desk.seatlist[1][1][i];
|
||||
_paiju.seatlist[2] = o_desk.seatlist[2][1][i];
|
||||
_paiju.callproc = o_desk.paiju_list[i].callproc;
|
||||
_paiju.banker = o_desk.paiju_list[i].banker;
|
||||
_paiju.call = o_desk.paiju_list[i].call;
|
||||
_paiju.flower = o_desk.paiju_list[i].flower;
|
||||
_paiju.result = o_desk.paiju_list[i].result;
|
||||
//数据量太多,json转数组后进行存储
|
||||
_paiju.cards = [];
|
||||
for (var j = 0; j < o_desk.paiju_list[i].cards.length; j++) {
|
||||
var _pai = o_desk.paiju_list[i].cards[j];
|
||||
var _card = [];
|
||||
_card.push(_pai.id);
|
||||
_card.push(_pai.flower);
|
||||
_card.push(_pai.number);
|
||||
_card.push(_pai.score);
|
||||
_card.push(_pai.dealowner);
|
||||
_card.push(_pai.playround);
|
||||
_card.push(_pai.playindex);
|
||||
_card.push(_pai.playowner);
|
||||
_paiju.cards.push(_card);
|
||||
}
|
||||
o_gameinfo2.push(_paiju);
|
||||
}
|
||||
|
||||
//累积游戏得分
|
||||
for (var i = 0; i < o_desk.o_room.seatlist.length; i++) {
|
||||
o_desk.o_room.seatlist[i].gameinfo.grade = o_desk.seatlist[i][0];
|
||||
}
|
||||
//保存战绩
|
||||
youle_erqiwang.import.save_grade(o_desk.o_room, o_gameinfo1, o_gameinfo2, 1);
|
||||
}
|
||||
min_ontimeout(do_save_grade, 1000);
|
||||
|
||||
//大局结算下发:组装成对象数组,字段自解释(内部 o_desk.seatlist 是数组,下标语义不清,不直接外抛)
|
||||
msg.data.account = [];
|
||||
for (var s = 0; s < o_desk.seatlist.length; s++){
|
||||
var _a = {};
|
||||
_a.score = o_desk.seatlist[s][0]; //累积总分
|
||||
_a.grades = o_desk.seatlist[s][1]; //每局总分列表
|
||||
_a.grade_jf_total = o_desk.seatlist[s][2]; //基础分(捡分子数得分)累计
|
||||
_a.grade_cg_total = o_desk.seatlist[s][3]; //冲关分累计(design §8.1,不含傍王)
|
||||
_a.grade_bw_total = o_desk.seatlist[s][4]; //傍王分累计(design §8.3)
|
||||
msg.data.account.push(_a);
|
||||
}
|
||||
return msg;
|
||||
}
|
||||
}
|
||||
|
||||
//Node 导出;友乐/浏览器无 module 时跳过(dev-guide 01 §1)。SendPack/youle_erqiwang.* 为平台全局
|
||||
if (typeof module !== "undefined"){
|
||||
module.exports = cls_youle_erqiwang_desk;
|
||||
}
|
||||
@@ -0,0 +1,279 @@
|
||||
///////////////////////////////////////////////////
|
||||
/////// cls_youle_erqiwang_export: 输出接口 ///////
|
||||
///////////////////////////////////////////////////
|
||||
//跨模块依赖:Node 走 require 守卫;友乐/浏览器由 min_loadJsFile 加载为同名全局(dev-guide 01 §1)
|
||||
if (typeof require !== "undefined"){
|
||||
var cls_youle_erqiwang_config = require("./class.config.js");
|
||||
var cls_youle_erqiwang_arith = require("./class.arith.js");
|
||||
var cls_youle_erqiwang_desk = require("./class.desk.js");
|
||||
var cls_youle_erqiwang_paiju = require("./class.paiju.js");
|
||||
}
|
||||
|
||||
var cls_youle_erqiwang_export = cls_youle_erqiwang_export || {
|
||||
|
||||
new: function() {
|
||||
|
||||
var exp = {};
|
||||
|
||||
//解析roomtype获取开房所需房卡(design §10.1)
|
||||
exp.get_needroomcard = function(roomtype){
|
||||
var cfg = cls_youle_erqiwang_config.parse(roomtype);
|
||||
if (cfg.deductAA){
|
||||
//AA每人扣卡:6局每人1张 / 12局每人2张
|
||||
return cfg.aset12 ? 2 : 1;
|
||||
}
|
||||
//房主扣卡:6局2张 / 12局4张
|
||||
return cfg.aset12 ? 4 : 2;
|
||||
}
|
||||
|
||||
//解析roomtype获取总局数(design §10.1:6局 / 12局)
|
||||
exp.get_asetcount = function(roomtype){
|
||||
var cfg = cls_youle_erqiwang_config.parse(roomtype);
|
||||
return cfg.aset12 ? 12 : 6;
|
||||
}
|
||||
|
||||
//加入房间时根据roomtype获取需要的房卡数量
|
||||
exp.get_needroomcard_joinroom = function(roomtype){
|
||||
var cfg = cls_youle_erqiwang_config.parse(roomtype);
|
||||
if (cfg.deductAA){
|
||||
//AA每人扣卡:加入者按自己那份扣(6局1张 / 12局2张)
|
||||
return cfg.aset12 ? 2 : 1;
|
||||
}
|
||||
//房主扣卡:加入者不扣
|
||||
return 0;
|
||||
}
|
||||
|
||||
//开战
|
||||
exp.makewar = function(o_room){
|
||||
//牌桌对象
|
||||
o_room.o_desk = cls_youle_erqiwang_desk.new(o_room);
|
||||
for (var i = 0; i < o_room.seatlist.length; i++) {
|
||||
o_room.seatlist[i].gameinfo.isbet = 1;
|
||||
}
|
||||
|
||||
//开始第一局
|
||||
var do_newpaiju = function(){
|
||||
o_room.o_desk.method.do_new_paiju(0);
|
||||
}
|
||||
min_ontimeout(do_newpaiju, 1000);
|
||||
}
|
||||
|
||||
//获取某个玩家的牌桌整桌当前信息
|
||||
exp.get_deskinfo = function(o_room, seat){
|
||||
if (!o_room.o_desk){
|
||||
return null;
|
||||
}
|
||||
var paiju = o_room.o_desk.method.curr_paiju();
|
||||
//本局房间可选规则:爬坡改变「叫分 → 基础子数」的取值(design §7.3.1),
|
||||
//各阶段下发的 multiple 必须与结算包 aset.multiple 用同一套规则
|
||||
var cfg = cls_youle_erqiwang_config.parse(o_room.roomtype);
|
||||
var deskinfo = {};
|
||||
deskinfo.count = o_room.asetcount; //总局数
|
||||
deskinfo.idx = paiju.idx; //当前局数
|
||||
deskinfo.PlayerInfo = []; //玩家当前累积得分
|
||||
for (var i = 0; i < o_room.o_desk.seatlist.length; i++) {
|
||||
deskinfo.PlayerInfo.push(o_room.o_desk.seatlist[i][0]);
|
||||
};
|
||||
deskinfo.step = paiju.step; //牌局状态
|
||||
deskinfo.MyCards = paiju.method.get_seat_cards(seat); //我手上的牌
|
||||
switch (paiju.step){
|
||||
case 1: //发完牌叫分
|
||||
deskinfo.CallRun = {};
|
||||
//当前叫分位置
|
||||
deskinfo.CallRun.seat = paiju.method.get_callgrade_seat();
|
||||
//叫分倒计时
|
||||
deskinfo.CallRun.countdown = o_room.o_desk.method.get_countdown_jiaofen();
|
||||
//当前叫分
|
||||
deskinfo.CallRun.nowcall = paiju.method.get_callgrade_value();
|
||||
//当前叫分倍数
|
||||
deskinfo.CallRun.multiple = cls_youle_erqiwang_arith.get_base_bycall(deskinfo.CallRun.nowcall, cfg.climb);
|
||||
//所有玩家的叫分情况
|
||||
deskinfo.CallRun.call = [null, null, null];
|
||||
deskinfo.CallRun.call[0] = paiju.method.get_seat_callgrade(0);
|
||||
deskinfo.CallRun.call[1] = paiju.method.get_seat_callgrade(1);
|
||||
deskinfo.CallRun.call[2] = paiju.method.get_seat_callgrade(2);
|
||||
break;
|
||||
case 2: //选主
|
||||
deskinfo.ChooseMain = {};
|
||||
//控制权:选主/投降都只由庄家做(mod.xuanzhu / mod.touxiang 的 SEAT 校验以 banker 为准)。
|
||||
//与 shangzhuang 推送的 nextseat 同源同值——两条路径都由服务端算好给出,
|
||||
//前端不再自己套「选主 = 庄家」这条规则(字段名与 CallRun.seat 一致:分组内的 seat = 轮到谁)
|
||||
deskinfo.ChooseMain.seat = paiju.banker;
|
||||
//庄家
|
||||
deskinfo.ChooseMain.banker = paiju.banker;
|
||||
//庄家叫分
|
||||
deskinfo.ChooseMain.call = paiju.call;
|
||||
//庄家叫分倍数
|
||||
deskinfo.ChooseMain.multiple = cls_youle_erqiwang_arith.get_base_bycall(paiju.call, cfg.climb);
|
||||
//选主倒计时
|
||||
deskinfo.ChooseMain.countdown = o_room.o_desk.method.get_countdown_xuanzhu();
|
||||
//底牌(发牌时没发给玩家、扣在桌面的 8 张):只有庄家可见(70分的3秒亮牌是上庄时的一次性事件,重连不重放)
|
||||
if (seat == paiju.banker){
|
||||
deskinfo.ChooseMain.bottomcards = paiju.method.get_bottomcards();
|
||||
}
|
||||
//是否可投降
|
||||
if (paiju.call != 70){
|
||||
deskinfo.ChooseMain.touxiang = 0;
|
||||
} else {
|
||||
deskinfo.ChooseMain.touxiang = 1;
|
||||
}
|
||||
//当前抓分倍数(design §7.2.0):与 shangzhuang 推送同源同值。
|
||||
//选主阶段一张牌都还没出,捡分恒为 0 → 必为 +3(大光);
|
||||
//漏发会让重连后顶部「抓分」角标掉回 0,与增量路径不一致
|
||||
deskinfo.ChooseMain.curmultiple = paiju.method.get_curmultiple();
|
||||
break;
|
||||
case 3: //埋牌
|
||||
deskinfo.BuryCards = {};
|
||||
//控制权:埋牌只由庄家做(mod.maipai 的 SEAT 校验以 banker 为准)。
|
||||
//与 xuanzhu 推送的 nextseat 同源同值
|
||||
deskinfo.BuryCards.seat = paiju.banker;
|
||||
//庄家
|
||||
deskinfo.BuryCards.banker = paiju.banker;
|
||||
//庄家叫分
|
||||
deskinfo.BuryCards.call = paiju.call;
|
||||
//庄家叫分倍数
|
||||
deskinfo.BuryCards.multiple = cls_youle_erqiwang_arith.get_base_bycall(paiju.call, cfg.climb);
|
||||
//埋牌倒计时
|
||||
deskinfo.BuryCards.countdown = o_room.o_desk.method.get_countdown_maipai();
|
||||
//底牌(发牌留桌的 8 张):只有庄家可见
|
||||
if (seat == paiju.banker){
|
||||
deskinfo.BuryCards.bottomcards = paiju.method.get_bottomcards();
|
||||
}
|
||||
//主牌花色
|
||||
deskinfo.BuryCards.flower = paiju.flower;
|
||||
//当前抓分倍数(design §7.2.0):埋牌阶段同样一张牌未出,捡分恒为 0 → 必为 +3
|
||||
deskinfo.BuryCards.curmultiple = paiju.method.get_curmultiple();
|
||||
//埋牌阶段无投降(投降是选主阶段与选主互斥的选择)
|
||||
break;
|
||||
case 5: //出牌
|
||||
deskinfo.PushCards = {};
|
||||
//庄家
|
||||
deskinfo.PushCards.banker = paiju.banker;
|
||||
//庄家叫分
|
||||
deskinfo.PushCards.call = paiju.call;
|
||||
//庄家叫分倍数
|
||||
deskinfo.PushCards.multiple = cls_youle_erqiwang_arith.get_base_bycall(paiju.call, cfg.climb);
|
||||
//出牌倒计时
|
||||
deskinfo.PushCards.countdown = o_room.o_desk.method.get_countdown_chupai();
|
||||
//主牌花色
|
||||
deskinfo.PushCards.flower = paiju.flower;
|
||||
//捡分
|
||||
var jian_grade = paiju.method.get_jian_grade();
|
||||
if (seat == paiju.banker){
|
||||
//埋牌底牌(庄家埋下的 8 张)。与「底牌」(发牌留桌 8 张,见 ChooseMain/BuryCards.bottomcards)
|
||||
//是两批不同的牌,字段名刻意区分,勿混用
|
||||
deskinfo.PushCards.burycards = paiju.method.get_burycard();
|
||||
} else {
|
||||
//闲家当前的捡分分牌
|
||||
deskinfo.PushCards.gradecards = jian_grade.cards;
|
||||
}
|
||||
//闲家当前的捡分分数
|
||||
deskinfo.PushCards.grade = jian_grade.grade;
|
||||
//当前抓分倍数(design §7.2.0):与 chupai 包同源同值,重连后角标立即正确
|
||||
deskinfo.PushCards.curmultiple = paiju.method.get_curmultiple();
|
||||
//当前的出牌情况:与 chupai1/2/3 共用同一处快照函数(结构一致、且是深拷贝,
|
||||
//不把活对象挂进下发包,见 class.paiju.js get_playproc)
|
||||
deskinfo.PushCards.playproc = cls_youle_erqiwang_paiju.get_playproc(paiju);
|
||||
//本轮跟牌的必出牌(design §5.2):只在【轮到本座位】且本轮已有人出牌时才有,
|
||||
//算的也只是本座位自己的手牌,不涉及他家(server 红线:按可见性下发)
|
||||
if (paiju.playproc && paiju.playproc.currseat == seat){
|
||||
var _must = paiju.method.get_mustcard(seat);
|
||||
if (_must){
|
||||
deskinfo.PushCards.mustcard = _must;
|
||||
}
|
||||
}
|
||||
//报无主:与 chupai1/2/3 的 baozhu 同源同门控(mod.chupai)。
|
||||
//【必须带】它是「余主公示 + 明牌按钮」的开关(design §9,前端
|
||||
//Sprites_Table.js 的明牌按钮据此显隐)。只在 chupai 包里给、重连包不给,
|
||||
//等于重连后按钮凭空消失——状态变更没有包承载(server 红线:发全下发面)。
|
||||
//不查牌模式恒 0(与 chupai 一致,协议 §11–§13 baozhu 条目)
|
||||
deskinfo.PushCards.baozhu = (!cfg.nocheck && paiju.method.have_baofu()) ? 1 : 0;
|
||||
//座位列表(报副统计)与亮牌:仅可查牌模式下展示(§9/§8.2)
|
||||
if (!cfg.nocheck){
|
||||
//【深拷贝】与 maipai / chupai1/2/3 共用同一处快照函数,且不把活表挂进下发包
|
||||
//(paiju.seatlist 是 do_playcard 每手就地改写的活对象,见 class.paiju.js get_seatlist)
|
||||
deskinfo.PushCards.seatlist = cls_youle_erqiwang_paiju.get_seatlist(paiju);
|
||||
if (seat != paiju.banker){
|
||||
var _lp = paiju.method.get_liangpai();
|
||||
if (_lp){
|
||||
deskinfo.PushCards.liangpai = _lp;
|
||||
}
|
||||
}
|
||||
}
|
||||
//出牌历史:往轮打出、已被收走的牌属于「查牌」范畴,仅可查牌模式下发(design §9)。
|
||||
//不查牌模式下不带此属性——当前这一轮桌面上的牌由上面的 playproc 恢复,两种模式都有,
|
||||
//否则后出的人无从跟牌(design §9 末尾的边界说明)
|
||||
if (!cfg.nocheck){
|
||||
//【直读归档、不重建】pushlist 的唯一来源是 do_playcard 出牌当场写下的
|
||||
//paiju.playhistory(见 class.paiju.js)。旧实现是从 paiju.cards 的 playround/dealowner
|
||||
//反查再自己排一次序——那是同一份数据「这一手打出的牌」的第二个写入处:
|
||||
//一手多张(甩牌/对子/拖拉机)时它与 chupai1/2/3 的 data.cards 会给出不同顺序,
|
||||
//前端增量回放与重连重建因此对不上(同编码的一对牌尤其:排序无法区分它们)。
|
||||
deskinfo.PushCards.pushlist = paiju.method.get_pushlist();
|
||||
}
|
||||
break;
|
||||
case 6: //结算
|
||||
deskinfo.Balance = {};
|
||||
//玩家的准备状态
|
||||
//【返回副本】o_room.o_desk.prepare 是一张全程就地改写的活数组
|
||||
//(do_prepare 每次准备就地把某一格置 1,do_new_paiju 又就地重建),
|
||||
//把它本体挂进下发包会让「已发出的包」随后续准备操作被回改。
|
||||
//与 get_playproc / get_seatlist / get_pushlist / get_bottomcards 同一条标准:
|
||||
//平台真实链路虽然立刻序列化看不出来,但这是隐患,不留。
|
||||
deskinfo.Balance.readystate = o_room.o_desk.prepare.concat();
|
||||
if (o_room.o_desk.prepare[seat] == 0){
|
||||
//结算面板的三件套:与 jiesuan 推送【同源同值】(都取 get_paiju_account 末尾
|
||||
//冻结的那份快照)。只给 aset 会让「结算面板还开着时断线重连/硬刷新」丢掉
|
||||
//抠底明细与末局大结算——增量路径有、重连路径空白,正是「同一份数据两条路径
|
||||
//给的不一样」(server 红线:发全下发面,漏发修在服务端发包处)。
|
||||
//结算情况
|
||||
deskinfo.Balance.aset = paiju.tmp_jiesuan_aset;
|
||||
//抠底明细:仅正常出牌结算存在(投降/解散没有抠底这一步),与 jiesuan.bottom 同一份
|
||||
if (paiju.tmp_jiesuan_bottom){
|
||||
deskinfo.Balance.bottom = paiju.tmp_jiesuan_bottom;
|
||||
}
|
||||
//大局结算:仅末局或解散时存在,与 jiesuan.account 同一份。
|
||||
//「有就带、没有就不带」与 jiesuan 推送的取舍完全一致,前端两条路径同一套解析
|
||||
if (paiju.tmp_jiesuan_account){
|
||||
deskinfo.Balance.account = paiju.tmp_jiesuan_account;
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
return deskinfo;
|
||||
}
|
||||
exp.createroom_needprepare = function(roomtype, o_game_config) {
|
||||
|
||||
return 1; // 1 = 需要准备
|
||||
};
|
||||
//解散房间
|
||||
//平台在 battlestate==1 时即会调用(rpc.js 开战处 makewar 后立刻置 1),而本游戏的首个牌局
|
||||
//是 makewar 里 min_ontimeout(...,1000) 延迟创建的——这段窗口内 o_desk 已存在但 paiju_list 为空。
|
||||
//此时返回 null(平台对 null 的 _deskfree 有分支:照常发解散包、不带 deskfree),
|
||||
//而不是让 curr_paiju() 的 undefined 解引用抛异常打断整条解散链路。
|
||||
exp.get_disbandRoom = function(o_room){
|
||||
if (!o_room.o_desk){
|
||||
return null;
|
||||
}
|
||||
var paiju = o_room.o_desk.method.curr_paiju();
|
||||
if (!paiju){
|
||||
return null;
|
||||
}
|
||||
var msg = {};
|
||||
return paiju.method.get_paiju_account(2, msg);
|
||||
}
|
||||
|
||||
return exp;
|
||||
}
|
||||
}
|
||||
|
||||
//对内输出接口(友乐/浏览器:youle_erqiwang 由 mod.js 先建;Node 单测无此全局时跳过)
|
||||
if (typeof youle_erqiwang !== "undefined"){
|
||||
youle_erqiwang.export = cls_youle_erqiwang_export.new();
|
||||
}
|
||||
|
||||
//Node 导出;友乐/浏览器无 module 时跳过(dev-guide 01 §1)
|
||||
if (typeof module !== "undefined"){
|
||||
module.exports = cls_youle_erqiwang_export;
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
///////////////////////////////////////////////////
|
||||
/////// cls_youle_platform_import: 输入接口 ///////
|
||||
///////////////////////////////////////////////////
|
||||
var cls_youle_erqiwang_import = cls_youle_erqiwang_import || {
|
||||
|
||||
new: function() {
|
||||
|
||||
var imp = {};
|
||||
|
||||
//检查玩家
|
||||
imp.check_player = function(agentid, gameid, roomcode, seat, playerid, conmode, fromid){
|
||||
return youle_erqiwang.app.youle_room.export.check_player(agentid, gameid, roomcode, seat, playerid, conmode, fromid);
|
||||
}
|
||||
|
||||
//第一局结算扣除房卡
|
||||
imp.deduct_roomcard = function(o_room){
|
||||
return youle_erqiwang.app.youle_room.export.deduct_roomcard(o_room);
|
||||
}
|
||||
|
||||
//保存战绩
|
||||
imp.save_grade = function(o_room, o_gameinfo1, o_gameinfo2, freeroomflag){
|
||||
youle_erqiwang.app.youle_room.export.save_grade(o_room, o_gameinfo1, o_gameinfo2, freeroomflag);
|
||||
}
|
||||
|
||||
return imp;
|
||||
}
|
||||
}
|
||||
|
||||
// 输入接口(友乐/浏览器:youle_erqiwang 由 mod.js 先建;Node 单测无此全局时跳过)
|
||||
if (typeof youle_erqiwang !== "undefined"){
|
||||
youle_erqiwang.import = cls_youle_erqiwang_import.new();
|
||||
}
|
||||
|
||||
//Node 导出;友乐/浏览器无 module 时跳过(dev-guide 01 §1)。youle_erqiwang.app 为平台全局
|
||||
if (typeof module !== "undefined"){
|
||||
module.exports = cls_youle_erqiwang_import;
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
///////////////////////////////////////////////////
|
||||
////////// cls_youle_erqiwang_pai: 牌 //////////
|
||||
///////////////////////////////////////////////////
|
||||
var cls_youle_erqiwang_pai = cls_youle_erqiwang_pai || {
|
||||
|
||||
//创建单张牌对象
|
||||
new: function(id, flower, number, score, dealowner){
|
||||
/*
|
||||
参数说明:
|
||||
id 绝对id
|
||||
flower 牌面花色
|
||||
number 牌面数值
|
||||
code 牌编码
|
||||
|
||||
扑克牌的统一编码规则:
|
||||
1,牌面花色的定义 5:王 4:黑桃 3:红心 2:梅花 1:方块
|
||||
2,牌面数值的定义 01:A 02:2 03:3 ... 09:9 10:10 11:J 12:Q 13:K 53:小王 54:大王
|
||||
3,牌编码的定义
|
||||
3.1 常主的牌编码定义:
|
||||
未叫主之前:
|
||||
2的牌编码定义 黑桃2:2402 红心2:2302 梅花2:2202 方块2:2102
|
||||
7的牌编码定义 黑桃7:7407 红心2:7307 梅花7:7207 方块7:7107
|
||||
王的牌编码定义 小王:9553 大王:9554
|
||||
叫主之后:
|
||||
主2牌编码定义 原有牌编码的基础上+1000,例如红心为主牌,则红心2的牌编码变为3302
|
||||
主7牌编码定义 原有牌编码的基础上+1000,例如红心为主牌,则红心7的牌编码变为8307
|
||||
3.2 普通牌的牌编码定义
|
||||
A的定义:14
|
||||
未叫主之前:
|
||||
3位数字 第一位为牌面花色 第二三位为牌面数值
|
||||
叫主之后:
|
||||
主牌花色 4位数字,即在原有牌编码的基础上+1000,表示是主牌
|
||||
非主牌花色 3位数字,即原有牌编码不变
|
||||
3.3 综上所述
|
||||
牌编码是3位的,即小于1000的都是非主牌
|
||||
牌编码是4位的,即大于1000的都是主牌
|
||||
可根据牌编码进行牌大小的比较和排序
|
||||
4 牌型定义
|
||||
>100: 单张, 101一张单张,102两张单张,103三张单张 ...
|
||||
>200: 对子, 201一对,202两对,203三对 ...
|
||||
>300: 拖拉机,302两连对拖拉机,303三连对拖拉机 ...
|
||||
|
||||
*/
|
||||
var pai = {};
|
||||
pai.id = id; //绝对id,即数组下标,从0开始计数
|
||||
pai.flower = flower; //牌面花色
|
||||
pai.number = number; //牌面数值
|
||||
pai.score = score; //牌在游戏中的分值
|
||||
pai.dealowner = dealowner; //发牌状态
|
||||
//-1:规则去除的牌
|
||||
// 0:未发的牌,即底牌
|
||||
//>0:发牌发到谁手上,从1开始计数,即位置编号+1,位置编号是从0开始计数的
|
||||
pai.playround = -1; //出牌状态
|
||||
//-1:未出的牌
|
||||
// 0:埋牌
|
||||
//>0:牌是第几轮出出去的,从1开始计数
|
||||
pai.playindex = -1; //本轮中的出牌顺序,从1开始计数
|
||||
pai.playowner = -1; //出牌后被谁得到,与位置编号对应
|
||||
return pai;
|
||||
}
|
||||
}
|
||||
|
||||
//Node 导出;友乐/浏览器无 module 时跳过(dev-guide 01 §1)
|
||||
if (typeof module !== "undefined"){
|
||||
module.exports = cls_youle_erqiwang_pai;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,53 @@
|
||||
# 二七王 服务端实现 vs 设计文档:初步不一致清单
|
||||
|
||||
> 本文是首轮通读 `server/games/erqiwang/*.js` 与 `docs/design/design.md` 后记录的**初步**疑点清单,作为逐条深入核对的线索。完整、逐节的核对结论见同目录 [`01-design合规逐节核对.md`](./01-design合规逐节核对.md)。
|
||||
>
|
||||
> 方向约定(与根 `CLAUDE.md` 一致):`design.md` 是玩法规则的唯一权威,代码与其冲突时**应改代码去符合规则**(除非规则标「待确认」);本清单只做记录,不代表已修复。
|
||||
|
||||
---
|
||||
|
||||
## 1. 算子(基础子数)与设计表格完全对不上
|
||||
|
||||
- 设计 §7.1 常规算子基础子数:65→2、60→3、55→4、50 及以下→6;70 投降→1、70 打牌→2。
|
||||
- 代码 `arith.get_multiple_bycall` 只返回 1/2/4/0(`call>60→1`、`call>40→2`、`call>0→4`),与设计四档数值、结构均不符。
|
||||
|
||||
## 2. 爬坡 / 傍王 房间选项未做成开关
|
||||
|
||||
- `arith.get_upgrade` 用 `halfcall = ceil(call/2)` 作小光/过庄分界与升级级距;设计常规算子该分界**固定 40**、级距**固定 40**。
|
||||
- 代码里**没有任何读取房间「爬坡 / 傍王」勾选的分支**:`grade_w`(傍王)无条件计算、`halfcall` 恒用。设计 §7.3 / §8.3 / §10.3 中这两项都是可选规则,需要开关。
|
||||
|
||||
## 3. 扣底倍数不符
|
||||
|
||||
- 设计 §6.3:单张主 ×1、主对子 ×2、两连对 ×4、三连对 ×6、N 连对 ×2N(线性)。
|
||||
- 代码 `arith.get_bottom_multiple`:单张 →2、对子 →4、两连对 →8、三连对 →16、四连对 →32、五连对 →64(近似指数),且单张即翻倍;六连对落空返回 `undefined`。
|
||||
|
||||
## 4. 「固定主牌 ≥10 张」给了算奖
|
||||
|
||||
- 代码 `arith.get_chongguan` 对 `王+2+7≥10` 追加 `total−9` 奖(「10 个老主判断」)。
|
||||
- 设计 §8.2 明确举例:固定主牌 ≥10 张只触发**亮牌**、**不触发任何算奖**。
|
||||
|
||||
## 5. 算奖归属模型与结算方式不同
|
||||
|
||||
- 设计 §8.1:常规算奖**只计庄家**;§8.3 傍王才庄闲都算(且需勾选);§8.4:算奖额外支付额 = `X × N`(`X` 为第 7 节算出的基础输赢子数)。
|
||||
- 代码:对**三家**都算「冲关」并两两结算,且叠加「冲关必叫 / 四王必踢」的**作废**规则(设计中无此机制);`grade_cg`、`grade_w` 是**扁平奖数差**(每奖记 1 子),**未乘 `X`**。
|
||||
|
||||
## 6. 叫分缺上限校验
|
||||
|
||||
- 设计 §4:叫分范围 5 ~ 70。
|
||||
- 代码 `mod.jiaofen` 只校验 `call%5==0` 与「后叫必须更低」,**缺 `call<=70` 上限**,首家可叫出 75、100 等非法分值。
|
||||
|
||||
## 7. 投降资格范围过宽
|
||||
|
||||
- 设计 §4.4 / §7.1:投降是 **70 分坐庄专属**分支。
|
||||
- 代码 `mod.touxiang` 用 `call < 65 → 拒绝`,即 **65 分也能投降**。
|
||||
|
||||
## 8. 70 分暗牌亮 3 秒等交互细节未实现
|
||||
|
||||
- 设计 §4.4 / §7.1:70 分坐庄,8 张暗牌需向两个闲家亮 3 秒(投降 / 打牌都要)。
|
||||
- 代码未见相关下发逻辑。
|
||||
|
||||
---
|
||||
|
||||
以上为初步清单。经逐节精查后,另发现若干**运行期代码缺陷**(如 `can_followcard` 引用未定义变量 `tuolaji_list` 会抛异常、`can_playcard` 的 14 张上限 `do_returnfalse` 漏写括号导致失效等)、以及**甩牌合法性 / 强制跟牌 / 局数扣卡 / 查牌模式**等更多不一致,详见 [`01-design合规逐节核对.md`](./01-design合规逐节核对.md)。
|
||||
|
||||
> **本文与 01 的前两轮都只沿 design.md 逐节核对玩法规则**,因此平台接入、客户端入参校验、下发面一致性、协议红线这几类问题整类不在核对范围内。第三轮补上这些维度后又查出 1 项阻断级(子游戏从未在 `server/youle/app.js` 注册,运行时完全不可达)、3 项严重(重复牌id 可伪造牌型、入参无类型校验、下发面 `multiple` 忽略爬坡开关)与协议红线缺失(全仓无 `data.success`),见 [`01`「第三轮核对」](./01-design合规逐节核对.md#第三轮核对平台接入--入参--下发面--协议红线)。
|
||||
@@ -0,0 +1,531 @@
|
||||
# 二七王 服务端实现 vs `design.md` 逐节合规核对
|
||||
|
||||
> 权威方向(同根 `CLAUDE.md`):`design.md` 是玩法规则的**唯一权威**。凡代码与规则冲突,除非规则项标注「待确认」,一律视为**代码缺陷**,应改代码去符合规则,**不得改规则迁就代码**。
|
||||
>
|
||||
> 核对对象:`server/games/erqiwang/` 下 `mod.js`、`class.pai.js`、`class.paiju.js`、`class.arith.js`、`class.desk.js`、`class.export.js`、`class.import.js`。
|
||||
>
|
||||
> 严重度标记:
|
||||
> - 🟥 **严重**:影响发牌 / 计分 / 输赢 / 核心玩法正确性。
|
||||
> - 🟧 **中等**:可选规则缺失、边界校验缺失、局部规则偏差。
|
||||
> - 🟨 **轻微**:交互提示 / 展示细节 / 死代码。
|
||||
> - 🐞 **代码缺陷**:运行期异常或明显写错(无论是否直接违背规则)。
|
||||
> - ✅ **符合**。
|
||||
|
||||
---
|
||||
|
||||
## 整改进度
|
||||
|
||||
> 本轮已按 design.md 修正的项(均已用 Node 脚本对 design 判定表逐格验证,见"验证"列)。下方"结论摘要"与各节详评仍保留**原始不一致**描述以留痕;本节记录当前状态。
|
||||
|
||||
> **二次全量复核结论(限定范围)**:对整改后代码做了一次独立对抗性复核(§7/§8 算子算奖、§5 出牌甩牌、§4 开局投降暗牌阶段机、§2/§3/§9/§10 编码查牌房间,逐条比对 + Node 实测)。结果:**就 design.md 的玩法规则维度而言,服务端符合**,且未发现整改引入的符号/下标/边界错、无 step4 死代码残留、投降 36 张快照与 X×N 算奖均正确。
|
||||
> - 复核时发现的唯一未落地项 **§5.4.4 强制跟牌分量拆解已于本轮修复**:合法甩牌在 `can_playcard` 记录分量需求 `shuai_demand`(拖拉机长度/对子/单张),存入 `playproc`;跟牌时 `do_playcard` 调 `arith.flush_follow_ok` 在"同花色凑张数、主牌最大化"之上追加"拖拉机→对子→单张"的强制分量匹配(能凑同长主拖必凑、能凑主对必凑,不得拆散去垫)。已用 Node 正反面/边界用例验证。
|
||||
>
|
||||
> ⚠️ **该结论只覆盖"玩法规则是否符合 design.md"这一个维度**。第三轮核对(见下方「第三轮核对」)在**平台接入、客户端入参校验、下发面一致性、协议红线**四个维度上又发现 1 项阻断级、3 项严重、若干中等缺陷——它们不属于 design.md 的玩法条款,所以逐节核对没有覆盖到,但同样会让服务端跑不起来或被利用。**"符合 design.md" ≠ "实现完整正确"**,后续复核不要再把前者当成后者。
|
||||
|
||||
| 项 | 位置 | 整改内容 | 验证 |
|
||||
| --- | --- | --- | --- |
|
||||
| D1 崩溃 | `class.arith.js` `can_followcard` | 用 `get_pairlist`+`get_tuolaji_list(_followcards)` 校验拖拉机牌型,消除未定义 `tuolaji_list` | 语法通过 |
|
||||
| D3 | `can_playcard` | `do_returnfalse;` → `do_returnfalse()`,恢复 14 张上限 | 语法通过 |
|
||||
| D4 | `do_choiceflower` | 删除空循环死代码 | 语法通过 |
|
||||
| §6.3 | `get_bottom_multiple` | 改为线性:单张 1、主对 2、N 连对 2N | ✅ 10 用例 |
|
||||
| §7 算子 | `get_base_bycall`/`get_qvalue`/`get_upgrade`(新)+ `get_paiju_account` | 实现常规算子(大光×3、Q=40) 与爬坡(§7.3 分段) 两套,按爬坡位(roomtype 位3)切换;`X=multiple×|upgrade|` | ✅ 74 格逐档 |
|
||||
| §8.1 | `get_chongguan` + `get_paiju_account` | 常规算奖只计庄家;连对链修复(传实际主花色);移除 ≥10 老主奖;去掉设计外的"冲关必叫/四王必踢"作废 | ✅ get_chongguan 用例 |
|
||||
| §8 快照 | `get_seat_cards_award`(新) | 庄家用埋牌后 28 张(排除已埋)、闲家用发牌后 28 张 | 语法通过 |
|
||||
| §8.3 傍王 | `get_paiju_account` | 改为按傍王位(roomtype 位2)开关,庄闲每王 1 奖,去作废 | ✅ 公式用例 |
|
||||
| §8.4 | `get_paiju_account` | 算奖并入结算改为 `X×(2Ni−Nj−Nk)`(乘 X) | ✅ §8.4 举例 |
|
||||
| §4.2 | `mod.jiaofen` | 增加叫分上限 `call<=70` | — |
|
||||
| §4.4 投降 | `mod.touxiang` + `do_burycard` + `get_deskinfo` | 投降仅 70 分、在**选主阶段 step2** 与选主互斥、不选主不埋牌;结算基础 1、庄输 1 子/闲;算奖用庄家 **36 张**(发牌+暗牌,无主花色→无连对链);埋牌后 step 直接进 5、删除已废弃的投降 step4 与重连 Surrender 视图 | — |
|
||||
| §10.1 | `get_asetcount`/`get_needroomcard` | 局数 6/12;房主扣卡 2/4 | — |
|
||||
| §5.4 甩牌 | `arith`(新增 `trump_rank`/`decompose_trump`/`opp_can_beat_flush`/`flush_follow_ok`/`remove_pairs`)+ `can_playcard` + `do_playcard` + `mod.chupai` | 副牌绝对禁甩;最大性改为**按对手全部主牌**逐分量判定;甩错惩罚(收回、只打最小一张、下发 `shuaicuo`);**§5.4.4 强制跟牌分量拆解**(拖拉机→对子→单张优先级,`shuai_demand`+`flush_follow_ok`) | ✅ 分解/最大性/各路径/跟牌分量用例 |
|
||||
| roomtype | `class.config.js`(新)+ `export`/`paiju` | 数组下标改为**位串**(`"01011"`),`class.config.js` 唯一解析(SSOT) | ✅ parse/扣卡/局数用例 |
|
||||
| §4 暗牌亮牌 | `shangzhuang` + `get_deskinfo` | 70 分坐庄把 8 张暗牌下发给所有玩家 + `ancard3s=1`(供摸牌前亮 3 秒);修正**非 70 分暗牌泄露给闲家**(现暗牌只发庄家,符合 §4"只有庄家可见");重连的暗牌一律仅庄家可见 | — |
|
||||
| §8.2 亮牌 | `get_liangpai`(新)+ `mod.maipai` + `get_deskinfo` | 庄家埋牌后 28 张按阈值(固定主≥10/王≥3/7≥6/2≥6)统计,出牌开始向闲家亮(只亮数量/结构);可查牌模式才下发 | ✅ 阈值/统计用例 |
|
||||
| §9 查牌 | `class.config.js`(nocheck) + `mod.chupai` + `get_deskinfo` + `mod.mingpai`(新) | 报无主的座位统计 `info`/`baozhu`/`PushCards.seatlist` 与亮牌均按 `cfg.nocheck` 门控;新增 `mingpai` RPC(可查牌+已报无主时查看他家全部主牌) | — |
|
||||
|
||||
**尚未整改(多需与客户端/协议协同,或为较大的独立子系统)**:
|
||||
|
||||
- ✅ **§5.4 甩牌(已全部完成)**:副牌绝对禁甩、最大性按对手全部主牌逐分量判定、甩错惩罚、以及 §5.4.4 强制跟牌分量拆解(`flush_follow_ok`:拖拉机→对子→单张优先级)均已实现并用 Node 验证。
|
||||
- 🟩 **§9 查牌 / §8.2 亮牌(服务端已完成)**:亮牌 `get_liangpai`(庄家埋牌后 28 张按阈值统计)在 `maipai`/重连下发给闲家;报无主的座位统计(`info`/`baozhu`/`PushCards.seatlist`)与亮牌均按查牌位 `cfg.nocheck` 门控(不查牌一律不下发);新增 `mingpai` RPC(可查牌 + 已报无主时查看他家全部主牌)。剩客户端展示(面板/明牌按钮)。
|
||||
- 🟩 **§4.4 70 分暗牌亮 3 秒(服务端已就绪)**:`shangzhuang` 对 70 分坐庄把 8 张暗牌下发给所有玩家并带 `ancard3s=1`,供客户端在庄家摸暗牌前亮 3 秒;顺带修正了**非 70 分把暗牌泄露给闲家**的问题(现非 70 分暗牌只发庄家,符合 §4"只有庄家可见")。剩下的 3 秒动画由客户端完成。
|
||||
- ✅ **§11 闲家 3 提示(踩/没分/有分)**:`mod.tishi` 服务端转发给对家已实现;剩客户端展示。
|
||||
- 🟧 **客户端建房配置**:`roomtype` 已改为位串(见下表:位0局数/位1扣卡/位2傍王/位3爬坡/位4查牌),需在 erqiwang 客户端建房界面按位勾选拼串;服务端已按该格式读取(`class.config.js`)。
|
||||
|
||||
### roomtype 位串约定(服务端已按此读取;解析入口 `class.config.js`)
|
||||
|
||||
`roomtype` 为**定长数字位串(字符串)**,每一位(`charAt`)一个开关 `'0'/'1'`,便于前端逐项勾选。缺省 `"00000"` = 6局/房主扣卡/无傍王/常规算子/可查牌。解析对缺失/过短字符串按 `'0'` 兜底。
|
||||
|
||||
| 位 | 含义 | `'0'` | `'1'` |
|
||||
| --- | --- | --- | --- |
|
||||
| 0 | 局数 | 6局(缺省)| 12局 |
|
||||
| 1 | 扣卡方式 | 房主扣卡(缺省)| AA每人扣卡 |
|
||||
| 2 | 傍王 | 关(缺省)| 开 |
|
||||
| 3 | 爬坡 | 常规算子(缺省)| 爬坡 |
|
||||
| 4 | 查牌模式 | 可查牌(缺省)| 不查牌 |
|
||||
|
||||
示例 `"10100"` = 12局 / 房主扣卡 / 傍王开 / 常规算子 / 可查牌。
|
||||
|
||||
---
|
||||
|
||||
## 第三轮核对(平台接入 / 入参 / 下发面 / 协议红线)
|
||||
|
||||
> 前两轮只沿 design.md 逐节核对玩法规则,因此**整类问题在核对范围之外**。本轮补上这些维度,结论如下(均已修复并有单测回归)。
|
||||
|
||||
| # | 严重度 | 位置 | 问题 | 处置 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| A1 | 🟥 阻断 | `server/youle/app.js` | 只加载 `games2/jinxianmahjong/mod.js`,**从未加载 `games/erqiwang/mod.js`**,全仓平台侧对 erqiwang 零引用 → `youle_erqiwang` 模块从不创建、`pack.route="erqiwang"` 无法命中三层路由,**整个子游戏运行时不可达** | 已在 app.js 补注册(server dev-guide README 允许触碰的唯一平台文件) |
|
||||
| B1 | 🟥 严重 | `class.paiju.js` `check_cards_inhand` | 不查重复牌id:`do_playcard([方块K,方块K])` 实测 `result=true, cardtype=201`——同一张牌被判成一对,可伪造拖拉机/甩牌分量,且该分牌 score 被重复累加;`maipai` 传 8 个相同 id 实测**只埋下 1 张**,庄家带 35 张进入出牌阶段 | 新增 `check_cards_valid`(非空数组 / 0~107 整数 / 无重复)作为唯一入参校验入口 |
|
||||
| B2 | 🟥 严重 | `mod.js` `maipai`/`chupai` | 越界 id 或非数组入参在解引用处抛异常,中断 `DoPack`;且 `cards.length` 读在校验之前 | 校验前置于 length 判断之前 |
|
||||
| B3 | 🟥 严重 | `arith.get_multiple_bycall` + 6 个调用点 | 写死 `climb=false`:**爬坡房对局全程界面显示常规算子子数,与结算 `aset.multiple` 不符**(叫 45 分:界面 6、结算 7) | 删除该兼容包装,全部改调 `get_base_bycall(call, cfg.climb)`,与结算同源 |
|
||||
| C1 | 🟥 红线 | 全部下发包 + 所有失败分支 | **全仓无 `data.success`**;失败一律裸 `return`、不回任何包,前端只能干等倒计时。违反 server dev-guide「成败唯一是 data.success、主动推送必须自带」与 client dev-guide「发包只请求、收包才表现」 | 成功包统一补 `success:true`;新增 `ERR` 码表 + `do_sendfail`,每条失败分支回 `success:false + errcode` 给请求者 |
|
||||
| D1 | 🟧 中等 | `arith.is_continuous` | `is_8`/`is_6` 只判 `code % 100`、不校验花色:`is_continuous(♥8=308, ♣6=206)` 实测为 `true`。`get_chongguan` 是唯一扫描整手牌(含各花色副牌)的调用点,连对链走到主 8 后会被任意花色的一对 6 错误接续、多算奖(design §8.1 要求 A~5 段必须是主花色) | 追加「编码相差 2」约束(同花色 8/6 恒差 2) |
|
||||
| D2 | 🟧 中等 | `export.get_deskinfo` pushlist | ① 内层循环上界误用轮次数当座位数,实测 5 轮时内层被撑成长度 5 且含 `undefined`;② 排序传的是上个循环泄漏的 `pai.flower`(=最后一张牌大王的花色 5),等于按「无主牌」排序 | 内层固定 3、改用 `paiju.flower`,并对缺失轮次加守卫 |
|
||||
| D3 | ✅ 规则如此 | `class.desk.js` | 下发了 `countdown`,但服务端无任何定时器做超时托管,玩家不操作则牌局停住等待 | **规则设计者确认:维持现状、不做超时动作**。已写入 design §11「倒计时只作展示,不触发任何自动操作」并注明不是缺口;牌局停住由玩家走房间解散流程收场(按当前累计分结算,design §12.2)。server dev-guide「服务端自动操作复用真人链路」约束的是「若要做代打就必须复用真人链路」,本局不做代打,故不适用 |
|
||||
| D4 | 🟨 轻微 | `paiju.do_playcard` | 报无主后只更新出牌者自己那一格,B/C 要等各自轮到出牌才补上,界面最多滞后两次出牌(design §9 要求「为全体三人显示另外两家」) | 改为一旦 `have_baofu()` 成立即按实际手牌刷新三个座位 |
|
||||
| D5 | 🟨 轻微 | `can_playcard` / `chupai1` | `cardtype` 只能表达单一牌型,甩牌被压平成 1xx/2xx(「主K对+主5」→103),客户端连"这是一次甩牌"都看不出来 | `chupai1` 增发 `shuai` 分量构成(与 `shuai_demand` 同源) |
|
||||
| D6 | 🟧 中等 | `export.get_deskinfo` | 不查牌模式下 `pushlist`(已打出牌历史)仍照发。规则设计者已确认:**不可查牌模式必须屏蔽出牌历史,只有可查牌模式才能查看** | 已按确认修复:`pushlist` 按 `cfg.nocheck` 门控;design §9 同步改写为无歧义表述(四项功能整体开关 + 「当前轮桌面牌不属于查牌、两种模式都可见」的边界) |
|
||||
| D7 | 🟨 轻微 | `check_cards_inhand` | 牌已出/已埋时 `return;`(undefined)而非 `false` | 已改为 `return false` |
|
||||
|
||||
### 尚未处置
|
||||
|
||||
**无。** 本轮发现的 11 项已全部收口:
|
||||
|
||||
- A1/B1/B2/B3/C1/D1/D2/D4/D5/D7 共 10 项为缺陷,已改代码 + 补单测;
|
||||
- D6(不查牌是否屏蔽出牌历史)原为规则歧义,已由规则设计者拍板「屏蔽」,改 design §9 + 代码 + 单测;
|
||||
- D3(超时托管)原列为待外部输入,已由规则设计者拍板 **维持现状、不做超时动作**,属**规则如此、并非缺陷**,已写入 design §11 固化,避免后续核对反复把它当缺口重提。
|
||||
|
||||
---
|
||||
|
||||
## 第四轮核对(全量重核 design.md:先流程、后规则细节)
|
||||
|
||||
> 从 design.md §1~§12 重新逐条对照当前代码(含流程阶段机与全部规则细则),**流程层无缺环**(发牌→叫分→摸暗牌/70分亮3秒→选主/投降互斥→先选主后埋牌→出牌→末轮扣底→算子+算奖→轮庄→大局/解散结算,`step` 迁移 1→2→3→5→6 闭合)。规则层新发现 3 项不一致,已全部修复并补回归单测。
|
||||
|
||||
| # | 严重度 | 位置 | 问题 | 处置 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| E1 | 🟧 中等 | `class.paiju.js` `get_liangpai` | 亮牌统计取 `get_seat_cards`(只含未出的牌),而 §8.2 的依据是**庄家埋牌后的手牌**。埋牌那一刻算对,但 `get_deskinfo` 在 step5 重连时会按庄家**当前剩余手牌**重算:实测「4王+6个7」的庄家打掉 2 大王 + 1 对♦7 后,重连再取从 `{zhu:10,zhupair:5,zhutuo:1,wang:4,qi:6}` 变成 `null`,闲家重连即丢失亮牌 | 改用已有的静态快照 `get_seat_cards_award(o_paiju, banker)`(埋牌后 28 张、含之后已打出的),并在函数注释里固化"不得用 get_seat_cards"的原因;补 3 条回归用例(快照 / 打出后重连不缩水 / 已埋牌不计入) |
|
||||
| E2 | 🟨 轻微 | `mod.js` `chupai` | 第三轮 D4 只修了服务端状态(`do_playcard` 里已按 `have_baofu()` 同时刷新三个座位),**下发面没跟上**:出牌包仍只带 `info = seatlist[seat]` 一家,另两家的主牌数/对子数要等它们各自出牌才送达,仍最多滞后两次出牌,与 §9「一旦有人报无主,立即为全体三人显示另外两家」不符 | 出牌包改发整表 `seatlist`(三家),字段名/结构与重连包 `PushCards.seatlist` 统一;协议文档三处 `info` 条目同步改写;补「整表带三家 + 含非出牌者统计」用例 |
|
||||
| E3 | 🟨 轻微 | `class.arith.js` `can_playcard` | 开头 `if (cards.length > 14)` 是"规则外的约定"硬上限,与 §5.4.3「甩牌无组合数量限制」冲突:手持 15 张以上顶级主牌时的合法甩牌会被整个拒掉(如 4王+8个7+正2对+副2对 共 16 张) | 上限改为结构性上界 28(出牌阶段单人最多持 28 张,且 28 张内牌型编码不溢出 1xx/2xx/3xx 区间),不再设规则性限制;补 16 张合法甩牌用例 |
|
||||
|
||||
**非缺陷的观察(本轮确认,勿再重提)**:
|
||||
|
||||
- 70 分「暗牌亮 3 秒」:服务端在上庄包里一次性下发 8 张暗牌 + `ancard3s=1`,"摸入前亮 3 秒"的先后由前端表现;重连不重放该一次性事件。design 未要求服务端表达先后,**按现状即合规**。
|
||||
- §4/§11「选主按钮显示各花色对子数」:design 标注为**前端表现**,服务端已把庄家全部手牌下发,前端可自算,**不是下发面缺口**。
|
||||
|
||||
### 尚未处置
|
||||
|
||||
**无。** E1/E2/E3 已全部改代码 + 补单测,协议文档同步。
|
||||
|
||||
### 第四轮·复验(E1~E3 修复后的独立取证复核)
|
||||
|
||||
对修复后的实现又做了一次**取证式**复核:不再靠通读,而是把 design 的判定语句直接做成可执行探针打在代码上。**未发现新的不一致**,全部符合。取证覆盖:
|
||||
|
||||
| 维度 | 取证方式 | 结果 |
|
||||
| --- | --- | --- |
|
||||
| §5.1/§5.2 跟牌 | 层级 2/3/4(放着更长拖拉机不出→拒、拆对出散张→拒、留着对子只出散张→拒)、同花色不足全出+缺口任意、混合出牌牌面判 0、毙牌须完全缺门且牌型对应、首家出主必须跟主 | 15/15 符合 |
|
||||
| §8.1 算奖 | 三/四王、链从正 7 起、无正 7 链断、正 7-副 7-正 2-副 2-主 A-主 K 链 5、副 K 对(非主花色)不入链、六/七/八个 7 与 2、固定主 ≥10 不给奖 | 13/13 符合 |
|
||||
| §7 算子 | 常规与爬坡逐档抽查 18 格(含大光/小光/过庄/升 N 级、爬坡 45 独有档、10 分与 5 分无小光档) | 18/18 符合 |
|
||||
| §5.4 甩牌 | 单一牌型不算甩牌、分量被更大同长拖拉机压→甩错、对手仅持更大散张压不了对子、甩错只打最小一张、"持有即算被压" | 6/6 符合 |
|
||||
| §6.3 扣底 | 混合甩牌取最高规格倍数、末轮用副牌赢不扣底 | 符合 |
|
||||
| §8.4 结算 | **闲家也持奖时的三条独立支付线**(此前未覆盖):`N=[4,1,0]`→`aw=[28,-8,-20]`,与捡分子数叠加后零和 | 符合 |
|
||||
| §12 全流程 | 端到端跑通整局 + 18 局随机模糊(含甩牌轮),校验牌张守恒/捡分一致/结算零和/分配公式/扣底触发条件 | 无异常 |
|
||||
| §4/§9/§10/§11 | 叫分 6 种序列(含跳过已"不叫"者)、70 分暗牌下发与投降资格、重连各阶段门控(暗牌仅庄家、pushlist/seatlist/亮牌按 `nocheck`、当前轮桌面牌两种模式都有)、爬坡房 `multiple` 三处同源、提示只发对家、明牌返回另两家全部主牌、扣卡 4 组合 | 全部符合 |
|
||||
|
||||
固化产物:新增 `test/test_endgame.js`(L4 端到端,21 项,断言均与具体牌面无关、连跑 10 次无抖动),全套单测 **389 项**全绿。
|
||||
|
||||
---
|
||||
|
||||
## 第五轮核对(design 复验 + 首次以 packet_protocol.md 为核对对象)
|
||||
|
||||
> 本轮做两件事:① 再次独立复核 design.md ↔ 代码;② **首次把 `docs/protocol/packet_protocol.md` 本身当作核对对象**(前四轮只在改代码时顺手同步文档,从未反向验证「文档写的 = 客户端实际会收到的」)。
|
||||
>
|
||||
> **design 维度**:§1~§12 逐条重核,含自行重算 §7 边界档(爬坡 10 分/5 分无小光、70 分打牌、常规 40 分无过庄)与 §8.1 连对链(正 7 起链、副 7 跨花色不成对、非主花色断链)——**规则层未发现新的不一致**,前四轮整改均真实落地。
|
||||
>
|
||||
> **新发现 4 项**(1 项严重缺陷 + 2 项协议文档不准确/不完整 + 1 项健壮性),已全部处置。
|
||||
|
||||
| # | 严重度 | 位置 | 问题 | 处置 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| F1 | 🟥 严重 | `class.arith.js` `can_followcard` 尾段 | 计算 `cardvalue` / `noflower` / `nopair` 时用的是**入参原始数组 `followcards`**(客户端提交顺序),而不是上面已排好序的副本;`get_pairlist` / `get_tuolaji_list` 都按**降序相邻**取对。端到端实测:庄家出红心 KKQQ、闲家用黑桃 KKQQ 主拖拉机毙牌且为末轮——降序提交 `[51,105,50,104]` → `maxseat=1`、闲家捡分 40、扣底 ×4;打乱成 `[51,50,105,104]` → 对子漏判、`cardvalue=0`、`maxseat=0`(庄家赢)、捡分 0(大光)、不扣底。**同一手合法牌,客户端只靠数组顺序就能翻转本轮胜负、捡分归属、扣底与最终结算**;同源问题还能抹掉 `noflower` 缺门标志、污染 §9 下发给全场的牌况表。踩 server 红线「前端不是数据源」「服务器权威」 | 在 `min_ary_deduct` 削减 `_followcards` **之前**另存完整排序快照 `_sortfollow`,尾段 8 处全部改用它(`_followcards` 会被削减、不能复用);函数内注释固化"绝不能用入参 followcards"的原因。补 6 条回归(`test_follow.js` 入参顺序无关性 5 条 + `test_endgame.js` 端到端 1 条),已验证**回退修复后这 6 条全红** |
|
||||
| F2 | 🟧 中等 | `packet_protocol.md` §14 | 「解散结算」被写成 `route=erqiwang / rpc=jiesuan` 的独立包。实际平台(`server_room/rpc.js`、`server_room/class.room.js`)是把 `get_disbandRoom` 的**返回值整个对象**塞进**房间路由**的解散包:`route=room / rpc=free_room`,`data.deskfree = { rpc:"jiesuan", data:{ success, aset, account } }`——**多一层 `data` 包装**,真实路径是 `data.deskfree.data.aset`。前端照原文档写会取空。另外该包外层 `data.success` 由平台填写,子游戏的 `success` 在 `deskfree.data.success`,与 §0.1 的读法不同 | 新增 §14.1「解散结算的投递方式」:给出真实包结构 JSON、三条取值要点、两层 `success` 的归属,并说明 `deskfree` 可能缺失(见 F4);§14 开头标注原表头只适用于正常出牌结算与投降结算 |
|
||||
| F3 | 🟨 轻微 | `packet_protocol.md` 全篇 | 文档在 `multiple` / `bangwang` / `climb` / `baozhu` / `seatlist` / `liangpai` / `pushlist` 等多处引用「roomtype 位2 / 位3」,但**全文从未给出 roomtype 位串定义**——它只存在于本 compliance 文档里。前端据协议文档无法拼出建房串 | 新增 §0.5「`roomtype` 房间选项位串」:位表(位0局数/位1扣卡/位2傍王/位3爬坡/位4查牌)+ 缺省与示例 + 缺失兜底规则 + 局数/扣卡 4 组合对照,并指明 `class.config.js` `parse()` 是唯一解析入口(SSOT) |
|
||||
| F4 | 🟨 轻微 | `class.export.js` `get_disbandRoom` | 未像 `get_deskinfo` 那样判 `o_room.o_desk` / `curr_paiju()` 为空。平台在 `rpc.js` 开战处 `makewar` 后**立刻**置 `battlestate=1`,而本游戏首局是 `makewar` 里 `min_ontimeout(...,1000)` 延迟创建的——这段窗口内解散会在 `curr_paiju()` 的 `undefined` 上解引用抛异常,打断平台整条解散链路 | 两级空守卫,返回 `null`(平台对 `null` 的 `_deskfree` 已有「照常发解散包、不带 deskfree」分支);补 2 条回归用例 |
|
||||
|
||||
**本轮同时复核并确认无误(勿再重提)**:
|
||||
|
||||
- **协议文档字段面**:跑完整局(含 `mingpai`/`tishi`/失败包/投降局)与重连五个阶段,把**实际下发的 `data` 键集**与文档逐包对齐——`fapai / jiaofen / shangzhuang / xuanzhu / maipai / chupai1,2,3 / jiesuan / mingpai / tishi / zhunbei` 及 `deskinfo` 的 `CallRun / ChooseMain / BuryCards / PushCards / Balance`,**字段名、出现条件、按座位裁剪(暗牌仅庄家、`gradecards` 仅闲家、`liangpai` 仅闲家+可查牌、`cardsinhand` 仅出牌者)全部一致**;`errcode` 码表、§0.1 `success` 约定、§0.3「`countdown` 无定时器」说明亦准确。
|
||||
- **`can_followcard` 首家/跟家牌面值的比较基准不一致(首家取拖拉机最小对、跟家取最大牌)**:因同花色同长拖拉机不可能共用点数(两副牌已被首家占满),两段区间必然不重叠,比较结果恒正确,**属既有写法差异、非缺陷**。
|
||||
- **`get_chongguan` 的 `cards.length < 28` 早退**:属隐式兜底写法,但三个调用点的快照恒为 28(闲家)或 36(70 分投降的庄家),**当前不可触发**。
|
||||
|
||||
### 尚未处置
|
||||
|
||||
**无。** F1/F4 已改代码 + 补回归单测(并已用「回退修复→用例转红」反验有效性),F2/F3 已改协议文档。全套单测 **401 项**全绿。
|
||||
|
||||
### 第五轮·复验(design 维度的穷举/差分取证)
|
||||
|
||||
F1~F4 修复后,对 **design.md 维度**又做了一次独立取证复核。方法上比前几轮更进一步:不再逐条读代码,而是**按 design 原文另写一份参考实现**,再和代码**穷举对拍**——参考实现只依据 design 的文字与表格,不看 `class.arith.js`。**未发现任何不一致。**
|
||||
|
||||
| design 章节 | 取证方式 | 规模 / 结果 |
|
||||
| --- | --- | --- |
|
||||
| §2 牌局构成 · §6.1 分牌 | 直接清点 `init_cards` 产物 | 108 张牌表 / 去 3-4 共 16 张 / 发 92 张 = 三家 84 + 暗牌 8 / 11 种点数各 8 张 + 大小王各 2 张 / 分值 5-10-K / 全场总分 200 ✅ |
|
||||
| §3 主牌顺序 · 对子判定 | 取全部主牌排序后归并成「等级序列」,与 §3 表逐级比对 | 大王-小王-正7-副7-正2-副2-主A…主5 共 15 级完全一致;副牌 A…5 顺序一致;♥7+♣7 / ♥2+♣2 / 大王+小王 均不成对,同花色两副成对 ✅ |
|
||||
| §5.3 拖拉机相邻 | 15 级相邻链逐段验证 + 反例 | 链全通;7 不与 6/8 相连、6 与 8 同花色相连、♥8+♣6 跨花色不相连、副2 只接主A ✅ |
|
||||
| §5.1 / §5.2 跟牌强制层级 | **穷举差分**:按 §5.2 原文写参考裁定(缺门/不足/够牌 × 单张/对子/N 连对 + 最长拖拉机优先的档案比较),随机手牌枚举**全部** C 张出牌组合逐个对拍 | 4000 手牌 / **86474 个出牌组合,失配 0** ✅ |
|
||||
| §5.4.1-3、§5.4.5 甩牌 | **随机差分**:按 §5.4.2 原文写「对手是否持有能压过任一分量的主牌/主对/同长主拖」的参考判定 | **6000 例,失配 0**;副牌禁甩 4 种组合全拒、副牌单一牌型全放行 ✅<br>(首轮出现的 170 例「最小张不符」经查是**同一等级的两副牌选了不同那张**,非分歧——改按牌面等级比对即归零) |
|
||||
| §5.4.4 强制跟牌分量拆解 | 按 §5.4.4 五条逐条造正反用例 | 同长主拖必出 / 无同长退化为主对 / 主对不够全出+主单补 / 主牌不够全出+垫副 / 无主牌随意垫 / 多组不同长拖拉机逐组匹配,12 条全符合 ✅ |
|
||||
| §5.4.5 甩错惩罚落地 | 走 `do_playcard` 真实链路 | 甩错后 `shuaicuo=true`、实际只打出最小一张、其余收回手牌、本轮牌型退化为 101、不再带 `shuai_demand` ✅ |
|
||||
| §6.2 捡分归属 | 造庄赢/闲赢两轮 | 庄家赢 → 台面分牌作废(闲家捡分 0);闲家赢 → **台面全部分牌(含庄家自己打出的)** 计入闲家 ✅ |
|
||||
| §6.3 扣底倍数 | §6.3 表逐格 | 单张 1 / 主对 2 / 2-6 连对 4·6·8·10·12;混合甩牌取最高规格;末轮用副牌或混合牌赢 → 0 不扣底 ✅ |
|
||||
| §7 算子(全部) | **全量对拍**:把 §7.2.1 / §7.3.3 的判定表转写成参考实现,两种算子模式 × 14 个叫分档 × grade 0~260 逐格比 base / Q / 判定 / 最终子数 | **14672 格失配 0**,另抽查 design 表格里写死的最终子数 **113 格失配 0** ✅ |
|
||||
| §8.1 常规算奖 | 逐组合造手牌 | 三王 1 / 四王 3 / 六·七·八个 7 → 1·2·3 / 六·八个 2 → 1·3;连对链 正7→副7→正2→副2→主A→主K 逐级 2·3·4·5·6·7 奖;链必起于正7(无正7 断链)、缺副7 断于正7、副A 对(非主花色)不入链、无三王则无链奖、固定主 ≥10 不给奖 ✅ |
|
||||
| §8.2 亮牌 | 四档阈值边界(含刚好差 1) | 王≥3 / 7≥6 / 2≥6 / 固定主≥10 分别触发,各自差 1 即不触发;输出只含数量与结构、无具体牌面 ✅ |
|
||||
| §8 快照时间点 | 走真实埋牌/出牌/投降链路 | 庄家=埋牌后 28 张(已埋 8 张不在内、出牌后不缩水)、闲家=发牌后 28 张、投降局庄家 36 张且 `flower=-1` ✅ |
|
||||
| §8.4 算奖并入结算 | §8.4 举例数值 | `X=6, N=[1,2,0]` → `aw=[0,18,-18]`,三条支付线零和;只庄家持奖 → 另两家各付 X ✅ |
|
||||
| §4.2 叫分 | **穷举全部叫分序列**(DFS,叫分集 {70,50,25,10,5}+不叫) | **80 条终局路径**:受理判定、待叫者永不落在已「不叫」者身上、上庄者恒为最低叫分者、上庄条件恒为「叫 5」或「另两家都不叫」,**异常 0**;首家不能不叫、75/71/67/3/-5 全按 PARAM 拒 ✅ |
|
||||
| §4.3 暗牌可见性 · §4.4 投降 | 非 70 分 / 70 分两条链路 + 重连 | 非 70 分暗牌只给庄家(闲家无 `bottomcards`、无 `ancard3s`、无 `cards`);70 分三家都收到 8 张 + `ancard3s=1`;重连一律只给庄家(3 秒亮牌不重放);`touxiang` 仅 70 分为 1;投降 base=1、庄 -2 闲各 +1 ✅ |
|
||||
| §4.6 轮庄 | 三种 result | 庄赢连庄 / 闲赢顺延下家 / 投降顺延下家;首局暂定庄家为 0 号座位 ✅ |
|
||||
| §9 查牌模式 · §10 房间设置 · §11 提示 | 可查牌与不查牌两套房间跑同一段对局 | 可查牌下 `chupai.seatlist` 整表三家、`baozhu` 有值、重连带 `pushlist`/`seatlist`;不查牌下三者全无、`baozhu` 恒 0、明牌回 RULE;**当前轮桌面牌(`playproc`)两种模式都下发**;局数 6/12、房主 2/4、AA 1/2 与加入者扣卡 4 组合正确;提示只转发给对家、庄家发 → RULE、非法 tip → PARAM;不操作则 `step`/`playproc` 不变(无超时托管)✅ |
|
||||
|
||||
> 一句话结论:**就 design.md 而言,服务端实现完整且准确**。本轮两次「疑似失配」(甩错最小张 170 例、闲家赢轮捡分 15 vs 10)经查**都是探针写错**,代码是对的;已在上表注明,后续核对不要再把它们当缺陷重提。
|
||||
>
|
||||
> **已固化两组差分测试入库**(其余取证为一次性探针):`test/test_calc.js`(§7 全量对拍,7308 格 + design 表 141 格)与 `test/test_followdiff.js`(§5.2 穷举差分,约 11 万组),全套单测由 401 项增至 **462 项**,总耗时仍 < 1.5s。
|
||||
>
|
||||
> 两组都做过**变异检验**:关掉 `follow_tractor_cover_ok` → `test_followdiff` 三种拖拉机首出全红;大光倍率 3→4 / 常规 55 档 base 4→5 / 爬坡 40·35 档 Q 20→40 → `test_calc` 精确指出档位与 grade。
|
||||
>
|
||||
> ⚠️ 固化过程中的一个教训(改这两个文件时别踩回去):**差分测试的强度取决于造牌器,不取决于对拍逻辑**。第一版用纯随机抽牌,跑 7.8 万组全绿,但关掉 `follow_tractor_cover_ok` **依然全绿**——因为「同花色三组互不相邻的两连对」(12 张)这种唯一能区分"只出零散对子"与"先凑最长拖拉机"的结构,随机抽根本抽不出来(少于 3 组时任取 3 对必含相邻对,测不出差别)。补上确定性的 `structHands()` 后才转红。同理,掺入的其他花色一度把这手撑到 15 张、超过枚举上限被**静默跳过**,表面照常全绿。**"跑了很多组且全绿"不等于"测到了",新增差分测试必须做变异检验。**
|
||||
|
||||
---
|
||||
|
||||
## 第六轮核对(多局大局 + 整局带牌型模糊)
|
||||
|
||||
> 前五轮的端到端**都只跑单局、且只出单张**。本轮补两个从未被驱动过的维度:① `design §12.1 步骤10 → §12.2` 的**多局大局**(跨局轮庄、累计分、大局结算、战绩);② **带牌型的整局链路**(对子/拖拉机/甩牌/甩错/毙牌/扣底与 `do_playcard`→`mod.chupai`→结算的集成)。**未发现任何不一致。**
|
||||
|
||||
| 维度 | 取证方式 | 结果 |
|
||||
| --- | --- | --- |
|
||||
| §12.1 步骤10 · §4.6 跨局轮庄 | 真实驱动 6 局(RPC 全链路,含每局 `zhunbei` 开新局) | 每局 `firstseat` 与「上局 banker + result」严格对应;另单独构造出 `result=0` 的**庄赢局**验证**连庄**(此前只有 `do_prepare` 的桩单测,端到端从未走过)✅ |
|
||||
| §7 累计分 | 6 局 | 每局零和;`desk.seatlist[i][0]` == 逐局累加;`aset.seatlist[i].score` == 累计分 ✅ |
|
||||
| §12.2 大局结算 | 6 局房与 12 局房各跑满 | `account` **只在末局**出现(6 局房第 6 局、12 局房第 12 局);打满后再 `zhunbei` 回 RULE 拒 ✅ |
|
||||
| §10.1 扣房卡 | 6 局 | 全程只扣一次(第一局结算),`save_grade` 一次 ✅ |
|
||||
| 战绩 | `save_grade` 载荷 | `gameinfo1.asetcount`/`players.score` 与累计分一致;`gameinfo2` 每局 `seatlist`/`banker`/`call`/`flower`/`result` 齐全 ✅ |
|
||||
| §12.2 中途解散 | 打完 2 局后在第 3 局解散 | 解散局 `multiple/upgrade` 均 0、三家得分 `[0,0,0]`、`result=3`;`account` 累计分 == 解散前累计分("按当前累计分结算")✅ |
|
||||
| 牌局隔离 | 每局开新 paiju | 新局重新发满 92 张;上一局的 `tmp_jiesuan_aset` 已清理 ✅ |
|
||||
| §5/§6/§7/§8 整局带牌型 | 160 局 / 约 3800 墩(4 个种子 × 40 局),**每一墩**用独立参考实现重算胜者与服务端比对 | 多张首出、合法甩牌、甩错、毙牌墩、扣底均大量出现;牌张守恒 84/8/16、捡分守恒、结算零和、扣底触发条件、算奖分配公式**全部相符,0 异常** ✅ |
|
||||
|
||||
**本轮两次"疑似缺陷"经查都不是**(勿再重提):
|
||||
|
||||
- 某局结算 `[0,0,0]`:是 `jf=[4,-8,4]` 与 `aw=[-4,8,-4]` 恰好抵消,不是漏算。
|
||||
- 首轮 40 次尝试跑不出庄赢局:是**用例设计反了**——叫 5 分是闲家最容易达标的档(`grade ≥ 5` 即赢),应叫 70 分才易守。改后立刻出现。
|
||||
|
||||
**固化产物**:新增 `test/test_fuzz.js`(整局模糊 + 每墩胜者差分,20 局约 480 墩,8 项聚合断言,0.3s)。多局大局部分为一次性探针未入库(跑满 12 局较慢,且其不变量已由 `test_desk`/`test_endgame`/本轮探针共同覆盖)。全套单测 **511 → 519 项**全绿。
|
||||
|
||||
---
|
||||
|
||||
## 第七轮核对(反方向:代码 → design)
|
||||
|
||||
> 前六轮都是「design → 代码」方向:拿规则去找代码有没有实现。本轮换**反方向**——从代码出发,把服务端每一个规则决策与常量拉出来,逐个追问「design 里有没有明文依据」。找的是两类此前查不到的东西:**代码做了 design 没授权的事**,以及 **design 有歧义时代码替规则做的选择**。
|
||||
>
|
||||
> **结论:没有发现与 design 冲突的行为。** 但列出 6 项「design 未明文规定、由代码自行决定」的项——它们不是缺陷,但也从未被规则设计者确认过,建议逐条拍板后写回 design。
|
||||
|
||||
| # | 代码的决定 | design 依据 | 性质 |
|
||||
| --- | --- | --- | --- |
|
||||
| R1 | 四个阶段的倒计时秒数 **15 / 20 / 25 / 30**(`class.desk.js`) | §11 只说「四个阶段都会给出一个倒计时秒数」,**未规定具体值** | 实现选择,需确认 |
|
||||
| R2 | **§5.4.4 拖拉机分量的降级阶梯不要求「最长优先」**:甩牌里有 3 连对分量而闲家只有 2 连对时,代码允许出任意 3 对,不强制先凑出那个 2 连对 | §5.4.4 第 1 条明文写「没有同等连对数的拖拉机,退而求其次用**同等张数的主对子**顶替」——支持代码;但该节开头又说「沿用 §5.2 强制层级同一原则」,而 §5.2 层级 2 要求「能凑多长就必须先凑多长」。**两处措辞可作两种解读** | 歧义,代码按显式的第 1 条实现 |
|
||||
| R3 | 服务端把提交的一组主牌按「**连续对子最大化合并成拖拉机**」分解(`decompose_trump`) | §5.4.3 只说甩牌可自由混搭,**未规定服务端如何分解**。该选择同时影响最大性判定、跟牌分量需求、扣底倍数(合并对甩牌方更有利:`AAKKQQ` 记 3 连对 ×6 而非 2 连对 ×4) | 实现选择,需确认 |
|
||||
| R4 | 两个**同级**的副 7 对(♥7对 + ♣7对)**不构成连对** | §5.3 的链只列一次「副7」,同级即非相邻——代码按此。但 design 未直说 | 解读,合理 |
|
||||
| R5 | `call` 用 `parseInt` 宽松解析:`"65"`(字符串)与 `65.5`(小数截断为 65)都会被接受;而 `cards` 是严格校验(字符串数字/小数一律拒) | design 不管入参类型;协议 §0.2 只对 `cards` 定了严格约束。**两者标准不一致**,但截断后语义正确、不改变规则结果 | 一致性瑕疵,非规则违反 |
|
||||
| R6 | `get_chongguan` 对 `cards.length < 28` 静默返回 0 奖 | 隐式兜底(违反工程总则「显式失败优于隐式兜底」)。三个调用点的快照恒为 28 或 36,**当前不可触发** | 代码风格,非缺陷 |
|
||||
|
||||
### 本轮新增取证:下发面泄露审计
|
||||
|
||||
design §4(暗牌只有庄家可见 / 70 分亮 3 秒)、§9(查牌模式)、§11(结束亮底牌)与 server 红线「**发全 ≠ 发多,按可见性下发**」此前**没有任何测试**——各处门控是逐条手工核对的,从未系统验证过「有没有哪个包把不该看的牌送到了某个座位」。
|
||||
|
||||
本轮把它做成可执行审计(`test/test_leak.js`):跑完整局(可查牌 / 不查牌 × 叫 65 / 70 / 5 共 6 局),对每一个「服务器 → 某座位」的下发面(含 6 个 RPC 的逐座位包 + 各阶段重连快照 + 明牌应答)做深度扫描,取出其中出现的**所有牌 id**,逐个判定该座位此刻是否有权知道这张牌。
|
||||
|
||||
**结果:1814 个下发面 / 18706 次「座位×牌」可见性判定,0 泄露。**
|
||||
|
||||
变异检验(注入真实泄露确认审计有效):
|
||||
|
||||
| 注入的泄露 | 结果 |
|
||||
| --- | --- |
|
||||
| 非 70 分也把 8 张暗牌发给闲家(这正是第三轮修过的历史缺陷) | ✅ 抓住 |
|
||||
| 重连时把庄家埋的底牌发给闲家 | ✅ 抓住 |
|
||||
| 出牌包把出牌者剩余手牌发给所有人 | ✅ 抓住 |
|
||||
|
||||
---
|
||||
|
||||
## 第八轮核对(按 流程 / 牌型 / 玩法 / 算分 四个维度复核)
|
||||
|
||||
> 规则方复述了一条叫分规则:「开局庄家先叫分,不允许不叫分;第一局在没有庄家的情况下,默认座位号 0 玩家开始叫分」。据此按四个维度重新取证,**未发现不一致**。
|
||||
|
||||
**新增取证(此前从未做过):阶段机迁移矩阵。** 前几轮每个 handler 只测了 1~2 条代表性失败路径,从没系统验证过「某个请求在**别的**阶段会不会被误放行、会不会静默丢弃」。本轮把 8 个 RPC × 5 个阶段做成 **40 格矩阵**逐格驱动:
|
||||
|
||||
| | step1 叫分 | step2 选主/投降 | step3 埋牌 | step5 出牌 | step6 结算 |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| jiaofen | ✓ 受理 | × STEP | × STEP | × STEP | × STEP |
|
||||
| xuanzhu | × STEP | ✓ 受理 | × STEP | × STEP | × STEP |
|
||||
| touxiang | × STEP | ✓ 受理 | × STEP | × STEP | × STEP |
|
||||
| maipai | × STEP | × STEP | ✓ 受理 | × STEP | × STEP |
|
||||
| chupai | × STEP | × STEP | × STEP | ✓ 受理 | × STEP |
|
||||
| mingpai | × STEP | × STEP | × STEP | ✓ 受理 | × STEP |
|
||||
| tishi | × STEP | × STEP | × STEP | ✓ 受理 | × STEP |
|
||||
| zhunbei | × STEP | × STEP | × STEP | × STEP | ✓ 受理 |
|
||||
|
||||
**40 格全部符合**,无误放行、无静默丢弃。(`mingpai` 在 step5 还需「已有人报无主」这一前置,首轮矩阵曾把它标红——是期望表漏了前置条件,不是缺陷。)
|
||||
|
||||
**叫分起始者规则逐条取证**(`design §4.2 + §4.6`,与规则方复述一致):
|
||||
|
||||
| 规则 | 取证 | 结果 |
|
||||
| --- | --- | --- |
|
||||
| 第一局默认座位 0 开始叫分 | `makewar → do_new_paiju(0)` → `firstseat=0`、待叫者=0 | ✅ |
|
||||
| 之后每局由**暂定庄家**先叫 | 连打 6 局,每局 `firstseat` 与「上局 banker + result」逐局比对 | ✅ |
|
||||
| 庄赢 → **连庄**(起始仍是该庄家) | 随机对局几乎打不出庄赢,用「叫 70 分 + 首家出最大牌」专门构造出 `result=0` 局 | ✅ |
|
||||
| 庄输 / 投降 → 起始顺延到下家 | 6 局中逐局验证 | ✅ |
|
||||
| **首家不允许"不叫"** | 第一局与其后每一局(含连庄局)的首家发 `call=0`,一律回 `RULE` 且叫分过程不变 | ✅ |
|
||||
|
||||
四个维度的现有取证汇总:
|
||||
|
||||
| 维度 | 取证手段 | 规模 |
|
||||
| --- | --- | --- |
|
||||
| **流程** | 阶段机 40 格矩阵、叫分序列 DFS 80 条终局、多局大局(6 局/12 局)、轮庄含连庄、中途解散、端到端整局 | 本轮新增矩阵 |
|
||||
| **牌型** | §3 等级序列逐级、§5.3 相邻链(副牌同花色 / 主牌不限花色 / 同档位不相邻)、§5.2 跟牌穷举差分 11 万组 | `test_arith`/`test_followdiff` |
|
||||
| **玩法** | §5.4 甩牌最大性 6000 例差分、甩错惩罚、§5.4.4 分量拆解、整局带牌型模糊每墩胜者差分、§9 查牌门控、下发面泄露审计 1.8 万次判定 | `test_fuzz`/`test_leak` |
|
||||
| **算分** | §7 算子全量对拍 7308 格 + design 表 141 格、§6.3 扣底、§8.1 算奖、§8.4 分配公式、结算零和 | `test_calc`/`test_paiju` |
|
||||
|
||||
**固化产物**:新增 `test/test_flow.js`(7 项聚合断言,0.3s)。变异检验:去掉 `chupai`/`maipai` 的阶段校验、把 `jiaofen` 的阶段校验改成静默丢弃——三条全部转红。全套单测 **540 → 547 项**全绿。
|
||||
|
||||
---
|
||||
|
||||
## 结论摘要
|
||||
|
||||
> **注**:下表是**首轮核对**的初始发现快照(保留以记录起点)。其中 §4.4、§5.4、§6.3、§7、§8.1-8.4、§9、§10、§11 等标 🟥/🟧/🟨 的项**后续均已修复并验证**——当前逐节状态以本文档后面的分节详情及全套单测(270 项全绿)为准。
|
||||
|
||||
| 设计章节 | 主题 | 结论(首轮快照,非当前状态) |
|
||||
| --- | --- | --- |
|
||||
| §2 | 牌局构成(92 张、去 3/4) | ✅ 符合 |
|
||||
| §3 | 主牌顺序(牌编码) | ✅ 符合(副 7/副 2 跨花色成对存疑,见下) |
|
||||
| §4.1-3 | 发牌 / 叫分 / 埋牌流程 | 🟧 缺叫分上限、投降资格过宽 |
|
||||
| §4.4 | 70 分投降 / 打牌分支 | 🟥 投降资格 65 分即可、暗牌亮 3 秒未实现、投降结算数值错 |
|
||||
| §4.6 | 坐庄轮换 | ✅ 符合 |
|
||||
| §5.1-5.3 | 跟牌 / 毙牌 / 垫牌 / 拖拉机 | ✅ 基本符合(1 处运行期崩溃缺陷) |
|
||||
| §5.4 | 甩牌 | 🟥 合法性判定模型、副牌禁甩、强制跟牌拆解、甩错惩罚均不符 |
|
||||
| §6.1-6.2 | 分牌 / 捡分 | ✅ 符合 |
|
||||
| §6.3 | 扣底倍数 | 🟥 倍数表完全不同 |
|
||||
| §7 | 算子(基础子数 / 大光小光过庄 / 升级 / 爬坡) | 🟥 系统性不符,且无爬坡开关 |
|
||||
| §8.1 | 常规算奖(只庄家 + 连对链) | 🟥 三家都算、含额外作废规则、连对链因传参失效、≥10 老主误给奖、庄家快照含底/埋牌牌 |
|
||||
| §8.2 | 亮牌 | 🟧 未实现 |
|
||||
| §8.3 | 傍王 | 🟥 无开关(恒开)、含设计外作废、且未乘 X |
|
||||
| §8.4 | 算奖并入结算 | 🟥 扁平奖数(每奖 1 子),未按 `X × N` |
|
||||
| §9 | 查牌 / 不查牌模式 | 🟧 无模式开关、明牌功能无服务端支持 |
|
||||
| §10.1 | 扣卡 / 局数 | 🟥 局数 2/4(应 6/12)、房主扣卡数不符 |
|
||||
| §10.2-10.3 | 查牌 / 傍王 / 爬坡选项 | 🟥 均无开关 |
|
||||
| §11 | 交互提示 | 🟨 亮底牌 ✅;甩牌见 §5.4;闲家 3 提示无服务端支持 |
|
||||
|
||||
---
|
||||
|
||||
## §2 牌局构成 — ✅ 符合
|
||||
|
||||
`class.paiju.js` `init_cards`:两副牌,点数 3、4 置 `dealowner=-1` 且不发(`if (k != 3 && k != 4) do_dealpai`);`tmpdeal` 共 `28×3 + 8 = 92` 个槽位。发出 92 张(11 种点数 ×4 ×2 + 4 王),去掉 16 张 3/4,与设计一致。分值 `5→5、10→10、K(13)→10` 亦一致(§6.1)。
|
||||
|
||||
---
|
||||
|
||||
## §3 主牌顺序 — ✅ 符合(1 处存疑)
|
||||
|
||||
`arith.id_to_code` 以 `flower*100+number` 为底,`A(+13)`、`2(+2000)`、`7(+7000)`、主花色 `(+1000)`、王固定 `9553/9554`,得到 code 降序即设计主牌顺序:
|
||||
|
||||
大王 9554 > 小王 9553 > 正 7(8xxx)> 副 7(7xxx)> 正 2(3xxx)> 副 2(2xxx)> 主花色普通牌 A…5(1xxx)> 副牌 A…5(<1000)。
|
||||
|
||||
`is_continuous` 正确实现全部相邻段(含 `小王↔正7`、`副2↔主A`、`8↔6`,以及「7 不与 6/8 连」——7 恒为主、不在普通链内)。
|
||||
|
||||
**存疑(待规则确认)**:不同花色的副 7(code 7107/7207/7307)、副 2(2107/2207/2307)编码互不相等。因此:
|
||||
|
||||
- 排序上三家副 7 被强行分出大小;比大小时 `can_followcard` 又把落在 7xxx/2xxx 段的牌**归一为 7000/2000**(视作同级),二者存在不对称,但因跟牌只能跟同花色、毙牌只能用更大主牌,实测不产生错误赢家。
|
||||
- 成对判定 `get_pairlist` 要求 `code 相等`,故「副 7 对 / 副 2 对」只认**同花色两张**,跨花色的两张副 7 不算一对。设计 §3 把副 7 列为同一等级,但未明确「两张不同花色副 7 是否成对」——**需与规则设计者确认**。若规则要求跨花色副牌可成对,则此处需改。
|
||||
|
||||
---
|
||||
|
||||
## §4 开局与坐庄
|
||||
|
||||
### §4.1 发牌(每人 28 + 8 暗牌)— ✅ 符合
|
||||
|
||||
### §4.2 叫分 — 🟧 缺上限校验
|
||||
|
||||
- ✅ 步进 5:`mod.jiaofen` 校验 `call % 5 != 0` 拒绝。
|
||||
- ✅ 暂定庄家必须叫:首家 `currcall==0 && call==0` 被拒。
|
||||
- ✅ 后叫更低:`curr_call != 0 && call >= curr_call` 被拒。
|
||||
- ✅ 叫 5 立即上庄、两家不叫则上庄:`paiju.do_callgrade` 实现。
|
||||
- 🟧 **缺 `call <= 70` 上限**:`mod.jiaofen` 未限制上界,首家可叫 75、100 等(设计 §4 范围 5~70)。
|
||||
|
||||
### §4.3 埋牌 — ✅ 符合(选主/埋牌顺序已确认解决)
|
||||
|
||||
庄家得暗牌(`get_seat_cards` 对庄家含 `dealowner==0`),`mod.maipai` 校验埋 8 张且都在庄家手上,`do_burycard` 置 `playround=0` 后由庄家开首轮。
|
||||
|
||||
✅ **顺序问题已解决**:复查时曾发现 design §4 列举顺序是「埋牌→选主」、而代码是「选主(step2)→埋牌(step3)」,二者相反。经与规则设计者确认,正确顺序为**先选主、后埋牌**(庄家知道主副后才好决定埋哪 8 张)——即**代码本就正确**,是 design §4 的列举笔误。已修正 design §4 的步骤顺序,并新增 design §12「完整牌局游玩流程」明确整局先后。代码无需改动。
|
||||
|
||||
### §4.4 70 分投降 / 打牌 — 🟥 多处不符
|
||||
|
||||
- 🟥 **投降资格过宽**:设计仅 **70 分**可投降;代码 `mod.touxiang` 判 `o_paiju.call < 65` 才拒绝,即 **65 分也能投降**。应改为「仅 `call == 70` 允许」。同样地 `shangzhuang.touxiang`、`get_deskinfo` 各阶段的 `touxiang` 字段都用 `call < 65` 判定,需一并改。
|
||||
- 🟥 **投降结算数值错**:设计投降为「基础子数 1、庄家每闲家输 1 子」。代码 `get_paiju_account(type=1)`:`upgrade=-99→-2`、`multiple=get_multiple_bycall(70)=1`,庄家 `grade_jf = -2 × 1 × 2 = -4`(每闲家 2 子)。金额翻倍且模型不对。
|
||||
- 🟨 **暗牌亮 3 秒未实现**:设计要求 70 分坐庄把 8 张暗牌向两闲家亮 3 秒(投降 / 打牌都要),代码无此下发。
|
||||
|
||||
### §4.6 坐庄轮换 — ✅ 符合
|
||||
|
||||
`class.export.js` `makewar` → `do_new_paiju(0)`:首局暂定庄家为 0 号座位(设计 §4.6)。`class.desk.js` `do_prepare`:`result==1(闲赢)||result==2(投降)` 时下一局 `firstseat=(banker+1)%3`(下家),否则连庄。两点均与设计一致。
|
||||
|
||||
---
|
||||
|
||||
## §5 出牌规则
|
||||
|
||||
### §5.1 / §5.2 跟牌 / 毙牌 / 垫牌 — ✅ 基本符合(含 1 处崩溃缺陷)
|
||||
|
||||
- 每轮由上轮最大者先出、每局首轮庄家先出:`new_playround(maxseat)` / `do_burycard→new_playround(1, banker)`。✅
|
||||
- 跟同花色、缺门可毙(主牌压过)或垫(任意副牌不争):`arith.get_followcard`/`can_followcard` 按「同花色是否够 / 有无对子 / 有无拖拉机」给必出牌与可出牌,毙牌能否赢由 `cardvalue`(副 7/副 2 归一、跨花色垫牌记 0、主牌对/拖拉机才 >0)裁定。✅ 与 §5.2「毙牌必须用对应牌型」一致。
|
||||
- 🐞 **`can_followcard` 引用未定义变量 `tuolaji_list` 会抛异常**(`class.arith.js` 约 801 行,`get.cantype` 为拖拉机型 3xx 分支内 `if (tuolaji_list.length == 0)`,该变量在此作用域未赋值)。当跟牌方手中存在**多个**符合长度的候选拖拉机(`get_followcard` 返回 `cantype=startcardtype`)时命中此分支,`DoPack` 抛错、该次出牌链中断。需修复。
|
||||
|
||||
### §5.3 拖拉机定义 — ✅ 符合(见 §3 的 `is_continuous`)
|
||||
|
||||
### §5.4 甩牌 — 🟥 原合法性 / 禁令 / 跟牌 / 惩罚多处不符(🟩 第 1/2/4 点已整改,见「整改进度」)
|
||||
|
||||
> **整改状态**:下述第 1(副牌禁甩)、第 2(最大性按全手牌判定)、第 3(强制跟牌分量优先级,`flush_follow_ok`)、第 4(甩错惩罚)**均已实现并验证**。以下保留原始不一致描述留痕。
|
||||
|
||||
1. 🟥 **副牌禁甩未落实**:设计 §5.4.1「所有副牌完全禁止甩牌,无论外面是否剩余该花色副牌」。代码 `can_playcard` 对副牌甩牌只要求「其他两家没有主牌 **且** 没有该副花色(`other_noflower`)」即**放行**(约 415-419、427-438 行),等于「对手缺门时允许甩副牌」,与绝对禁令冲突。
|
||||
2. 🟥 **甩牌合法性判定模型不符**:设计 §5.4.2 要求服务端**按全部手牌**判断「对手是否持有能压过甩牌任一分量的更大主牌 / 主对 / 更长主拖拉机」。代码改用出牌过程累积的 `seatlist[i][flower-1][0/1]`、`[4]` 等**报副标志**(`other_noflower`/`other_noflowerpair`),语义是「对手是否已被记录为该花色 / 主牌 / 对子出空」,既非「按全部手牌」也非「更大」而是「有没有」,与设计判定完全不同。
|
||||
3. 🟥 **强制跟牌拆解未按分量**:设计 §5.4.4 要求闲家把甩牌拆成各分量(拖拉机优先跟拖拉机、对子优先跟对子…)逐个匹配。代码把甩牌压成单一 `cardtype`(如 `102 两张单张甩`、`203 三对甩`),`get_followcard` 对甩牌型只要求「同花色、张数相等」,不校验对子 / 拖拉机结构(如多对甩分支 `cantype=100+startcount` 只按单张计数)。
|
||||
4. 🟥 **甩错惩罚未实现**:设计 §5.4.5 规定甩错要「收回甩牌、本轮只强制打出最小一张、失去甩牌资格」。代码把非法甩牌当作无效输入(`can_playcard` 返回 `result=false`→`mod.chupai` 直接 `return`),无任何惩罚流程。
|
||||
5. ✅ **报无主**:`do_playcard` 出牌后自动检测主牌出空并置 `seatlist[..][4][0]=0`,属自动判定(设计允许由服务端精确计算)。
|
||||
|
||||
---
|
||||
|
||||
## §6 捡分与扣底
|
||||
|
||||
### §6.1 分牌 / §6.2 捡分 — ✅ 符合
|
||||
|
||||
`do_playcard` 仅在 `maxseat != banker` 时累计本轮分;`get_jian_grade` 汇总 `playowner != banker` 的分牌。庄家赢的轮分牌作废、不计入。与设计一致。
|
||||
|
||||
### §6.3 扣底 — 🟥 倍数表完全不同
|
||||
|
||||
- ✅ 触发条件「闲家用主牌赢下最后一轮」:`get_bottom_account` 仅在 `maxseat != banker` 时调 `get_bottom_multiple`,后者首末张非主(code<1000)即返回 0(不扣底),与「非主牌赢末轮不算扣底」一致。
|
||||
- 🟥 **倍数不符**:
|
||||
|
||||
| 闲家扣底牌型 | 设计倍数 | 代码 `get_bottom_multiple` |
|
||||
| --- | --- | --- |
|
||||
| 单张主 | 1 | 2 |
|
||||
| 主对子 | 2 | 4 |
|
||||
| 两连对 | 4 | 8 |
|
||||
| 三连对 | 6 | 16 |
|
||||
| 四连对 | 8 | 32 |
|
||||
| 五连对 | 10 | 64 |
|
||||
| 六连对 | 12 | `undefined`(🐞 落空返回未定义) |
|
||||
|
||||
代码为近似 `2^k`、且单张即翻倍;设计为线性 `2N`(单张不翻倍)。
|
||||
|
||||
---
|
||||
|
||||
## §7 结算:子数与升级 — 🟥 系统性不符
|
||||
|
||||
设计的四层模型(§7.0):`基础子数(由叫分定) × 判定倍率(大光×3/小光×2/过庄×1/升N级×N) = X`(每「庄–闲」对子的基础金额)。常规算子分界 `Q` 固定 40、升级级距固定 40;勾选爬坡才改用 §7.3 的梯度子数与分段 `Q`。
|
||||
|
||||
代码实际实现(`get_paiju_account` + `get_multiple_bycall` + `get_upgrade`):
|
||||
|
||||
- `multiple = get_multiple_bycall(call)`:`call>60→1`、`>40→2`、`>0→4`、`≤0→0`。
|
||||
- `upgrade = get_upgrade(call, grade)`:大光→4、小光→2、过庄→1、升级→`-(floor((grade-call)/halfcall)+1)`;分界与级距用 `halfcall = ceil(call/2)`。
|
||||
- 每对子金额 = `multiple × upgrade`,庄家收两家(×2)、闲家各付(×-1)。
|
||||
|
||||
**问题**:
|
||||
|
||||
1. 🟥 **基础子数不符**:代码 `multiple∈{1,2,4}` 与设计常规算子 `{65:2, 60:3, 55:4, 50↓:6}` 数值、档位都不同。
|
||||
2. 🟥 **大光倍率错**:代码大光 `=4`,设计大光 `×3`。(小光 ×2、过庄 ×1 与设计一致。)
|
||||
3. 🟥 **小光 / 过庄分界与升级级距错**:代码用 `halfcall=ceil(call/2)`;设计常规算子固定 40。例如叫 65:代码分界 35(35~64 记过庄),设计分界 40(40~64 才过庄,35~39 仍是小光)。
|
||||
4. 🟥 **无爬坡开关**:设计 §7.3 是「勾选爬坡才生效」的可选梯度;代码无任何房间选项分支,只有一套公式。巧合的是 `halfcall` 对低档(40/35→20、30/25→15、20/15→10、10/5→5)恰等于爬坡的 `Q`,但对高档(65→35≠40、60→30≠40、50→25≠40)不等,且基础子数 `{1,2,4}` 与常规 `{2,3,4,6}`、爬坡 `{2,3,4,6,7,8,…}` 都不符——即代码既不是常规算子、也不是爬坡,而是第三套数值。
|
||||
|
||||
**常规算子每对子最终子数对照(代码 vs 设计)**:
|
||||
|
||||
| 叫分 | 判定 | 设计 | 代码 |
|
||||
| --- | --- | --- | --- |
|
||||
| 65 | 大光 / 小光 / 过庄 / 升1 / 升2 | 6 / 4 / 2 / 2 / 4 | 4 / 2 / 1 / 1 / 2 |
|
||||
| 60 | 同上 | 9 / 6 / 3 / 3 / 6 | 8 / 4 / 2 / 2 / 4 |
|
||||
| 55 | 同上 | 12 / 8 / 4 / 4 / 8 | 8 / 4 / 2 / 2 / 4 |
|
||||
| 50 | 同上 | 18 / 12 / 6 / 6 / 12 | 8 / 4 / 2 / 2 / 4 |
|
||||
| 40 | 大光 / 小光 /(无过庄)/ 升1 / 升2 | 18 / 12 / — / 6 / 12 | 16 / 8 /(误记过庄 4)/ 4 / 8 |
|
||||
|
||||
(叫 40 一行还暴露分界问题:设计 `call≤40` 无过庄档,代码却在 grade 20~39 记出「过庄」。代码升级子数 = `multiple(=4) × 级数`,故升 1 级 4 子、升 2 级 8 子。)
|
||||
|
||||
> **70 分打牌**同样落在本套公式里且同样不符:代码 `multiple = get_multiple_bycall(70) = 1`(设计打牌基础子数应为 2),`halfcall = 35`(设计 `Q = 40`);大光 4×1=4、小光 2、过庄 1,而设计应为大光 6、小光 4、过庄 2。
|
||||
|
||||
---
|
||||
|
||||
## §8 算奖规则 — 🟥 归属、开关、数值均不符
|
||||
|
||||
### §8.1 常规算奖(只庄家) — 🟥
|
||||
|
||||
- ✅ 基础组合计数正确:`get_chongguan` 中 `3 王→1、4 王→3`、`6/7/8 个 7→1/2/3`、`6/7/8 个 2→1/2/3`。
|
||||
- 🟥 **只应计庄家,代码却算三家**:`get_paiju_account` 对 0/1/2 号位都调 `get_chongguan` 并两两结算(`grade_cg = cg×2 − 另两家`)。设计 §8.1「常规算奖只认庄家身份,不看闲家手牌」。
|
||||
- 🟥 **设计外的作废规则**:`do_obsolete_with_call` 引入「冲关必叫」「四王必踢(`call>60`)」,设计中不存在。
|
||||
- 🟥 **连对链算奖失效(传参 bug)**:设计 §8.1「三 / 四王在有正 7 及以后连续对子时每对 +1 奖」。但 `get_chongguan` 在正常 / 投降结算被以 `mainflower = -1` 调用(`get_paiju_account` 中 `if (type != 2) _flower = -1;`)。此时 `id_to_code` 不加 `+1000`,正 7 与副 7 都落到 7xxx、主花色普通对子不被识别为主,`is_continuous(小王, 正7)` 因 `is_zheng7` 只认 8xxx 而返回假——连对链一对也加不上。即该奖项**实质未生效**。
|
||||
- 🟥 **≥10 老主误给奖**:`if (_total >= 10) re.count += _total - 9`(「10 个老主判断」)。设计 §8.2 明示固定主牌 ≥10 只亮牌、**不算奖**。
|
||||
- 🟥 **庄家算奖手牌快照错(含底牌与已埋牌)**:设计 §8「算奖依据静态初始手牌——庄家是**埋牌完成后的那 28 张**,闲家是发牌后的 28 张」。代码 `get_paiju_account` 用 `get_seat_cards_owner(seat)` 取牌喂给 `get_chongguan`,而该函数按 `dealowner ∈ {seat+1, 0}` 取牌、**不排除 `playround==0`(已埋)**:庄家因此拿到「28 张发牌 + 8 张底牌 = 36 张」,把**已埋进底牌的 8 张也计入算奖**,闲家则是正确的 28 张。庄家的常规算奖与傍王都因此可能虚高(例如把埋掉的王/7/2 仍算进去)。应改为庄家取「埋牌后保留的 28 张」(排除 `playround==0`)。
|
||||
|
||||
### §8.2 亮牌 — 🟧 未实现
|
||||
|
||||
设计 §8.2 的四档亮牌门槛(固定主 ≥10 / 王 ≥3 / 7 ≥6 / 2 ≥6,向闲家亮统计信息,受「不查牌」模式抑制)在服务端无对应计算与下发。
|
||||
|
||||
### §8.3 傍王(可选) — 🟥
|
||||
|
||||
- 🟥 **无开关(恒开)**:`grade_w`(傍王得分)在 `get_paiju_account` 中无条件计算。设计 §8.3 / §10.3 傍王需勾选才生效。
|
||||
- 🟥 **含设计外作废**:`grade_w` 复用 `obsolete` 置 0 逻辑;设计傍王「每张王算一奖、庄闲都算」,无作废概念。
|
||||
|
||||
### §8.4 算奖并入结算 — 🟥 未乘 X
|
||||
|
||||
- 设计 §8.4:某玩家 `N` 奖,则另外两人各额外付 `X × N`(`X` 为 §7 第三层的每对子子数)。
|
||||
- 代码:`grade_cg`、`grade_w` 是**扁平奖数两两差**(`cg×2 − 另两家`),每奖等价 1 子,**未乘 `X`**。举例设计(X=6、庄家 1 奖)应从每闲家收 6 子;代码只收 1 子/家。
|
||||
- ✅ 结构上「所有两两之间(含闲–闲)都结算」这一点与设计一致,仅倍率错。
|
||||
|
||||
---
|
||||
|
||||
## §9 牌局查看模式 — 🟧
|
||||
|
||||
- 报无主后,服务端确有记录他家剩余主牌数 / 对子数(`seatlist[..][4]` 与花色标志),并通过 `chupai*`/`PushCards` 的 `info`、`seatlist`、`baozhu` 下发,供客户端展示——方向正确。
|
||||
- 🟧 **无「可查牌 / 不查牌」模式开关**:`baozhu` 恒发、`seatlist` 恒带,未按房间设置抑制(设计 §9 / §10.2 的不查牌模式应屏蔽这些信息)。
|
||||
- 🟧 **「明牌」(查看他家具体主牌)无服务端支持**:服务端不会向闲家下发他家的具体主牌牌面。
|
||||
|
||||
---
|
||||
|
||||
## §10 房间设置选项 — 🟥
|
||||
|
||||
### §10.1 扣卡方式 / 局数 — ✅ 已整改(下述为原始不一致留痕)
|
||||
|
||||
> **现状(已改)**:`get_asetcount` 按位串返回 6/12;`get_needroomcard` 房主 6局2张/12局4张、AA 6局1张/12局2张,均符合 §10.1。以下为原始(数组下标版)不一致记录。
|
||||
|
||||
- 🟥(原始)**局数不符**:旧 `export.get_asetcount` 返回 `roomtype[0]==1→2`、`==2→4`。设计 §10.1 为 **6 局 / 12 局**。
|
||||
- 🟥(原始)**房主扣卡数不符**:旧 `get_needroomcard` 房主档返回 1/2;设计房主扣卡为 6 局 2 张 / 12 局 4 张。
|
||||
|
||||
### §10.2 查牌模式 — 🟥 无开关(见 §9)
|
||||
|
||||
### §10.3 附加规则(傍王 / 爬坡,可同时勾选)— 🟥 均无开关(见 §7.4、§8.3)
|
||||
|
||||
---
|
||||
|
||||
## §11 牌局交互提示 — 🟩
|
||||
|
||||
- ✅ 牌局结束亮底牌:`get_bottom_account` 结算恒带 `bottom.cards`。
|
||||
- ✅ 选主对子数 / 70 分投降按钮门控:随 `shangzhuang`/`xuanzhu` 阶段包下发(见 §4)。
|
||||
- ✅ 甩牌相关提示:见 §5.4(甩牌实现已核对一致)。
|
||||
- ✅ **闲家 3 提示(踩 / 没分 / 有分)**:`mod.tishi`(design §11)——闲家在出牌阶段(step5)发 踩/没分/有分,服务端不校验真实性、只 `SendPack` 转发给对家(另一闲家 `3-banker-seat`);庄家不参与。协议见 packet_protocol §13.7/13.8,测试见 `test_rpc`。
|
||||
|
||||
---
|
||||
|
||||
## 附录:运行期代码缺陷汇总(与规则合规相对独立,但都需修)
|
||||
|
||||
| # | 位置 | 现象 | 影响 |
|
||||
| --- | --- | --- | --- |
|
||||
| D1 | `class.arith.js` `can_followcard`(约 801 行) | 引用未赋值的 `tuolaji_list.length` | 跟牌方有多个候选拖拉机时抛异常、出牌中断 🐞 |
|
||||
| D2 | `class.arith.js` `get_bottom_multiple`(六连对分支) | 六连对时无返回值 | 返回 `undefined`,扣底倍数计算异常 🐞 |
|
||||
| D3 | `class.arith.js` `can_playcard`(约 276 行) | `do_returnfalse;` 漏写 `()`,语句空转 | 「一次最多 14 张」上限失效,超量出牌被当作合法 🐞 |
|
||||
| D4 | `class.paiju.js` `do_choiceflower`(约 417-421 行) | `for` 循环体为空、取出 `o_card` 后无操作 | 死代码(主牌重编码实际在 `id_to_code` 动态完成),无害但应清理 🟨 |
|
||||
|
||||
> D1~D3 均为真实缺陷;虽多数属「代码写错」而非「规则理解错」,但会导致对应规则(跟拖拉机校验、扣底、出牌张数上限)无法正确执行,需按缺陷修复处理。
|
||||
|
||||
---
|
||||
|
||||
## 总体判断
|
||||
|
||||
- **流程骨架**(发牌、叫分推进、选主埋牌、逐轮出牌收束、断线重连快照、战绩存储)实现完整且大体正确。
|
||||
- **牌型与大小体系**(编码、排序、相邻、跟牌 / 毙牌 / 垫牌的必出可出计算)设计良好、基本符合规则,主要缺陷是 D1 崩溃与甩牌子系统。
|
||||
- **不符合集中在「算分 / 算奖 / 房间选项」三块**:§6.3 扣底倍数、§7 算子全套、§8 算奖归属与并入方式、§10 局数 / 扣卡 / 各类开关——这几处需按 `design.md` 重做,是后续整改的重点。
|
||||
- 结合数值特征(`multiple` 只有 1/2/4、局数 2/4、扣底 2^k、含「冲关必叫 / 四王必踢」等设计外机制),**服务端很可能是在早期或另一套规则版本上实现的,与当前 `design.md` 已系统性脱节**,而非个别笔误。
|
||||
@@ -0,0 +1,236 @@
|
||||
# 二七王服务端 · 合规测试计划
|
||||
|
||||
> 目标:用 Node 单测/集成测**系统覆盖 `design.md` 的全部服务端可验证规则**,做到"规则 → 用例"可追溯,作为"代码是否符合手册"的**自动化证据**(与 `01-design合规逐节核对.md` 的人工审计互补)。
|
||||
>
|
||||
> 运行:`node server/games/erqiwang/test/run.js`。测试原理与双运行时见 `test/README.md`。测试纪律(正/反/边界、不软化断言、失败先裁根因)见 `docs/server/development-guide/04` §10。
|
||||
|
||||
---
|
||||
|
||||
## 0. 分层与测试类型
|
||||
|
||||
| 层 | 含义 | 依赖 | 现状 |
|
||||
| --- | --- | --- | --- |
|
||||
| **L1 单元** | 纯函数直接调用(arith/config/paiju 静态计算) | 仅 `_shim` 的平台工具 | 已有 `test_arith`/`test_config`/`test_paiju`(部分) |
|
||||
| **L2 局内集成** | `cls_youle_erqiwang_paiju.new(o_desk,firstseat)` 造牌局,驱动 `do_callgrade→do_choiceflower→do_burycard→do_playcard…→get_paiju_account` 验证状态/结算 | mock `o_desk`/`o_room` + shim;**不经 mod.js** | 仅 `get_paiju_account` 造了极简对象,**无完整一局驱动** |
|
||||
| **L3 RPC/广播** | `mod.js` 收包入口 → 校验 → 广播 | mock `check_player`/`SendPack`/`sendpack_toother`,捕获下发包 | **完全未覆盖** |
|
||||
|
||||
**发牌随机性处理**:`do_dealpai` 用 `min_random`。L2 需要**可控发牌**——测试 `_shim` 提供可注入的 `min_random`(预置序列或指定发牌结果),使一局可复现。这是 L2 的前置基建。
|
||||
|
||||
---
|
||||
|
||||
## 1. 覆盖矩阵(按 design 章节)
|
||||
|
||||
状态:✅ 已覆盖 · 🟡 部分 · ❌ 待补。用例列标注 正/反/边界 三类(`docs/server/development-guide/04` §10 要求三类齐全)。
|
||||
|
||||
### §2 牌局构成
|
||||
| 规则 | 被测 | 用例(正/反/边界) | 层 | 状态 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| 92 张、去两副 3/4 | `init_cards`/`do_dealpai` | 正:发完 3 家各 28 + 底 8 = 92;反:牌堆不含 number 3/4;边界:两副各花色计数 | L2 | ❌ |
|
||||
| 分值 5→5、10→10、K→10、余 0 | `init_cards` | 正:各面值 score;边界:非分牌 score=0 | L1/L2 | 🟡(扣底/结算间接) |
|
||||
|
||||
### §3 主牌顺序 / 编码
|
||||
| 规则 | 被测 | 用例 | 层 | 状态 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| 主牌 code 降序=设计顺序(大王>小王>正7>副7>正2>副2>主A…主5>副牌) | `id_to_code`/`order_cards` | 正:排序结果逐位;边界:正/副 7、正/副 2 的相对大小;主A vs 副2 | L1 | ❌(间接用到,无专测) |
|
||||
| 相邻链 + 6/8 可连、7 不与 6/8 连 | `is_continuous` | 正:全相邻段逐对;反:7-6/8-7/9-7 不连;边界:8-6 连、大王-小王连 | L1 | 🟡(甩牌/算奖间接) |
|
||||
| 副7/副2 跨花色比大小归一 | `trump_rank`/`can_followcard` | 正:副7 vs 副7 不可压;反:正7 压副7 | L1 | 🟡(甩牌间接) |
|
||||
|
||||
### §4 开局与坐庄
|
||||
| 规则 | 被测 | 用例 | 层 | 状态 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| 叫分 5~70、步进5、暂定庄必叫、后叫更低/不叫 | `mod.jiaofen` 校验 + `do_callgrade` | 正:合法叫分推进;反:>70/非5倍/首家0/叫≥当前;边界:叫70、叫5 | L3(校验)+L1(do_callgrade) | ❌ |
|
||||
| 叫5立即上庄、两家不叫上庄 | `do_callgrade` | 正:叫5→banker;正:一家叫另两家不叫→banker;边界:三家叫分序列 | L1 | ❌ |
|
||||
| 坐庄轮换:庄赢连庄、庄输/投降下家、首局庄=0号 | `desk.do_prepare`/`makewar` | 正:result=0 连庄;正:result=1/2 顺延;边界:首局 firstseat=0 | L2/L3 | ❌ |
|
||||
| 阶段机 1→2→3→5→6,无 step4 | `do_up_banker`/`do_choiceflower`/`do_burycard` | 正:各 handler 后 step 值;反:错误 step 调用被拒 | L2/L3 | ❌ |
|
||||
| 投降仅70分/step2/庄家/不埋牌 | `mod.touxiang` | 正:70分 step2 投降→结算;反:≠70/step≠2/非庄;边界:投降后 step=6 | L3 | ❌ |
|
||||
| 70分暗牌向所有玩家亮3秒(+ancard3s)、非70只发庄 | `mod.jiaofen`(shangzhuang) | 正:70→闲家有 bottomcards+ancard3s;反:非70 闲家无 bottomcards | L3 | ❌ |
|
||||
| 选主后先选主后埋牌 | `do_choiceflower`/`do_burycard` | 正:flower 记录、step 推进;边界:埋牌须8张且在手 | L2 | 🟡(check_cards_inhand 未测) |
|
||||
|
||||
### §5 出牌规则
|
||||
| 规则 | 被测 | 用例 | 层 | 状态 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| **§5.1/5.2 跟牌/毙牌/垫牌/混合出牌/拖拉机覆盖度** | `get_followcard`/`can_followcard` | 正:同花色跟、有对必出对、有拖必出拖、两对不连必出、尽量长拖优先;反:缺牌型被拒、放长拖只出散对被拒;毙牌(须完全缺门):副单→主单、副对→主对、副N连→主N连;垫牌/混合出牌:cardvalue=0 不争权 | L1 | ✅(`test_follow` 50 例,含 else 欠约束修复回归) |
|
||||
| §5.3 拖拉机相邻 | `is_continuous`/`get_tuolaji_list` | 见 §3 | L1 | 🟡 |
|
||||
| §5.4 甩牌:副禁甩/最大性/甩错 | `can_playcard`/`opp_can_beat_flush`/`decompose_trump` | 已覆盖(合法/甩错含smallest/副禁甩/单张/对子/拖拉机) | L1 | ✅ |
|
||||
| §5.4.4 跟甩牌强制分量拆解 | `flush_follow_ok` | 已覆盖(有对必打对/有拖必打拖/退化/主牌最大化/无主全垫) | L1 | ✅ |
|
||||
|
||||
### §6 捡分与扣底
|
||||
| 规则 | 被测 | 用例 | 层 | 状态 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| §6.1 分值 | `init_cards` | 见 §2 | L1 | 🟡 |
|
||||
| §6.2 闲家赢归闲、庄赢作废、两闲谁赢都算闲 | `do_playcard`/`get_jian_grade` | 正:闲赢累计分;反:庄赢不计;边界:两闲各赢 | L2 | 🟡(1 集成例) |
|
||||
| §6.3 扣底倍数 单1/对2/N连2N、全主牌守卫、取最高规格 | `get_bottom_multiple` | 已覆盖 | L1 | ✅ |
|
||||
| §6.3 触发:闲家用主牌赢末轮才扣底 | `get_bottom_account` | 正:闲家主牌赢末轮翻倍;反:非主牌赢不扣、庄家赢不扣 | L2 | ❌ |
|
||||
|
||||
### §7 结算:子数与升级
|
||||
| 规则 | 被测 | 用例 | 层 | 状态 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| 常规算子 基础子数 + 大光×3/小光×2/过庄×1/升N级、Q=40 | `get_base_bycall`/`get_qvalue`/`get_upgrade` | 已覆盖各档代表值 | L1 | ✅(可补每档过庄/小光分界与升3级) |
|
||||
| 爬坡 基础子数梯度 + 分段Q | 同上(climb=true) | 已覆盖各档 | L1 | ✅ |
|
||||
| X=基础×倍率、庄赢/闲赢符号、按房间爬坡切换 | `get_paiju_account` | 正:大光/过庄/升级/爬坡局 seatlist;边界:banker=-1 | L2 | 🟡(仅65小光+投降) |
|
||||
|
||||
### §8 算奖
|
||||
| 规则 | 被测 | 用例 | 层 | 状态 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| §8.1 常规算奖只庄家、三/四王+连对链、6-8个7/2、无≥10老主/无作废 | `get_chongguan` | 已覆盖 | L1 | ✅ |
|
||||
| §8.2 亮牌阈值/统计、只亮结构 | `get_liangpai` | 已覆盖 4 例 | L1 | 🟡(补 王≥3/7≥6 组合、边界9/10) |
|
||||
| §8.3 傍王按位开关、庄闲每王1奖 | `get_paiju_account`(bangwang) | 正:傍王局 grade_aw 含王奖;反:未勾选不计 | L2 | ❌ |
|
||||
| §8.4 算奖并入 X×(2Ni−Nj−Nk)、含闲-闲、投降X=1 | `get_paiju_account` | 正:庄1奖/闲傍王2奖的三家净额;边界:三家同时有奖 | L2 | ❌(公式单测有,集成未测) |
|
||||
| §8 快照:庄埋后28/闲发后28;投降庄36无主 | `get_seat_cards_award` | 正:庄排除已埋;正:投降含底36;边界:无主花色无连对链 | L2 | 🟡(投降 count 间接) |
|
||||
|
||||
### §9 查牌 / 明牌
|
||||
| 规则 | 被测 | 用例 | 层 | 状态 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| 报无主统计 info/baozhu 门控 | `mod.chupai` | 正:可查牌下发 info/baozhu;反:不查牌 info 缺失、baozhu=0 | L3 | ❌ |
|
||||
| 亮牌/PushCards.seatlist 门控 | `mod.maipai`/`get_deskinfo` | 正:可查牌闲家有 liangpai;反:不查牌无 | L3 | ❌ |
|
||||
| 明牌 mingpai:可查牌+已报无主+出牌阶段 | `mod.mingpai` | 正:条件满足返回他家主牌;反:不查牌/未报无主/非出牌阶段被拒 | L3 | ❌ |
|
||||
|
||||
### §10 房间设置
|
||||
| 规则 | 被测 | 用例 | 层 | 状态 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| 位串解析、局数6/12、扣卡房主2·4/AA1·2 | `config.parse`/`export.get_asetcount`/`get_needroomcard` | 已覆盖(config 复刻映射) | L1 | ✅(可改为直接调 export 函数) |
|
||||
| 傍王/爬坡/查牌三开关生效 | `get_paiju_account`/`mod.*` | 傍王/爬坡见 §7/§8;查牌见 §9 | L2/L3 | 🟡 |
|
||||
|
||||
### §12 完整流程
|
||||
| 规则 | 被测 | 用例 | 层 | 状态 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| 一局端到端:发牌→叫分坐庄→选主→埋牌→逐轮出牌→末轮扣底→结算→轮庄 | 全链路 | 正:一局可控发牌打到结算,各家总分正确;含连庄/下家轮转 | L2 | ❌ |
|
||||
|
||||
> §11 牌局交互提示中,选主对子数/亮底牌等随对应阶段包下发;闲家三提示(踩/没分/有分)为服务端转发项(`mod.tishi`,见 P1)。
|
||||
|
||||
---
|
||||
|
||||
## 2. 待补用例清单(按优先级)
|
||||
|
||||
### P0 · 规则/金额关键 —— ✅ 已完成
|
||||
1. ✅ **§5.1/5.2 正常跟牌/毙牌/垫牌/混合出牌/拖拉机覆盖度**(`can_followcard`,`test_follow.js` 50 例)——单张/对子/拖拉机的必出、毙牌数量对应(副单→主单/副对→主对/副N连→主N连、且毙牌须完全缺门)、垫牌与"混合出牌"(有该花色不够+补主牌→`cardvalue=0` 不争权)、缺门/同花色不足/对子不够各分支、多候选拖拉机(D1 崩溃回归)、**降级递归 else 欠约束回归**(三组等长 2 连对时出孤立对→拒),正反边界齐全。
|
||||
2. ✅ **§7/§8 结算集成扩充**(`get_paiju_account`,`test_paiju.js`)——大光/过庄/升2级、爬坡局、傍王局(庄3王,N=4)、不傍王对照、投降,逐档验证 seatlist 各家 grade。
|
||||
3. ✅ **§4.2 叫分坐庄**(`do_callgrade`,`test_callgrade.js` 6 例)——叫5立即上庄、两家不叫上庄、后叫更低→最低者上庄、不同首家。
|
||||
4. ✅ **§6.3 扣底触发**(`get_bottom_account`,`test_paiju.js`)——闲家主对/两连对赢末轮才扣底(×2/×4);副牌赢、庄家赢均不扣。
|
||||
|
||||
> 注:§4.2 中 `mod.jiaofen` 的**入参校验**(>70/非5倍/首家必叫/后叫更低)属 L3,随投降/暗牌/查牌门控一起在 P1 补。
|
||||
|
||||
### P1 · 规则关键、需 RPC 脚手架 —— ✅ 已完成(`_rpc.js` + `test_rpc.js` / `test_desk.js`)
|
||||
5. ✅ **§9 查牌门控 + mingpai**——chupai 的 info/baozhu 按 `nocheck` 门控(可查有/不查无);mingpai 合法下发他家主牌,不查牌/未报无主/非出牌阶段均拒。
|
||||
6. ✅ **§4 投降 + 暗牌亮牌**——touxiang 仅 70分/step2/庄家;真实 jiaofen 驱动到 70分上庄,验证 shangzhuang 闲家有 bottomcards+ancard3s、叫5上庄闲家无。
|
||||
7. ✅ **§4.2 `mod.jiaofen` 入参校验**——>70/非5倍/首家必叫/后叫更低 全部拒,合法接受。
|
||||
8. ✅ **§4.6 坐庄轮换 `do_prepare`**——庄赢连庄、闲赢/投降下家。
|
||||
|
||||
### P2 · 补强既有 —— ✅ 已完成
|
||||
9. ✅ §2 牌局构成专测(`test_deal`)、§3 编码/排序专测(`test_arith`:order_cards + is_continuous 链)。
|
||||
10. ✅ §9 `mod.maipai` 亮牌门控 / `get_deskinfo` PushCards 门控(`test_rpc` 端到端驱动后验证)。
|
||||
11. ✅ §8.2 亮牌更多阈值(6个7/固定主9-10边界)、§7 算子每档分界+升3级(`test_arith`)。
|
||||
12. ✅ §12 端到端(`test_rpc` 真实驱动到出牌就绪)。
|
||||
13. ✅ §10 直接调 `export.get_asetcount`/`get_needroomcard`/`get_needroomcard_joinroom`(`test_config`)。
|
||||
14. ✅ **§11 闲家三提示 `mod.tishi`**——正:闲家 step5 发 踩/没分/有分,只转发给对家(`3-banker-seat`,验证 fromid/seat/tip);反:庄家发/非出牌阶段/非法 tip/缺 tip 均拒(`test_rpc` 11 例)。
|
||||
|
||||
---
|
||||
|
||||
## 3. 集成脚手架需求(实现 L2/L3 的前置)
|
||||
|
||||
- **L2 局内驱动器 `test/_harness.js`**:
|
||||
- 可注入发牌的 `min_random`(或直接构造 `o_paiju.cards` 指定各家手牌),使牌局可复现。
|
||||
- mock `o_desk`(seatlist 累积、`get_desk_account`、`o_room.roomtype/asetcount`)与 `o_room`。
|
||||
- 提供"驱动一局到某阶段"的辅助:叫分序列 → 选主 → 埋牌 → 出牌序列。
|
||||
- **L3 RPC 捕获器**:mock `youle_erqiwang.import.check_player`(返回构造的 `o_room`)、`o_room.method.sendpack_toother`/`youle_erqiwang.app.SendPack`(把下发包收集进数组),断言包字段(含 §9 门控、暗牌、投降、chupai1/2/3、jiesuan)。
|
||||
|
||||
---
|
||||
|
||||
## 4. 完成定义(DoD)—— ✅ 已达成
|
||||
|
||||
- ✅ 覆盖矩阵中所有 ❌/🟡 项补到 ✅(详见 §5 现状),每条规则至少含正/反/边界;
|
||||
- `node test/run.js` 全绿、退出码 0;
|
||||
- 新增用例遵守测试纪律:不改正式代码去迁就测试、不软化断言、失败先用证据裁根因;
|
||||
- 本计划与 `01-design合规逐节核对.md` 的结论一致(自动化测试成为人工审计的可回归证据)。
|
||||
|
||||
---
|
||||
|
||||
## 5. 当前状态小结 —— ✅ DoD 达成,P0/P1/P2 全部完成
|
||||
|
||||
**共 511 项断言全绿**(`test_arith` 122 / `test_calc` 49 / `test_callgrade` 6 / `test_config` 19 / `test_deal` 12 / `test_desk` 6 / `test_endgame` 25 / `test_follow` 58 / `test_followdiff` 12 / `test_input` 34 / `test_paiju` 50 / `test_rpc` 89 / `test_success` 29)。其中 `test_calc`/`test_followdiff` 见 §7、G1~G9 见 §8。
|
||||
|
||||
design 全部服务端可验证章节均有正/反/边界单测:
|
||||
- **§2 构成**:`test_deal` 92张/去3-4/三家28+底8/分值。
|
||||
- **§3 编码/排序**:`test_arith` order_cards 降序=设计顺序、is_continuous 相邻链逐段+反例。
|
||||
- **§4**:叫分坐庄 do_callgrade、`mod.jiaofen` 入参校验、投降 touxiang 条件、70分上庄暗牌亮牌下发、坐庄轮换 do_prepare。
|
||||
- **§5**:**正常跟牌全面覆盖(§5.1/5.2)**——按跟牌方手牌各分支穷举:主牌首出与副牌首出、单张/对子/拖拉机各牌型、有同花必出对应牌型、同花不足则出仅有的+补、缺门毙牌(数量对应)与垫牌(不压)、拖拉机的对子不够/同花无对/**多候选拖拉机(D1崩溃回归)**/3连对/富余对子必出拖、手牌数==首出张数全部必出;**甩牌全面覆盖(§5.4)**——合法(纯单/纯对/单+拖/单+对+拖三混)、甩错(单张/主对/拖拉机分量各自被压、双对之一被压)、副牌禁甩(对手缺门与非缺门)、甩错惩罚**实际执行**(do_playcard 只打最小一张、其余留手);**跟甩牌全面覆盖(§5.4.4)**——对子必出、拖拉机必出/退化对子/退化到单张、多组拖拉机、混合demand逐分量对位、主牌最大化、无主全垫,以及"首出甩牌→跟牌违规→do_playcard 拒"的集成。
|
||||
- **§6**:捡分(结算集成间接)、扣底倍数+触发。
|
||||
- **§7**:算子常规+爬坡逐档、每档分界+升3级、结算集成(大光/过庄/升级/爬坡/傍王/投降)。
|
||||
- **§8**:算奖 get_chongguan、亮牌 get_liangpai(含 6个7/固定主9-10边界)、算奖并入 X×N(傍王对照)。
|
||||
- **§9**:查牌门控(chupai info/baozhu、maipai liangpai、deskinfo PushCards seatlist)、明牌 mingpai。
|
||||
- **§10**:位串解析、局数/扣卡(直接调 export 接口)。
|
||||
- **§12**:端到端(叫分→上庄→选主→埋牌→出牌ready,真实 RPC 驱动)。
|
||||
|
||||
> 说明:§12 覆盖到"出牌准备就绪"(各阶段串接正确);完整 28 墩逐墩对打需一个合法出牌选择器,属后续增强,非规则合规缺口(逐墩规则已由 §5/§6 单测覆盖)。
|
||||
|
||||
---
|
||||
|
||||
## 6. 第三轮补充:design 之外的可验证维度
|
||||
|
||||
> 前两轮的覆盖矩阵完全按 design 章节组织,因此**平台接入、客户端入参、下发面一致性、协议红线**这几类根本不在矩阵里——270 项全绿却漏掉了 1 项阻断级与 3 项严重缺陷(见 `01-design合规逐节核对.md`「第三轮核对」)。本节把这些维度补进测试计划,后续复核必须一并跑。
|
||||
|
||||
| 维度 | 被测 | 用例(正/反/边界) | 文件 | 状态 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| 客户端入参合法性 | `check_cards_valid` / `check_cards_inhand` | 正:单张/多张/边界 id 0 与 107;反:非数组(undefined/null/字符串/伪数组)、空数组、重复 id、越界、负数、小数、数字字符串;反:不在手上/已出/已埋 | `test_input` | ✅ 21 |
|
||||
| 重复 id 不得伪造牌型 | `mod.maipai` / `mod.chupai` | 反:8 个重复 id 埋牌被拒且一张未埋;反:重复 id 出牌被拒;正:8 张不同牌埋牌成功且实埋 8 张;反:越界/非数组入参不抛异常且不产生出牌 | `test_input` | ✅ 7 |
|
||||
| §9 报无主即时刷新 | `do_playcard` | 正:打空瞬间三家统计与主花色标志同时刷新;边界:报无主前三家均为 `[-1,-1]` | `test_input` | ✅ 6 |
|
||||
| 红线 `data.success`(成功包) | `class.desk`/`mod.js`/`get_paiju_account` | 正:fapai/zhunbei/jiaofen/shangzhuang/xuanzhu/maipai/chupai1/mingpai/tishi 均带 `success:true`;正:jiesuan 的正常/投降/解散三种 type 都带 | `test_success` | ✅ 12 |
|
||||
| 红线 `data.success`(失败回包) | 各 handler 失败分支 | 反:每个 handler 取代表性失败路径,断言"恰好回 1 包 + `success:false` + errcode 正确 + 回到请求者 fromid",覆盖 STEP/SEAT/PARAM/RULE/PLAYER 五类 | `test_success` | ✅ 17 |
|
||||
| §7.3 下发面 multiple 与结算同源 | `mod.jiaofen` / `get_deskinfo` / `get_paiju_account` | 正:叫 45 分在常规房/爬坡房分别得 6/7;正:上庄包与重连 ChooseMain 一致;边界:与结算包 `aset.multiple` 逐一对齐 | `test_rpc` | ✅ 7 |
|
||||
| §5.4 甩牌下发面 | `chupai1` | 正:合法甩牌带 `shuai` 分量;反:非甩牌不带;反:甩错只打最小一张且不带 `shuai` | `test_rpc` | ✅ 8 |
|
||||
| 重连 pushlist 结构 | `get_deskinfo` | 正:轮数正确、内层恒 3 个座位、无 undefined;正:按本局主牌花色排序(主5 排在副A 前) | `test_rpc` | ✅ 4 |
|
||||
| §9 出牌历史按查牌模式门控 | `get_deskinfo` | 反:不查牌房无 `pushlist`;正:可查牌房有 `pushlist`;边界:两种模式下当前轮桌面牌 `playproc.cards` 都必须恢复 | `test_rpc` | ✅ 4 |
|
||||
| §3 相邻链花色 | `is_continuous` | 正:同花色 8-6 连;反:三组跨花色 8-6 不连;反:差 2 但非 8/6 不连 | `test_arith` | ✅ 5 |
|
||||
|
||||
**关于超时**:`countdown` 只作展示、服务端不做任何超时动作,这是 design §11 确认的规则(并非缺口),因此**没有也不需要「超时自动操作」的用例**;反过来,若将来有人给服务端加了超时代打,那才是违反 design §11——现已由 `test_rpc` 的**守卫用例**拦下(见 §8 G9)。
|
||||
|
||||
---
|
||||
|
||||
## 7. 第五轮:按 design 原文另写参考实现的差分测试
|
||||
|
||||
> 前六节的用例都是**手写的场景枚举**——覆盖的是"我想到的情况"。第五轮补两组**差分测试**:把 design 的规则整条转写成参考实现,与代码大面积对拍,覆盖手写用例想不到的组合。
|
||||
|
||||
| 文件 | 覆盖 | 规模 |
|
||||
| --- | --- | --- |
|
||||
| `test_calc.js` | §7 算子全量对拍:参考实现逐字转写自 §7.1/§7.2/§7.3 判定表,比 base / Q / 判定倍率 / 最终子数 | 2 模式 × 14 档 × grade 0~260 = **7308 格**,另按 design 的 17 张表逐格抽查 **141 个写死的最终子数** |
|
||||
| `test_followdiff.js` | §5.2 跟牌强制层级穷举差分:参考裁定按原文写,对每手牌**枚举全部 C 张出牌组合**逐个对拍 | 约 **11 万组** |
|
||||
|
||||
**差分测试的强度取决于造牌器,不取决于对拍逻辑。** 第一版 `test_followdiff` 用纯随机抽牌跑 7.8 万组全绿,但把 `follow_tractor_cover_ok` 整个关掉**依然全绿**——因为唯一能区分"只出零散对子"与"先凑最长拖拉机"的形状是「同花色三组互不相邻的两连对」(12 张),随机抽根本抽不出来。故:
|
||||
|
||||
- 随机源必须是**固定种子 PRNG**(不用 `Math.random`),保证用例集可复现;
|
||||
- 关键结构必须用**确定性手牌**钉死(`structHands()`),不能指望随机覆盖到;
|
||||
- 注意别让掺入的其他花色把手牌撑过枚举上限而被**静默跳过**(踩过一次)。
|
||||
|
||||
---
|
||||
|
||||
## 8. 第五轮:逐规则的正/反/边界补全
|
||||
|
||||
> 对 design §1~§12 重新做了一次"每条规则是否三类用例齐全"的审计。覆盖面本来就广,但仍有 9 处 design 明文规则缺用例(多是**规则本身**而非入参校验,前几轮的矩阵按"被测函数"组织,容易漏掉这类"跨函数才体现"的规则)。
|
||||
|
||||
| # | design | 规则 | 补的用例(正/反/边界) | 文件 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| G1 | §4.2 | 暂定庄家必须叫、其后才可"更低或不叫" | 边界:下限 5 接受;正:非首家 `call=0` 接受;反:负数 / 缺 `call`(NaN) / 步进外小值 1 / 与当前叫分相同 均拒 | `test_rpc` |
|
||||
| G2 | §4.2 | **"不叫"即退出本局叫分,轮次跳过他** | 正:首家叫 65 后轮到 seat1;正:seat1 不叫后跳到 seat2;反:seat1 反悔再叫 → SEAT 拒、`callproc` 不变、仍轮到 seat2、未产生庄家 | `test_rpc` |
|
||||
| G3 | §4.5 | 埋牌仅庄家、仅埋牌阶段、恰 8 张 | 反:非庄家 → SEAT;反:step2/step5 埋 → STEP;边界:7 张 / 9 张 / 空数组 → PARAM | `test_rpc` |
|
||||
| G4 | §5.1 | **每轮由上一轮牌面最大的一方先出** | 正:闲 1 赢 → 下一轮 `start/currseat` 都是 1;边界:闲 2 赢 → 改由闲 2 先出 | `test_paiju` |
|
||||
| G5 | §6.2 | 闲家赢 → 台面**全部**分牌计入;庄家赢 → 分牌作废 | 反:庄赢 → 下发面无 `grade`、闲家捡分 0;正:闲 1 赢 → 15 分(**含庄家自己打出的 ♥5**);边界:闲 2 赢结果与闲 1 赢一致("两个闲家谁大不影响") | `test_paiju` |
|
||||
| G6 | §8 | 算奖快照的时间点与静态性 | 正:庄家 = 埋牌后 28 张、已埋 8 张不在内;正:闲家 = 发牌后 28 张;边界:投降局庄家 = 发牌+暗牌 36 张;边界:之后打出 10 张,快照仍是同样的 28 张 | `test_paiju` |
|
||||
| G7 | §3 | **对子只认同花色同点数的两副** | 正:同花色两副 ♥7/♥2/大王成对;反:♥7+♣7、♥2+♣2、正7+副7、正2+副2、大王+小王 均不成对;边界:四张跨花色 7/2 组不出拖拉机,而正7对+副7对可以 | `test_arith` |
|
||||
| G8 | §6.3 | 赢末轮的牌不全是主牌就不扣底 | 反:副牌两连对赢 → 0;反:混合出牌(主K+副K)→ 0;边界:主副提交顺序颠倒仍判 0 | `test_arith` |
|
||||
| G9 | §11 | **无超时托管**(守卫) | 守卫:叫分→选主→埋牌→出牌全程 `min_ontimeout` 调用次数为 0。将来若有人加"超时自动出牌/代打",必然要注册定时器,这条即转红 | `test_rpc` |
|
||||
|
||||
**每条都做了变异检验**(在副本上注入违反该规则的改动,确认对应断言转红):
|
||||
|
||||
| 注入的缺陷 | 转红的断言 |
|
||||
| --- | --- |
|
||||
| 去掉 `mod.jiaofen` 的"非当前叫分位"校验 | G2 三条 |
|
||||
| 埋牌张数 `!= 8` 放宽为 `< 1` | G3 两条边界 |
|
||||
| §6.2 改成庄家赢也累计闲家捡分 | G5 一条 |
|
||||
| 下一轮改为固定庄家先出 | G4 两条 |
|
||||
| 算奖快照不排除已埋的牌 | G6 两条 + 亮牌快照一条 |
|
||||
| `get_pairlist` 放宽为"只看点数" | G7 五条 |
|
||||
| 在 `mod.chupai` 里注册一个定时器 | G9 一条 |
|
||||
|
||||
> 过程中的一个教训:M2(埋牌张数)第一次跑出来是**整个文件崩溃**而不是干净的 FAIL——因为 `mkBury` 桩缺 `do_burycard`,校验一被改松就走到成功路径抛异常,后面的断言全不执行。**桩要完整到能走完成功路径**,否则"校验被改松"这类变异只会表现为崩溃,定位与信号都变差。已修。
|
||||
|
||||
> 原列在此处的两项「待外部输入」均已由规则设计者拍板:「不查牌模式是否屏蔽出牌历史」确认屏蔽,已改 design §9 + 代码 + 用例(见上表);「超时托管默认动作」确认维持现状、不做超时动作,已写入 design §11。至此测试计划无待外部输入项。
|
||||
@@ -0,0 +1,788 @@
|
||||
# 二七王 玩法设计文档
|
||||
|
||||
> 本文档是二七王玩法的唯一权威规则说明。内容随讨论持续与规则设计者逐项确认、修订;文中标注"待确认"的地方是当前仍未有定论的开放问题,其余内容均已确认生效。
|
||||
|
||||
## 目录
|
||||
|
||||
1. [术语约定](#1-术语约定)
|
||||
2. [牌局构成](#2-牌局构成)
|
||||
3. [主牌顺序](#3-主牌顺序)
|
||||
4. [开局与坐庄流程](#4-开局与坐庄流程)
|
||||
5. [出牌规则](#5-出牌规则)
|
||||
- [5.1 跟牌基本原则](#51-跟牌基本原则)
|
||||
- [5.2 按牌型跟牌](#52-按牌型跟牌)
|
||||
- [5.3 拖拉机定义与相邻关系](#53-拖拉机定义与相邻关系)
|
||||
- [5.4 甩牌详细规则](#54-甩牌详细规则)
|
||||
6. [捡分与扣底](#6-捡分与扣底)
|
||||
- [6.1 分牌](#61-分牌)
|
||||
- [6.2 捡分规则](#62-捡分规则)
|
||||
- [6.3 扣底](#63-扣底)
|
||||
7. [结算:子数与升级](#7-结算子数与升级)
|
||||
- [7.0 四层关系总览 / 查表索引](#70-四层关系总览)
|
||||
- [7.1 常规算子:叫分 → 基础子数](#71-常规算子叫分-→-基础子数未勾选爬坡时的默认规则)
|
||||
- [7.2 常规算子的判定表](#72-捡分-vs-叫分庄家的过庄-小光-大光闲家的升级常规算子)
|
||||
- [7.3 爬坡](#73-爬坡叫分-→-基础子数与保小光分段可选规则开房时勾选爬坡生效)
|
||||
8. [算奖规则](#8-算奖规则)
|
||||
- [8.1 冲关](#81-冲关)
|
||||
- [8.2 亮牌规则](#82-亮牌规则)
|
||||
- [8.3 傍王(可选规则)](#83-傍王可选规则)
|
||||
- [8.4 算奖如何影响最终结算](#84-算奖如何影响最终结算)
|
||||
9. [牌局查看模式](#9-牌局查看模式)
|
||||
10. [房间设置选项](#10-房间设置选项)
|
||||
11. [牌局交互提示](#11-牌局交互提示)
|
||||
12. [完整牌局游玩流程](#12-完整牌局游玩流程)
|
||||
|
||||
---
|
||||
|
||||
## 1. 术语约定
|
||||
|
||||
| 术语 | 含义 |
|
||||
| --- | --- |
|
||||
| 主牌 / 副牌 | 庄家选定的花色为"主牌",其余三个花色为"副牌";主牌大于副牌 |
|
||||
| 固定主牌 | 不论选择哪个花色为主,大王、小王、以及所有花色的"2""7"恒为主牌 |
|
||||
| 正2 / 正7 | 选定花色的"2""7",大于其余花色的"2""7"(口语里也叫"主2""主7",本文档统一用"正2""正7")|
|
||||
| 副2 / 副7 | 非选定花色的"2""7" |
|
||||
| 底牌 | 发牌时**没有发给玩家**、扣在桌面的 8 张牌;庄家确认后可摸起查看,摸起后就不再扣在桌面,而是并入庄家手牌 |
|
||||
| 埋牌底牌 | 庄家"埋牌"时从手牌中选出重新扣下的 8 张牌;"底牌"与"埋牌底牌"是两批不同的牌,不通用、不互换 |
|
||||
| 埋牌 | 庄家看到底牌、摸起查看后,从手牌中选出 8 张重新扣下,作为"埋牌底牌" |
|
||||
| 捡分 | 出牌中赢下的分牌(5、10、K)计入闲家总得分的过程 |
|
||||
| 扣底 | 出完所有手牌后的最后一轮,若被闲家用主牌牌型压过庄家,则埋下的 8 张埋牌底牌也计入闲家捡分,并按牌型翻倍 |
|
||||
| 亮牌 | 庄家埋牌后、出牌前,若手中固定主牌达到门槛(总数 ≥10 / 王 ≥3 / 7 ≥6 / 2 ≥6 任一),向两个闲家**亮出自己全部固定主牌的具体牌面**(见第 8.2 节)。不限叫分,仅可查牌模式 |
|
||||
| 余主公示 | 任一玩家主牌出空(报无主)后,为**全体三人**展示另外两家各自的**剩余主牌数与剩余主对数**(见第 9 节)。只给数量,不给具体牌面 |
|
||||
| 明牌 | 可查牌模式下,查看**另外两家**手中全部未出主牌的具体牌面(见第 9 节)。与"亮牌"同样给牌面,区别在:亮牌是**庄家公开自己的**,明牌是**去看别人的** |
|
||||
| 摸底 | 庄家坐定后把那 8 张**底牌**摸起查看并并入手牌(见第 4 节)。**非 70 分坐庄时只有庄家看得到这 8 张** |
|
||||
| 开底 | **70 分坐庄**时,在庄家摸底**之前**,先把这 8 张底牌向**所有玩家**翻开 3 秒,之后才由庄家摸入手牌(见第 4 节)。给的是具体牌面 |
|
||||
| 查底牌 | 对局中随时回看那 8 张底牌的功能(前端底栏按钮)。庄家全程可用;闲家仅在**开底**过的局(即 70 分坐庄)可用 |
|
||||
| 拖拉机 | 同一花色(含主牌)里点数相邻的连续对子,如 K K Q Q 为"两托"(两连对) |
|
||||
| 甩牌 | 首家出牌时,一次性打出多组主牌组合(单张 / 对子 / 拖拉机自由搭配);仅主牌可以甩牌,副牌禁止甩牌,只能分单张 / 对子 / 拖拉机依次打出(见第 5.4 节) |
|
||||
| 毙牌 | 跟牌时手中没有本轮花色(缺门),改用主牌打出、且牌面大过本轮目前所有人的牌(见第 5.1 / 5.2 节) |
|
||||
| 垫牌 | 跟牌时手中没有本轮花色(缺门)、且不用主牌毙牌,随意出一张其他副牌,不参与、不争夺本轮的出牌权(见第 5.1 节) |
|
||||
| 混合出牌 | 首家出副牌,你手中有该花色副牌但张数不够凑齐首家总张数,又没有(或不用)别的副牌补差额,只能用主牌补足缺口——打出的是"该花色副牌 + 主牌"的混合牌。这既不是垫牌(不是缺门、不是纯副牌),也不是毙牌(不是纯主牌牌型、压不过首家),服务端判定其牌面为 0,**与垫牌等效:不参与、不争夺本轮出牌权**(见第 5.1 / 5.2 节) |
|
||||
| 子 | 结算时用于计算输赢筹码的单位,与"倍数"是两个独立概念(见第 7 节) |
|
||||
| 冲关 | 局末对固定组合(三王/四王、六 2/七 2/八 2、六 7/七 7/八 7、连续对子链等)的额外奖励结算,**只计庄家**(见第 8.1 节)。旧称"常规算奖",现统一叫**冲关**;服务端字段名 `chongguan` 与此同源 |
|
||||
| 算奖 | **上位概念** = 冲关(8.1,只计庄家)+ 傍王(8.3,可选、庄闲都算)。两个分量求和后才是某玩家的总奖数 `N`,据此结算(见第 8.4 节)。**说"冲关"指的是庄家那一份,说"算奖"指的是合计** |
|
||||
| 傍王 | 可选房间规则:勾选后每张王额外算一奖,庄家、闲家都要算;这是在 8.1 冲关之外**额外叠加**的奖励,与冲关互不冲突(见第 8.3 节) |
|
||||
| 爬坡 | 可选房间规则:改变叫分对应的基础子数与保小光分数线,未勾选时按"常规算子"结算(见第 7 节) |
|
||||
|
||||
> **术语变更记录(2026-08-25)**:原「**暗牌**」(发牌留桌的 8 张)改称「**底牌**」;原「**底牌**」(庄家埋下的 8 张)改称「**埋牌底牌**」。
|
||||
>
|
||||
> 改名原因:前端界面上「底牌」按钮查看的正是发牌留桌那 8 张,旧命名与界面倒挂、极易写反。
|
||||
>
|
||||
> **本文已全文改用新术语。** 但 `docs/protocol/packet_protocol.md` 与服务端代码里的 `bottomcards` 字段名**尚未拆分**——两批牌目前仍共用这一个字段名,只靠「出现在哪个包」区分语义:`shangzhuang` / `ChooseMain` / `BuryCards` 里的是**底牌**,`maipai` / `PushCards` / 结算 `bottom` 分组里的是**埋牌底牌**。拆分计划见 `docs_dev/二七王-UI资源与精灵清单.md` §7.5 的 S-4。
|
||||
|
||||
> **易混术语对照(都带"亮/明"字,但方向与粒度各不相同,务必分清)**:
|
||||
>
|
||||
> | 术语 | 谁公开给谁 | 给的是什么 | 触发条件 | 协议字段 |
|
||||
> | --- | --- | --- | --- | --- |
|
||||
> | **亮牌** | 庄家 → 两个闲家 | **具体牌面**(庄家全部固定主牌) | 庄家埋牌后手牌达门槛(§8.2) | `liangpai` |
|
||||
> | **余主公示** | 全体 → 全体 | **只有数量**(剩余主牌数、主对数) | 任一玩家报无主(§9) | `seatlist[seat][4]` + `baozhu` |
|
||||
> | **明牌** | 他家 → 请求者 | **具体牌面**(他家全部未出主牌) | 可查牌 + 报无主,主动点击(§9) | `mingpai.others[].zhucards` |
|
||||
> | **开底** | 桌面 → 所有玩家 | **具体牌面**(8 张底牌) | 70 分坐庄,**摸底之前** 3 秒(§4) | `ancard3s` + `bottomcards` |
|
||||
>
|
||||
> 一句话记:**只有「余主公示」是统计类(给数字),「亮牌」「明牌」「开底」都给具体牌面**——区别在给谁的牌:亮牌给庄家自己的、明牌给他家的、开底给桌上那 8 张。
|
||||
> 另有一组「底」字族专管那 8 张底牌:**摸底**(庄家摸起看)、**开底**(70 分时全场看 3 秒)、**查底牌**(随时回看)、**扣底**(末轮翻开埋牌底牌计分)。
|
||||
> 另注:**「亮主」不是本文档的术语**——它是前端选主界面的标题文案(庄家选主牌花色那一步),与上表四项无关。
|
||||
|
||||
---
|
||||
|
||||
## 2. 牌局构成
|
||||
|
||||
两副扑克牌共 108 张,去掉两副牌中的"3"和"4"(2 种点数 × 4 花色 × 2 副 = 16 张),剩余 **92 张**:
|
||||
|
||||
- 点数 5、6、7、8、9、10、J、Q、K、A、2:共 11 种点数,每种 8 张(2 副 × 4 花色)
|
||||
- 大王、小王:各 2 张(每副各 1 对)
|
||||
|
||||
花色为黑桃 ♠、红桃 ♥、梅花 ♣、方块 ♦。
|
||||
|
||||
---
|
||||
|
||||
## 3. 主牌顺序
|
||||
|
||||
每局开局前庄家选择一个花色作为本局主牌,其余三个花色为副牌。
|
||||
|
||||
固定主牌(大王、小王、所有花色的 2 和 7)不论选择哪个花色为主,恒定属于主牌;选定花色里的其余普通牌也算主牌。
|
||||
|
||||
主牌从大到小排列:
|
||||
|
||||
| 顺序 | 牌 |
|
||||
| --- | --- |
|
||||
| 1 | 大王 |
|
||||
| 2 | 小王 |
|
||||
| 3 | 正 7(选定花色的 7) |
|
||||
| 4 | 副 7(其余花色的 7) |
|
||||
| 5 | 正 2(选定花色的 2) |
|
||||
| 6 | 副 2(其余花色的 2) |
|
||||
| 7 及以下 | 选定花色的其余普通牌:A、K、Q、J、10、9、8、6、5 |
|
||||
|
||||
副牌(非选定花色,且非 2、7、王)从大到小:A、K、Q、J、10、9、8、6、5。
|
||||
|
||||
> **关于"对子"的判定**:一副牌里同一张牌有两副(deck1 / deck2),"对子"指**同一张具体牌的两副**(如两张♦7、两张大王)。正 7、副 7、正 2、副 2 虽在上表中各占一个**大小等级**(用于排序与比大小时,同级的三张副 7 视为等大),但**成对只认同一花色同点数的两副**——即两张**不同花色**的副 7(如♠7 + ♥7)**不构成一对**,两张不同花色的副 2 同理。拖拉机的连对判定也以此为基础(每一节连对里的每个对子都须是同花色同点数两副)。
|
||||
|
||||
---
|
||||
|
||||
## 4. 开局与坐庄流程
|
||||
|
||||
1. 每人摸 28 张牌,剩余 8 张扣在桌面作为"底牌"。
|
||||
2. 由暂定庄家开始,按逆时针方向依次叫分:
|
||||
- 叫分范围 5 ~ 70 分,步进 5 分。
|
||||
- 叫分数值代表"庄家承诺闲家捡分总数将严格低于该数值"(即闲家捡分只要达到或超过这个数值,庄家就算没达成承诺,见第 7 节);数值越低代表叫分者越有信心,因此后叫的人必须叫出比当前更低的分数才能压过前者,不能叫相同或更高的分数。
|
||||
- **暂定庄家必须叫分,不能"不叫"**:叫分从暂定庄家开始,他必须报出一个 5 ~ 70 之间的具体分数,不存在"暂定庄家弃权、三家都不叫"这种情况;起始叫分确定之后,后面的玩家才可以选择"叫出更低的分数"或"不叫"。
|
||||
- 若某玩家直接叫出 5 分,则无人能再压过,立即确定为庄家。
|
||||
- 若一家叫分后,另外两家都选择"不叫",则该叫分玩家成为本局庄家。
|
||||
3. **摸底**:庄家确定后把 8 张底牌摸入手牌(摸完后庄家共 36 张):
|
||||
- **非 70 分坐庄**:桌面 8 张底牌**只有庄家可见**,庄家查看后摸入手牌。
|
||||
- **70 分坐庄**(触发**开底**):在庄家**摸底**之前,先把这 8 张底牌**向所有玩家(含两个闲家)翻开 3 秒**,然后才由庄家把它们摸入手牌。
|
||||
4. **选主 / 投降(同一决策点,二选一,互斥)**:
|
||||
- **选主**:庄家在 4 个花色中选定一个作为本局主牌(见第 3 节)→ 进入下一步埋牌、随后出牌。
|
||||
- **投降**:仅当**叫分为 70 分**时,此决策点才额外提供"投降"选项;选择投降表示放弃本局,**直接结束、不再选主、不埋牌、不出牌**,按 7.1 节"70 分坐庄 · 投降"的固定结果结算(基础子数 1 个,庄家直接输;算奖仍照常,见 8.4 节)。选主与投降互斥——**选了花色就等于放弃投降、正常打牌;选了投降就不再选主**。
|
||||
- 叫分 < 70 分时没有投降选项,只能选主。
|
||||
- (**开底**——70 分坐庄的 8 张底牌向所有玩家翻开 3 秒,发生在上一步"庄家摸底之前",见步骤 3。)
|
||||
- **前端表现**:此阶段界面显示 4 个花色选主按钮,且**每个花色按钮上要显示该花色在庄家手中有多少对**(供庄家判断选哪门为主);70 分坐庄时并列再显示一个投降按钮。
|
||||
5. **埋牌**(仅"选主/打牌"路径):庄家从 36 张里选出 8 张牌重新扣下("埋牌"),作为"埋牌底牌",供最后判断"扣底"使用;埋牌后庄家保留 28 张。(**本局先选主、后埋牌**——庄家先定主牌花色,知道主副后再决定埋哪 8 张。)
|
||||
6. 坐庄轮换规则(决定下一局的"暂定庄家"):
|
||||
- 第一局的暂定庄家为 **0 号座位**的玩家(此前没有打过任何牌局、还不存在"上一局的庄家",故取固定座位)。
|
||||
- 若庄家本局获胜(庄赢),下一局暂定庄家仍为该玩家(连庄)。
|
||||
- 若庄家本局失败(闲家捡分达标 / 庄家投降),下一局暂定庄家变为该玩家的下家(按逆时针顺延)。
|
||||
|
||||
---
|
||||
|
||||
## 5. 出牌规则
|
||||
|
||||
### 5.1 跟牌基本原则
|
||||
|
||||
- 每一轮由上一轮牌面最大的一方先出牌;**每一局(每一手牌)的第一轮固定由庄家先出**——此时还没有"上一轮"可以比较,只能由庄家开局。
|
||||
- 首家出什么花色,其余两家必须跟出同花色的牌;若手中没有该花色的牌或数量不够(缺门),可以任选以下一种方式补齐:
|
||||
- **毙牌**:改用主牌打出,且牌面要大过本轮目前所有人的牌,即可抢下本轮的出牌权;
|
||||
- **垫牌**:随意出一张其他副牌,不参与、不争夺本轮出牌权。
|
||||
- 首家若出主牌,其余两家必须跟出主牌,规则同上(此时缺门只能垫副牌,不存在再用主牌毙牌的情况)。
|
||||
|
||||
### 5.2 按牌型跟牌
|
||||
|
||||
跟牌的总原则:**在本轮首家花色内,尽量凑出与首家相同的牌型结构;能凑出的高规格部分必须优先打出,凑不齐的部分才用低规格牌补足。** 服务端按此层级精确计算每名跟牌者的"必出牌"与"可选牌",玩家无法跳过更高规格的强制部分。
|
||||
|
||||
| 首家牌型 | 跟牌要求 |
|
||||
| --- | --- |
|
||||
| 单张 | 跟同花色任意单张;同花色缺门则任意牌补齐 |
|
||||
| 对子 | 有同花色对子必须打出对子;没有对子则打两张同花色单牌;同花色牌不足两张时,现有的同花色牌必须打出、不足部分任意补齐 |
|
||||
| 拖拉机(N 连对) | 见下方「拖拉机跟牌的强制层级」 |
|
||||
|
||||
#### 拖拉机跟牌的强制层级(同花色牌足够时)
|
||||
|
||||
首家出 N 连对拖拉机,跟牌者手中该花色牌数量 ≥ 首家张数时,按以下优先级**从高到低**确定必出部分,高一级能凑出就必须先凑,不允许跳过:
|
||||
|
||||
1. **有同长(N 连对)的同花色拖拉机** → 必须打出该拖拉机;若手中有多组等长拖拉机,可任选其一打出。
|
||||
2. **没有 N 连对,但持有更短的同花色拖拉机(仅 3 连对及以上首家才会走到这一步)** → 必须先拆出手中能凑到的**最长**拖拉机作为强制部分,再用次长拖拉机 / 对子 / 单张继续补足到相同张数——即"能凑多长的拖拉机,就必须先凑多长",不允许放着更长的拖拉机不出而只出零散对子。
|
||||
3. **没有任何同花色拖拉机,但对子数量足够(对子数 × 2 ≥ 首家张数)** → 用现有对子凑够张数打出(多余的对子可自选保留)。
|
||||
4. **对子数量不够(对子数 × 2 < 首家张数)** → 手中现有的同花色对子**全部**必须打出,剩余缺口用同花色单张补齐。
|
||||
5. **完全没有同花色对子** → 用同花色单张随意补齐张数。
|
||||
|
||||
**同花色牌数量不足**首家张数时(手中还有该花色的牌,但张数不够——无论首家出的是单张、对子还是 N 连对拖拉机):手中该花色的牌**全部必须打出**(不论它们是否成对、是否成拖拉机),不足的缺口用**任意其他花色的牌**补齐——补齐的牌**不要求成对、不要求成拖拉机、也不要求是主牌**,随意垫即可。特别地,**即便首家出的是拖拉机,缺口部分也不强制补成对子或拖拉机**(服务端只校验总张数,不校验缺口部分的结构)。若缺口只能用主牌补(手中没有别的副牌可垫),打出的就是"该花色副牌 + 主牌"的**混合出牌**:它不是缺门、不是毙牌,牌面判为 0,**与垫牌等效,不争夺本轮出牌权**(见术语表「混合出牌」)。
|
||||
|
||||
若**完全缺门**(手中没有该花色的任何一张牌):整手都可任意出,可选择垫牌或用主牌毙牌(见下)。
|
||||
|
||||
毙牌的前提是**完全缺门**——手中**没有本轮首家花色的任何一张牌**。只要手里还剩该花色的牌(哪怕凑不成对子、凑不成拖拉机),都必须优先打出这些同花色牌(按上文 5.2 层级),**不允许留着该花色的牌改用主牌毙**。缺门后用主牌毙牌时,牌型与张数须与首家一一对应,才算"压过"、抢下出牌权:
|
||||
|
||||
- 若首家出副牌单张,其余玩家**完全缺门**该副牌花色,可以任意出牌(含主牌);但要压过首家,出的主牌也必须是单张(数量对应)。
|
||||
- 若首家出副牌对子,其余玩家**完全缺门**该副牌花色,要压过首家,出的主牌必须也是对子(不能用任意两张主牌顶替)。
|
||||
- 若首家出副牌拖拉机(N 连对),其余玩家**完全缺门**该副牌花色,要压过首家,出的主牌必须也是同样连对组数(N 连对)的主拖拉机,总张数完全对应;单张主牌或零散的主对子都不能顶替拖拉机。(注意:只要手里还有该副牌花色的牌,哪怕凑不成同长度拖拉机,也必须先出这些同花色牌,不能改用主牌毙。)
|
||||
|
||||
### 5.3 拖拉机定义与相邻关系
|
||||
|
||||
**拖拉机 = 在各自的大小序列里「位置相邻」的两个(或以上)对子**,如 K K Q Q 为两连对(两托),K K Q Q J J 为三连对(三托)。副牌与主牌各有自己的序列,相邻规则不同,分开说:
|
||||
|
||||
#### 副牌拖拉机(非主花色)
|
||||
|
||||
**必须同一花色**,点数在下面这条序列里相邻:
|
||||
|
||||
```
|
||||
A - K - Q - J - 10 - 9 - 8 - 6 - 5
|
||||
```
|
||||
|
||||
序列里没有 7 和 2(它们是固定主牌,见第 3 节),所以 **7 不与 6、8 相连,而 6 与 8 相连**(中间隔的 7 已被抽走);同理 4、3 不在牌堆里(见第 2 节),5 是副牌序列的最小一档。**跨花色不构成连对**——♥K 对 + ♣Q 对不是拖拉机。
|
||||
|
||||
#### 主牌拖拉机(主花色 + 固定主牌)
|
||||
|
||||
主牌只有**一条**完整的大小序列(即第 3 节的主牌顺序):
|
||||
|
||||
```
|
||||
大王 - 小王 - 正7 - 副7 - 正2 - 副2 - 主A - 主K - 主Q - 主J - 主10 - 主9 - 主8 - 主6 - 主5
|
||||
```
|
||||
|
||||
只要在这条序列里**位置相邻**的对子,就构成拖拉机,**不限花色**。因此「正7 对 + 副7 对」「副7 对 + 正2 对」「副2 对 + 主A 对」「大王对 + 小王对」都是合法的两连对,尽管两个对子的花色不同。序列末段(主A 到 主5)都是**选定花色**的普通牌,其中同样是 6 与 8 相连、7 不入此段。
|
||||
|
||||
> **同一档位的两个对子不相邻**:「副7」在序列里只占**一个**位置,所以 ♥7 对 + ♣7 对是**两个平级的对子、不是拖拉机**;副2 同理。这与第 3 节「成对只认同花色同点数的两副」是同一条原则的两面——先按同花色同点数成对,再看这些对子在序列里的位置是否相邻。
|
||||
|
||||
### 5.4 甩牌详细规则
|
||||
|
||||
#### 5.4.1 基础定义
|
||||
|
||||
**甩牌**:当你是本轮首家出牌人时,一次性打出多组主牌混合牌型(单张、对子、拖拉机自由组合),一轮打完多张主牌,无需分多轮依次打出。
|
||||
|
||||
两条硬性基础禁令:
|
||||
|
||||
1. **仅主牌允许甩牌,所有副牌完全禁止甩牌**:无论外面是否剩余该花色副牌,副牌只能分开单出、出对子、出拖拉机,不能一次性打包甩出。
|
||||
2. **只有本轮先手才能执行甩牌**:若他人先出牌,你只能跟牌、垫牌、用主牌毙牌,不具备甩牌资格。
|
||||
|
||||
#### 5.4.2 生效条件(最大性原则)与报无主
|
||||
|
||||
想要成功甩牌,必须同时满足:**剩余两名对手手中,不存在任何一张、一对、一组拖拉机能压制你甩出的整套主牌组合**。简单说,全场所有更大的主牌、更大的主对子、更长的主拖拉机必须全部在你手中,两家闲家没有任何牌型能盖过你的甩牌组合。
|
||||
|
||||
**合法性由服务端在甩牌提交时自动判定**:服务端掌握全部玩家的手牌,甩牌一旦提交,立即检查另外两名玩家手中是否"持有"(不要求对方真正打出)能压过这套组合任意一部分的主牌;只要有人持有,就直接判定为甩错(见 5.4.5),不需要、也不会等到对方实际出牌反制才判定。
|
||||
|
||||
"报无主"是给玩家参考的辅助信息,**不是合法性判定的依据**(合法性判定始终由服务端按全部手牌精确计算):对局中非最后一轮,任意玩家手里没有主牌了,必须口头报无主,方便其他玩家在决定要不要甩牌前,大致判断风险:
|
||||
|
||||
- **另外两家都报了无主**:全场只剩你自己持有主牌,甩牌必然合法,无需记牌。
|
||||
- **另外两家只有一家报了无主、还有一家没报**:那一家仍持有主牌,你若甩牌需要自行记牌估算风险;但即使判断失误,最终是否算甩错仍由服务端精确判定,不取决于你的记牌是否正确。
|
||||
|
||||
#### 5.4.3 甩牌可包含的牌型组合
|
||||
|
||||
甩牌仅限本局主牌(固定主牌 + 选定主花色的普通牌),内部可自由混搭任意数量、任意种类牌型:
|
||||
|
||||
- **纯单张**:大王、小王、正 7、副 7、正 2、副 2、主花色单牌混合;
|
||||
- **纯对子**:大王对 / 小王对(各自成对,大王与小王点数不同,不能混成一对)、正七对、副七对、正二对、主花色数字对子;
|
||||
- **主拖拉机**:大小王拖拉机、七七连对、二二连对、主花色数字连对;
|
||||
- **混合搭配**:单张 + 对子 + 多连拖拉机一同甩出,无组合数量限制。
|
||||
|
||||
#### 5.4.4 其余两家应对甩牌的强制跟牌规则
|
||||
|
||||
甩牌一旦被判定合法(见 5.4.2:服务端已确认两名闲家都不持有能盖压这套组合任何一部分的主牌),闲家就不可能再用主牌把它压下去——"能不能盖压"在甩牌提交的那一刻就已经由服务端裁定完毕,不会走到"闲家出牌盖压"这一步。
|
||||
|
||||
甩牌本身可能是单张、对子、多个不同长度拖拉机自由混搭的复杂组合(见 5.4.3)。闲家跟牌沿用 5.2「拖拉机跟牌的强制层级」同一原则(高规格能凑就必须先凑:拖拉机 → 对子 → 单张),只是这里要把整套甩牌**拆解成一个个独立的分量,逐个分量分别匹配**,而不是把整套甩牌当成一个笼统的整体来对待:
|
||||
|
||||
1. **每一组拖拉机分量**:闲家手中若有同等连对数的主拖拉机,必须优先拆出来跟这一组;没有同等连对数的拖拉机,退而求其次用同等张数的主对子顶替;主对子也不够,才能拆主单张顶替。甩牌里若有多组不同长度的拖拉机,逐组分别按此规则处理。
|
||||
2. **每一组单独的对子分量**:闲家手中若有主对子,必须优先拆出来跟这一组;没有主对子,才能拆主单张顶替。
|
||||
3. **每一组单独的单张分量**:用主单张顶替即可。
|
||||
4. **闲家手中的主牌拆解完仍不够覆盖甩牌剩余的分量**:不够的那部分只能随意垫副牌。
|
||||
5. **闲家完全没有主牌**:只能随意垫任意副牌,本轮无法压制你的甩牌,本轮出牌权归你。
|
||||
|
||||
无论怎么拆解跟牌,都不影响这套甩牌本身的合法性——合法性在提交时已经由服务端按 5.4.2 判定完毕,闲家的跟牌动作只是"凑够同等张数、同等结构"的手牌垫上去,不可能真正反超压制。
|
||||
|
||||
#### 5.4.5 甩错判定与惩罚规则
|
||||
|
||||
**甩错判定**:甩牌提交时,服务端按全部玩家的真实手牌自动检查——只要两名对手任意一人**持有**(不要求已经打出)能盖压你甩牌内任意一组牌型的主牌,本次甩牌立即判定为甩错,对方是否愿意打出来压制不影响判定结果。
|
||||
|
||||
**统一惩罚标准**:
|
||||
|
||||
- 本次甩牌全部收回手牌;
|
||||
- 本轮仅强制打出你甩出组合里最小的那一张牌;
|
||||
- 本轮失去再次甩牌、一次性多出牌的资格,剩余手牌只能分轮正常打出;
|
||||
- 线下娱乐、线上棋牌平台均统一执行该惩罚,不额外扣分。
|
||||
|
||||
甩错判罚后,本轮实际只算打出了那一张最小的单张主牌,其余两家按 5.2 节的"单张"跟牌规则正常应对即可,不再涉及甩牌相关规则。
|
||||
|
||||
---
|
||||
|
||||
## 6. 捡分与扣底
|
||||
|
||||
### 6.1 分牌
|
||||
|
||||
牌面 5 记 5 分,牌面 10 记 10 分,牌面 K 记 10 分,其余牌不计分。
|
||||
|
||||
### 6.2 捡分规则
|
||||
|
||||
每一轮出完牌后比较牌面大小:
|
||||
|
||||
- 若闲家最大,则本轮牌面上的分牌全部计入闲家捡分(两个闲家谁大不影响,都算闲家捡到)。
|
||||
- 若庄家最大,则本轮牌面上的分牌作废——不计入闲家捡分总数,也不属于庄家的个人分数(庄家本身没有"捡分"这个概念,庄家的输赢只看闲家捡分总数是否达到叫分,见第 7 节)。
|
||||
|
||||
### 6.3 扣底
|
||||
|
||||
所有手牌出完后,若最后一轮闲家**用主牌**(不论单张、对子、拖拉机)**牌面压过庄家**、赢下最后一轮,才视为"扣底"(若闲家赢了最后一轮但用的不是主牌,则不算扣底)。触发后,庄家埋下的 8 张埋牌底牌翻开,若有分则计入闲家捡分。
|
||||
|
||||
埋牌底牌的分要不要翻倍、翻几倍,取决于闲家扣底所用的那手牌(赢下最后一轮的牌)本身是什么牌型:
|
||||
|
||||
| 闲家扣底所用牌型 | 倍数 |
|
||||
| --- | --- |
|
||||
| 单张主牌 | 不翻倍,按原始分值计入 |
|
||||
| 主对子(一对主牌) | 2 倍 |
|
||||
| 两连对拖拉机 | 4 倍 |
|
||||
| 三连对拖拉机 | 6 倍 |
|
||||
| N 连对拖拉机 | 2 × N 倍,以此类推递增 |
|
||||
|
||||
若最后一轮闲家是用第 5.4 节的**甩牌**(单张 + 对子 + 拖拉机混合的组合)扣底,取这套组合里**最高规格牌型**对应的倍数计算,其余较低规格的部分不额外叠加。例如甩出"单张 + 一对 + 两连对拖拉机"并以此扣底,最高规格是两连对拖拉机,整手按 4 倍计算。
|
||||
|
||||
> **重要区分**:扣底翻的是**闲家的捡分得分(grade)**本身,属于"算分"阶段;这与第 7 节里"大光 ×3""小光 ×2""升 N 级 ×N"这些子数倍率是完全不同的两件事——那些乘的是**子数**,属于"算子"阶段。两者的先后顺序是:扣底先把埋牌底牌的分(可能翻倍)计入闲家的捡分总数,这个最终捡分总数再拿去跟庄家叫分比较,判定过庄 / 小光 / 大光 / 闲家赢(升级),最后才由判定结果决定子数乘几倍。
|
||||
|
||||
---
|
||||
|
||||
## 7. 结算:子数与升级
|
||||
|
||||
叫分与结算涉及两套并存的数值:**倍数**(用于计算"捡分得分" grade)与**子数**(用于计算最终筹码输赢),两者是独立概念,不能混用。
|
||||
|
||||
> **重要区分**:本节里提到的倍率(大光 ×3、小光 ×2、过庄 ×1、升 N 级 ×N),乘的都是**子数**(最终结算给玩家的筹码单位),不是对局过程中闲家捡到的分数(捡分得分)本身。捡分得分只用来判定庄家的过庄 / 小光 / 大光,或闲家的升级级数,一旦等级判定完成,实际相乘的对象是这一局最终要结算的子数。
|
||||
|
||||
> **算子模式的选择**:7.1 + 7.2 描述的"常规算子"是默认结算方式;开房时勾选"爬坡"后,才改用 7.3 的子数梯度和保小光分段;未勾选"爬坡"时,一律按常规算子(7.1 + 7.2)结算,两套规则不会同时生效。
|
||||
|
||||
### 7.0 四层关系总览
|
||||
|
||||
结算分四层,前三层是"算子"(本节),第四层是"算奖"(第 8 节),一层的输出是下一层的输入:
|
||||
|
||||
1. **叫分 → 基础子数**:叫分档位本身决定这一局的基础子数(见 7.1 / 7.3 的对照表),叫分越低(庄家承诺越苛刻),基础子数越高。
|
||||
2. **捡分 vs 叫分 → 判定结果**:局末闲家的捡分总数(含扣底,见 6.3 节)与庄家叫分作比较,只会落入互斥的两个分支之一:
|
||||
- **闲家没达到叫分**(庄家赢)→ 结果记在**庄家**名下,分三级:**过庄 / 小光 / 大光**。
|
||||
- **闲家达到或超过叫分**(闲家赢,庄家倒庄)→ 结果记在**闲家**名下,只有一个概念:**升级**(第几级)。
|
||||
- **大光 / 小光和升级是两套完全独立的命名体系,分别只属于庄家一侧和闲家一侧,不能混用或类比**:庄家赢的时候没有"升级",闲家赢的时候也没有"大光 / 小光"。
|
||||
3. **判定结果 → 子数倍率**:过庄 / 小光 / 大光 / 升级第几级,各自对应一个子数倍率,乘上第 1 层的基础子数,得到这一局的**子数结算结果 `X`**(前三层,即"算子",到此为止)。`X` 是庄家与**每一个**闲家之间"一对一"结算的基础金额:庄家赢(过庄 / 小光 / 大光)时,两个闲家各输给庄家 `X` 子;闲家赢(升级)时,庄家各输给两个闲家 `X` 子。
|
||||
4. **算奖 → 结算倍率**:第 8 节算出的"奖数"(8.1 冲关 + 8.3 傍王,各玩家分别求和)**是"持有奖数的这个人"从其余两名玩家那里各自多收一笔钱的独立结算线,作用对象是这个人本身,不是整场结算的统一系数**——某玩家 P 这一局总共有 `N_P` 奖,则**另外两名玩家(不分庄闲)都要各自额外付给 P:`X × N_P` 子**。三名玩家可能同时都持有各自的奖数,会产生最多 3 条互相独立的额外支付线,与第 1~3 层的基础输赢结算加总,才是每个人这一局的最终盈亏。算奖的计算过程本身不受叫分、过庄 / 小光 / 大光、升级影响(只看手牌结构),详见 8.3 节的完整举例。
|
||||
|
||||
> **例外**:70 分坐庄选择投降时不走第 1~3 层模型——直接固定基础子数 1 个、庄家输掉,不比较捡分、不判定过庄 / 小光 / 大光,见 7.1 末尾说明;但第 4 层算奖仍然照常叠加(投降是选主阶段的选择、未选主也未埋牌,按庄家"发牌 + 底牌"共 36 张、无主牌花色计算,见 8.4 节)。
|
||||
|
||||
**查表索引**:本节按叫分档位给出了完整判定表,位置如下:
|
||||
|
||||
| 叫分 | 常规算子(未勾选"爬坡") | 爬坡(勾选"爬坡") |
|
||||
| --- | --- | --- |
|
||||
| 70 分 | 7.2.1 末尾"70 分坐庄" | 与常规算子一致,见 7.3.3 末尾说明 |
|
||||
| 65 / 60 / 55 / 50 / 45 分 | 7.2.1 对应表格 | 与常规算子数值一致(45 分子数不同),见 7.3.3 末尾说明 |
|
||||
| 40 / 35 / 30 / 25 / 20 / 15 / 10 分 | 7.2.1"40 分及以下坐庄"通用规则 | 7.3.3 对应表格(子数、`Q` 值均与常规算子不同) |
|
||||
| 5 分 | 7.2.1"40 分及以下坐庄"内的"5 分坐庄没有小光"说明 | 7.3.3"5 分坐庄"(没有小光) |
|
||||
|
||||
### 7.1 常规算子:叫分 → 基础子数(未勾选"爬坡"时的默认规则)
|
||||
|
||||
叫分范围是 5 ~ 70 分(见第 4 节),常规算子下完整的"叫分 → 基础子数"对照:
|
||||
|
||||
| 叫分 | 65 分 | 60 分 | 55 分 | 50 分及以下(50 / 45 / 40 / 35 / 30 / 25 / 20 / 15 / 10 / 5) |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| 基础子数 | 2 个子 | 3 个子 | 4 个子 | 统一 6 个子,不再随叫分继续增加 |
|
||||
|
||||
即叫分从 65 分往下每降 5 分子数依次是 2 → 3 → 4,但**到 50 分封顶**:50 分及以下所有档位(50、45、40、35、30、25、20、15、10、5)基础子数都固定是 6 个子,不会像爬坡那样继续往上加(爬坡的区别见 7.3)。
|
||||
|
||||
70 分叫庄是特例,基础子数不接着上面的阶梯往下走,而是在"投降"和"打牌"两条路径下分别取值:
|
||||
|
||||
- **选择投降**:基础子数按 **1 个**算,庄家直接输 1 个子(此时局面还没有真正出牌捡分,不经过 7.2 的大光 / 小光 / 升级判定,是一次性的固定结算)。
|
||||
- **选择打牌**:基础子数按 **2 个**算(与 65 分相同),之后**照样按 7.2 节的方法,用捡分总数判定大光 / 小光 / 过庄 / 升级**,不是不细分——完整判定表见 7.2.1。
|
||||
|
||||
70 分叫庄时,发牌时桌面留下的那 8 张底牌(见第 4 节;不是埋牌时埋下的那 8 张「埋牌底牌」)需要**在庄家把它们摸入手牌之前、向所有玩家亮出 3 秒**,之后庄家才摸入手中;投降和打牌两种情况都适用(因为亮牌发生在选主 / 投降决策之前)。
|
||||
|
||||
### 7.2 捡分 vs 叫分:庄家的过庄 / 小光 / 大光,闲家的升级(常规算子)
|
||||
|
||||
#### 7.2.0 判定方法(对任意叫分档位通用)
|
||||
|
||||
**大光的判定条件是固定的、不随叫分或算子模式变化**:闲家捡分 `grade == 0`(一分未捡到)就是大光,常规算子和爬坡完全一样。会随叫分变化的,是**小光 / 过庄**之间的分界线——常规算子下这条分界线**固定为 40 分**,不随叫分变化(这是与爬坡最核心的区别,爬坡的分界线 `Q` 会随叫分变化,见 7.3.2)。设叫分为 `call`,闲家捡分为 `grade`,按以下顺序依次判断(**必须先判断第 1 步,即是否达标,再判断大光 / 小光 / 过庄**,顺序不能颠倒):
|
||||
|
||||
1. **先判断是否达标**:若 `grade ≥ call`,闲家赢,判为**升级**——先升 1 级,此后每再多捡 40 分再升 1 级;子数倍率就是当前的级数本身:升 1 级 ×1、升 2 级 ×2、升 3 级 ×3……逐级 **+1**,不是逐级翻倍。
|
||||
2. **未达标(`grade < call`)时,再看捡分具体落在哪个区间**:
|
||||
- `grade == 0`(一分未捡到)→ **庄家:大光**,子数倍率 ×3。
|
||||
- `0 < grade < 40`(捡了一点,但没满 40 分)→ **庄家:小光**,子数倍率 ×2。
|
||||
- `40 ≤ grade < call`(捡满 40 分,但仍没达标)→ **庄家:过庄**,子数倍率 ×1(即基础子数,不翻倍)。
|
||||
|
||||
之所以要先判断"是否达标":当叫分本身 ≤ 40 时(比如叫 35 分坐庄),闲家只要捡到 35 分就已经达标升级了,根本不可能出现"捡到 40 分却还没达标"这种情况——"过庄"这一档这时候是空的,直接从"小光"跳到"升级"(完整例子见 7.2.1"40 分及以下坐庄")。
|
||||
|
||||
**大光 / 小光只描述庄家(闲家没达标时,庄家赢得有多彻底);升级只描述闲家(闲家达标之后,赢得有多彻底),两套命名不通用、不能互换。**
|
||||
|
||||
#### 7.2.1 常规算子完整判定表
|
||||
|
||||
按上面的方法,叫分 > 40 分的档位(65 / 60 / 55 / 50 / 45)"过庄"区间是存在的;叫分 ≤ 40 分的档位(40 / 35 / 30 / 25 / 20 / 15 / 10 / 5)"过庄"区间不存在,闲家捡分只要达到叫分就直接升级。
|
||||
|
||||
**65 分坐庄(基础 2 个子)**
|
||||
|
||||
| 闲家捡分 | 判定 | 倍率 | 最终子数 |
|
||||
| --- | --- | --- | --- |
|
||||
| 0 分 | 庄家:大光 | ×3 | 6 个子 |
|
||||
| 1 ~ 39 分 | 庄家:小光 | ×2 | 4 个子 |
|
||||
| 40 ~ 64 分 | 庄家:过庄 | ×1 | 2 个子 |
|
||||
| 65 ~ 104 分 | 闲家:升 1 级 | ×1 | 2 个子 |
|
||||
| 105 ~ 144 分 | 闲家:升 2 级 | ×2 | 4 个子 |
|
||||
| 145 分起,每再多 40 分 | 闲家:再升 1 级 | 再 +1 | 逐级 +2 个子 |
|
||||
|
||||
**60 分坐庄(基础 3 个子)**
|
||||
|
||||
| 闲家捡分 | 判定 | 倍率 | 最终子数 |
|
||||
| --- | --- | --- | --- |
|
||||
| 0 分 | 庄家:大光 | ×3 | 9 个子 |
|
||||
| 1 ~ 39 分 | 庄家:小光 | ×2 | 6 个子 |
|
||||
| 40 ~ 59 分 | 庄家:过庄 | ×1 | 3 个子 |
|
||||
| 60 ~ 99 分 | 闲家:升 1 级 | ×1 | 3 个子 |
|
||||
| 100 ~ 139 分 | 闲家:升 2 级 | ×2 | 6 个子 |
|
||||
| 140 分起,每再多 40 分 | 闲家:再升 1 级 | 再 +1 | 逐级 +3 个子 |
|
||||
|
||||
**55 分坐庄(基础 4 个子)**
|
||||
|
||||
| 闲家捡分 | 判定 | 倍率 | 最终子数 |
|
||||
| --- | --- | --- | --- |
|
||||
| 0 分 | 庄家:大光 | ×3 | 12 个子 |
|
||||
| 1 ~ 39 分 | 庄家:小光 | ×2 | 8 个子 |
|
||||
| 40 ~ 54 分 | 庄家:过庄 | ×1 | 4 个子 |
|
||||
| 55 ~ 94 分 | 闲家:升 1 级 | ×1 | 4 个子 |
|
||||
| 95 ~ 134 分 | 闲家:升 2 级 | ×2 | 8 个子 |
|
||||
| 135 分起,每再多 40 分 | 闲家:再升 1 级 | 再 +1 | 逐级 +4 个子 |
|
||||
|
||||
**50 分坐庄(基础 6 个子)**
|
||||
|
||||
| 闲家捡分 | 判定 | 倍率 | 最终子数 |
|
||||
| --- | --- | --- | --- |
|
||||
| 0 分 | 庄家:大光 | ×3 | 18 个子 |
|
||||
| 1 ~ 39 分 | 庄家:小光 | ×2 | 12 个子 |
|
||||
| 40 ~ 49 分 | 庄家:过庄 | ×1 | 6 个子 |
|
||||
| 50 ~ 89 分 | 闲家:升 1 级 | ×1 | 6 个子 |
|
||||
| 90 ~ 129 分 | 闲家:升 2 级 | ×2 | 12 个子 |
|
||||
| 130 分起,每再多 40 分 | 闲家:再升 1 级 | 再 +1 | 逐级 +6 个子 |
|
||||
|
||||
**45 分坐庄(基础 6 个子——从这一档开始进入"50 分及以下统一 6 个子"的范围)**
|
||||
|
||||
| 闲家捡分 | 判定 | 倍率 | 最终子数 |
|
||||
| --- | --- | --- | --- |
|
||||
| 0 分 | 庄家:大光 | ×3 | 18 个子 |
|
||||
| 1 ~ 39 分 | 庄家:小光 | ×2 | 12 个子 |
|
||||
| 40 ~ 44 分 | 庄家:过庄 | ×1 | 6 个子 |
|
||||
| 45 ~ 84 分 | 闲家:升 1 级 | ×1 | 6 个子 |
|
||||
| 85 ~ 124 分 | 闲家:升 2 级 | ×2 | 12 个子 |
|
||||
| 125 分起,每再多 40 分 | 闲家:再升 1 级 | 再 +1 | 逐级 +6 个子 |
|
||||
|
||||
**40 分及以下坐庄(40 / 35 / 30 / 25 / 20 / 15 / 10 / 5,基础都是 6 个子;此时 `call ≤ 40`,没有"过庄"这一档)**
|
||||
|
||||
| 闲家捡分 | 判定 | 倍率 | 最终子数 |
|
||||
| --- | --- | --- | --- |
|
||||
| 0 分 | 庄家:大光 | ×3 | 18 个子 |
|
||||
| 1 分 ~(叫分 − 1)分 | 庄家:小光 | ×2 | 12 个子 |
|
||||
| 达到叫分 | 闲家:升 1 级 | ×1 | 6 个子 |
|
||||
| 达到叫分 + 40 分 | 闲家:升 2 级 | ×2 | 12 个子 |
|
||||
| 此后每再多 40 分 | 闲家:再升 1 级 | 再 +1 | 逐级 +6 个子 |
|
||||
|
||||
例如叫 10 分坐庄(基础 6 个子):0 分为大光(18 子);1 ~ 9 分为小光(12 子);捡到 10 分起升 1 级(6 子);捡到 50 分起升 2 级(12 子);之后每再多 40 分再升 1 级(每级再 +6 子)。
|
||||
|
||||
> **5 分坐庄没有"小光"**:分牌只有 5 分、10 分两种面值(见 6.1 节),闲家捡分永远是 5 的倍数。上面"1 分 ~(叫分 − 1)分为小光"这一行,在叫分为 5 分时对应区间是 `1 ~ 4 分`,里面不存在任何 5 的倍数,是空区间。所以叫 5 分坐庄时,闲家捡分只有 0(大光)和达到 5 分及以上(直接升 1 级,之后每再多 40 分再升 1 级)两种结果,没有小光这一档;这是"叫分 ≤ 40 分"这组档位里唯一的特例,其余档位(40/35/30/25/20/15/10)小光区间都至少包含一个 5 的倍数,正常存在。
|
||||
|
||||
**70 分坐庄**
|
||||
|
||||
- **投降**:基础子数按 1 个算,庄家直接输 1 个子,不经过大光 / 小光 / 过庄 / 升级判定(还没出牌捡分,直接放弃)。
|
||||
- **打牌**:基础子数按 2 个算,之后按 7.2.0 的方法正常判定,`Q` 同样固定为 40 分——判定表和 65 分坐庄完全一致,只是把"基础 2 个子"原样代入(数值恰好和 65 分相同):
|
||||
|
||||
| 闲家捡分 | 判定 | 倍率 | 最终子数 |
|
||||
| --- | --- | --- | --- |
|
||||
| 0 分 | 庄家:大光 | ×3 | 6 个子 |
|
||||
| 1 ~ 39 分 | 庄家:小光 | ×2 | 4 个子 |
|
||||
| 40 ~ 69 分 | 庄家:过庄 | ×1 | 2 个子 |
|
||||
| 70 ~ 109 分 | 闲家:升 1 级 | ×1 | 2 个子 |
|
||||
| 110 ~ 149 分 | 闲家:升 2 级 | ×2 | 4 个子 |
|
||||
| 150 分起,每再多 40 分 | 闲家:再升 1 级 | 再 +1 | 逐级 +2 个子 |
|
||||
|
||||
### 7.3 爬坡:叫分 → 基础子数与保小光分段(可选规则,开房时勾选"爬坡"生效)
|
||||
|
||||
#### 7.3.1 基础子数:叫分 → 子数的完整对照
|
||||
|
||||
叫分从 65 分(2 个子)开始,每叫低 5 分加 1 个子,直到 50 分(6 个子)——这一段与常规算子的四档数值完全相同;50 分以下,继续按每叫低 5 分加 1 个子延伸,一直到叫分范围的下限 5 分:
|
||||
|
||||
| 叫分 | 65 | 60 | 55 | 50 | 45 | 40 | 35 | 30 | 25 | 20 | 15 | 10 | 5 |
|
||||
| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
|
||||
| 基础子数 | 2 | 3 | 4 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 |
|
||||
|
||||
#### 7.3.2 保小光分段:叫分 → 小光 / 过庄分界线(Q 值)
|
||||
|
||||
判定方法与 7.2.0 完全相同,只是把固定的 `Q = 40` 换成按叫分区间取值的 `Q`;**大光的判定条件不受 `Q` 影响,永远是 `grade == 0`**,常规算子和爬坡完全一样——`Q` 只决定"小光"和"过庄"之间的分界线:
|
||||
|
||||
| 叫分区间 | 小光 / 过庄的分界线(Q) |
|
||||
| --- | --- |
|
||||
| ≥ 45 分(含 65 / 60 / 55 / 50 / 45) | 40 分 |
|
||||
| 40 / 35 分 | 20 分 |
|
||||
| 30 / 25 分 | 15 分 |
|
||||
| 20 / 15 分 | 10 分 |
|
||||
| 10 分 | 5 分 |
|
||||
| 5 分 | 5 分(升级级距仍是 5,只是"小光"这一档因为区间为空而不存在,见下方说明) |
|
||||
|
||||
`≥ 45 分`这一档的 `Q = 40`,与 7.2 节常规算子沿用的分界线数值相同,只是子数改按 7.3.1 的公式取值(65/60/55/50 与常规算子数值一致,45 分是爬坡独有的新增档位)。
|
||||
|
||||
**5 分档没有"小光"**:分牌只有牌面 5(5 分)和牌面 10 / K(10 分)两种面值(见 6.1 节),闲家的捡分总数(`grade`)因此永远是 5 的倍数(0、5、10、15…),不可能出现 1 ~ 4 这样的中间值。当叫分是 5 分时,"小光"原本对应的区间是 `0 < grade < 5`,这个开区间里不存在任何 5 的倍数,也就是说这个区间**必然是空的**——闲家捡分要么是 0(大光),要么一旦捡到任何分牌(最少 5 分)就已经达到叫分、直接升级,中间不存在"捡到了一点、但没达标"的"小光"状态。所以 5 分档只有**大光**和**升级**两种结果,没有"小光"这一档;但 `Q` 值本身仍然是 5(与 10 分档一样),只是在"小光 / 过庄分界线"这个用途上区间恰好收窄为空,`Q = 5` 仍然继续作为"升级级距"生效(见 7.3.3 的 5 分坐庄判定表)。
|
||||
|
||||
**升级级距的通用规则**:不管哪个叫分档位,闲家赢了之后,"超过叫分多少分算升一级",用的分数差**就是该档位的 `Q` 值**——即上表里每一档的 `Q` 值,既是"小光 / 过庄"的分界线,也是"升级"的级距单位,两者共用同一个数字。例如叫分 40 分时 `Q = 20`:闲家捡分达到 40 分先升 1 级,之后每再多捡 20 分(60、80、100…)就再升一级。
|
||||
|
||||
#### 7.3.3 各分段的完整判定表(按 7.2.0 的方法代入对应 `call`、`Q`、基础子数)
|
||||
|
||||
**40 分坐庄(基础 8 个子,Q = 20)**
|
||||
|
||||
| 闲家捡分 | 判定 | 倍率 | 最终子数 |
|
||||
| --- | --- | --- | --- |
|
||||
| 0 分 | 庄家:大光 | ×3 | 24 个子 |
|
||||
| 1 ~ 19 分 | 庄家:小光 | ×2 | 16 个子 |
|
||||
| 20 ~ 39 分 | 庄家:过庄 | ×1 | 8 个子 |
|
||||
| 40 ~ 59 分 | 闲家:升 1 级 | ×1 | 8 个子 |
|
||||
| 60 ~ 79 分 | 闲家:升 2 级 | ×2 | 16 个子 |
|
||||
| 80 分起,每再多 20 分 | 闲家:再升 1 级 | 再 +1 | 逐级 +8 个子 |
|
||||
|
||||
**35 分坐庄(基础 9 个子,Q = 20)**
|
||||
|
||||
| 闲家捡分 | 判定 | 倍率 | 最终子数 |
|
||||
| --- | --- | --- | --- |
|
||||
| 0 分 | 庄家:大光 | ×3 | 27 个子 |
|
||||
| 1 ~ 19 分 | 庄家:小光 | ×2 | 18 个子 |
|
||||
| 20 ~ 34 分 | 庄家:过庄 | ×1 | 9 个子 |
|
||||
| 35 ~ 54 分 | 闲家:升 1 级 | ×1 | 9 个子 |
|
||||
| 55 ~ 74 分 | 闲家:升 2 级 | ×2 | 18 个子 |
|
||||
| 75 分起,每再多 20 分 | 闲家:再升 1 级 | 再 +1 | 逐级 +9 个子 |
|
||||
|
||||
**30 分坐庄(基础 10 个子,Q = 15)**
|
||||
|
||||
| 闲家捡分 | 判定 | 倍率 | 最终子数 |
|
||||
| --- | --- | --- | --- |
|
||||
| 0 分 | 庄家:大光 | ×3 | 30 个子 |
|
||||
| 1 ~ 14 分 | 庄家:小光 | ×2 | 20 个子 |
|
||||
| 15 ~ 29 分 | 庄家:过庄 | ×1 | 10 个子 |
|
||||
| 30 ~ 44 分 | 闲家:升 1 级 | ×1 | 10 个子 |
|
||||
| 45 ~ 59 分 | 闲家:升 2 级 | ×2 | 20 个子 |
|
||||
| 60 分起,每再多 15 分 | 闲家:再升 1 级 | 再 +1 | 逐级 +10 个子 |
|
||||
|
||||
**25 分坐庄(基础 11 个子,Q = 15)**
|
||||
|
||||
| 闲家捡分 | 判定 | 倍率 | 最终子数 |
|
||||
| --- | --- | --- | --- |
|
||||
| 0 分 | 庄家:大光 | ×3 | 33 个子 |
|
||||
| 1 ~ 14 分 | 庄家:小光 | ×2 | 22 个子 |
|
||||
| 15 ~ 24 分 | 庄家:过庄 | ×1 | 11 个子 |
|
||||
| 25 ~ 39 分 | 闲家:升 1 级 | ×1 | 11 个子 |
|
||||
| 40 ~ 54 分 | 闲家:升 2 级 | ×2 | 22 个子 |
|
||||
| 55 分起,每再多 15 分 | 闲家:再升 1 级 | 再 +1 | 逐级 +11 个子 |
|
||||
|
||||
**20 分坐庄(基础 12 个子,Q = 10)**
|
||||
|
||||
| 闲家捡分 | 判定 | 倍率 | 最终子数 |
|
||||
| --- | --- | --- | --- |
|
||||
| 0 分 | 庄家:大光 | ×3 | 36 个子 |
|
||||
| 1 ~ 9 分 | 庄家:小光 | ×2 | 24 个子 |
|
||||
| 10 ~ 19 分 | 庄家:过庄 | ×1 | 12 个子 |
|
||||
| 20 ~ 29 分 | 闲家:升 1 级 | ×1 | 12 个子 |
|
||||
| 30 ~ 39 分 | 闲家:升 2 级 | ×2 | 24 个子 |
|
||||
| 40 分起,每再多 10 分 | 闲家:再升 1 级 | 再 +1 | 逐级 +12 个子 |
|
||||
|
||||
**15 分坐庄(基础 13 个子,Q = 10)**
|
||||
|
||||
| 闲家捡分 | 判定 | 倍率 | 最终子数 |
|
||||
| --- | --- | --- | --- |
|
||||
| 0 分 | 庄家:大光 | ×3 | 39 个子 |
|
||||
| 1 ~ 9 分 | 庄家:小光 | ×2 | 26 个子 |
|
||||
| 10 ~ 14 分 | 庄家:过庄 | ×1 | 13 个子 |
|
||||
| 15 ~ 24 分 | 闲家:升 1 级 | ×1 | 13 个子 |
|
||||
| 25 ~ 34 分 | 闲家:升 2 级 | ×2 | 26 个子 |
|
||||
| 35 分起,每再多 10 分 | 闲家:再升 1 级 | 再 +1 | 逐级 +13 个子 |
|
||||
|
||||
**10 分坐庄(基础 14 个子,Q = 5——这一档同样没有"小光")**
|
||||
|
||||
分牌只有 5 分、10 分两种面值,捡分永远是 5 的倍数。`Q = 5` 时,"小光"对应的区间 `0 < grade < 5` 里不存在任何 5 的倍数,同样是空区间:闲家捡分不可能停在 1 ~ 4 分,只要捡到分就已经是 5 分(直接落入"过庄"区间)。
|
||||
|
||||
| 闲家捡分 | 判定 | 倍率 | 最终子数 |
|
||||
| --- | --- | --- | --- |
|
||||
| 0 分 | 庄家:大光 | ×3 | 42 个子 |
|
||||
| 5 ~ 9 分 | 庄家:过庄(没有小光这一档) | ×1 | 14 个子 |
|
||||
| 10 ~ 14 分 | 闲家:升 1 级 | ×1 | 14 个子 |
|
||||
| 15 ~ 19 分 | 闲家:升 2 级 | ×2 | 28 个子 |
|
||||
| 20 分起,每再多 5 分 | 闲家:再升 1 级 | 再 +1 | 逐级 +14 个子 |
|
||||
|
||||
**5 分坐庄(基础 15 个子,没有"小光",见 7.3.2 的说明)**
|
||||
|
||||
5 分是叫分范围的下限,捡分只能是 5 的倍数:一旦捡到任何分牌就已经是 5 分,等于直接达标(`grade ≥ call = 5`)。所以"小光"和"过庄"这两个区间都不存在,闲家捡分只有 0(大光)和达到 5 分及以上(直接升 1 级)两种结果。
|
||||
|
||||
| 闲家捡分 | 判定 | 倍率 | 最终子数 |
|
||||
| --- | --- | --- | --- |
|
||||
| 0 分 | 庄家:大光 | ×3 | 45 个子 |
|
||||
| 5 ~ 9 分 | 闲家:升 1 级(没有小光、过庄这两档) | ×1 | 15 个子 |
|
||||
| 10 ~ 14 分 | 闲家:升 2 级 | ×2 | 30 个子 |
|
||||
| 15 分起,每再多 5 分 | 闲家:再升 1 级 | 再 +1 | 逐级 +15 个子 |
|
||||
|
||||
`≥ 45` 分档(65 / 60 / 55 / 50 / 45)判定逻辑与 7.2.1 完全相同(Q = 40),只是 45 分档基础子数按 7.3.1 取 7 个子,其余数值套用 7.2.1 对应叫分的表即可,此处不重复列出。
|
||||
|
||||
70 分叫庄时与常规算子规则一致(见 7.2.1 末尾"70 分坐庄":投降固定基础子数 1 个,打牌基础子数 2 个并正常套用大光 / 小光 / 过庄 / 升级判定),不适用本节的保小光分段。
|
||||
|
||||
---
|
||||
|
||||
## 8. 算奖规则
|
||||
|
||||
算奖在**每个小局结算时**计算(与第 7 节的子数结算属于同一次小局结算,不是等大局所有局数打完才统一算,也不是在出牌过程中途算)。8.1 冲关只计入庄家;8.3 傍王是额外叠加的独立奖励,庄家、闲家都要算——两者的计入范围不同,注意区分。
|
||||
|
||||
**8.1 冲关只认庄家的身份,不看闲家的手牌是否达标**:即使某位闲家的手牌结构凑巧也满足 8.1 表里的条件(比如手里也有 3 张王),这份冲关也**不会**计入这位闲家——冲关从规则上就只针对庄家一人。闲家想要获得算奖,只能靠 8.3 傍王(且必须房间勾选了这条可选规则)。
|
||||
|
||||
**算奖依据的手牌快照时间点**:不管是 8.1(只看庄家)还是 8.3 傍王(庄闲都算),都按**静态初始手牌**计算——庄家是埋牌完成后的那一刻手牌(28 张),闲家是发牌完成后的那 28 张手牌;这个快照全程固定,不随之后的出牌、被吃、被打出而改变。**例外**:70 分投降局庄家未选主也未埋牌,其快照为"发牌 + 底牌"共 36 张(无主牌花色),见 8.4 节。
|
||||
|
||||
**算奖(8.1 / 8.3)与亮牌(8.2)是两个互不干涉的独立概念**:算奖是结算阶段"该给多少额外结算分"的规则;亮牌只是出牌开始前"要不要向对手公开一部分手牌"的展示规则。两者各自有自己的触发条件,只是恰好都以庄家埋牌后的手牌结构为依据,因此部分门槛数值相同——但达成亮牌的条件不代表一定触发算奖,反之亦然,具体对照见 8.2 末尾的说明。
|
||||
|
||||
### 8.1 冲关
|
||||
|
||||
> **旧称「常规算奖」**,现统一叫**冲关**(与服务端字段 `chongguan`、界面「冲关分」「冲关牌型」同口径)。
|
||||
> 它只是「算奖」的**其中一个分量**——另一个是 8.3 傍王;两者求和才是某玩家的总奖数 `N`(见 8.4)。
|
||||
|
||||
|
||||
| 组合 | 奖数 |
|
||||
| --- | --- |
|
||||
| 三个王 | 1 奖 |
|
||||
| 四个王 | 3 奖 |
|
||||
| 六个 7 | 1 奖 |
|
||||
| 七个 7 | 2 奖 |
|
||||
| 八个 7 | 3 奖 |
|
||||
| 六个 2 | 1 奖 |
|
||||
| 七个 2 | 2 奖 |
|
||||
| 八个 2 | 3 奖 |
|
||||
|
||||
三王 / 四王在拥有"正 7"及以后连续对子的情况下,每多一对连续对子再加 1 奖,连续顺序为:
|
||||
|
||||
```
|
||||
正7 - 副7 - 正2 - 副2 - A - K - Q - J - 10 - 9 - 8 - 6 - 5
|
||||
```
|
||||
|
||||
其中从 A 到 5(即 A、K、Q、J、10、9、8、6、5 这一段普通点数)都必须是选定花色的主牌,才能计入连续对子——非主花色的普通对子不算数,链条到这里就断了。
|
||||
|
||||
### 8.2 亮牌规则
|
||||
|
||||
**亮牌与算奖(8.1 / 8.3)是两个独立概念,互不干涉**:亮牌只是"要不要向对手公开一部分手牌"的展示规则,本身不产生任何奖数、不影响结算;算奖该怎么算、算多少,完全按 8.1 / 8.3 的规则来,不因为触发了亮牌而增加或减少。
|
||||
|
||||
**亮牌要求**(仅针对庄家):庄家**埋牌后**、**出牌开始前**,若手中剩余的**固定主牌**满足下表任一条件,就要向两个闲家**亮出自己全部固定主牌的具体牌面**。
|
||||
|
||||
| 触发条件(任一满足即亮牌) |
|
||||
| --- |
|
||||
| 固定主牌(双王 + 全部 2 + 全部 7)总数 ≥ 10 张 |
|
||||
| 王 ≥ 3 张 |
|
||||
| 7 ≥ 6 张 |
|
||||
| 2 ≥ 6 张 |
|
||||
|
||||
**亮出的内容是固定的一份**——**庄家手中全部固定主牌的具体牌面**(双王 + 全部 2 + 全部 7;**不含**主花色的普通牌 A/K/Q/J/10/9/8/6/5)。不论是被上表哪一条触发、还是同时满足多条,亮出的都是这同一份牌,不因触发条件不同而增减。
|
||||
|
||||
- **不限叫分**:任何叫分档位都适用,不是 70 分坐庄专有。
|
||||
- **仅可查牌模式**:房间勾选了"不查牌"时,闲家看不到亮牌(见第 9 节)。
|
||||
- **只亮庄家的**:闲家不亮牌。
|
||||
- 亮牌依据的是**埋牌后**的手牌,与 8.1 冲关用的是同一份静态快照(见第 8 节开头),全局固定、不随之后出牌缩水。
|
||||
|
||||
> **亮牌给的是牌面,不是统计**。这一点与「余主公示」(只给剩余主牌数与对子数,见第 9 节)不同;与「明牌」(查看**他家**全部未出主牌的牌面,见第 9 节)也不同——亮牌是**庄家主动公开自己的**,明牌是**闲家去看别人的**。
|
||||
|
||||
**亮牌门槛与 8.1 冲关起点的数值巧合,不代表两者是同一条规则**:王 ≥ 3 张 / 7 ≥ 6 张 / 2 ≥ 6 张这三个门槛,数值上恰好分别与 8.1 表里"三个王""六个 7""六个 2"的冲关起点相同,达到时会同时触发"亮牌"和"冲关"这两件独立的事——但它们是各自独立判定后凑巧同时发生,不是"亮牌导致冲关"或者"冲关导致亮牌"。这一点在"固定主牌 ≥ 10 张"这一档表现得最清楚:它只在亮牌这条规则里有定义、会触发亮牌,但 8.1 的冲关表里根本没有为这一档定义奖数,所以**不产生任何冲关奖**——如果亮牌和冲关是同一回事,这里就该矛盾;现在不矛盾,正说明两者互不干涉。
|
||||
|
||||
### 8.3 傍王(可选规则)
|
||||
|
||||
开房时勾选"傍王"后,每张王额外算一奖:这个奖是**庄家、闲家都要各自计算**(每个人按自己手里的王算,不限庄家)。
|
||||
|
||||
**傍王奖是在 8.1 冲关之外额外叠加的一份奖励,与冲关互不冲突、不互相替代**:庄家照常按 8.1 的规则算冲关(只计庄家),同时如果勾选了傍王,庄家、闲家还要**各自另外**再按自己手里的王数算一份傍王奖;两份奖励分开计算,最后相加。
|
||||
|
||||
### 8.4 算奖如何影响最终结算
|
||||
|
||||
**算奖是"持有奖数的那个人"从其余两名玩家那里各自多收一笔钱的独立结算线,不是把第 7 节算出的输赢结果整体重新乘一遍**:设第 7 节前三层算出的结算子数为 `X`(庄家与每个闲家之间"一对一"结算的基础金额,见 7.0 第 3 层),玩家 P 这一局总共积累了 `N_P` 奖(8.1 冲关 + 8.3 傍王,各自求和后相加;只有庄家可能有 8.1 的分量,8.3 傍王则庄闲都可能有):
|
||||
|
||||
- 若 `N_P > 0`,**另外两名玩家(不分是庄家还是闲家)都要各自额外付给 P:`X × N_P` 子**;
|
||||
- 若 `N_P = 0`,P 没有额外收入。
|
||||
|
||||
三名玩家可能同时都持有各自的奖数,这时会产生最多 3 条互相独立、方向不同的额外支付线,互不冲突、按各自的 `N` 值同时生效,最后与第 1~3 层的基础输赢结算加总,才是每个人这一局的最终盈亏。
|
||||
|
||||
**举例**(`X = 6` 个子,庄家为 A,闲家为 B、C):
|
||||
|
||||
- 若庄家 A 本局有 1 奖(`N_A = 1`):B、C 各自额外付给 A `6 × 1 = 6` 子。
|
||||
- 若闲家 B 同时勾选了"傍王"、本局有 2 奖(`N_B = 2`):A、C 各自额外付给 B `6 × 2 = 12` 子。
|
||||
- 这两条支付线同时成立、互不影响:A 从 B、C 各多收 6 子,B 从 A、C 各多收 12 子——包括"闲家 C 要付给闲家 B"这样的支付,即使 B、C 之间在第 1~3 层的基础输赢结算里并不直接结算(基础结算只发生在"庄家 vs 单个闲家"之间),算奖这一层是单独叠加在所有玩家两两之间的。
|
||||
|
||||
以下三点已确认:
|
||||
|
||||
- **庄家倒庄(闲家达标升级)时,8.1 冲关仍然生效**:冲关只看庄家埋牌后的手牌结构,与本局谁输谁赢无关。
|
||||
- **70 分投降时,仍叠加算奖倍率**:投降只是跳过了第 1~3 层的叫分 / 捡分判定(此时 `X = 1`)。投降是选主阶段的选择,庄家**未选主、未埋牌**,故算奖按庄家"发牌 + 底牌"共 36 张手牌计算——因没有选定主牌花色,**没有连对链**,只有三 / 四王、六~八个 7、六~八个 2 这些组合,以及(勾选傍王时)按王数的傍王奖计入;8.1(及勾选傍王时的 8.3)照常计算并叠加。
|
||||
- **算奖对象的身份限制会带进 `N` 的计算**:8.1 只算庄家、8.3 傍王勾选后才不分庄闲(见第 8 节开头说明),把这名玩家实际适用的分量求和,才是他自己的 `N`;"其余两人向他支付 `X × N`"这条支付规则本身不因身份而改变,但闲家的 `N` 从一开始就不可能包含 8.1 的分量。
|
||||
|
||||
---
|
||||
|
||||
## 9. 牌局查看模式
|
||||
|
||||
"查牌"指的是**回看那些不该随时可见的信息**:已经打过去的牌、他家手里还剩多少主牌、他家主牌的具体牌面。房间的"查牌模式"开关决定下列四项功能**整体**是否提供。
|
||||
|
||||
- **可查牌模式**(四项功能全部提供):
|
||||
1. **出牌历史**:当前牌局中,所有玩家已经打出的牌(含之前每一轮打出、已被收走的牌),任何时候都可以回看。
|
||||
2. **余主公示**:一旦有玩家的主牌全部打空(即 5.4.2 节所说的"报无主"状态;"主牌"指大王、小王、所有花色的 2、所有花色的 7,以及主花色的其余普通牌,见第 3 节),系统就为**全体三人**(含刚刚报无主的这位玩家自己)显示另外两家各自的主牌数量、以及这些主牌里有多少对子。**只给数量、不给具体牌面**——要看具体牌面是下一条的"明牌"。
|
||||
3. **明牌**:同时**为这三个人都**出现一个"明牌"按钮——判定依据是「**场上有人**报无主」,不是「**自己**报无主」,所以报无主的那位和另外两位一样都能点。点击后可以查看另外两家手中全部主牌的**具体牌面**(不只是数量 / 对子结构),再点一次取消查看、恢复隐藏;**不限次数,整个出牌阶段随时可查**。
|
||||
4. 庄家若触发了 8.2 节的"亮牌要求",两个闲家可以看到**庄家全部固定主牌的具体牌面**(见 8.2 节)。
|
||||
|
||||
- **不可查牌模式**:以上四项**一项都不提供**——
|
||||
- **不提供出牌历史**:之前各轮打出过什么牌一律不能回看,只能靠玩家自己记牌;
|
||||
- **不提供余主公示**:即使有玩家报无主,也不会显示任何人的主牌数量、对子结构,也没有"明牌"按钮;
|
||||
- 庄家即使触发了 8.2 节的亮牌条件,闲家也看不到那些牌(见 8.2 节)。
|
||||
|
||||
> **"查牌"与"当前这一轮桌面上的牌"是两回事**:本轮已经出过牌的玩家、打在桌面上的这几张牌,**两种模式下都必须对全体可见**——否则后出的人无从跟牌、也无从判断本轮谁最大。查牌模式管的是**已经收走的往轮牌**与**他家手牌信息**能不能回看,不影响当前这一轮的正常出牌展示(断线重连回到牌桌时同理:当前轮桌面上的牌照常恢复,往轮历史则按本节的模式开关决定给不给)。
|
||||
|
||||
---
|
||||
|
||||
## 10. 房间设置选项
|
||||
|
||||
创建页依次展示:**局数、扣卡方式、查牌模式、傍王、爬坡**。前三项各自是独立的单选必选组,每组必须且只能保留一个有效选项;傍王和爬坡各自独立可选,可以均不选、任选其一或同时勾选。
|
||||
|
||||
### 10.1 局数与牌局扣卡方式
|
||||
|
||||
局数为 6 局 / 12 局,单选必选;扣卡方式为房主扣卡 / AA 制,单选必选。两组分别选择,费用按下表联动展示。
|
||||
|
||||
| 扣卡方式 | 6 局 | 12 局 |
|
||||
| --- | --- | --- |
|
||||
| AA 制(每人扣卡) | 每人 1 张 | 每人 2 张 |
|
||||
| 房主扣卡 | 2 张 | 4 张 |
|
||||
|
||||
### 10.2 查牌模式
|
||||
|
||||
可查牌 / 不查牌,单选必选(见第 9 节)。
|
||||
|
||||
### 10.3 附加规则
|
||||
|
||||
傍王、爬坡:均可不选,也可以同时勾选,互不冲突。未选傍王不计算傍王奖励;未选爬坡使用常规算子。
|
||||
|
||||
### 10.4 创建参数与当前实现差异
|
||||
|
||||
默认选择:6 局、房主扣卡、可查牌,傍王和爬坡均不勾选。前三项已有有效默认选择,用户无需重复点击才能提交。
|
||||
|
||||
规则设计者要求 `roomtype` 使用 11 位字符串,前 5 位依次为局数、扣卡方式、查牌模式、傍王、爬坡,后 6 位预留。
|
||||
|
||||
**2026-09-07 服务端核验:长度约定已支持,位序尚不一致。** 当前 `class.config.js` 按局数、扣卡方式、傍王、爬坡、查牌模式解析前 5 位;尾部 6 位当前约定为 `000000`。默认完整值为 `00000000000`。设计要求的位序不能直接当作当前服务器已经支持的编码,否则会把查牌、傍王、爬坡解释成另一项规则。
|
||||
|
||||
实际传输编码、示例和兼容行为统一见 [协议 §0.5](../protocol/packet_protocol.md#05-roomtype-房间选项位串)。界面顺序可以独立于传输位序;当前前端接入应通过规则编码层匹配现有服务端。若要求传输位序也改为设计顺序,需要另行明确服务端契约变更,本次文档核验未修改服务器实现。
|
||||
|
||||
---
|
||||
|
||||
## 11. 牌局交互提示
|
||||
|
||||
- 选主阶段:4 个花色选主按钮上各显示"该花色在庄家手中的对子数"(供庄家判断选哪门为主);70 分坐庄时并列再显示一个投降按钮——选主与投降是同一决策点的互斥选择(见第 4 节)。
|
||||
- 首家出主牌时可以甩牌,甩牌的生效条件、跟牌规则、甩错惩罚详见 5.4 节。
|
||||
- 牌局结束需要亮出埋牌底牌。
|
||||
- 闲家在出牌时需要有 3 个提示选项:
|
||||
1. **踩**:提示对家"我能大过庄家"。
|
||||
2. **没分**:提示对家"我手上没分了"。
|
||||
3. **有分**:提示对家"我手上有分"。
|
||||
- **倒计时只作展示,不触发任何自动操作**:叫分 / 选主 / 埋牌 / 出牌四个阶段都会给出一个倒计时秒数,用于界面提醒当前该谁操作。**倒计时归零后不做任何代打**——不自动叫分、不自动选主、不自动埋牌、不自动出牌,也不判负、不跳过该玩家;轮到谁而谁不操作,牌局就停在这一步一直等。
|
||||
|
||||
> **"无超时托管"是确认过的规则,不是待实现的缺口**:本局不设服务端代打/AI 托管,也不因超时改变任何对局状态。若玩家长时间不操作导致牌局停住,由玩家走房间的**解散**流程收场,按当前累计分结算(见 12.2)。**后续核对不要把"没有超时动作"当成缺陷去补"超时托管/自动出牌"。**
|
||||
|
||||
---
|
||||
|
||||
## 12. 完整牌局游玩流程
|
||||
|
||||
本节把前面各节的规则串成一局(一个"小局")从头到尾的完整流程,供整体理解与实现参照。这里只给出**步骤与先后顺序**,每一步的具体细则以其对应章节为准(避免与细则重复而产生分歧)。
|
||||
|
||||
### 12.1 一个小局的完整流程
|
||||
|
||||
1. **开局准备**:房间按第 10 节的设置(局数、扣卡方式、傍王、爬坡、查牌)建好后开战。第一局的暂定庄家为 0 号座位;之后每局按 4.6 的轮庄规则确定暂定庄家。
|
||||
|
||||
2. **发牌**(第 2 节):92 张牌洗匀,三家各摸 28 张,剩余 8 张扣在桌面作为"底牌"。
|
||||
|
||||
3. **叫分坐庄**(4.2):由暂定庄家起、按逆时针依次叫分。暂定庄家必叫(5~70 分、步进 5,不能"不叫");其后每家只能叫比当前更低的分,或选择"不叫";有人叫出 5 分即立即坐庄,或一家叫分后另外两家都"不叫"也即坐庄。叫分越低表示庄家对"压住闲家捡分"越有信心,对应的基础子数越高(第 7 节)。
|
||||
|
||||
4. **摸底**:庄家坐定后把 8 张底牌摸入手牌(摸完共 36 张)。非 70 分:这 8 张仅庄家可见。**70 分坐庄先触发「开底」:庄家摸底之前,先把这 8 张向所有玩家翻开 3 秒,再摸入手牌**(4.4 / 第 4 节)。
|
||||
|
||||
5. **选主 / 投降(同一决策点,二选一,互斥)**(第 4 节):
|
||||
- **选主**:庄家选定一门花色为本局主牌(大王、小王、所有花色的 2、7 恒为主牌)→ 进入下一步埋牌。前端此阶段每个花色按钮上要显示该花色在庄家手中的对子数;70 分坐庄时并列显示投降按钮。
|
||||
- **投降**:仅 70 分坐庄才有此选项;选投降即放弃本局,**不选主、不埋牌、不出牌**,直接按 7.1"70 分 · 投降"结算(算奖仍按下方 8 计,但用庄家 36 张、无主牌花色)。选花色就等于放弃投降、正常打牌。
|
||||
- (**开底**——70 分的 8 张底牌翻开 3 秒,发生在步骤 4"摸底之前"。)
|
||||
|
||||
6. **埋牌**(第 4 节,仅"选主/打牌"路径):庄家从 36 张里选 8 张重新扣下作为"埋牌底牌"(供最后"扣底"判断),保留 28 张。(**先选主、后埋牌**。)
|
||||
|
||||
7. **出牌对局**(第 5、6 节):
|
||||
- 第一轮固定由庄家先出;此后每轮由上一轮牌面最大的一方先出。
|
||||
- 首家出什么花色 / 牌型,其余两家按 5.1~5.3 跟牌(同花色不够时可用主牌"毙牌"抢权,或"垫牌"不争)。
|
||||
- 首家出主牌时可"甩牌"(5.4):一次性打出多组主牌组合;服务端按全场手牌判定"最大性",甩错则按 5.4.5 收回、只强制打出最小一张。副牌一律不能甩。
|
||||
- 每轮比大小定出胜者:闲家赢下的那一轮,牌面上的分牌(5→5 分、10→10 分、K→10 分)计入闲家捡分;庄家赢的轮分牌作废(6.1~6.2)。
|
||||
- 过程中:有玩家主牌出空需"报无主",可查牌模式下触发**余主公示**(为全体展示他家主牌数量 / 对子结构,第 9 节);庄家手牌达到 8.2 的门槛时触发**亮牌**。
|
||||
|
||||
8. **末轮扣底**(6.3):所有手牌出完后,若最后一轮由闲家**用主牌**赢下,则庄家埋的 8 张埋牌底牌翻开,其中的分按闲家赢牌的牌型翻倍(单张 ×1、主对 ×2、N 连对 ×2N)后计入闲家捡分。
|
||||
|
||||
9. **小局结算**(第 7、8 节,两部分同时结算):
|
||||
- **算子**:闲家总捡分(含扣底)与庄家叫分比较 → 判定庄家的**过庄 / 小光 / 大光**,或闲家的**升级第几级** → 结合叫分档的基础子数,得到庄家与每个闲家"一对一"的结算子数 `X`(勾选爬坡则改用 7.3 的梯度与分段)。
|
||||
- **算奖**:以静态初始手牌(正常局:庄家埋牌后 28 张、闲家发牌后 28 张;**70 分投降局**:庄家用发牌+底牌共 36 张、无主牌花色)计算奖数 `N` —— 冲关(8.1)**只计庄家**;勾选傍王(8.3)则庄闲每张王各算一奖。持有 `N` 奖的玩家从另外两人各多收 `X × N`(8.4)。
|
||||
- 以上两部分加总即各家本局盈亏;牌局结束需亮出埋牌底牌(第 11 节)。
|
||||
|
||||
10. **进入下一局**(4.6):庄家本局赢则连庄(暂定庄家仍是他);庄家输(闲家捡分达标 / 庄家投降)则暂定庄家顺延到其下家。回到第 2 步开新的一小局。
|
||||
|
||||
### 12.2 大局与结束
|
||||
|
||||
按房间设定的局数(6 局或 12 局,见第 10 节)打满全部小局后,进行大局结算(累计各家总分并记录战绩);若中途解散,则按当前累计分结算。
|
||||
|
||||
### 12.3 阶段速览
|
||||
|
||||
```
|
||||
建房(§10) → 发牌(§2) → 叫分坐庄(§4.2) → 摸底[70分先开底] → 选主(§3) → 埋牌(§4)
|
||||
→ [70分:投降/打牌决策(§4.4)] → 出牌对局(§5,§6.1-6.2) → 末轮扣底(§6.3)
|
||||
→ 小局结算=算子(§7)+算奖(§8) → 轮庄(§4.6) → 下一局 …… 打满局数 → 大局结算
|
||||
```
|
||||
|
||||
@@ -0,0 +1,722 @@
|
||||
# 二七王协议包列表
|
||||
|
||||
说明:`data` 均为发送方与接收方之间约定的 JSON 内容;成败判定只看推送包里的 `data.success`。
|
||||
|
||||
---
|
||||
|
||||
## 0. 通用约定(所有包适用,下文各表不再逐一重复)
|
||||
|
||||
### 0.0 术语:底牌 vs 埋牌底牌(先读这一条)
|
||||
|
||||
本项目于 2026-08-25 修订了这两个术语(`design.md` §1 已同步,本文亦已改用新称):
|
||||
|
||||
| 术语 | 指哪 8 张 | 旧称 |
|
||||
| --- | --- | --- |
|
||||
| **底牌** | 发牌时**没有发给玩家**、扣在桌面的 8 张 | ~~暗牌~~ |
|
||||
| **埋牌底牌** | 庄家**埋牌**时从手里扣下的 8 张 | ~~底牌~~ |
|
||||
|
||||
**两批牌的字段名已拆分**(2026-08-25,原先共用 `bottomcards`):
|
||||
|
||||
| 字段名 | 含义 | 出现在 |
|
||||
| --- | --- | --- |
|
||||
| **`bottomcards`** | **底牌** | `shangzhuang`、`deskinfo.ChooseMain`、`deskinfo.BuryCards` |
|
||||
| **`burycards`** | **埋牌底牌** | `maipai`、`deskinfo.PushCards` |
|
||||
| `bottom.cards` | **埋牌底牌** | 结算包的 `bottom` 分组(分组名已表明是抠底相关,字段未改名) |
|
||||
|
||||
同一个包里不会同时出现这两个字段。**下发面**:`burycards` 与 `bottomcards`(非 70 分时)都**只发给庄家**,闲家两者皆无——已由 `test/test_leak.js` 的泄露审计覆盖。
|
||||
|
||||
### 0.0b 四类「公开信息」术语(都带亮/明字,别读混)
|
||||
|
||||
| 术语 | 谁公开给谁 | 给的是什么 | 触发 | 本文相关字段 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| **亮牌** | 庄家 → 两个闲家 | **具体牌面**(庄家全部固定主牌) | 庄家埋牌后固定主牌达门槛(design §8.2) | `liangpai.cards` |
|
||||
| **余主公示** | 全体 → 全体 | **只有数量**(剩余主牌数、主对数) | 任一玩家报无主(design §9) | `seatlist[seat][4]`、`baozhu` |
|
||||
| **明牌** | 他家 → 请求者 | **具体牌面**(他家全部未出主牌) | 可查牌 + **任一**玩家报无主 → **三家均可点、随时可查**(design §9) | `mingpai.others[].zhucards` |
|
||||
| **开底** | 桌面 → 所有玩家 | **具体牌面**(8 张底牌) | 70 分坐庄,**摸底之前** 3 秒(design §4) | `ancard3s` + `bottomcards` |
|
||||
|
||||
一句话记:**只有「余主公示」是统计类(给数字),「亮牌」「明牌」「开底」都给具体牌面**——区别在给谁的牌:亮牌给庄家自己的、明牌给他家的、开底给桌上那 8 张。
|
||||
|
||||
另有一组「底」字族专管发牌留桌的那 8 张:**摸底**(庄家摸起看,仅庄家)、**开底**(70 分时全场看 3 秒)、**查底牌**(对局中随时回看)、**扣底**(末轮翻开埋牌底牌计分)。
|
||||
(「亮主」不是术语,是前端选主界面的标题文案,与上表无关。)
|
||||
|
||||
|
||||
### 0.0c 算奖 = 冲关 + 傍王
|
||||
|
||||
| 术语 | 范围 | 相关字段 |
|
||||
| --- | --- | --- |
|
||||
| **冲关** | design §8.1,**只计庄家**。旧称"常规算奖",现统一叫冲关 | `chongguan`(奖数)、`cards`(冲关牌型) |
|
||||
| **傍王** | design §8.3,可选规则,勾选后**庄闲都算**,每张王 1 奖 | `wang`(王数)、`bangwang`(开关) |
|
||||
| **算奖** | **上位概念** = 冲关 + 傍王 | `naward`(总奖数 N)、`grade_aw`(算奖得分) |
|
||||
|
||||
说「**冲关**」指庄家那一份,说「**算奖**」指两者合计。界面上小局结算显示「冲关分」、按钮叫「冲关牌型」。
|
||||
|
||||
> ⚠️ `grade_aw` 是按合计后的 `N` 算出来的,**冲关与傍王的贡献事后无法拆分**。大局结算若要分别显示「冲关分」「傍王分」,须服务端在算钱时就分开代入公式——见前端清单 §7.5 **S-6**。
|
||||
|
||||
### 0.1 成败标志 `data.success`
|
||||
|
||||
**每一个「服务器 → 客户端」的包,`data` 都必带 `success`**(布尔):
|
||||
|
||||
- `success: true` —— 操作成功 / 正常推送。下文各包的字段表描述的都是这种情况,表里不再重复列 `success` 这一行。
|
||||
- `success: false` —— 操作失败,见 0.2。
|
||||
|
||||
前端一律 `if (!data.success)` 判成败,**不看 `status` / `code`,也不写 `status` 兼容兜底**。
|
||||
|
||||
### 0.2 失败回包
|
||||
|
||||
服务端受理请求时,任一校验不通过都会**回一个失败包**(此前是静默丢弃、前端只能干等倒计时):
|
||||
|
||||
- **rpc 与请求包同名**(如 `chupai` 失败回 `chupai`,而不是 `chupai1/2/3`);
|
||||
- **只回发给请求者**(`conmode`/`fromid` 取自请求包),其他两家收不到;
|
||||
- `data` 只含 `success: false` 与 `errcode`,无业务字段。
|
||||
|
||||
| 参数名 | 类型 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| success | 布尔 | 恒为 `false` |
|
||||
| errcode | 整数 | 失败原因,见下表 |
|
||||
|
||||
`errcode` 取值(服务端 `youle_erqiwang.ERR`,`mod.js`):
|
||||
|
||||
| 值 | 名称 | 含义 |
|
||||
| --- | --- | --- |
|
||||
| 1 | PLAYER | 玩家/房间/座位校验不通过(平台 `check_player` 返回 null) |
|
||||
| 2 | NODESK | 牌桌或牌局不存在 |
|
||||
| 3 | STEP | 当前阶段不允许该操作(如非出牌阶段发 `chupai`) |
|
||||
| 4 | SEAT | 位置不符,或还没轮到该玩家操作 |
|
||||
| 5 | PARAM | 参数非法:类型/范围/张数不对、牌不在手上、牌id 重复 |
|
||||
| 6 | RULE | 规则不允许:叫分未更低、出牌不合法、投降条件不满足、房间模式禁止等 |
|
||||
|
||||
> **牌id 列表的入参约束**(`cards` 字段,`maipai`/`chupai`):必须是**非空数组**,元素必须是 `0 ~ 107` 的**整数**牌id,且**互不重复**。字符串形式的数字(如 `"5"`)、小数、越界值、重复值一律按 `PARAM` 拒绝,服务端不做类型兜底转换。
|
||||
|
||||
> **例外:`tishi` 成功时不回执**。该包是闲家给对家的主观提示、不含任何对局状态,服务端只转发给对家(design §11),发送者收不到成功包;只有失败才回给发送者。
|
||||
|
||||
### 0.3 `countdown` 只是展示用的秒数
|
||||
|
||||
多个包带 `countdown`(叫分 / 选主 / 埋牌 / 出牌倒计时,取自 `class.desk.js` 的四个常量)。它**只供客户端显示提醒,服务端不据此做任何事**:
|
||||
|
||||
- 服务端**没有**对应的定时器,倒计时归零后**不会**自动叫分 / 选主 / 埋牌 / 出牌,也不判负、不跳过该玩家;
|
||||
- 轮到谁而谁不操作,牌局就停在该阶段一直等,`step` 与 `playproc` 都不变;
|
||||
- 这是 design §11 确认过的规则(无超时托管),不是未实现的功能。牌局因此停住时,由玩家走平台的**房间解散**流程收场(平台回调子游戏 `get_disbandRoom` → 解散结算,见 §14)。
|
||||
|
||||
因此**客户端不要在倒计时归零时做任何乐观的界面推进**(不要自行跳阶段、不要清控制权),一切仍以收到服务端推送为准。
|
||||
|
||||
### 0.4 断线重连包不在此约定内
|
||||
|
||||
`deskinfo` 由平台组装在 `pack.data.deskinfo` 下(见文末「断线重连」),`data.success` 由**平台**填写,子游戏不注入。
|
||||
|
||||
### 0.5 `roomtype` 房间选项位串
|
||||
|
||||
**核对日期:2026-09-07。** 本节描述当前服务端实际编码,依据 [class.config.js](../../class.config.js) 的 `IDX_*`、`RESERVE_LEN`、`parse()`,以及 [test_config.js](../../test/test_config.js)。局数与扣卡由 [class.export.js](../../class.export.js) 的对应接口读取解析结果。
|
||||
|
||||
当前新前端发送约定为 **11 位字符串 = 前 5 位选项 + 后 6 位预留扩展位**,后 6 位当前填 `000000`。默认完整值为 `00000000000`。索引从 0 开始,对应 `charAt(0..4)`;前端不得把字符串转换为数字、数组或按界面排列顺序直接拼接。
|
||||
|
||||
| 当前传输位索引 | 含义 | '0' | '1' | 创建页要求 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| 0 | 局数 | 6 局(默认) | 12 局 | 单选必选 |
|
||||
| 1 | 扣卡方式 | 房主扣卡(默认) | AA 每人扣卡 | 单选必选 |
|
||||
| 2 | 傍王 | 关(默认) | 开 | 独立可选 |
|
||||
| 3 | 爬坡 | 常规算子(默认) | 爬坡 | 独立可选 |
|
||||
| 4 | 查牌模式 | 可查牌(默认) | 不查牌 | 单选必选 |
|
||||
| 5~10 | 预留扩展位 | 当前发送填 0 | 当前无玩法定义 | 无控件 |
|
||||
|
||||
**设计与实现差异:** 规则设计者指定的前 5 位顺序为“局数、扣卡方式、查牌模式、傍王、爬坡”,而当前源码与测试仍按上表解析。创建页按设计顺序展示,编码层按已核验的服务器位索引构造参数;这不表示设计要求的传输位序已在服务器落地。若后续变更服务端位序,需要同步核验协议和兼容方案,不能仅更新文档就声称契约已改变。
|
||||
|
||||
局数、扣卡、查牌分别单选必选;傍王与爬坡允许均不勾选、任选其一或同时勾选,未选仍写对应位的 '0'。编码示例(均为当前服务端顺序):
|
||||
|
||||
- `00000000000`:6 局、房主扣卡、可查牌、不傍王、不爬坡。
|
||||
- `10100000000`:12 局、房主扣卡、可查牌、傍王、不爬坡。
|
||||
- `00001000000`:6 局、房主扣卡、不查牌、不傍王、不爬坡。
|
||||
- `11011000000`:12 局、AA 扣卡、不查牌、不傍王、爬坡。
|
||||
|
||||
**实际解析兼容行为:** 服务端 `parse()` 接受至少 5 位的字符串,仅读取前 5 位;旧 5 位串及更长扩展串均可解析,不强制长度为 11。缺失、非字符串或不足 5 位时整体使用 `00000` 的语义;每个选项位只有字符 '1' 判为开启,其余字符判为关闭。预留位不校验内容,也不影响当前五项配置。此处记录的是来源实现,前端仍应在编码入口生成约定的合法 11 位串,不能依赖服务器容错掩盖错误。
|
||||
|
||||
`class.config.js` 是玩法配置的唯一解析入口;下游使用具名配置,不重新按下标解释。`multiple` / `bangwang` / `climb` / `baozhu` / `seatlist` / `liangpai` / `pushlist` 按相应配置取值或门控。源码 `cfg.climb` 行的行末注释写着“true=常规算子”,与位定义及算法分支不符;实际 true 表示启用爬坡,以执行逻辑为准。
|
||||
|
||||
对应的房卡与局数(`export.get_asetcount` / `get_needroomcard` / `get_needroomcard_joinroom`,design §10.1):
|
||||
|
||||
| 扣卡方式(位1) | 局数(位0) | 房主开房扣 | 加入者扣 |
|
||||
| --- | --- | --- | --- |
|
||||
| 房主扣卡 `'0'` | 6 局 | 2 张 | 0 |
|
||||
| 房主扣卡 `'0'` | 12 局 | 4 张 | 0 |
|
||||
| AA 每人 `'1'` | 6 局 | 1 张 | 1 张 |
|
||||
| AA 每人 `'1'` | 12 局 | 2 张 | 2 张 |
|
||||
|
||||
### 0.6 「一手出牌」的顺序口径
|
||||
|
||||
同一份业务数据「某一手打出的牌」会出现在**三个下发位置**:
|
||||
|
||||
| 位置 | 字段 |
|
||||
|---|---|
|
||||
| 出牌推送 | `chupai1` / `chupai2` / `chupai3` 的 `data.cards` |
|
||||
| 本轮进行态 | `playproc.cards[座位]`(`chupai*` 与重连包 `PushCards` 都带)|
|
||||
| 重连出牌历史 | `PushCards.pushlist[轮次-1][座位]` |
|
||||
|
||||
**这三处的数组内容逐元素完全相等**——顺序统一为「按**本局主牌花色**从大到小」的**权威顺序**。
|
||||
服务端只在落牌那一刻归一化一次(`class.paiju.js` 的 `order_playcards`),随后:
|
||||
出牌推送读它、`playproc.cards` 存它、出牌历史 `playhistory` 归档它,下游一律**只读、不重排**。
|
||||
|
||||
因此前端**「增量回放」与「重连重建」得到的出牌历史必定一致**,两条路径可以复用同一份解析。
|
||||
|
||||
> ⚠️ **请求包里的 `cards` 顺序无意义**:那是玩家的点击顺序,同一手牌每次都可能不同。
|
||||
> 服务端不采信、不回显、也不据它判定牌型(server dev-guide 04 §8「前端不是数据源」)。
|
||||
> 一手单张时看不出差别;**一手多张(甩牌 / 对子 / 拖拉机)时才会显形**,尤其是同编码的一对牌
|
||||
> ——排序本身区分不了它们,只有「出牌当场归档」这一个写入处才能保证两条路径给出同一个数组。
|
||||
|
||||
---
|
||||
|
||||
## 1. 发牌(fapai)
|
||||
|
||||
发包者:服务器 收包者:游戏 app:youle route:erqiwang rpc:fapai
|
||||
|
||||
| 参数名 | 类型 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| asetidx | 整数 | 当前局数 |
|
||||
| asetcount | 整数 | 总局数 |
|
||||
| cards | 数组 | 自己得到的牌id列表 |
|
||||
| step | 整数 | **本局阶段**(发完牌恒为 `1` 叫分;取值 1叫分 2选主/投降 3埋牌 5出牌 6结算)。阶段由服务端唯一维护、逐包显式给出,客户端**不得按 rpc 名反推**(server 03 §1.4「任何状态变更都要有包承载」/ 前端红线「数据驱动、前端无对局状态机」)。与同一时刻的重连包 `deskinfo.step` 同源同值 |
|
||||
| seat | 整数 | **控制权**:当前等待叫分者的位置。与重连包 `CallRun.seat` 同源(`get_callgrade_seat()`)|
|
||||
| countdown | 整数 | 叫分倒计时 |
|
||||
|
||||
---
|
||||
|
||||
## 2. 叫分或不叫(jiaofen,客户端→服务器)
|
||||
|
||||
发包者:游戏 收包者:服务器 app:youle route:erqiwang rpc:jiaofen
|
||||
|
||||
| 参数名 | 类型 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| agentid | 字符 | 代理id |
|
||||
| playerid | 整数 | 玩家id |
|
||||
| gameid | 字符 | 游戏id |
|
||||
| roomcode | 整数 | 房间号 |
|
||||
| seat | 整数 | 叫分者的位置序号 |
|
||||
| call | 整数 | 分数,0表示不叫 |
|
||||
|
||||
---
|
||||
|
||||
## 3. 叫分或不叫(jiaofen,服务器→客户端)
|
||||
|
||||
发包者:服务器 收包者:游戏 app:youle route:erqiwang rpc:jiaofen
|
||||
|
||||
| 参数名 | 类型 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| seat | 整数 | 叫分者的位置序号 |
|
||||
| call | 整数 | 分数,0表示不叫 |
|
||||
| currcall | 整数 | 当前叫到的分数 |
|
||||
| multiple | 整数 | 叫分对应的基础子数 `get_base_bycall(call, climb)`,**随房间「爬坡」开关取值**(roomtype 位3):常规算子 65→2 60→3 55→4 50及以下→6;爬坡 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;两种模式下 70→2;无叫分→0。与结算包 `aset.multiple` 同源同值(design §7.1/§7.3.1)|
|
||||
| step | 整数 | **本局阶段**(叫分尚未结束,恒为 `1`;取值 1叫分 2选主/投降 3埋牌 5出牌 6结算)。阶段由服务端唯一维护、逐包显式给出,客户端**不得按 rpc 名反推**(server 03 §1.4「任何状态变更都要有包承载」/ 前端红线「数据驱动、前端无对局状态机」)。与同一时刻的重连包 `deskinfo.step` 同源同值 |
|
||||
| nextseat | 整数 | **控制权**:下一个叫分者的位置序号,即本包之后「轮到谁」。与重连包 `CallRun.seat` 同源(`get_callgrade_seat()`)|
|
||||
| countdown | 整数 | 叫分倒计时 |
|
||||
|
||||
---
|
||||
|
||||
## 4. 上庄(shangzhuang)
|
||||
|
||||
发包者:服务器 收包者:游戏 app:youle route:erqiwang rpc:shangzhuang
|
||||
|
||||
| 参数名 | 类型 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| seat | 整数 | 叫分者的位置序号 |
|
||||
| call | 整数 | 分数,0表示不叫 |
|
||||
| banker | 整数 | 庄家的位置序号 |
|
||||
| grade | 整数 | 庄家的叫分 |
|
||||
| step | 整数 | **本局阶段**(叫分结束、进入选主/投降,恒为 `2`;取值 1叫分 2选主/投降 3埋牌 5出牌 6结算)。阶段由服务端唯一维护、逐包显式给出,客户端**不得按 rpc 名反推**(server 03 §1.4「任何状态变更都要有包承载」/ 前端红线「数据驱动、前端无对局状态机」)。与同一时刻的重连包 `deskinfo.step` 同源同值 |
|
||||
| nextseat | 整数 | **控制权**:本包之后「轮到谁」——恒为 `banker`,因为选主与投降都只由庄家做(服务端 `mod.xuanzhu`/`mod.touxiang` 的座位校验同样以 `banker` 为准)。<br>⚠️ **别和本包的 `seat` 混用**:`seat` 是**最后一个叫分者**,不是控制权。与重连包 `ChooseMain.seat` 同源同值 |
|
||||
| multiple | 整数 | 叫分对应的基础子数 `get_base_bycall(call, climb)`,**随房间「爬坡」开关取值**(roomtype 位3):常规算子 65→2 60→3 55→4 50及以下→6;爬坡 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;两种模式下 70→2;无叫分→0。与结算包 `aset.multiple` 同源同值(design §7.1/§7.3.1)|
|
||||
| bottomcards | 数组 | 8 张**底牌**(发牌时没发给玩家、扣在桌面的 8 张)。**庄家恒有**;**闲家仅当 70 分坐庄时才有**(供 3 秒亮牌用),非 70 分时闲家没有该属性(底牌只有庄家可见,design §4)。<br>**顺序在发牌结束时即冻结**(服务端 `paiju.bottomcards`,按「还没有主牌」的口径排一次):底牌是在**选主之前**翻给庄家看的,那时主牌花色尚不存在,故不按主牌花色排。本包与重连包 `ChooseMain.bottomcards` / `BuryCards.bottomcards` 三处**同序**,客户端存一次即可全程复用(含「查底牌」回看)|
|
||||
| ancard3s | 整数 | **开底**标志。仅 70 分坐庄时出现且为 `1`:表示庄家**摸底**之前,需将 `bottomcards` 这 8 张**底牌**向所有玩家翻开 3 秒(design §4/§7.1);非 70 分无此属性 |
|
||||
| cards | 数组 | 拿了底牌后手上的牌(36 张,含底牌),庄家才有此属性,闲家没有该属性 |
|
||||
| countdown | 整数 | 选主倒计时 |
|
||||
| touxiang | 整数 | 是否允许投降 0:不允许 1:允许(仅 70 分坐庄为 1)。投降与选主互斥、同为选主阶段(step2)的决策,见 touxiang 包与 design §4 |
|
||||
| curmultiple | 整数 | **当前抓分倍数**(design §7.2.0):按此刻累计捡分实时算出的判定倍率,**带符号**,与结算包 `aset.upgrade` 同口径——`3`/`2`/`1` = 庄家 大光/小光/过庄,`-N` = 闲家升 N 级,`0` = 叫分未定。上庄时捡分恒为 0,故必为 `3`。三家同值(捡分本就公开)。**不含扣底**——扣底要到末轮才产生。<br>顶部「抓分」角标只关心倍数大小,取 `Math.abs` 即可;**符号供客户端的判定动画分辨该播哪个**(不能取绝对值,否则 3 大光与 -3 升3级会撞在一起)|
|
||||
|
||||
---
|
||||
|
||||
## 5. 投降(touxiang,客户端→服务器)
|
||||
|
||||
发包者:游戏 收包者:服务器 app:youle route:erqiwang rpc:touxiang
|
||||
|
||||
| 参数名 | 类型 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| agentid | 字符 | 代理id |
|
||||
| playerid | 整数 | 玩家id |
|
||||
| gameid | 字符 | 游戏id |
|
||||
| roomcode | 整数 | 房间号 |
|
||||
| seat | 整数 | 庄家的位置序号。投降是**选主阶段(step 2)与选主互斥**的选择:`mod.touxiang` 仅在 `step==2`、`banker==seat`、`call==70` 时受理;点投降即直接结算,不选主、不埋牌、不出牌(design §4) |
|
||||
|
||||
---
|
||||
|
||||
## 6. 选主(xuanzhu,客户端→服务器)
|
||||
|
||||
发包者:游戏 收包者:服务器 app:youle route:erqiwang rpc:xuanzhu
|
||||
|
||||
| 参数名 | 类型 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| agentid | 字符 | 代理id |
|
||||
| playerid | 整数 | 玩家id |
|
||||
| gameid | 字符 | 游戏id |
|
||||
| roomcode | 整数 | 房间号 |
|
||||
| seat | 整数 | 选主者的位置序号 |
|
||||
| flower | 整数 | 花色 1方块 2梅花 3红心 4黑桃 |
|
||||
|
||||
---
|
||||
|
||||
## 7. 选主(xuanzhu,服务器→客户端)
|
||||
|
||||
发包者:服务器 收包者:游戏 app:youle route:erqiwang rpc:xuanzhu
|
||||
|
||||
| 参数名 | 类型 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| banker | 整数 | 庄家的位置序号 |
|
||||
| flower | 整数 | 花色 1方块 2梅花 3红心 4黑桃 |
|
||||
| step | 整数 | **本局阶段**(选主完成、进入埋牌,恒为 `3`;取值 1叫分 2选主/投降 3埋牌 5出牌 6结算)。阶段由服务端唯一维护、逐包显式给出,客户端**不得按 rpc 名反推**(server 03 §1.4「任何状态变更都要有包承载」/ 前端红线「数据驱动、前端无对局状态机」)。与同一时刻的重连包 `deskinfo.step` 同源同值 |
|
||||
| nextseat | 整数 | **控制权**:本包之后「轮到谁」——恒为 `banker`,埋牌只由庄家做(服务端 `mod.maipai` 的座位校验以 `banker` 为准)。与重连包 `BuryCards.seat` 同源同值 |
|
||||
| countdown | 整数 | 埋牌倒计时 |
|
||||
| cards | 数组 | 选主后自己手上的牌id列表 |
|
||||
|
||||
---
|
||||
|
||||
## 8. 埋牌(maipai,客户端→服务器)
|
||||
|
||||
发包者:游戏 收包者:服务器 app:youle route:erqiwang rpc:maipai
|
||||
|
||||
| 参数名 | 类型 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| agentid | 字符 | 代理id |
|
||||
| playerid | 整数 | 玩家id |
|
||||
| gameid | 字符 | 游戏id |
|
||||
| roomcode | 整数 | 房间号 |
|
||||
| seat | 整数 | 埋牌者的位置序号 |
|
||||
| cards | 数组 | 埋牌的id列表 |
|
||||
|
||||
---
|
||||
|
||||
## 9. 埋牌(maipai,服务器→客户端)
|
||||
|
||||
发包者:服务器 收包者:游戏 app:youle route:erqiwang rpc:maipai
|
||||
|
||||
| 参数名 | 类型 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| cards | 数组 | 埋牌后手上的牌,去掉了埋牌,庄家才有此属性,闲家没有该属性 |
|
||||
| burycards | 数组 | **埋牌底牌**(庄家埋下的 8 张),庄家才有此属性,闲家没有该属性。注意与 `shangzhuang.bottomcards`(**底牌**,发牌留桌 8 张)是两批不同的牌,见 §0.0。<br>**取服务端权威快照 `get_burycard()`,不是客户端请求包里 `cards` 的原序**:按本局主牌花色从大到小排好,与重连包 `PushCards.burycards` **同源同序** |
|
||||
| seatlist | 数组 | 三家座位牌况,**仅可查牌模式下发**(不查牌无此属性,design §9),结构与门控同 `chupai1/2/3.seatlist` 与重连包 `PushCards.seatlist`。<br>埋牌完成时它是刚初始化的**空表**(每家 `[[0,0],[0,0],[0,0],[0,0],[-1,-1]]`)——下发它是为了让「埋牌完成 → 庄家首出」这段窗口内,增量路径与重连路径拿到同一张表,客户端无需为这段窗口特判 |
|
||||
| playproc | json | **本轮进行态**(design §5.1),**恒有、三家同值**,结构与门控同 [`chupai1.playproc`](#11-第一个玩家出牌chupai1)/重连包 `PushCards.playproc`(同一个快照函数 `get_playproc()`)。`do_burycard` 内部已 `new_playround` 就地初始化好 round-1 的进行态,此包带的正是这份初值:`round=1`、`start`/`currseat` 均为即将首出的庄家、`cards` 全空、`shuai_demand` 为 `null`。<br>**可见性**:全部字段由桌面公开信息推出(此刻尚无一张牌打出),三家整体下发、不逐座位裁剪 |
|
||||
| step | 整数 | **本局阶段**(埋牌完成、进入出牌,恒为 `5`;取值 1叫分 2选主/投降 3埋牌 5出牌 6结算)。阶段由服务端唯一维护、逐包显式给出,客户端**不得按 rpc 名反推**(server 03 §1.4「任何状态变更都要有包承载」/ 前端红线「数据驱动、前端无对局状态机」)。与同一时刻的重连包 `deskinfo.step` 同源同值 |
|
||||
| seat | 整数 | **控制权**:出牌者的位置序号(即将首出的庄家)。服务端取自权威的 `playproc.currseat`,与重连包 `PushCards.playproc.currseat` 同源同值 |
|
||||
| countdown | 整数 | 出牌倒计时 |
|
||||
| liangpai | json | **亮牌**(design §8.2)。**只有闲家、且可查牌模式、且庄家达门槛时才有**;不达标或不查牌则无此属性。结构:`{ cards: [牌id...] }`——**庄家手中全部固定主牌的具体牌面**,按本局主牌序从大到小排好。<br>**固定主牌 = 双王 + 全部花色的 2 + 全部花色的 7**,**不含**主花色的普通牌 A/K/Q/J/10/9/8/6/5。<br>**门槛**(任一满足即下发):固定主牌总数 ≥10 / 王 ≥3 / 7 ≥6 / 2 ≥6。**不限叫分**。<br>亮出的是**固定的一份**,不随被哪条门槛触发而增减;也**不给数量统计**——数量前端自己数 `cards.length` 即可。<br>口径是庄家**埋牌后的静态快照**(排除已埋的 8 张,但包含之后已打出的牌),全局固定不随出牌缩水,故重连包里取值一致 |
|
||||
|
||||
---
|
||||
|
||||
## 10. 出牌(chupai,客户端→服务器)
|
||||
|
||||
发包者:游戏 收包者:服务器 app:youle route:erqiwang rpc:chupai
|
||||
|
||||
| 参数名 | 类型 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| agentid | 字符 | 代理id |
|
||||
| playerid | 整数 | 玩家id |
|
||||
| gameid | 字符 | 游戏id |
|
||||
| roomcode | 整数 | 房间号 |
|
||||
| seat | 整数 | 出牌者的位置序号 |
|
||||
| cards | 数组 | 出牌的id列表 |
|
||||
|
||||
---
|
||||
|
||||
## 11. 第一个玩家出牌(chupai1)
|
||||
|
||||
发包者:服务器 收包者:游戏 app:youle route:erqiwang rpc:chupai1
|
||||
|
||||
| 参数名 | 类型 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| seat | 整数 | 出牌者的位置序号 |
|
||||
| cards | 数组 | 实际打出的牌id列表;**甩错时**(见 `shuaicuo`)为被强制打出的那一张最小主牌单张。**顺序:按本局主牌花色从大到小(权威顺序)**,不是客户端提交时的点击顺序——请求包里的 `cards` 顺序服务端不采信、不回显(见 [§0.6](#06-一手出牌的顺序口径))|
|
||||
| shuaicuo | 整数 | 甩错标志,仅甩错时出现且为 `1`(design §5.4.5:甩牌未通过最大性判定,整套甩牌收回,本轮只强制打出最小一张、失去本轮甩牌资格);正常出牌无此属性 |
|
||||
| seatlist | 数组 | 三家座位牌况 `o_paiju.seatlist`(长度 3,下标 = 座位序号),**仅可查牌模式下发**(不查牌无此属性,design §9)。每个座位为 5 元素数组:前 4 个对应花色1~4 的 `[无该花色标志, 该花色无对标志]`(0/1),第 5 个为**余主公示**数据 `[剩余主牌数, 剩余主对数]`(初始 `[-1,-1]`,代码内旧称「报副」)。**一旦全场有人报无主,服务端会按各家实际手牌同时刷新三个座位并整表下发**(design §9「为全体三人显示另外两家」),无需等另两家各自出牌才补。字段名与结构同重连包 `PushCards.seatlist` |
|
||||
| count | 整数 | 出牌数量(甩错时为 1) |
|
||||
| flower | 整数 | 出牌花色 |
|
||||
| cardtype | 整数 | 出牌牌型:`>100` 单张(101 一张、102 两张…)、`>200` 对子(201 一对、202 两对…)、`>300` 拖拉机(302 两连对、303 三连对…),见 `class.pai.js` 顶部注释。**注意:cardtype 只能表达单一牌型**,而甩牌是单张/对子/拖拉机自由混搭(design §5.4.3),会被牌型推导压平成 1xx 或 2xx(例如「主K对 + 主5」得 103、「两连对 + 一散对」得 203)。**甩牌的真实结构请读 `shuai`,不要据 cardtype 反推** |
|
||||
| shuai | json | 甩牌分量构成,**仅本次出牌是合法甩牌时才有**(非甩牌、以及甩错退化为单张时都没有该属性)。结构 `{ tractors: [连对数...], pairs: 独立对子数, singles: 单张数 }`,例如「主K对 + 主5」为 `{tractors:[],pairs:1,singles:1}`。与服务端跟牌时逐分量强制匹配用的 `shuai_demand` 同源(design §5.4.3/§5.4.4)|
|
||||
| nextseat | 整数 | 下一个出牌者的位置序号 |
|
||||
| countdown | 整数 | 下一个出牌者的出牌倒计时 |
|
||||
| baozhu | 整数 | 是否已有玩家报无主(主牌出空)0否 1是;对应服务端 `have_baofu()`(任一玩家 `seatlist[seat][4][0]==0`)。**仅可查牌模式**下才可能为 1,是**余主公示**与"明牌"按钮的开关;**不查牌模式恒为 0**(design §9)|
|
||||
| cardsinhand | 数组 | 出牌者出牌后手上剩下的牌id列表,只有出牌者才有此属性 |
|
||||
| curmultiple | 整数 | **当前抓分倍数**(design §7.2.0):**带符号**,与结算包 `aset.upgrade` 同口径(`3`/`2`/`1` 庄家大光/小光/过庄,`-N` 闲家升 N 级,`0` 叫分未定)。同源同算法,差别仅在此处**不含扣底**。三家同值,随本包下发、不另开推送。符号是「谁赢」的区分,客户端判定动画靠它分辨 |
|
||||
| playproc | json | <a id="playproc-def"></a>**本轮进行态**(design §5.1)。**`chupai1/2/3` 三个包恒有,三家同值**;结构与重连包 [`PushCards.playproc`](#断线重连deskinfo) **完全一致**(服务端同一个快照函数 `get_playproc()`,前端增量回放与重连重建复用同一份解析)。<br>字段:`round` 第几轮、`start` 本轮首出位置、`currseat` 当前该谁出、`startcount` 首出张数、`startflower` 首出花色、`starttype` 首出牌型、`maxseat` 本轮暂时最大者、`maxcard` 其牌编码、`cards` 本轮三家各自出的牌(**定长 3,下标 = 座位序号**,未出的位置为 `null`)、`shuai_demand` 首家甩牌的分量需求 `{tractors:[连对数...],pairs,singles}`(非甩牌为 `null`)。<br>⚠️ **`chupai3` 带的是【下一轮】的进行态**(`round+1`、`cards` 全空、`currseat == nextseat == maxseat`):本轮第三家一出完,服务端就地开了新一轮。这与「此刻断线重连拿到的 `PushCards.playproc`」完全相同——本轮那三手牌客户端已由 `chupai1/2/3` 各自的 `seat`+`cards` 收到,收牌动画后即清台。**唯一例外**:`chupai3` 打完最后一张牌时本包会转成 `jiesuan`(见 §14),那种情况下**不带** `playproc`。<br>**可见性**:全部字段都由桌面公开信息推出(`cards` 就是已摊在桌上的牌,`shuai_demand` 与 `chupai1.shuai` 等价且甩出的牌本身已公开),故三家整体下发、不逐座位裁剪 |
|
||||
| mustcard | 数组 | **下一个出牌者本轮跟牌的必出牌**(design §5.2),供其客户端自动选中。**只发给 `nextseat` 那一家**,其余两家无此属性——它是该玩家自己手牌的子集,整表下发会泄露他家手牌结构。以下情形不下发:`nextseat` 是本轮首家、首家为**甩牌**(甩牌跟牌走逐分量匹配,见 design §5.4.4)、或算出的必出牌为空 |
|
||||
|
||||
---
|
||||
|
||||
## 12. 第二个玩家出牌(chupai2)
|
||||
|
||||
发包者:服务器 收包者:游戏 app:youle route:erqiwang rpc:chupai2
|
||||
|
||||
| 参数名 | 类型 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| seat | 整数 | 出牌者的位置序号 |
|
||||
| cards | 数组 | 出的牌id列表。**顺序:按本局主牌花色从大到小(权威顺序)**,不是客户端提交时的点击顺序(见 [§0.6](#06-一手出牌的顺序口径))|
|
||||
| seatlist | 数组 | 三家座位牌况 `o_paiju.seatlist`(长度 3,下标 = 座位序号),**仅可查牌模式下发**(不查牌无此属性,design §9)。每个座位为 5 元素数组:前 4 个对应花色1~4 的 `[无该花色标志, 该花色无对标志]`(0/1),第 5 个为**余主公示**数据 `[剩余主牌数, 剩余主对数]`(初始 `[-1,-1]`,代码内旧称「报副」)。**一旦全场有人报无主,服务端会按各家实际手牌同时刷新三个座位并整表下发**(design §9「为全体三人显示另外两家」),无需等另两家各自出牌才补。字段名与结构同重连包 `PushCards.seatlist` |
|
||||
| nextseat | 整数 | 下一个出牌者的位置序号 |
|
||||
| countdown | 整数 | 出牌倒计时 |
|
||||
| baozhu | 整数 | 是否已有玩家报无主(主牌出空)0否 1是;对应服务端 `have_baofu()`(任一玩家 `seatlist[seat][4][0]==0`)。**仅可查牌模式**下才可能为 1,是**余主公示**与"明牌"按钮的开关;**不查牌模式恒为 0**(design §9)|
|
||||
| cardsinhand | 数组 | 出牌后出牌者手上剩下的牌id列表,只有出牌者才有此属性 |
|
||||
| playproc | json | **本轮进行态**,恒有、三家同值,结构见 [§11 `playproc`](#11-第一个玩家出牌chupai1)。此时 `cards` 已含首家与本家两手牌,`currseat` 指向第三家 |
|
||||
| mustcard | 数组 | **下一个出牌者本轮跟牌的必出牌**(design §5.2),供其客户端自动选中。**只发给 `nextseat` 那一家**,其余两家无此属性——它是该玩家自己手牌的子集,整表下发会泄露他家手牌结构。以下情形不下发:`nextseat` 是本轮首家、首家为**甩牌**、或算出的必出牌为空 |
|
||||
|
||||
---
|
||||
|
||||
## 13. 第三个玩家出牌(chupai3)
|
||||
|
||||
发包者:服务器 收包者:游戏 app:youle route:erqiwang rpc:chupai3
|
||||
|
||||
| 参数名 | 类型 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| seat | 整数 | 出牌者的位置序号 |
|
||||
| cards | 数组 | 出的牌id列表。**顺序:按本局主牌花色从大到小(权威顺序)**,不是客户端提交时的点击顺序(见 [§0.6](#06-一手出牌的顺序口径))|
|
||||
| seatlist | 数组 | 三家座位牌况 `o_paiju.seatlist`(长度 3,下标 = 座位序号),**仅可查牌模式下发**(不查牌无此属性,design §9)。每个座位为 5 元素数组:前 4 个对应花色1~4 的 `[无该花色标志, 该花色无对标志]`(0/1),第 5 个为**余主公示**数据 `[剩余主牌数, 剩余主对数]`(初始 `[-1,-1]`,代码内旧称「报副」)。**一旦全场有人报无主,服务端会按各家实际手牌同时刷新三个座位并整表下发**(design §9「为全体三人显示另外两家」),无需等另两家各自出牌才补。字段名与结构同重连包 `PushCards.seatlist` |
|
||||
| nextseat | 整数 | 下一个出牌者的位置序号 |
|
||||
| countdown | 整数 | 出牌倒计时 |
|
||||
| baozhu | 整数 | 是否已有玩家报无主(主牌出空)0否 1是;对应服务端 `have_baofu()`(任一玩家 `seatlist[seat][4][0]==0`)。**仅可查牌模式**下才可能为 1,是**余主公示**与"明牌"按钮的开关;**不查牌模式恒为 0**(design §9)|
|
||||
| cardsinhand | 数组 | 出牌后出牌者手上剩下的牌id列表,只有出牌者才有此属性 |
|
||||
| maxseat | 整数 | 本轮出牌谁最大,只有本轮最后一个玩家出牌后才有此属性 |
|
||||
| grade | 整数 | 本轮闲家得分,只有本轮最后一个玩家出牌后且闲家有得分才有此属性 |
|
||||
| curmultiple | 整数 | **当前抓分倍数**(design §7.2.0):**带符号**,口径同 `aset.upgrade`。同源同算法,差别仅在此处**不含扣底**。三家同值,随本包下发、不另开推送 |
|
||||
| playproc | json | **本轮进行态**,三家同值,结构见 [§11 `playproc`](#11-第一个玩家出牌chupai1)。⚠️ 本包带的是**下一轮**的进行态(`round+1`、`cards` 全空、`currseat == nextseat == maxseat`),与此刻重连拿到的 `PushCards.playproc` 一致。**牌局在本包打完(转为 `jiesuan`)时不带此属性** |
|
||||
|
||||
---
|
||||
|
||||
## 13.5 明牌(mingpai,客户端→服务器)
|
||||
|
||||
发包者:游戏 收包者:服务器 app:youle route:erqiwang rpc:mingpai
|
||||
|
||||
查看另外两家手中全部主牌的具体牌面(design §9)。服务端仅在**可查牌模式、出牌阶段(step5)、且已有【任一】玩家报无主**(`have_baofu()`)时受理——注意判定的是「场上有人报无主」而非「请求者本人报无主」,报无主那位与另外两位同样有权查看(design §9.3);不满足时按 0.2 回 `mingpai` 失败包(不查牌 / 未报无主 → `RULE`,非出牌阶段 → `STEP`)。
|
||||
|
||||
| 参数名 | 类型 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| agentid | 字符 | 代理id |
|
||||
| playerid | 整数 | 玩家id |
|
||||
| gameid | 字符 | 游戏id |
|
||||
| roomcode | 整数 | 房间号 |
|
||||
| seat | 整数 | 请求者的位置序号 |
|
||||
|
||||
## 13.6 明牌(mingpai,服务器→客户端)
|
||||
|
||||
发包者:服务器 收包者:游戏 app:youle route:erqiwang rpc:mingpai (只回发给请求者)
|
||||
|
||||
| 参数名 | 类型 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| seat | 整数 | 请求者的位置序号 |
|
||||
| others | 数组 | 另外两家各自未出的全部主牌,元素 `{ seat: 位置序号, zhucards: [主牌id列表] }` |
|
||||
|
||||
> "再点一次取消查看"是客户端的显示开关,无需再请求服务端。
|
||||
|
||||
## 13.7 出牌提示(tishi,客户端→服务器)
|
||||
|
||||
发包者:游戏 收包者:服务器 app:youle route:erqiwang rpc:tishi
|
||||
|
||||
闲家在出牌阶段向对家(另一闲家)发"踩/没分/有分"提示(design §11)。服务端仅在**出牌阶段(step5)、且发起者为闲家**(`seat != banker`)、**tip 合法(1/2/3)** 时受理;**不校验提示真实性**(玩家可主观发送);庄家无对家,不受理。不满足时按 0.2 回 `tishi` 失败包给发送者(庄家发 → `RULE`,非出牌阶段 → `STEP`,tip 非法或缺失 → `PARAM`)。**成功时不给发送者回执**,只转发给对家。
|
||||
|
||||
| 参数名 | 类型 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| agentid | 字符 | 代理id |
|
||||
| playerid | 整数 | 玩家id |
|
||||
| gameid | 字符 | 游戏id |
|
||||
| roomcode | 整数 | 房间号 |
|
||||
| seat | 整数 | 发出提示的闲家位置序号 |
|
||||
| tip | 整数 | 提示类型:1=踩(我能大过庄家)、2=没分(我手上没分了)、3=有分(我手上有分) |
|
||||
|
||||
## 13.8 出牌提示(tishi,服务器→客户端)
|
||||
|
||||
发包者:服务器 收包者:游戏 app:youle route:erqiwang rpc:tishi (**只转发给对家**,即另一闲家 `3 - banker - seat`)
|
||||
|
||||
| 参数名 | 类型 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| seat | 整数 | 发出提示的闲家位置序号 |
|
||||
| tip | 整数 | 提示类型:1=踩、2=没分、3=有分 |
|
||||
|
||||
---
|
||||
|
||||
## 14. 结算(jiesuan)
|
||||
|
||||
发包者:服务器 收包者:游戏 app:youle route:erqiwang rpc:jiesuan
|
||||
|
||||
结算包由以下几个子结构拼装而成。三种结算来源的组成不同:
|
||||
|
||||
- **正常出牌结算**(`mod.chupai` 中最后一张牌出完,`get_paiju_account(0, ...)`):含 `chupai` + `bottom` + `aset`(末局再加 `account`)。
|
||||
- **投降结算**(`mod.touxiang`,`get_paiju_account(1, ...)`):只含 `aset`(末局再加 `account`),无 `chupai`、无 `bottom`。
|
||||
- **解散结算**(`export.get_disbandRoom`,`get_paiju_account(2, ...)`):只含 `aset` + `account`,无 `chupai`、无 `bottom`。**投递方式与上面两种不同,见 §14.1。**
|
||||
|
||||
上面这张表头(`route: erqiwang / rpc: jiesuan`)**只适用于前两种**——正常出牌结算与投降结算,由子游戏自己 `sendpack_toother` 广播,客户端在 `rpc == "jiesuan"` 的分支里按 `data.aset` 取值。
|
||||
|
||||
### 14.1 解散结算的投递方式(与前两种不同,客户端需单独处理)
|
||||
|
||||
解散**不是**由子游戏发包,而是平台在解散流程里回调 `youle_erqiwang.export.get_disbandRoom(o_room)`,把**返回值整个对象**塞进**房间路由**的解散包里下发(平台 `server_room/rpc.js`、`server_room/class.room.js`)。因此客户端收到的是:
|
||||
|
||||
```json
|
||||
{
|
||||
"app": "youle", "route": "room", "rpc": "free_room",
|
||||
"data": {
|
||||
"seats": [],
|
||||
"deskfree": {
|
||||
"rpc": "jiesuan",
|
||||
"data": { "success": true, "aset": {}, "account": [] }
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
要点(照 §14 的表去 `data.aset` 取值会取空):
|
||||
|
||||
- **rpc 是平台的 `free_room`(route 也是 `room`),不是 `erqiwang/jiesuan`**;子游戏返回的 `rpc: "jiesuan"` 只是嵌在 `deskfree` 里的一个普通字段。
|
||||
- **多一层 `data` 包装**:真实取值路径是 `data.deskfree.data.aset` 与 `data.deskfree.data.account`。
|
||||
- 本包的成败标志有两层:外层 `data.success` 由**平台**填写(解散流程本身成功与否),子游戏结算自带的 `success` 在 `data.deskfree.data.success`。§0.1「每个包 `data` 必带 `success`」说的是子游戏自己发的包;本包外层归平台。
|
||||
- `aset` / `account` 的字段结构与下面 §14 的表完全一致(`aset.multiple = 0`、`upgrade = 0`、各家 `grade = 0`,即解散局不结算子数与算奖;`account` 恒有,见 design §12.2「按当前累计分结算」)。
|
||||
- 若解散发生在**开战后、首局牌发出前**的窗口内(本游戏首局由 `makewar` 延迟 1 秒创建),`get_disbandRoom` 返回 `null`,平台走「不带 `deskfree`」分支——客户端需容忍 `data.deskfree` 缺失。
|
||||
|
||||
**顶层字段(三种结算来源恒有)**
|
||||
|
||||
| 参数名 | 类型 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| success | 布尔 | 恒 `true`(§0.1)|
|
||||
| step | 整数 | **本局阶段**,结算包恒为 `6`。三种结算来源(正常/投降/解散)统一由 `get_paiju_account` 给出——解散在此之前 `step` 可能还停在 1/2/3/5,由它落定为 6。客户端**不得按 rpc 名硬编码**,一律读此字段 |
|
||||
|
||||
**chupai(出牌包,仅正常出牌结算存在)**
|
||||
|
||||
| 参数名 | 类型 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| seat | 整数 | 出牌者的位置序号,投降和解散无此属性 |
|
||||
| cards | 数组 | 出的牌id列表,投降和解散无此属性 |
|
||||
| maxseat | 整数 | 本轮出牌谁最大,投降和解散无此属性 |
|
||||
| grade | 整数 | 本轮闲家得分,投降和解散无此属性 |
|
||||
| gradecards | 数组 | 本轮闲家得分分牌列表,投降和解散无此属性。**注意:当前源码中赋值语句被注释(`class.paiju.js`/`mod.js` 中相关行均被注释掉),该字段实际永远不会出现在下发的包里,属于失效字段** |
|
||||
|
||||
**bottom(抠底包,仅正常出牌结算存在;`cards` 恒有,其余仅闲家抠底时有)**
|
||||
|
||||
> 本分组的 `cards` 是**埋牌底牌**(庄家埋下的 8 张),不是发牌留桌的底牌。
|
||||
|
||||
| 参数名 | 类型 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| cards | 数组 | **埋牌底牌**(庄家埋下的 8 张),投降和解散无此属性 |
|
||||
| multiple | 整数 | 闲家抠底倍数,闲家没抠底无此属性,投降和解散无此属性 |
|
||||
| grade1 | 整数 | 埋牌底牌的分数,闲家没抠底无此属性,投降和解散无此属性 |
|
||||
| grade2 | 整数 | 闲家抠底得分,闲家没抠底无此属性,投降和解散无此属性 |
|
||||
|
||||
**aset(单局结算包)**
|
||||
|
||||
| 参数名 | 类型 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| banker | 整数 | 庄家的位置序号,-1:无庄 |
|
||||
| call | 整数 | 庄家叫分,-1:无叫分 |
|
||||
| multiple | 整数 | 基础子数 `get_base_bycall(call, climb)`(design §7.1/§7.3.1):常规算子 65→2、60→3、55→4、50 及以下→6、70打牌→2;投降固定 1、解散 0;无叫分 0 |
|
||||
| flower | 整数 | 主牌花色,-1:无主牌花色 |
|
||||
| grade | 整数 | 闲家捡分(含抠底) |
|
||||
| upgrade | 整数 | 判定倍率(带符号,design §7.2.0):3大光 / 2小光 / 1过庄 / -N升N级(倒庄)/ -99投降 / 0解散或无判定 |
|
||||
| bangwang | 整数 | 本局是否启用傍王规则 0否 1是(roomtype 位2) |
|
||||
| climb | 整数 | 本局是否启用爬坡规则 0否 1是(roomtype 位3) |
|
||||
| seatlist | 数组 | 玩家列表,元素结构见下 |
|
||||
|
||||
`seatlist` 数组元素结构:
|
||||
|
||||
```json
|
||||
{
|
||||
"cards": [], // 冲关牌型:参与冲关的牌(王 + 冲关组合牌)列表,供「冲关牌型」界面展示
|
||||
"chongguan": 0, // 冲关奖数(design §8.1,旧称"常规算奖";只有庄家才计入自己的 N)
|
||||
"wang": 0, // 手牌中的王数(傍王按此计奖)
|
||||
"naward": 0, // 该家总奖数 N =(庄家?冲关奖数:0)+(傍王?王数:0)
|
||||
"grade_aw": 0, // 算奖得分 = X×(2Ni−Nj−Nk),X 为每对子子数。= grade_cg + grade_bw
|
||||
"grade_cg": 0, // 其中的【冲关】分量(design §8.1,不含傍王)
|
||||
"grade_bw": 0, // 其中的【傍王】分量(design §8.3;未勾傍王时恒 0)
|
||||
"grade_jf": 0, // 捡分子数得分
|
||||
"grade": 0, // 本局总分 = grade_aw + grade_jf
|
||||
"score": 0 // 累计得分
|
||||
}
|
||||
```
|
||||
|
||||
> 结算数值模型(design §7~§8):每「庄–闲」对子的基础金额 `X = multiple × |upgrade|`(投降 X=1、解散 X=0)。捡分子数:庄赢时两闲家各付庄家 X、庄家收 2X;闲赢(升级)时庄家各付两闲家 X。算奖:持有 N 奖的玩家从另外两人各多收 `X×N`,三家两两独立叠加(含闲–闲),即 `grade_aw = X×(2Ni−Nj−Nk)`。
|
||||
>
|
||||
> **算奖的两个分量**(供大局结算分项展示):`N = N_冲关 + N_傍王`(冲关只计庄家、傍王勾选后庄闲都算)。
|
||||
> 服务端把两个分量**分别代入同一公式**各算一次,得到 `grade_cg` 与 `grade_bw`。
|
||||
> 公式对 `N` 是线性的,因此恒有 **`grade_cg + grade_bw == grade_aw`**,且两个分量各自零和。
|
||||
> 之所以必须在算钱时就拆,是因为两个 `N` 一旦相加就再也分不开——事后无法从 `grade_aw` 反推各自占比。
|
||||
|
||||
**account(大局结算包)**
|
||||
|
||||
> 仅当打到最后一局(`o_paiju.idx >= o_room.asetcount`)或房间中途解散(解散结算)时,`jiesuan` 包才会带上此分组;普通的中间局结算包没有 `account` 字段(`class.paiju.js` `get_paiju_account`)。
|
||||
|
||||
| 参数名 | 类型 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| account | 数组 | 玩家列表(长度 3,下标 = 座位序号),元素结构见下 |
|
||||
|
||||
```json
|
||||
[
|
||||
{ "score": 0, "grades": [], "grade_jf_total": 0, "grade_cg_total": 0, "grade_bw_total": 0 },
|
||||
{ "score": 0, "grades": [], "grade_jf_total": 0, "grade_cg_total": 0, "grade_bw_total": 0 },
|
||||
{ "score": 0, "grades": [], "grade_jf_total": 0, "grade_cg_total": 0, "grade_bw_total": 0 }
|
||||
]
|
||||
```
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| score | 整数 | 累积总分(全部小局 `aset.seatlist[i].grade` 之和) |
|
||||
| grades | 数组 | 每局总分列表,按局序 |
|
||||
| grade_jf_total | 整数 | **基础分**累计:各局 `grade_jf`(捡分子数得分)之和 |
|
||||
| grade_cg_total | 整数 | **冲关分**累计:各局 `grade_cg` 之和(design §8.1,**不含**傍王) |
|
||||
| grade_bw_total | 整数 | **傍王分**累计:各局 `grade_bw` 之和(design §8.3;未勾傍王时恒 0) |
|
||||
|
||||
> 恒等式:`grade_jf_total + grade_cg_total + grade_bw_total == score`,供大局结算面板分项展示(基础分 / 冲关分 / 傍王分 / 总分)。
|
||||
>
|
||||
> ⚠️ **结构变更(2026-08-26)**:原为 `[[累积得分, [每局得分...]], ...]` 的二元数组,现改为**对象数组**并增加三项分解。数组下标语义不清,且前端尚未开工,此时改代价最小。
|
||||
|
||||
---
|
||||
|
||||
## 15. 准备(zhunbei,客户端→服务器)
|
||||
|
||||
发包者:游戏 收包者:服务器 app:youle route:erqiwang rpc:zhunbei
|
||||
|
||||
| 参数名 | 类型 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| agentid | 字符 | 代理id |
|
||||
| playerid | 整数 | 玩家id |
|
||||
| gameid | 字符 | 游戏id |
|
||||
| roomcode | 整数 | 房间号 |
|
||||
| seat | 整数 | 位置序号 |
|
||||
|
||||
---
|
||||
|
||||
## 16. 准备(zhunbei,服务器→客户端)
|
||||
|
||||
发包者:服务器 收包者:游戏 app:youle route:erqiwang rpc:zhunbei
|
||||
|
||||
| 参数名 | 类型 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| seat | 整数 | 位置序号 |
|
||||
|
||||
---
|
||||
|
||||
## 断线重连(deskinfo)
|
||||
|
||||
> 由平台在玩家进入房间/断线重连时回调 `youle_erqiwang.export.get_deskinfo(o_room, seat)` 生成并下发(服务器→客户端);下发的 app/route/rpc 由平台的进房/重连流程决定,不在本子游戏代码内固定。平台把它挂在 `pack.data.deskinfo` 下,**`data.success` 由平台填写、子游戏不注入**(见 0.4)。下列字段即该函数返回的 `deskinfo` 对象结构,按当前 `paiju.step` 只带对应阶段的分组。
|
||||
|
||||
| 参数名 | 类型 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| count | 整数 | 总局数 |
|
||||
| idx | 整数 | 当前局数 |
|
||||
| PlayerInfo | 数组 | 三个玩家目前的总积分 `[0,0,0]` |
|
||||
| step | 整数 | 牌桌状态:1发完牌叫分 2选主/投降 3埋牌 5出牌 6结算(无独立投降阶段——投降在 step2 与选主互斥;埋牌后直接进入 step5 出牌) |
|
||||
| MyCards | 数组 | 自己手上的牌 |
|
||||
|
||||
**CallRun(不在叫分阶段无此属性)**
|
||||
|
||||
| 参数名 | 类型 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| seat | 整数 | 当前叫分位置 |
|
||||
| countdown | 整数 | 叫分倒计时 |
|
||||
| nowcall | 整数 | 当前叫分 |
|
||||
| multiple | 整数 | 叫分对应的基础子数 `get_base_bycall(call, climb)`,**随房间「爬坡」开关取值**(roomtype 位3):常规算子 65→2 60→3 55→4 50及以下→6;爬坡 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;两种模式下 70→2;无叫分→0。与结算包 `aset.multiple` 同源同值(design §7.1/§7.3.1)|
|
||||
| call | 数组 | 三家叫分 `[null,0,65]`,null还未叫分,0不叫,>0叫了多少分 |
|
||||
|
||||
**ChooseMain(step 2 选主/投降阶段,不在此阶段无此属性)**
|
||||
|
||||
| 参数名 | 类型 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| seat | 整数 | **控制权**:此刻轮到谁操作——恒为 `banker`(选主与投降都只由庄家做)。字段名与 `CallRun.seat` 一致:**各阶段分组里的 `seat` 统一表示「轮到谁」**。与增量推送 `shangzhuang.nextseat` 同源同值;客户端据此设控制权,**不要自己用 `banker` 反推**(那是把「选主=庄家」这条规则搬到前端)|
|
||||
| banker | 整数 | 庄家 |
|
||||
| call | 整数 | 叫分 |
|
||||
| multiple | 整数 | 叫分对应的基础子数 `get_base_bycall(call, climb)`,**随房间「爬坡」开关取值**(roomtype 位3):常规算子 65→2 60→3 55→4 50及以下→6;爬坡 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;两种模式下 70→2;无叫分→0。与结算包 `aset.multiple` 同源同值(design §7.1/§7.3.1)|
|
||||
| countdown | 整数 | 选主倒计时 |
|
||||
| bottomcards | 数组 | 8 张**底牌**(发牌留桌的 8 张),**只有庄家有此属性**(底牌仅庄家可见;70 分的 3 秒亮牌是上庄时的一次性事件,重连不重放)。顺序取发牌时冻结的快照,与 `shangzhuang.bottomcards`、`BuryCards.bottomcards` **完全同序**(见 §4)|
|
||||
| touxiang | 整数 | 是否允许投降 0:不允许 1:允许(仅 70 分坐庄为 1)。投降与选主互斥、同一决策点,见 touxiang 包与 design §4 |
|
||||
| curmultiple | 整数 | **当前抓分倍数**(design §7.2.0):**带符号**,与 `shangzhuang.curmultiple` 同源同值。选主阶段一张牌都还没出、捡分恒为 0,故**必为 `3`**(大光)。三家同值。有此字段,重连后顶部「抓分」角标才不会掉回 0 |
|
||||
|
||||
> 选主阶段前端需在每个花色按钮上显示"该花色在庄家手中的对子数"(design §4/§11)——庄家的完整手牌由 `MyCards` 提供(庄家为 36 张),对子数由前端据此计算,服务端不额外下发。
|
||||
|
||||
**BuryCards(step 3 埋牌阶段,不在此阶段无此属性)**
|
||||
|
||||
| 参数名 | 类型 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| seat | 整数 | **控制权**:此刻轮到谁操作——恒为 `banker`(埋牌只由庄家做)。与增量推送 `xuanzhu.nextseat` 同源同值 |
|
||||
| banker | 整数 | 庄家 |
|
||||
| call | 整数 | 叫分 |
|
||||
| multiple | 整数 | 叫分对应的基础子数 `get_base_bycall(call, climb)`,**随房间「爬坡」开关取值**(roomtype 位3):常规算子 65→2 60→3 55→4 50及以下→6;爬坡 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;两种模式下 70→2;无叫分→0。与结算包 `aset.multiple` 同源同值(design §7.1/§7.3.1)|
|
||||
| flower | 整数 | 主牌花色 |
|
||||
| countdown | 整数 | 埋牌倒计时 |
|
||||
| bottomcards | 数组 | 8 张**底牌**(发牌留桌的 8 张),**只有庄家有此属性**(底牌仅庄家可见;70 分的 3 秒亮牌是上庄时的一次性事件,重连不重放)。顺序取发牌时冻结的快照,与 `shangzhuang.bottomcards`、`ChooseMain.bottomcards` **完全同序**——**不**按本局主牌花色重排(见 §4)|
|
||||
| curmultiple | 整数 | **当前抓分倍数**(design §7.2.0):**带符号**,与 `shangzhuang.curmultiple` 同源同值。埋牌阶段同样一张牌未出、捡分恒为 0,故**必为 `3`**(大光)。三家同值 |
|
||||
|
||||
> 埋牌阶段无投降(投降是 step2 与选主互斥的选择,选主后即不可再投降)。
|
||||
|
||||
**PushCards(step 5 出牌阶段,不在此阶段无此属性)**
|
||||
|
||||
| 参数名 | 类型 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| banker | 整数 | 庄家 |
|
||||
| call | 整数 | 叫分 |
|
||||
| multiple | 整数 | 叫分对应的基础子数 `get_base_bycall(call, climb)`,**随房间「爬坡」开关取值**(roomtype 位3):常规算子 65→2 60→3 55→4 50及以下→6;爬坡 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;两种模式下 70→2;无叫分→0。与结算包 `aset.multiple` 同源同值(design §7.1/§7.3.1)|
|
||||
| flower | 整数 | 主牌花色 |
|
||||
| countdown | 整数 | 出牌倒计时 |
|
||||
| burycards | 数组 | **埋牌底牌**(庄家埋下的 8 张),只有庄家有此属性。注意与 `ChooseMain`/`BuryCards.bottomcards`(**底牌**)是两批不同的牌,见 §0.0。与 `maipai.burycards` **同源同序**(同一个 `get_burycard()`,按本局主牌花色从大到小)|
|
||||
| grade | 整数 | 当前的捡分分数 |
|
||||
| gradecards | 数组 | 当前的捡分分牌,只有闲家有此属性 |
|
||||
| playproc | json | **本轮进行态**,与 `chupai1/2/3.playproc` **同源同结构**(服务端同一个 `get_playproc()` 快照函数,见 [§11 `playproc`](#11-第一个玩家出牌chupai1)):`round` 第几轮、`start` 本轮首出位置、`currseat` 当前出牌位置、`startcount` 首出张数、`startflower` 首出花色、`starttype` 首出牌型、`maxseat` 本轮最大者位置、`maxcard` 最大牌编码、`cards` 本轮三家各自出的牌(**定长 3**,下标=位置序号,未出为 `null`)、`shuai_demand` 首家甩牌的分量需求 `{tractors:[连对数...],pairs,singles}`(非甩牌为 null,供跟牌逐分量强制匹配,design §5.4.4)。**两种查牌模式下恒有此属性**——`cards` 是当前这一轮桌面上的牌,不属于「查牌」,屏蔽了后出的人就无从跟牌(design §9 末尾)|
|
||||
| baozhu | 整数 | 是否已有玩家报无主(主牌出空)0否 1是,**恒有此属性**;与 `chupai1/2/3` 的 `baozhu` **同源同值同门控**(同一个 `have_baofu()` + 同一个查牌位)。它是**余主公示**与“明牌”按钮的开关,重连必须一并恢复,否则重连后按钮凭空消失;**不查牌模式恒为 0**(design §9)|
|
||||
| seatlist | 数组 | 三家座位牌况,与上文 `maipai` / `chupai1/2/3` 包的 `seatlist` 同名同结构(每个玩家一个 5 元素数组:4 个花色的 `[无该花色,无对]` + 报副 `[剩余主牌数,剩余主对数]`)。**仅可查牌模式下有此属性**(design §9)|
|
||||
| liangpai | json | **亮牌**,结构同 maipai 包的 `liangpai`(`{ cards: [牌id...] }`);**仅可查牌模式、且请求者为闲家、且庄家达标时有**(供闲家重连后仍能看到,design §8.2)|
|
||||
| pushlist | 数组 | 出牌历史 `[[[], [], []], [[], [], []], ...]`,外层下标=轮次(从第 1 轮起,含进行中的当前轮),内层**恒为 3 个数组**、按位置序号存该轮各家出的牌id。**每一手与当时那个 `chupai1/2/3` 包的 `cards` 逐元素完全相等**(同一份数据、同一个顺序口径,见 [§0.6](#06-一手出牌的顺序口径)),因此前端“增量回放”与“重连重建”得到的出牌历史必定一致。**仅可查牌模式下有此属性**——往轮打出、已被收走的牌属于「查牌」范畴,不查牌模式一律不下发(design §9)。注意当前这一轮桌面上的牌由 `playproc.cards` 恢复,**两种模式下都有**,否则后出的人无从跟牌 |
|
||||
| curmultiple | 整数 | **当前抓分倍数**(design §7.2.0):**带符号**,与 `chupai1/2/3` 同源同值,供重连后顶部「抓分」角标与判定动画状态立即正确 |
|
||||
| mustcard | 数组 | **本轮跟牌的必出牌**(design §5.2)。**仅当 `playproc.currseat == 请求者座位` 时才有**,且只算请求者自己的手牌;未轮到本家、本轮首家、甩牌局面、必出牌为空时均无此属性 |
|
||||
|
||||
**Balance(不在结算阶段无此属性)**
|
||||
|
||||
| 参数名 | 类型 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| readystate | 数组 | 所有玩家的准备状态 |
|
||||
| aset | json | 单局结算包,同上面结算包中的 `aset`,只有当自己的准备状态为0时才有此属性 |
|
||||
| bottom | json | **抠底包**,结构同 §14 的 `bottom`,与该局 `jiesuan` 推送的 `data.bottom` **同源同值**(服务端在 `get_paiju_account` 末尾冻结的同一份快照)。**仅正常出牌结算才有**(投降/解散没有抠底这一步);同样受「自己的准备状态为 0」门控。<br>**为什么必须有**:结算面板还开着时断线重连/硬刷新,只恢复 `aset` 会让抠底明细整块空白——同一份数据两条路径给的不一样(server 红线「发全下发面」)。<br>**可见性**:埋牌底牌在本局结算时已随 `jiesuan` 广播给三家(design §11 结束亮底),此处不构成额外泄露 |
|
||||
| account | json | **大局结算包**,结构同 §14 的 `account`,与该局 `jiesuan` 推送的 `data.account` **同源同值**。**仅末局或中途解散才有**,与 `jiesuan` 的取舍完全一致(有就带、没有就不带)|
|
||||
|
||||
---
|
||||
|
||||
## 战绩(大局列表)gameinfo1
|
||||
|
||||
> 非客户端收发包:大局结束/解散时由 `class.desk.js` `get_desk_account` 组装,经 `import.save_grade(o_room, o_gameinfo1, o_gameinfo2, 1)` 传给平台战绩服务持久化(服务器→平台)。
|
||||
|
||||
| 参数名 | 类型 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| roomcode | 整数 | 房号 |
|
||||
| asetcount | 整数 | 实际局数 |
|
||||
| createtime | 字符 | 开房时间 |
|
||||
| makewartime | 字符 | 开战时间 |
|
||||
| players | 数组 | 玩家列表,元素结构见下 |
|
||||
|
||||
`players` 数组元素结构:
|
||||
|
||||
```json
|
||||
{
|
||||
"seat": 0, // 座位
|
||||
"playerid": 100001, // 玩家ID
|
||||
"name": "", // 昵称
|
||||
"avatar": "", // 头像
|
||||
"score": 0 // 成绩
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 战绩(大局)gameinfo2
|
||||
|
||||
> 非客户端收发包:与 gameinfo1 同批,由 `import.save_grade` 的第 3 个参数传给平台战绩服务(服务器→平台)。
|
||||
|
||||
牌局列表,数组元素结构:
|
||||
|
||||
```json
|
||||
{
|
||||
"starttime": "", // 开始时间
|
||||
"endtime": "", // 结束时间
|
||||
"seatlist": [6, -6, 0], // 玩家成绩
|
||||
"callproc": [], // 叫分过程,详情见代码中的注释
|
||||
"banker": 0, // 庄
|
||||
"call": 0, // 叫分
|
||||
"flower": 0, // 主牌花色
|
||||
"result": 0, // 牌局结果
|
||||
"cards": [] // 发牌出牌情况,详情见代码中的注释
|
||||
}
|
||||
```
|
||||
Binary file not shown.
@@ -0,0 +1,791 @@
|
||||
//////////////////////////////////////////////////////////////////
|
||||
/////////////////////////////咻一咻/////////////////////////////
|
||||
//////////////////////////////////////////////////////////////////
|
||||
|
||||
var youle_erqiwang = youle_erqiwang || cls_mod.new("youle_erqiwang", "erqiwang", youle_app);
|
||||
|
||||
//配置解析类(roomtype 位串解析,SSOT)
|
||||
min_loadJsFile("games/erqiwang/class.config.js", function(){
|
||||
//牌类
|
||||
min_loadJsFile("games/erqiwang/class.pai.js", function(){
|
||||
//牌局类
|
||||
min_loadJsFile("games/erqiwang/class.paiju.js", function(){
|
||||
//牌桌类
|
||||
min_loadJsFile("games/erqiwang/class.desk.js", function(){
|
||||
//前后端共享牌值算法
|
||||
min_loadJsFile("games/erqiwang/shared/cards.js", function(){
|
||||
//算法类
|
||||
min_loadJsFile("games/erqiwang/class.arith.js", function(){
|
||||
//对内的输入接口类
|
||||
min_loadJsFile("games/erqiwang/class.import.js", function(){
|
||||
//对内的输出接口类
|
||||
min_loadJsFile("games/erqiwang/class.export.js", function(){
|
||||
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
//对内的输入接口类(需要的外部接口)
|
||||
youle_erqiwang.import = null;
|
||||
//对内的输出接口类(提供给外部使用的接口)
|
||||
youle_erqiwang.export = null;
|
||||
|
||||
//失败码(协议层约定,非 design 玩法规则;详见 docs/protocol/packet_protocol.md)
|
||||
//前端一律只看 data.success 判成败,errcode 仅用于区分失败原因、给出对应提示
|
||||
youle_erqiwang.ERR = {
|
||||
PLAYER: 1, //玩家/房间/座位校验不通过
|
||||
NODESK: 2, //牌桌或牌局不存在
|
||||
STEP: 3, //当前阶段不允许该操作
|
||||
SEAT: 4, //位置不符,或还没轮到该玩家操作
|
||||
PARAM: 5, //参数非法(类型/范围/张数/牌不在手上)
|
||||
RULE: 6 //规则不允许(叫分未更低、出牌不合法、投降条件不满足、房间模式禁止等)
|
||||
};
|
||||
|
||||
//失败回包:只回发给请求者,data.success=false
|
||||
//(dev-guide server 03 §4/§5:成败标志唯一是 data.success,主动推送的 data 必须自带 success)
|
||||
youle_erqiwang.do_sendfail = function(pack, rpc, errcode){
|
||||
var msg = {};
|
||||
msg.app = "youle";
|
||||
msg.route = "erqiwang";
|
||||
msg.rpc = rpc;
|
||||
msg.data = {};
|
||||
msg.data.success = false;
|
||||
msg.data.errcode = errcode;
|
||||
msg.conmode = pack.conmode;
|
||||
msg.fromid = pack.fromid;
|
||||
youle_erqiwang.app.SendPack(msg);
|
||||
}
|
||||
|
||||
//叫分或不叫
|
||||
youle_erqiwang.jiaofen = function(pack){
|
||||
var agentid = pack.data.agentid;
|
||||
var playerid = parseInt(pack.data.playerid);
|
||||
var gameid = pack.data.gameid;
|
||||
var roomcode = parseInt(pack.data.roomcode);
|
||||
var seat = parseInt(pack.data.seat);
|
||||
var call = parseInt(pack.data.call);
|
||||
//检查玩家的位置是否正确
|
||||
var o_room = youle_erqiwang.import.check_player(agentid, gameid, roomcode, seat, playerid, pack.conmode, pack.fromid);
|
||||
if (!o_room){
|
||||
youle_erqiwang.do_sendfail(pack, "jiaofen", youle_erqiwang.ERR.PLAYER);
|
||||
return;
|
||||
}
|
||||
//牌桌
|
||||
var o_desk = o_room.o_desk;
|
||||
if (!o_desk){
|
||||
youle_erqiwang.do_sendfail(pack, "jiaofen", youle_erqiwang.ERR.NODESK);
|
||||
return;
|
||||
}
|
||||
//牌局
|
||||
var o_paiju = o_desk.method.curr_paiju();
|
||||
if (!o_paiju){
|
||||
youle_erqiwang.do_sendfail(pack, "jiaofen", youle_erqiwang.ERR.NODESK);
|
||||
return;
|
||||
}
|
||||
//检查当前的游戏阶段是否是叫分阶段
|
||||
if (o_paiju.step != 1){
|
||||
youle_erqiwang.do_sendfail(pack, "jiaofen", youle_erqiwang.ERR.STEP);
|
||||
return;
|
||||
}
|
||||
//检查当前的叫分位置
|
||||
if (o_paiju.method.get_callgrade_seat() != seat){
|
||||
youle_erqiwang.do_sendfail(pack, "jiaofen", youle_erqiwang.ERR.SEAT);
|
||||
return;
|
||||
}
|
||||
//检查叫分:0(不叫)或 5~70 且为 5 的整数倍(design §4)
|
||||
if (call < 0 || call > 70 || call % 5 != 0){
|
||||
youle_erqiwang.do_sendfail(pack, "jiaofen", youle_erqiwang.ERR.PARAM);
|
||||
return;
|
||||
}
|
||||
var curr_call = o_paiju.method.get_callgrade_value();
|
||||
//第一家不能不叫分
|
||||
if (curr_call == 0 && call == 0){
|
||||
youle_erqiwang.do_sendfail(pack, "jiaofen", youle_erqiwang.ERR.RULE);
|
||||
return;
|
||||
}
|
||||
//叫分必须小于之前的叫分
|
||||
if (curr_call != 0 && call >= curr_call){
|
||||
youle_erqiwang.do_sendfail(pack, "jiaofen", youle_erqiwang.ERR.RULE);
|
||||
return;
|
||||
}
|
||||
|
||||
//本局房间可选规则:爬坡改变「叫分 → 基础子数」的取值(design §7.3.1),
|
||||
//下发面的 multiple 必须与结算包 aset.multiple 用同一套规则,否则界面与结算不符
|
||||
var _cfg = cls_youle_erqiwang_config.parse(o_room.roomtype);
|
||||
|
||||
//叫分成功
|
||||
o_paiju.method.do_callgrade(call);
|
||||
|
||||
//返回前端
|
||||
if (o_paiju.step == 1){
|
||||
//等待下一家叫分
|
||||
var msg = {};
|
||||
msg.app = "youle";
|
||||
msg.route = "erqiwang";
|
||||
msg.rpc = "jiaofen";
|
||||
msg.data = {};
|
||||
msg.data.success = true;
|
||||
msg.data.seat = seat;
|
||||
msg.data.call = call;
|
||||
msg.data.currcall = o_paiju.method.get_callgrade_value();
|
||||
msg.data.multiple = cls_youle_erqiwang_arith.get_base_bycall(msg.data.currcall, _cfg.climb);
|
||||
//阶段:叫分尚未结束,仍是 step1。阶段唯一由服务端维护、并在包里显式给出——
|
||||
//前端不得按 rpc 名反推(server 红线「状态机唯一在服务端 / 任何状态变更都要有包承载」)
|
||||
msg.data.step = o_paiju.step;
|
||||
//控制权:下一个叫分者(本包的「轮到谁」就是它,与重连包 CallRun.seat 同一个函数)
|
||||
msg.data.nextseat = o_paiju.method.get_callgrade_seat();
|
||||
msg.data.countdown = o_desk.method.get_countdown_jiaofen();
|
||||
o_room.method.sendpack_toother(msg, -1);
|
||||
return;
|
||||
}
|
||||
if (o_paiju.step == 2){
|
||||
//等待庄家叫主
|
||||
var msg = {};
|
||||
msg.app = "youle";
|
||||
msg.route = "erqiwang";
|
||||
msg.rpc = "shangzhuang";
|
||||
msg.data = {};
|
||||
msg.data.success = true;
|
||||
msg.data.seat = seat;
|
||||
msg.data.call = call;
|
||||
msg.data.banker = o_paiju.banker;
|
||||
msg.data.grade = o_paiju.call;
|
||||
msg.data.multiple = cls_youle_erqiwang_arith.get_base_bycall(msg.data.grade, _cfg.climb);
|
||||
//阶段:叫分结束、进入选主/投降(step2)。与重连包 deskinfo.step 同源同值
|
||||
msg.data.step = o_paiju.step;
|
||||
//控制权:选主与投降都只由庄家做(mod.xuanzhu / mod.touxiang 的 SEAT 校验就以 banker 为准),
|
||||
//故这里的「轮到谁」= o_paiju.banker —— 权威值取自牌局对象本身,不是前端按 banker 自行推导。
|
||||
//本包的 data.seat 是【最后一个叫分者】、不是控制权,别混用(协议 §4)
|
||||
msg.data.nextseat = o_paiju.banker;
|
||||
msg.data.countdown = o_desk.method.get_countdown_xuanzhu();
|
||||
if (o_paiju.call != 70){
|
||||
msg.data.touxiang = 0;
|
||||
} else {
|
||||
msg.data.touxiang = 1;
|
||||
}
|
||||
//70分坐庄:庄家摸底牌前,8张底牌向所有玩家亮3秒(design §4/§7.1)
|
||||
if (o_paiju.call == 70){
|
||||
msg.data.ancard3s = 1;
|
||||
}
|
||||
var _bottomcards = o_paiju.method.get_bottomcards();
|
||||
//当前抓分倍数(design §7.2.0):此刻捡分为 0,即大光×3;供客户端顶部「抓分」角标显示
|
||||
msg.data.curmultiple = o_paiju.method.get_curmultiple();
|
||||
|
||||
for (var i = 0; i < o_room.seatlist.length; i++) {
|
||||
msg.conmode = o_room.seatlist[i].conmode;
|
||||
msg.fromid = o_room.seatlist[i].fromid;
|
||||
if (i == o_paiju.banker){
|
||||
//庄家:手牌(含摸起的底牌)与底牌本身都可见
|
||||
msg.data.cards = o_paiju.method.get_seat_cards(i);
|
||||
msg.data.bottomcards = _bottomcards;
|
||||
} else {
|
||||
delete msg.data.cards;
|
||||
//闲家:仅70分坐庄时才下发底牌(供亮3秒),否则底牌只有庄家可见
|
||||
if (o_paiju.call == 70){
|
||||
msg.data.bottomcards = _bottomcards;
|
||||
} else {
|
||||
delete msg.data.bottomcards;
|
||||
}
|
||||
}
|
||||
youle_erqiwang.app.SendPack(msg);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
//选主
|
||||
youle_erqiwang.xuanzhu = function(pack){
|
||||
var agentid = pack.data.agentid;
|
||||
var playerid = parseInt(pack.data.playerid);
|
||||
var gameid = pack.data.gameid;
|
||||
var roomcode = parseInt(pack.data.roomcode);
|
||||
var seat = parseInt(pack.data.seat);
|
||||
var flower = parseInt(pack.data.flower);
|
||||
//检查玩家的位置是否正确
|
||||
var o_room = youle_erqiwang.import.check_player(agentid, gameid, roomcode, seat, playerid, pack.conmode, pack.fromid);
|
||||
if (!o_room){
|
||||
youle_erqiwang.do_sendfail(pack, "xuanzhu", youle_erqiwang.ERR.PLAYER);
|
||||
return;
|
||||
}
|
||||
//牌桌
|
||||
var o_desk = o_room.o_desk;
|
||||
if (!o_desk){
|
||||
youle_erqiwang.do_sendfail(pack, "xuanzhu", youle_erqiwang.ERR.NODESK);
|
||||
return;
|
||||
}
|
||||
//牌局
|
||||
var o_paiju = o_desk.method.curr_paiju();
|
||||
if (!o_paiju){
|
||||
youle_erqiwang.do_sendfail(pack, "xuanzhu", youle_erqiwang.ERR.NODESK);
|
||||
return;
|
||||
}
|
||||
//检查当前的游戏阶段是否是选主阶段
|
||||
if (o_paiju.step != 2){
|
||||
youle_erqiwang.do_sendfail(pack, "xuanzhu", youle_erqiwang.ERR.STEP);
|
||||
return;
|
||||
}
|
||||
//检查庄家位置
|
||||
if (o_paiju.banker != seat){
|
||||
youle_erqiwang.do_sendfail(pack, "xuanzhu", youle_erqiwang.ERR.SEAT);
|
||||
return;
|
||||
}
|
||||
//检查花色
|
||||
if (flower != 1 && flower != 2 && flower != 3 && flower != 4){
|
||||
youle_erqiwang.do_sendfail(pack, "xuanzhu", youle_erqiwang.ERR.PARAM);
|
||||
return;
|
||||
}
|
||||
|
||||
//选主成功
|
||||
o_paiju.method.do_choiceflower(flower);
|
||||
|
||||
//返回前端
|
||||
var msg = {};
|
||||
msg.app = "youle";
|
||||
msg.route = "erqiwang";
|
||||
msg.rpc = "xuanzhu";
|
||||
msg.data = {};
|
||||
msg.data.success = true;
|
||||
msg.data.banker = o_paiju.banker;
|
||||
msg.data.flower = flower;
|
||||
//阶段:选主完成即进入埋牌(step3)。与重连包 deskinfo.step 同源同值
|
||||
msg.data.step = o_paiju.step;
|
||||
//控制权:埋牌只由庄家做(mod.maipai 的 SEAT 校验同样以 banker 为准),
|
||||
//权威值取自牌局对象;前端不再自己套「埋牌 = 庄家」这条规则
|
||||
msg.data.nextseat = o_paiju.banker;
|
||||
msg.data.countdown = o_desk.method.get_countdown_maipai();
|
||||
for (var i = 0; i < o_room.seatlist.length; i++) {
|
||||
msg.conmode = o_room.seatlist[i].conmode;
|
||||
msg.fromid = o_room.seatlist[i].fromid;
|
||||
msg.data.cards = o_paiju.method.get_seat_cards(i);
|
||||
youle_erqiwang.app.SendPack(msg);
|
||||
}
|
||||
}
|
||||
|
||||
//埋牌
|
||||
youle_erqiwang.maipai = function(pack){
|
||||
var agentid = pack.data.agentid;
|
||||
var playerid = parseInt(pack.data.playerid);
|
||||
var gameid = pack.data.gameid;
|
||||
var roomcode = parseInt(pack.data.roomcode);
|
||||
var seat = parseInt(pack.data.seat);
|
||||
var cards = pack.data.cards;
|
||||
//检查玩家的位置是否正确
|
||||
var o_room = youle_erqiwang.import.check_player(agentid, gameid, roomcode, seat, playerid, pack.conmode, pack.fromid);
|
||||
if (!o_room){
|
||||
youle_erqiwang.do_sendfail(pack, "maipai", youle_erqiwang.ERR.PLAYER);
|
||||
return;
|
||||
}
|
||||
//牌桌
|
||||
var o_desk = o_room.o_desk;
|
||||
if (!o_desk){
|
||||
youle_erqiwang.do_sendfail(pack, "maipai", youle_erqiwang.ERR.NODESK);
|
||||
return;
|
||||
}
|
||||
//牌局
|
||||
var o_paiju = o_desk.method.curr_paiju();
|
||||
if (!o_paiju){
|
||||
youle_erqiwang.do_sendfail(pack, "maipai", youle_erqiwang.ERR.NODESK);
|
||||
return;
|
||||
}
|
||||
//检查当前的游戏阶段是否是埋牌阶段
|
||||
if (o_paiju.step != 3){
|
||||
youle_erqiwang.do_sendfail(pack, "maipai", youle_erqiwang.ERR.STEP);
|
||||
return;
|
||||
}
|
||||
//检查庄家位置
|
||||
if (o_paiju.banker != seat){
|
||||
youle_erqiwang.do_sendfail(pack, "maipai", youle_erqiwang.ERR.SEAT);
|
||||
return;
|
||||
}
|
||||
//检查埋牌入参本身是否合法(数组/整数牌id/范围/无重复),须先于 length 判断
|
||||
if (!o_paiju.method.check_cards_valid(cards)){
|
||||
youle_erqiwang.do_sendfail(pack, "maipai", youle_erqiwang.ERR.PARAM);
|
||||
return;
|
||||
}
|
||||
//检查埋牌是否是8张
|
||||
if (cards.length != 8){
|
||||
youle_erqiwang.do_sendfail(pack, "maipai", youle_erqiwang.ERR.PARAM);
|
||||
return;
|
||||
}
|
||||
//检查埋牌是否都在庄家手上
|
||||
if (!o_paiju.method.check_cards_inhand(cards, seat)){
|
||||
youle_erqiwang.do_sendfail(pack, "maipai", youle_erqiwang.ERR.PARAM);
|
||||
return;
|
||||
}
|
||||
|
||||
//埋牌成功
|
||||
o_paiju.method.do_burycard(cards);
|
||||
|
||||
//亮牌(design §8.2):可查牌模式下,出牌开始向两个闲家亮出庄家手牌统计
|
||||
var _cfg = cls_youle_erqiwang_config.parse(o_room.roomtype);
|
||||
var _liangpai = null;
|
||||
if (!_cfg.nocheck){
|
||||
_liangpai = o_paiju.method.get_liangpai();
|
||||
}
|
||||
|
||||
//返回前端
|
||||
var msg = {};
|
||||
msg.app = "youle";
|
||||
msg.route = "erqiwang";
|
||||
msg.rpc = "maipai";
|
||||
msg.data = {};
|
||||
msg.data.success = true;
|
||||
//阶段:埋牌完成即进入出牌(step5,无独立投降阶段)。与重连包 deskinfo.step 同源同值
|
||||
msg.data.step = o_paiju.step;
|
||||
//控制权:本包的 seat 就是「轮到谁」——即将首出的庄家,取自权威的 playproc.currseat
|
||||
//(do_burycard 里 new_playround 刚写好),与重连包 PushCards.playproc.currseat 同源
|
||||
msg.data.seat = o_paiju.playproc.currseat;
|
||||
msg.data.countdown = o_desk.method.get_countdown_chupai();
|
||||
if (!_cfg.nocheck){
|
||||
//三家座位牌况(缺门/无对 + 报副统计),仅可查牌下发——与 chupai1/2/3、重连包
|
||||
//PushCards.seatlist 同一份数据、同一道门控。埋牌完成时它是刚初始化的空表
|
||||
//(每家 [[0,0],[0,0],[0,0],[0,0],[-1,-1]]),本身不含任何信息;但「埋牌完成 →
|
||||
//庄家首出」这段窗口内前端也要有表可画,漏发会让增量路径此刻为空、重连路径却有表。
|
||||
//可见性:表内只有「某家缺某花色/某花色无对」(由公开的跟牌行为推出)与报无主后
|
||||
//全场公示的余主数量(design §9 明确要求向全体三人展示),故三家整表下发不构成泄露。
|
||||
//【深拷贝】seatlist 是全程就地改写的活表,不把活对象挂进下发包(同 playproc/pushlist)
|
||||
msg.data.seatlist = cls_youle_erqiwang_paiju.get_seatlist(o_paiju);
|
||||
}
|
||||
//本轮进行态(design §5.1):do_burycard 内部已调用 new_playround 就地初始化好了 round-1
|
||||
//的进行态,与重连包 PushCards.playproc 同一个快照函数、同一结构(三家无差别)。
|
||||
//没有它,「埋牌完成 → 庄家首出」这段窗口内增量路径的 table.playproc 是 null,
|
||||
//重连路径却已有 round-1 初值,两条路径不一致。可见性:round/start/currseat 等全部
|
||||
//由桌面公开信息推出,cards 此刻全空、shuai_demand 为 null,不含任何私密信息,
|
||||
//与 chupai1/2/3.playproc 一样三家整体下发、不逐座位裁剪。
|
||||
msg.data.playproc = cls_youle_erqiwang_paiju.get_playproc(o_paiju);
|
||||
//埋牌底牌(本次埋下的 8 张):取服务端权威快照,【不回显客户端提交的 cards 原序】。
|
||||
//get_burycard 与重连包 PushCards.burycards 是同一个函数、同一套按本局主牌花色的排序,
|
||||
//两条路径因此顺序一致(SSOT);直接回显 cards 会把客户端的选牌顺序当成下发顺序。
|
||||
var _burycards = cls_youle_erqiwang_paiju.get_burycard(o_paiju);
|
||||
for (var i = 0; i < o_room.seatlist.length; i++) {
|
||||
msg.conmode = o_room.seatlist[i].conmode;
|
||||
msg.fromid = o_room.seatlist[i].fromid;
|
||||
if (i == o_paiju.banker){
|
||||
msg.data.cards = o_paiju.method.get_seat_cards(i);
|
||||
//埋牌底牌(本次埋下的 8 张)。与 shangzhuang.bottomcards(底牌,发牌留桌 8 张)
|
||||
//是两批不同的牌,字段名刻意区分,勿混用
|
||||
msg.data.burycards = _burycards;
|
||||
delete msg.data.liangpai;
|
||||
} else {
|
||||
delete msg.data.cards;
|
||||
delete msg.data.burycards;
|
||||
//闲家:可查牌且庄家达标时亮牌
|
||||
if (_liangpai){
|
||||
msg.data.liangpai = _liangpai;
|
||||
} else {
|
||||
delete msg.data.liangpai;
|
||||
}
|
||||
}
|
||||
youle_erqiwang.app.SendPack(msg);
|
||||
}
|
||||
}
|
||||
|
||||
//投降
|
||||
youle_erqiwang.touxiang = function(pack){
|
||||
var agentid = pack.data.agentid;
|
||||
var playerid = parseInt(pack.data.playerid);
|
||||
var gameid = pack.data.gameid;
|
||||
var roomcode = parseInt(pack.data.roomcode);
|
||||
var seat = parseInt(pack.data.seat);
|
||||
//检查玩家的位置是否正确
|
||||
var o_room = youle_erqiwang.import.check_player(agentid, gameid, roomcode, seat, playerid, pack.conmode, pack.fromid);
|
||||
if (!o_room){
|
||||
youle_erqiwang.do_sendfail(pack, "touxiang", youle_erqiwang.ERR.PLAYER);
|
||||
return;
|
||||
}
|
||||
//牌桌
|
||||
var o_desk = o_room.o_desk;
|
||||
if (!o_desk){
|
||||
youle_erqiwang.do_sendfail(pack, "touxiang", youle_erqiwang.ERR.NODESK);
|
||||
return;
|
||||
}
|
||||
//牌局
|
||||
var o_paiju = o_desk.method.curr_paiju();
|
||||
if (!o_paiju){
|
||||
youle_erqiwang.do_sendfail(pack, "touxiang", youle_erqiwang.ERR.NODESK);
|
||||
return;
|
||||
}
|
||||
//投降是选主阶段(step 2)与选主互斥的选择:点投降直接结算,不选主、不埋牌(design §4)
|
||||
if (o_paiju.step != 2){
|
||||
youle_erqiwang.do_sendfail(pack, "touxiang", youle_erqiwang.ERR.STEP);
|
||||
return;
|
||||
}
|
||||
//检查庄家位置
|
||||
if (o_paiju.banker != seat){
|
||||
youle_erqiwang.do_sendfail(pack, "touxiang", youle_erqiwang.ERR.SEAT);
|
||||
return;
|
||||
}
|
||||
//仅 70 分坐庄可投降(design §4 / §7.1)
|
||||
if (o_paiju.call != 70){
|
||||
youle_erqiwang.do_sendfail(pack, "touxiang", youle_erqiwang.ERR.RULE);
|
||||
return;
|
||||
}
|
||||
|
||||
//投降结算
|
||||
o_paiju.step = 6;
|
||||
var msg = {};
|
||||
msg.app = "youle";
|
||||
msg.route = "erqiwang";
|
||||
msg.rpc = "jiesuan";
|
||||
msg.data = {};
|
||||
msg = o_paiju.method.get_paiju_account(1, msg);
|
||||
o_room.method.sendpack_toother(msg, -1);
|
||||
}
|
||||
|
||||
//出牌
|
||||
youle_erqiwang.chupai = function(pack){
|
||||
var agentid = pack.data.agentid;
|
||||
var playerid = parseInt(pack.data.playerid);
|
||||
var gameid = pack.data.gameid;
|
||||
var roomcode = parseInt(pack.data.roomcode);
|
||||
var seat = parseInt(pack.data.seat);
|
||||
var cards = pack.data.cards;
|
||||
//检查玩家的位置是否正确
|
||||
var o_room = youle_erqiwang.import.check_player(agentid, gameid, roomcode, seat, playerid, pack.conmode, pack.fromid);
|
||||
if (!o_room){
|
||||
youle_erqiwang.do_sendfail(pack, "chupai", youle_erqiwang.ERR.PLAYER);
|
||||
return;
|
||||
}
|
||||
//牌桌
|
||||
var o_desk = o_room.o_desk;
|
||||
if (!o_desk){
|
||||
youle_erqiwang.do_sendfail(pack, "chupai", youle_erqiwang.ERR.NODESK);
|
||||
return;
|
||||
}
|
||||
//牌局
|
||||
var o_paiju = o_desk.method.curr_paiju();
|
||||
if (!o_paiju){
|
||||
youle_erqiwang.do_sendfail(pack, "chupai", youle_erqiwang.ERR.NODESK);
|
||||
return;
|
||||
}
|
||||
//检查当前的游戏阶段是否是出牌阶段
|
||||
if (o_paiju.step != 5){
|
||||
youle_erqiwang.do_sendfail(pack, "chupai", youle_erqiwang.ERR.STEP);
|
||||
return;
|
||||
}
|
||||
//检查出牌位置
|
||||
if (seat != o_paiju.playproc.currseat){
|
||||
youle_erqiwang.do_sendfail(pack, "chupai", youle_erqiwang.ERR.SEAT);
|
||||
return;
|
||||
}
|
||||
//检查出牌入参本身是否合法(数组/整数牌id/范围/无重复,含非空)
|
||||
if (!o_paiju.method.check_cards_valid(cards)){
|
||||
youle_erqiwang.do_sendfail(pack, "chupai", youle_erqiwang.ERR.PARAM);
|
||||
return;
|
||||
}
|
||||
//检查出牌是否都在玩家手上
|
||||
if (!o_paiju.method.check_cards_inhand(cards, seat)){
|
||||
youle_erqiwang.do_sendfail(pack, "chupai", youle_erqiwang.ERR.PARAM);
|
||||
return;
|
||||
}
|
||||
|
||||
//检查选中的牌是否可出,并出牌
|
||||
var re = o_paiju.method.do_playcard(cards);
|
||||
if (!re.result){
|
||||
youle_erqiwang.do_sendfail(pack, "chupai", youle_erqiwang.ERR.RULE);
|
||||
return;
|
||||
}
|
||||
|
||||
//查牌模式(design §9):不查牌时不下发任何主牌统计(info/报无主)
|
||||
var _cfg = cls_youle_erqiwang_config.parse(o_room.roomtype);
|
||||
|
||||
//返回前端
|
||||
var msg = {};
|
||||
msg.app = "youle";
|
||||
msg.route = "erqiwang";
|
||||
msg.data = {};
|
||||
msg.data.success = true;
|
||||
msg.data.seat = seat;
|
||||
//实际打出的牌(甩错时为单张),顺序由 do_playcard 的 order_playcards 统一给出——
|
||||
//与 playproc.cards、重连包 PushCards.pushlist 同一个口径(SSOT)。
|
||||
//【不回落到入参 cards】那是客户端的点击顺序,服务端不采信、更不回显(server 04 §8);
|
||||
//do_playcard 成功时三个分支都会给出 re.cards,缺了就是它的缺陷,显式暴露、不兜底。
|
||||
msg.data.cards = re.cards;
|
||||
if (!_cfg.nocheck){
|
||||
//三家座位牌况(缺门/无对 + 报副统计),仅可查牌下发。
|
||||
//必须整表下发、不能只带出牌者一家:design §9 要求「一旦有玩家报无主,就为全体三人
|
||||
//显示另外两家各自的主牌数量与对子数」,服务端已在 do_playcard 里同时刷新三个座位
|
||||
//(class.paiju.js),只带一家会让另两家的统计滞后到它们各自出牌时才补上。
|
||||
//字段名与重连包 PushCards.seatlist 一致,前端两条路径复用同一份解析。
|
||||
//【深拷贝】seatlist 是全程就地改写的活表(本函数下方 do_playcard 每手都在改它),
|
||||
//不把活对象挂进下发包(同 playproc/pushlist 的理由)
|
||||
msg.data.seatlist = cls_youle_erqiwang_paiju.get_seatlist(o_paiju);
|
||||
}
|
||||
msg.data.nextseat = o_paiju.playproc.currseat;
|
||||
msg.data.countdown = o_desk.method.get_countdown_chupai();
|
||||
//当前抓分倍数(design §7.2.0):随本包一起下发、不另开推送;三家同值,grade 本就公开
|
||||
msg.data.curmultiple = o_paiju.method.get_curmultiple();
|
||||
//本轮进行态(design §5.1):与重连包 PushCards.playproc 同一个快照函数、同一结构。
|
||||
//没有它,前端在非重连时就拿不到「本轮谁先手/谁最大/三家各出了什么/第几轮」,只能自己
|
||||
//累积推导——那就是把对局推进逻辑搬到前端(前端红线「数据驱动、前端无对局状态机」)。
|
||||
//注意 re.idx==3 时 do_playcard 已在内部调用 new_playround 就地开了新一轮,
|
||||
//故 chupai3 带的是【新一轮】的进行态(与 nextseat 同步),这与此刻重连拿到的完全一致。
|
||||
//可见性:round/start/currseat/startcount/startflower/starttype/maxseat/maxcard 都是
|
||||
//由桌面公开信息直接推出的;cards 就是本轮已摊在桌上的牌;shuai_demand 是首家甩牌的
|
||||
//分量构成,而甩出的牌本身已公开、chupai1 也已把等价的 shuai 下发给三家。全部对三家可见,
|
||||
//无需逐座位裁剪(重连包 PushCards.playproc 本就对三家无差别下发,两条路径口径一致)
|
||||
msg.data.playproc = cls_youle_erqiwang_paiju.get_playproc(o_paiju);
|
||||
if (re.shuaicuo){
|
||||
msg.data.shuaicuo = 1; //甩错:本次甩牌被收回,只打出了最小一张(§5.4.5)
|
||||
}
|
||||
//报无主:仅可查牌模式下才据此展示他家主牌数量/对子(§9)
|
||||
if (!_cfg.nocheck && o_paiju.method.have_baofu()){
|
||||
msg.data.baozhu = 1;
|
||||
} else {
|
||||
msg.data.baozhu = 0;
|
||||
}
|
||||
|
||||
switch (re.idx)
|
||||
{
|
||||
case 1: //第一个出牌
|
||||
msg.rpc = "chupai1";
|
||||
msg.data.count = re.count;
|
||||
msg.data.flower = re.flower;
|
||||
msg.data.cardtype = re.cardtype;
|
||||
//合法甩牌的分量构成(design §5.4.3),供前端按 拖拉机/对子/单张 分组展示;
|
||||
//非甩牌(含甩错退化为单张)无此属性
|
||||
if (re.shuai){
|
||||
msg.data.shuai = re.shuai;
|
||||
}
|
||||
break;
|
||||
case 2: //第二个出牌
|
||||
msg.rpc = "chupai2";
|
||||
break;
|
||||
case 3: //第三个出牌
|
||||
msg.rpc = "chupai3";
|
||||
msg.data.maxseat = re.maxseat;
|
||||
if (re.grade > 0){
|
||||
msg.data.grade = re.grade;
|
||||
// msg.data.gradecards = re.gradecards;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
if (o_paiju.step == 5){
|
||||
//下一个出牌者本轮跟牌的必出牌(design §5.2),供其客户端自动选中。
|
||||
//【只发给 nextseat 一家】——mustcard 是该玩家自己手牌的子集,发给他本人不泄露;
|
||||
//整表下发三家会把另两家的手牌结构透露出去(server 红线:发全 ≠ 发多,按可见性下发)。
|
||||
var _nextseat = o_paiju.playproc.currseat;
|
||||
var _mustcard = o_paiju.method.get_mustcard(_nextseat);
|
||||
for (var i = 0; i < o_room.seatlist.length; i++){
|
||||
msg.conmode = o_room.seatlist[i].conmode;
|
||||
msg.fromid = o_room.seatlist[i].fromid;
|
||||
if (i == seat){
|
||||
msg.data.cardsinhand = o_paiju.method.get_seat_cards(i);
|
||||
} else {
|
||||
delete msg.data.cardsinhand;
|
||||
}
|
||||
if (i == _nextseat && _mustcard){
|
||||
msg.data.mustcard = _mustcard;
|
||||
} else {
|
||||
delete msg.data.mustcard;
|
||||
}
|
||||
youle_erqiwang.app.SendPack(msg);
|
||||
}
|
||||
} else if (o_paiju.step == 6){
|
||||
//小局结算
|
||||
//出牌包的调整
|
||||
msg.data.chupai = {};
|
||||
msg.data.chupai.seat = msg.data.seat;
|
||||
msg.data.chupai.cards = msg.data.cards;
|
||||
msg.data.chupai.maxseat = msg.data.maxseat;
|
||||
if (msg.data.grade){
|
||||
msg.data.chupai.grade = msg.data.grade;
|
||||
}
|
||||
if (msg.data.gradecards){
|
||||
msg.data.chupai.gradecards = msg.data.gradecards;
|
||||
}
|
||||
delete msg.data.seat;
|
||||
delete msg.data.cards;
|
||||
delete msg.data.seatlist;
|
||||
delete msg.data.nextseat;
|
||||
delete msg.data.countdown;
|
||||
delete msg.data.baozhu;
|
||||
//本轮进行态只对「还要继续出牌」有意义;本包已转为 jiesuan,不带
|
||||
delete msg.data.playproc;
|
||||
delete msg.data.cardsinhand;
|
||||
delete msg.data.maxseat;
|
||||
delete msg.data.grade;
|
||||
delete msg.data.gradecards;
|
||||
|
||||
//获取抠底包
|
||||
msg = o_paiju.method.get_bottom_account(msg);
|
||||
//获取小局结算包
|
||||
msg = o_paiju.method.get_paiju_account(0, msg);
|
||||
o_room.method.sendpack_toother(msg, -1);
|
||||
}
|
||||
}
|
||||
|
||||
//明牌:查看另外两家手中全部主牌的具体牌面(design §9,仅可查牌模式且已有人报无主)
|
||||
youle_erqiwang.mingpai = function(pack){
|
||||
var agentid = pack.data.agentid;
|
||||
var playerid = parseInt(pack.data.playerid);
|
||||
var gameid = pack.data.gameid;
|
||||
var roomcode = parseInt(pack.data.roomcode);
|
||||
var seat = parseInt(pack.data.seat);
|
||||
//检查玩家的位置是否正确
|
||||
var o_room = youle_erqiwang.import.check_player(agentid, gameid, roomcode, seat, playerid, pack.conmode, pack.fromid);
|
||||
if (!o_room){
|
||||
youle_erqiwang.do_sendfail(pack, "mingpai", youle_erqiwang.ERR.PLAYER);
|
||||
return;
|
||||
}
|
||||
//牌桌
|
||||
var o_desk = o_room.o_desk;
|
||||
if (!o_desk){
|
||||
youle_erqiwang.do_sendfail(pack, "mingpai", youle_erqiwang.ERR.NODESK);
|
||||
return;
|
||||
}
|
||||
//牌局
|
||||
var o_paiju = o_desk.method.curr_paiju();
|
||||
if (!o_paiju){
|
||||
youle_erqiwang.do_sendfail(pack, "mingpai", youle_erqiwang.ERR.NODESK);
|
||||
return;
|
||||
}
|
||||
//只在出牌阶段
|
||||
if (o_paiju.step != 5){
|
||||
youle_erqiwang.do_sendfail(pack, "mingpai", youle_erqiwang.ERR.STEP);
|
||||
return;
|
||||
}
|
||||
//不查牌模式禁止明牌
|
||||
var cfg = cls_youle_erqiwang_config.parse(o_room.roomtype);
|
||||
if (cfg.nocheck){
|
||||
youle_erqiwang.do_sendfail(pack, "mingpai", youle_erqiwang.ERR.RULE);
|
||||
return;
|
||||
}
|
||||
//必须已有玩家报无主才允许明牌
|
||||
if (!o_paiju.method.have_baofu()){
|
||||
youle_erqiwang.do_sendfail(pack, "mingpai", youle_erqiwang.ERR.RULE);
|
||||
return;
|
||||
}
|
||||
|
||||
//另外两家未出的全部主牌,只回发给请求者
|
||||
var msg = {};
|
||||
msg.app = "youle";
|
||||
msg.route = "erqiwang";
|
||||
msg.rpc = "mingpai";
|
||||
msg.data = {};
|
||||
msg.data.success = true;
|
||||
msg.data.seat = seat;
|
||||
msg.data.others = [];
|
||||
for (var i = 0; i < 3; i++){
|
||||
if (i != seat){
|
||||
var one = {};
|
||||
one.seat = i;
|
||||
one.zhucards = cls_youle_erqiwang_paiju.get_seat_zhucards(o_paiju, i);
|
||||
msg.data.others.push(one);
|
||||
}
|
||||
}
|
||||
msg.conmode = pack.conmode;
|
||||
msg.fromid = pack.fromid;
|
||||
youle_erqiwang.app.SendPack(msg);
|
||||
}
|
||||
|
||||
//闲家出牌提示(design §11):闲家在出牌阶段向对家(另一闲家)发"踩/没分/有分"信号。
|
||||
//纯转发、不校验真实性、只发给对家;庄家无对家,不参与。tip:1=踩 2=没分 3=有分。
|
||||
youle_erqiwang.tishi = function(pack){
|
||||
var agentid = pack.data.agentid;
|
||||
var playerid = parseInt(pack.data.playerid);
|
||||
var gameid = pack.data.gameid;
|
||||
var roomcode = parseInt(pack.data.roomcode);
|
||||
var seat = parseInt(pack.data.seat);
|
||||
var tip = parseInt(pack.data.tip);
|
||||
//检查玩家的位置是否正确
|
||||
var o_room = youle_erqiwang.import.check_player(agentid, gameid, roomcode, seat, playerid, pack.conmode, pack.fromid);
|
||||
if (!o_room){
|
||||
youle_erqiwang.do_sendfail(pack, "tishi", youle_erqiwang.ERR.PLAYER);
|
||||
return;
|
||||
}
|
||||
//牌桌
|
||||
var o_desk = o_room.o_desk;
|
||||
if (!o_desk){
|
||||
youle_erqiwang.do_sendfail(pack, "tishi", youle_erqiwang.ERR.NODESK);
|
||||
return;
|
||||
}
|
||||
//牌局
|
||||
var o_paiju = o_desk.method.curr_paiju();
|
||||
if (!o_paiju){
|
||||
youle_erqiwang.do_sendfail(pack, "tishi", youle_erqiwang.ERR.NODESK);
|
||||
return;
|
||||
}
|
||||
//只在出牌阶段(step5)任意时刻可发
|
||||
if (o_paiju.step != 5){
|
||||
youle_erqiwang.do_sendfail(pack, "tishi", youle_erqiwang.ERR.STEP);
|
||||
return;
|
||||
}
|
||||
//只有闲家可发提示,庄家无对家、不参与
|
||||
if (seat == o_paiju.banker){
|
||||
youle_erqiwang.do_sendfail(pack, "tishi", youle_erqiwang.ERR.RULE);
|
||||
return;
|
||||
}
|
||||
//提示类型:1=踩 2=没分 3=有分
|
||||
if (tip != 1 && tip != 2 && tip != 3){
|
||||
youle_erqiwang.do_sendfail(pack, "tishi", youle_erqiwang.ERR.PARAM);
|
||||
return;
|
||||
}
|
||||
|
||||
//对家 = 另一个闲家(三座位号之和为 0+1+2=3)
|
||||
var partner = 3 - o_paiju.banker - seat;
|
||||
|
||||
//只转发给对家
|
||||
var msg = {};
|
||||
msg.app = "youle";
|
||||
msg.route = "erqiwang";
|
||||
msg.rpc = "tishi";
|
||||
msg.data = {};
|
||||
msg.data.success = true;
|
||||
msg.data.seat = seat; //发出提示的闲家
|
||||
msg.data.tip = tip;
|
||||
msg.conmode = o_room.seatlist[partner].conmode;
|
||||
msg.fromid = o_room.seatlist[partner].fromid;
|
||||
youle_erqiwang.app.SendPack(msg);
|
||||
}
|
||||
|
||||
//准备
|
||||
youle_erqiwang.zhunbei = function(pack){
|
||||
var agentid = pack.data.agentid;
|
||||
var playerid = parseInt(pack.data.playerid);
|
||||
var gameid = pack.data.gameid;
|
||||
var roomcode = parseInt(pack.data.roomcode);
|
||||
var seat = parseInt(pack.data.seat);
|
||||
//检查玩家的位置是否正确
|
||||
var o_room = youle_erqiwang.import.check_player(agentid, gameid, roomcode, seat, playerid, pack.conmode, pack.fromid);
|
||||
if (!o_room){
|
||||
youle_erqiwang.do_sendfail(pack, "zhunbei", youle_erqiwang.ERR.PLAYER);
|
||||
return;
|
||||
}
|
||||
//牌桌
|
||||
var o_desk = o_room.o_desk;
|
||||
if (!o_desk){
|
||||
youle_erqiwang.do_sendfail(pack, "zhunbei", youle_erqiwang.ERR.NODESK);
|
||||
return;
|
||||
}
|
||||
//牌局
|
||||
var o_paiju = o_desk.method.curr_paiju();
|
||||
if (!o_paiju){
|
||||
youle_erqiwang.do_sendfail(pack, "zhunbei", youle_erqiwang.ERR.NODESK);
|
||||
return;
|
||||
}
|
||||
//检查当前的游戏阶段是否是结算阶段
|
||||
if (o_paiju.step != 6){
|
||||
youle_erqiwang.do_sendfail(pack, "zhunbei", youle_erqiwang.ERR.STEP);
|
||||
return;
|
||||
}
|
||||
//检查当前局数小于总局数
|
||||
if (o_desk.paiju_list.length >= o_room.asetcount){
|
||||
youle_erqiwang.do_sendfail(pack, "zhunbei", youle_erqiwang.ERR.RULE);
|
||||
return;
|
||||
}
|
||||
|
||||
//准备成功
|
||||
o_desk.method.do_prepare(seat);
|
||||
}
|
||||
|
||||
//Node(本地/单元测试)导出模块对象;友乐/浏览器无 module 时跳过(dev-guide 01 §1)
|
||||
if (typeof module !== "undefined"){
|
||||
module.exports = youle_erqiwang;
|
||||
}
|
||||
@@ -0,0 +1,304 @@
|
||||
///////////////////////////////////////////////////////////////
|
||||
////// youle_erqiwang_shared_cards: 前后端共享牌值算法 //////////
|
||||
///////////////////////////////////////////////////////////////
|
||||
// 【权威源】本文件是前后端共享算法的唯一可修改处。
|
||||
// 前端副本 client/js/01_SubGame/codes/shared/cards.js 由仓库根目录的
|
||||
// sync_shared.cmd 单向同步生成,为只读;任何改动都必须落在本文件,
|
||||
// 改完跑一次 sync_shared.cmd,否则 client/tests/test_shared_sync.js 会红。
|
||||
//
|
||||
// 内容:牌值编码与牌型分组的纯函数,不依赖对局状态、不依赖平台 API。
|
||||
// 牌编码区间约定(id_to_code 的产物,各处判定据此):
|
||||
// 9554 大王 / 9553 小王 / 8000–9000 正7 / 7000–8000 副7
|
||||
// 3000–4000 正2 / 2000–3000 副2 / 1000–2000 主花色普通牌 / 100–500 副牌普通牌
|
||||
// ⇒ 主牌 ⟺ code > 1000
|
||||
var youle_erqiwang_shared_cards = youle_erqiwang_shared_cards || {
|
||||
|
||||
//——— 牌编码区间边界:上面那张区间表的机器可读形式,是【全部区间判定的唯一权威来源】———
|
||||
//一律是【开区间】:CODE_ZHENG7_MIN < code < CODE_ZHENG7_MAX ⟺ 正7,其余同理。
|
||||
//本文件内部(is_continuous / trump_rank / id_to_code)与前端 core/CardMark.js 都引用这些常量,
|
||||
//不再各处重抄 3000/4000/8000/9000 之类裸字面量(工程总则 §1 SSOT / §6 配置优先于硬编码)。
|
||||
CODE_XIAOWANG: 9553, //小王(单值,非区间)
|
||||
CODE_DAWANG: 9554, //大王(单值,非区间)
|
||||
CODE_ZHENG7_MIN: 8000, //正7:8000 < code < 9000
|
||||
CODE_ZHENG7_MAX: 9000,
|
||||
CODE_FU7_MIN: 7000, //副7:7000 < code < 8000
|
||||
CODE_FU7_MAX: 8000,
|
||||
CODE_ZHENG2_MIN: 3000, //正2:3000 < code < 4000
|
||||
CODE_ZHENG2_MAX: 4000,
|
||||
CODE_FU2_MIN: 2000, //副2:2000 < code < 3000
|
||||
CODE_FU2_MAX: 3000,
|
||||
CODE_ZHU_MIN: 1000, //主牌 ⟺ code > CODE_ZHU_MIN;主花色普通牌:1000 < code < 2000
|
||||
CODE_ZHU_MAX: 2000,
|
||||
CODE_A_IN_ZHU: 14, //主A 的 code % 100(A 在 id_to_code 里 +13 提到 14)
|
||||
|
||||
//几对连续对子起算拖拉机(design §5.3)。group_tractor_runs 返回极大连续段,
|
||||
//段长 >= 本值即拖拉机——服务端分解甩牌与前端手牌标记共用这一个阈值
|
||||
TRACTOR_MIN_PAIRS: 2,
|
||||
|
||||
//牌id转花色
|
||||
id_to_flower: function(cardid){
|
||||
if (cardid == 52 || cardid == 106){
|
||||
//小王
|
||||
return 5;
|
||||
}
|
||||
if (cardid == 53 || cardid == 107){
|
||||
//大王
|
||||
return 5;
|
||||
}
|
||||
return parseInt((cardid % 54) / 13) + 1;
|
||||
},
|
||||
|
||||
//牌id转牌面数值
|
||||
id_to_number: function(cardid){
|
||||
if (cardid == 52 || cardid == 106){
|
||||
//小王
|
||||
return 53;
|
||||
}
|
||||
if (cardid == 53 || cardid == 107){
|
||||
//大王
|
||||
return 54;
|
||||
}
|
||||
return (cardid % 54) % 13 + 1;
|
||||
},
|
||||
|
||||
//牌id转牌编码
|
||||
id_to_code: function(mainflower, cardid){
|
||||
if (cardid == 52 || cardid == 106){
|
||||
//小王
|
||||
return youle_erqiwang_shared_cards.CODE_XIAOWANG;
|
||||
}
|
||||
if (cardid == 53 || cardid == 107){
|
||||
//大王
|
||||
return youle_erqiwang_shared_cards.CODE_DAWANG;
|
||||
}
|
||||
var flower = youle_erqiwang_shared_cards.id_to_flower(cardid);
|
||||
var number = youle_erqiwang_shared_cards.id_to_number(cardid);
|
||||
var code = flower * 100 + number;
|
||||
//特殊处理
|
||||
switch (number)
|
||||
{
|
||||
case 1:
|
||||
//A 提到 K 之上(14)
|
||||
code = code + 13;
|
||||
break;
|
||||
case 2:
|
||||
//2 提到副2 段
|
||||
code = code + youle_erqiwang_shared_cards.CODE_FU2_MIN;
|
||||
break;
|
||||
case 7:
|
||||
//7 提到副7 段
|
||||
code = code + youle_erqiwang_shared_cards.CODE_FU7_MIN;
|
||||
break;
|
||||
}
|
||||
//主牌花色处理:整体再上移一个主牌段(副2→正2、副7→正7、普通牌→主花色普通牌)
|
||||
if (flower == mainflower){
|
||||
code = code + youle_erqiwang_shared_cards.CODE_ZHU_MIN;
|
||||
}
|
||||
return code;
|
||||
},
|
||||
|
||||
//从大到小排序(冒泡排序)
|
||||
order_cards: function(mainflower, aryCardIDs){
|
||||
if (aryCardIDs){
|
||||
for (var i = 0; i < aryCardIDs.length; i++) {
|
||||
for (var j = i + 1; j < aryCardIDs.length; j++) {
|
||||
var i_code = youle_erqiwang_shared_cards.id_to_code(mainflower, aryCardIDs[i]);
|
||||
var j_code = youle_erqiwang_shared_cards.id_to_code(mainflower, aryCardIDs[j]);
|
||||
if (i_code < j_code) {
|
||||
var tmp = aryCardIDs[j];
|
||||
aryCardIDs[j] = aryCardIDs[i];
|
||||
aryCardIDs[i] = tmp;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return aryCardIDs;
|
||||
},
|
||||
|
||||
//判断两个牌是否是连续的
|
||||
is_continuous: function(bigcode, smallcode){
|
||||
var S = youle_erqiwang_shared_cards;
|
||||
//是小王
|
||||
var is_xiaowang = function(_code){
|
||||
if (_code == S.CODE_XIAOWANG){
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
//是正7
|
||||
var is_zheng7 = function(_code){
|
||||
if (_code > S.CODE_ZHENG7_MIN && _code < S.CODE_ZHENG7_MAX){
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
//是负7
|
||||
var is_fu7 = function(_code){
|
||||
if (_code > S.CODE_FU7_MIN && _code < S.CODE_FU7_MAX){
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
//是正2
|
||||
var is_zheng2 = function(_code){
|
||||
if (_code > S.CODE_ZHENG2_MIN && _code < S.CODE_ZHENG2_MAX){
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
//是负2
|
||||
var is_fu2 = function(_code){
|
||||
if (_code > S.CODE_FU2_MIN && _code < S.CODE_FU2_MAX){
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
//是主A
|
||||
var is_zhuA = function(_code){
|
||||
if (_code > S.CODE_ZHU_MIN && _code < S.CODE_ZHU_MAX && _code % 100 == S.CODE_A_IN_ZHU){
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
//是8
|
||||
var is_8 = function(_code){
|
||||
if (_code % 100 == 8){
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
//是6
|
||||
var is_6 = function(_code){
|
||||
if (_code % 100 == 6){
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
//普通的连牌
|
||||
if (bigcode - smallcode == 1){
|
||||
return true;
|
||||
}
|
||||
//小王、正7
|
||||
if (is_xiaowang(bigcode) && is_zheng7(smallcode)){
|
||||
return true;
|
||||
}
|
||||
//正7、负7
|
||||
if (is_zheng7(bigcode) && is_fu7(smallcode)){
|
||||
return true;
|
||||
}
|
||||
//负7、正2
|
||||
if (is_fu7(bigcode) && is_zheng2(smallcode)){
|
||||
return true;
|
||||
}
|
||||
//正2、负2
|
||||
if (is_zheng2(bigcode) && is_fu2(smallcode)){
|
||||
return true;
|
||||
}
|
||||
//负2、主A
|
||||
if (is_fu2(bigcode) && is_zhuA(smallcode)){
|
||||
return true;
|
||||
}
|
||||
//8、6(design §5.3:7 不与 6/8 相连,6 与 8 相连)
|
||||
//必须是同一花色的 8 与 6:牌编码同花色相差 2(主花色 1408-1406=2、副牌 308-306=2),
|
||||
//跨花色则相差 100 的倍数。只判 %100 会把「♥8 + ♣6」误判为连对,
|
||||
//进而在 get_chongguan 扫描整手牌时让副牌的 6 对错误接续主 8、多算奖(design §8.1 连对链)
|
||||
if (is_8(bigcode) && is_6(smallcode) && (bigcode - smallcode == 2)){
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
|
||||
//获取对子列表,cards为同一花色的排过序的牌列表
|
||||
get_pairlist: function(mainflower, flowercards){
|
||||
var pairlist = [];
|
||||
var i = 0;
|
||||
while (i < flowercards.length - 1){
|
||||
//牌编码,两张牌两张牌一取
|
||||
var code_i = youle_erqiwang_shared_cards.id_to_code(mainflower, flowercards[i]);
|
||||
var code_j = youle_erqiwang_shared_cards.id_to_code(mainflower, flowercards[i + 1]);
|
||||
if (code_i == code_j){
|
||||
pairlist.push([flowercards[i], flowercards[i + 1]]);
|
||||
i = i + 2;
|
||||
} else {
|
||||
i++;
|
||||
}
|
||||
}
|
||||
return pairlist;
|
||||
},
|
||||
|
||||
//把对子列表扫成【极大连续段】。pairlist 为同一牌组内、已按牌编码从大到小排好的对子列表
|
||||
//(形如 get_pairlist 的返回值:[[cardid, cardid], ...])。
|
||||
//返回 [[pair, ...], ...]:每一段是若干【相邻且 is_continuous】的对子,段与段之间必不连续;
|
||||
//孤立对子也作为长度 1 的段返回,故 concat 所有段 == 原 pairlist(顺序不变)。
|
||||
//段长 >= TRACTOR_MIN_PAIRS 即拖拉机。
|
||||
//【唯一实现】服务端 decompose_trump(甩牌分解)与前端 core/CardMark.js(手牌「拖」标记)
|
||||
//此前各写过一遍同样的扫描,规则内容(什么算连续、几对起算拖拉机)散在两处——现收敛到这里。
|
||||
group_tractor_runs: function(mainflower, pairlist){
|
||||
var runs = [];
|
||||
var i = 0;
|
||||
while (i < pairlist.length){
|
||||
var run = [pairlist[i]];
|
||||
var k = i;
|
||||
while (k + 1 < pairlist.length){
|
||||
var code_k = youle_erqiwang_shared_cards.id_to_code(mainflower, pairlist[k][0]);
|
||||
var code_k1 = youle_erqiwang_shared_cards.id_to_code(mainflower, pairlist[k + 1][0]);
|
||||
if (!youle_erqiwang_shared_cards.is_continuous(code_k, code_k1)){
|
||||
break;
|
||||
}
|
||||
run.push(pairlist[k + 1]);
|
||||
k++;
|
||||
}
|
||||
runs.push(run);
|
||||
i = k + 1;
|
||||
}
|
||||
return runs;
|
||||
},
|
||||
|
||||
//获取拖拉机列表,pairlist为同一花色的排过序的对子列表,cardtype为拖拉机牌型
|
||||
get_tuolaji_list: function(mainflower, pairlist, cardtype){
|
||||
var tuolaji = [];
|
||||
var count = cardtype - 300; //拖拉机的对子数量
|
||||
var i = 0;
|
||||
for (var i = 0; i < pairlist.length - count + 1; i++){
|
||||
//每次按拖拉机的对子数量取N对
|
||||
var tmplist = pairlist.slice(i, i + count);
|
||||
//判断是否是cardtype类型的拖拉机
|
||||
var is_tlj = true;
|
||||
for (var j = 0; j < tmplist.length - 1; j++) {
|
||||
var code_j1 = youle_erqiwang_shared_cards.id_to_code(mainflower, tmplist[j][0]);
|
||||
var code_j2 = youle_erqiwang_shared_cards.id_to_code(mainflower, tmplist[j+1][0]);
|
||||
if (!youle_erqiwang_shared_cards.is_continuous(code_j1, code_j2)){
|
||||
is_tlj = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (is_tlj){
|
||||
var tlj = [];
|
||||
for (var k = 0; k < tmplist.length; k++){
|
||||
tlj = tlj.concat(tmplist[k]);
|
||||
}
|
||||
tuolaji.push(tlj);
|
||||
}
|
||||
}
|
||||
return tuolaji;
|
||||
},
|
||||
|
||||
//主牌归一化大小(副7 同级取 7000、副2 同级取 2000,其余用牌编码),仅用于甩牌分量比大小
|
||||
trump_rank: function(mainflower, cardid){
|
||||
var code = youle_erqiwang_shared_cards.id_to_code(mainflower, cardid);
|
||||
if (code > youle_erqiwang_shared_cards.CODE_FU7_MIN && code < youle_erqiwang_shared_cards.CODE_FU7_MAX){ //副7
|
||||
return youle_erqiwang_shared_cards.CODE_FU7_MIN;
|
||||
}
|
||||
if (code > youle_erqiwang_shared_cards.CODE_FU2_MIN && code < youle_erqiwang_shared_cards.CODE_FU2_MAX){ //副2
|
||||
return youle_erqiwang_shared_cards.CODE_FU2_MIN;
|
||||
}
|
||||
return code;
|
||||
},
|
||||
|
||||
}
|
||||
|
||||
//Node 导出;友乐/浏览器无 module 时跳过(dev-guide 01 §1)
|
||||
if (typeof module !== "undefined"){
|
||||
module.exports = youle_erqiwang_shared_cards;
|
||||
}
|
||||
@@ -0,0 +1,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 限制);**正式代码不得为测试放宽/加后门**。
|
||||
- 正反面 + 边界都要覆盖;失败先裁根因(业务缺陷改业务、脚本缺陷改脚本),不软化断言。
|
||||
@@ -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 };
|
||||
};
|
||||
@@ -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) };
|
||||
@@ -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 = {};
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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');
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
Reference in New Issue
Block a user