Files
erqiwang_youle/server/games/erqiwang/test/test_hint.js
T
joywayerandClaude Opus 5 4aab71e6a3 二七王:实现 S-1 curmultiple 与 S-2 mustcard
S-1 当前抓分倍数(design §7.2.0):
- class.paiju.js 新增 get_curmultiple(),复用 get_qvalue + get_upgrade,
  与结算 aset.upgrade 同源同算法,差别仅在不含扣底(末轮才产生)。
- 随现有包下发、不另开推送:shangzhuang / chupai1-3 / deskinfo.PushCards。
- 三家同值,整表下发不涉及泄露(捡分本就公开);叫分未定时为 0。

S-2 跟牌必出牌(design §5.2):
- class.paiju.js 新增 get_mustcard(seat),内部复用与出牌校验完全相同的
  get_followcard,故建议与校验天然一致。
- 【只发给 nextseat 一家】:它是该玩家自己手牌的子集,整表下发会泄露
  他家手牌结构(server 红线:发全 ≠ 发多)。
- 四种情形不下发:非出牌阶段、该座位是本轮首家、首家甩牌、必出牌为空。
  甩牌一条尤其重要——甩牌跟牌走 flush_follow_ok 的逐分量匹配,与
  get_followcard 不是同一条路径,给建议会给错。

测试(新增 test/test_hint.js,33 checks):
- curmultiple:算法层逐档对齐 design §7.2.0 判定表(含爬坡 Q 分段)、
  三家同值、重连与出牌包一致、叫分未定为 0、与 get_upgrade 绝对值一致。
- mustcard:只有 nextseat 收到、另两家没有、内容确属收包者自己的牌、
  首家无、缺门有富余时无、手牌数恰等于首家张数时整手必出、甩牌不发、
  非出牌阶段不发、重连按 currseat 门控。

过程中修正的两个测试自身问题(均为脚本缺陷,非业务缺陷,业务判定经证据核实正确):
- 「缺门方无必出」原用例给闲2 只发 2 张牌,恰等于首家张数,get_followcard
  正确判定为「整手必出」;改为发 4 张才留出选择余地,并补一条对照用例
  锁住「恰等于张数则整手必出」这个正确行为。
- 「甩牌不发」「非出牌阶段不发」两条原本是假通过——桩的 startcount 仍是 -1,
  前置守卫先返回了 null,根本没测到被测因素。改为先把桩推进到跟牌态、
  每个用例只变动一个因素,并加一条「自证」用例确认推进后确实能算出必出牌。

另:mustcard 补进 test_leak.js 的 CARD_FIELDS 审计白名单(与 burycards 同理,
不加则误发不会被泄露审计发现)。反向验证:改成整表下发后,审计立刻报出
5 张越权牌;把 curmultiple 写死为 1 后,两条断言失败。均已还原、全绿。

协议文档同步 shangzhuang / chupai1-3 / PushCards 的字段说明。
至此 §7.5 服务端待补 5 条全部完成,服务端侧无遗留。

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

192 lines
10 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.
// 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)——
const cm = (call, grade, climb) => {
const q = A.get_qvalue(call, climb ? 1 : 0);
const u = A.get_upgrade(call, grade, q);
return u < 0 ? -u : u;
};
t.eq('curmultiple 65分 捡0 → 大光x3', cm(65, 0), 3);
t.eq('curmultiple 65分 捡35 → 小光x2', cm(65, 35), 2);
t.eq('curmultiple 65分 捡40 → 过庄x1', cm(65, 40), 1);
t.eq('curmultiple 65分 捡65 → 升1级x1', cm(65, 65), 1);
t.eq('curmultiple 65分 捡105 → 升2级x2', cm(65, 105), 2);
t.eq('curmultiple 65分 捡145 → 升3级x3', cm(65, 145), 3);
// 爬坡:40分档 Q=20,分界线与常规不同(design §7.3.2)
t.eq('curmultiple 爬坡40分 捡15 → 小光x2', cm(40, 15, true), 2);
t.eq('curmultiple 爬坡40分 捡20 → 过庄x1', cm(40, 20, true), 1);
t.eq('curmultiple 爬坡40分 捡60 → 升2级x2', cm(40, 60, true), 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 < 0 ? -u : 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: {}, 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)
};
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');