二七王:实现 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>
This commit is contained in:
2026-08-25 20:25:32 +08:00
co-authored by Claude Opus 5
parent ecec44e93a
commit 4aab71e6a3
9 changed files with 320 additions and 45 deletions
+14
View File
@@ -160,6 +160,8 @@ youle_erqiwang.jiaofen = function(pack){
msg.data.ancard3s = 1;
}
var _bottomcards = o_paiju.method.get_bottomcards();
//当前抓分倍数(design §7.2.0):此刻捡分为 0,即大光×3;供客户端顶部「抓分」角标显示
msg.data.curmultiple = o_paiju.method.get_curmultiple();
for (var i = 0; i < o_room.seatlist.length; i++) {
msg.conmode = o_room.seatlist[i].conmode;
@@ -467,6 +469,8 @@ youle_erqiwang.chupai = function(pack){
}
msg.data.nextseat = o_paiju.playproc.currseat;
msg.data.countdown = o_desk.method.get_countdown_chupai();
//当前抓分倍数(design §7.2.0):随本包一起下发、不另开推送;三家同值,grade 本就公开
msg.data.curmultiple = o_paiju.method.get_curmultiple();
if (re.shuaicuo){
msg.data.shuaicuo = 1; //甩错:本次甩牌被收回,只打出了最小一张(§5.4.5)
}
@@ -504,6 +508,11 @@ youle_erqiwang.chupai = function(pack){
}
if (o_paiju.step == 5){
//下一个出牌者本轮跟牌的必出牌(design §5.2),供其客户端自动选中。
//【只发给 nextseat 一家】——mustcard 是该玩家自己手牌的子集,发给他本人不泄露;
//整表下发三家会把另两家的手牌结构透露出去(server 红线:发全 ≠ 发多,按可见性下发)。
var _nextseat = o_paiju.playproc.currseat;
var _mustcard = o_paiju.method.get_mustcard(_nextseat);
for (var i = 0; i < o_room.seatlist.length; i++){
msg.conmode = o_room.seatlist[i].conmode;
msg.fromid = o_room.seatlist[i].fromid;
@@ -512,6 +521,11 @@ youle_erqiwang.chupai = function(pack){
} else {
delete msg.data.cardsinhand;
}
if (i == _nextseat && _mustcard){
msg.data.mustcard = _mustcard;
} else {
delete msg.data.mustcard;
}
youle_erqiwang.app.SendPack(msg);
}
} else if (o_paiju.step == 6){