问题:S-1 实现时取了绝对值,而判定的正负号恰恰是「谁赢」的区分—— 3 大光 vs -3 升3级、2 小光 vs -2 升2级、1 过庄 vs -1 升1级,取绝对值后 三对完全撞在一起,D-8 的过程中判定动画无从分辨该播哪个。 改法:get_curmultiple 直接返回 get_upgrade 原值,与结算包 aset.upgrade 完全同口径(3/2/1 庄家大光/小光/过庄,-N 闲家升N级,0 叫分未定)。 顶部「抓分」角标只关心大小,取 Math.abs 即可;判定动画靠符号分辨, 前端一套映射通吃「过程中」与「结算前」两条路径。 协议 4 处 curmultiple 说明同步(shangzhuang / chupai1 / chupai2·3 / PushCards)。 测试(hint 33 → 46 checks): - 钉住三对「绝对值相同、判定相反」的组合可区分,以及符号语义 (>0 庄赢 / <0 闲赢 / ==0 叫分未定)。 - 新增一组【直接调 get_curmultiple 本身】覆盖负数分支,含爬坡 Q 分段。 补上一个测试盲点:第一次反向验证(把实现退回取绝对值)竟然全绿——因为 原有断言里算法层的 cm() 走的是 A.get_upgrade、绕过了 get_curmultiple, 而端到端那局恰好是大光(正数),取不取绝对值都一样。补齐直接调用的用例后 再验,4 条断言立刻失败。 至此 §7.5 服务端待补 8 条全部完成,服务端侧无遗留;D-8 只差动画设计稿 与触发细节。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
229 lines
12 KiB
JavaScript
229 lines
12 KiB
JavaScript
// 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: {}, 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');
|