Files
joywayerandClaude Opus 5 4508fe005f 二七王:出牌顺序收敛为唯一口径,重连包补发 baozhu
同一份数据「这一手打出的牌」此前有两个顺序口径:chupai*/playproc.cards
回显客户端请求原序,重连包 pushlist 则从 playround 反查后自己再排一次序。
一手多张(甩牌/对子/拖拉机)时两者必然分岔;同编码的一对牌更是排序都
区分不了,靠"两处各自排序"永远收敛不了。

改为单一写入处:
- 新增 order_playcards(),do_playcard 入口归一化一次(顺带隔离
  can_playcard 的原地排序,不再就地改写 pack.data.cards)
- 新增 paiju.playhistory,出牌当场归档;新增 get_pushlist() 只做深拷贝
- class.export.js 删掉整段反查+重排,改为直读归档
- do_playcard 三个分支都给 re.cards,mod.js 去掉回落到入参的兜底

顺带修复重连包漏发 baozhu:它是余主公示与明牌按钮的开关,此前只在
chupai 包里给,报无主后重连按钮会凭空消失。现与 chupai 同源同门控下发。

协议文档新增 §0.6「一手出牌」的顺序口径,并补 PushCards.baozhu 字段。
测试:pushlist 一节改为真实链路驱动(一手多张 + 乱序提交),断言升级为
「重连每一手与当时的 chupai.cards 逐元素相等」;三处修复均已退回验证转红。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 20:12:03 +08:00

230 lines
13 KiB
JavaScript
Raw Permalink 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.
// 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');