design §9:「一旦有玩家的主牌全部打空,系统就为全体三人显示另外 两家各自的主牌数量、以及这些主牌里有多少对子」。 原实现只在出牌者自己出牌时更新自己那一格 seatlist[seat][4],所以 A 打空主牌报无主的那一刻,B、C 的主牌数/对子数仍是初始 [-1,-1], 要等各自轮到出牌才补上,界面最多滞后两次出牌才完整。 改为:出牌后先判定出牌者主牌是否出空,一旦 have_baofu() 成立,就 按各家实际手牌为三个座位统一重算 [剩余主牌数, 剩余主对数] 及主花 色标志。重算幂等——主牌一旦出空不会再有,[4][0] 恒为 0。 test_rpc 的 mkChupai 原先只把 seatlist[1][4][0] 置 0 伪造报无主, 而不真的清空 seat1 的主牌;新逻辑按实际手牌重算会把这个假状态纠 正回去,导致 baozhu 断言失败。这是新实现更严格的正确表现,故改测 试造真状态(把 seat1 的主牌标为已出),不是放宽断言。 test_input 新增 6 项:报无主前三家均为 [-1,-1];打空瞬间三家统计 与主花色标志同时刷新到实际值。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
227 lines
13 KiB
JavaScript
227 lines
13 KiB
JavaScript
// 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 的查牌门控(info/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('出牌 可查牌 有info', cv.sent[0] && cv.sent[0].data.info !== undefined, true);
|
||
t.eq('出牌 可查牌 baozhu=1(有人报无主)', cv.sent[0] && cv.sent[0].data.baozhu, 1);
|
||
let cn = mkChupai("00001"); // 不查牌(位4=1)
|
||
t.eq('出牌 不查牌 无info', cn.sent[0] && cn.sent[0].data.info, 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);
|
||
|
||
// ===================== 重连 PushCards.pushlist:结构恒 3 座位 + 按本局主牌花色排序 =====================
|
||
// 造 5 轮已出牌的牌局(每轮每家 1 张),外加 seat0 在第 1 轮同时出「主5 + 副♥A」用于验证排序
|
||
function mkPushlist() {
|
||
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: { round: 5, start: 0, currseat: 0, cards: [[], [], []] },
|
||
cards: make108()
|
||
};
|
||
for (let i = 0; i < 108; i++) pj.cards[i].dealowner = (i % 3) + 1;
|
||
// 每轮每家一张
|
||
let next = 0;
|
||
for (let r = 1; r <= 5; r++) for (let s = 0; s < 3; s++) {
|
||
for (let i = next; i < 108; i++) {
|
||
if (pj.cards[i].dealowner === s + 1 && pj.cards[i].playround === -1) { pj.cards[i].playround = r; break; }
|
||
}
|
||
}
|
||
// seat0 第1轮补出:主5(flower1) 与 副♥A(flower3),主牌必须排在副牌前
|
||
const zhu5 = id(1, 1, 5), fuA = id(1, 3, 1);
|
||
for (const c of [zhu5, fuA]) { pj.cards[c].dealowner = 1; pj.cards[c].playround = 1; }
|
||
pj.method = {
|
||
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
|
||
};
|
||
return { pj, zhu5, fuA };
|
||
}
|
||
const pl = mkPushlist();
|
||
const plRoom = setup("00000", pl.pj).o_room;
|
||
const di = E.get_deskinfo(plRoom, 1).PushCards.pushlist;
|
||
t.eq('重连 pushlist 轮数=5', di.length, 5);
|
||
t.eq('重连 pushlist 每轮恒3个座位', di.map(r => r.length), [3, 3, 3, 3, 3]);
|
||
t.eq('重连 pushlist 不含 undefined', di.some(r => r.some(x => x === undefined)), false);
|
||
// 第1轮 seat0 的牌:主5 必须排在副♥A 之前(按 paiju.flower=1 排序,而非泄漏的大王花色5)
|
||
t.eq('重连 pushlist 按本局主牌花色排序(主5在副A前)',
|
||
di[0][0].indexOf(pl.zhu5) < di[0][0].indexOf(pl.fuA), true);
|
||
|
||
// ===================== §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, []]], 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);
|
||
|
||
process.exit(t.done('rpc') ? 0 : 1);
|