Files
erqiwang_youle/server/games/erqiwang/test/test_rpc.js
T
joywayerandClaude Opus 5 4276d38267 二七王:报无主后立即为全体三人刷新主牌统计
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>
2026-08-12 04:20:03 +08:00

227 lines
13 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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);