-
房间: {currentRoom.name}
-
- 房间ID: {currentRoom.id}
-
-
+
+
+
+
+
房间: {currentRoom.name}
+
房间ID: {currentRoom.id}
+
+
+
+ {gameInProgress && (
+
+
+ 你的座位仍在房间中
+ 返回房间不会断线或退出,牌局仍会继续;轮到你操作时请及时重返牌桌。
+
+
+
+ )}
+
+
玩家数: {currentRoom.playerCount} / {currentRoom.maxPlayers}
-
+
玩家列表:
{currentRoom.players.map((player, index) => (
-
- {index + 1}. {player.isBot && '🤖 '}{player.name}
- {player.socketId === currentRoom.hostId && ' (房主)'}
- {player.id === currentPlayer?.id && ' (你)'}
- {player.isBot && ' (Bot)'}
- {' - '} 分数: {player.score} - 等级: {player.level}
+
+
+ {index + 1}. {player.isBot && '🤖 '}{player.name}
+ {player.socketId === currentRoom.hostId && 房主}
+ {player.id === currentPlayer?.id && 你}
+ {player.isBot && Bot}
+
+
+ 分数 {player.score} · 等级 {formatLevel(player.level)}
+
))}
-
+
房间配置:
-
底牌数量: {currentRoom.config.bottomCardsCount} 张
+
默认底牌: 8 张(特殊规则可能调整)
发牌间隔: {currentRoom.config.dealInterval} 毫秒
+
规则模式: {currentRoom.config.testMode
+ ? `测试模式(${RULE_SELECT_OPTIONS.find(option => option.value === currentRoom.config.testRuleId)?.label || currentRoom.config.testRuleId})`
+ : '正常随机二选一'}
{/* Bot管理区域 - 仅房主可见 */}
- {currentPlayer?.socketId === currentRoom.hostId && currentRoom.players.some(p => p.isBot) && (
-
+ {!gameInProgress && currentPlayer?.socketId === currentRoom.hostId && currentRoom.players.some(p => p.isBot) && (
+
房间内的Bot
{currentRoom.players.filter(p => p.isBot).map(bot => (
@@ -287,8 +387,8 @@ function App() {
)}
-
- {currentPlayer?.socketId === currentRoom.hostId && (
+
+ {!gameInProgress && currentPlayer?.socketId === currentRoom.hostId && (
<>
@@ -333,16 +433,6 @@ function App() {
cancelText="取消"
>
- 底牌数量:
-
-
-
发牌间隔(毫秒):
+ setTestMode(event.target.checked)}>
+ 规则测试模式
+
+ {testMode && (
+
+ )}
+
+
设置将在下一局游戏开始时生效
diff --git a/tractor-game-simulator/client/src/components/Game/Card.css b/tractor-game-simulator/client/src/components/Game/Card.css
index 7fd7ce2..d16db57 100644
--- a/tractor-game-simulator/client/src/components/Game/Card.css
+++ b/tractor-game-simulator/client/src/components/Game/Card.css
@@ -1,135 +1,678 @@
.card {
position: relative;
- width: 80px;
- height: 112px;
- background: white;
- border: 2px solid #d9d9d9;
+ width: 78px;
+ height: 110px;
+ box-sizing: border-box;
+ display: block;
+ margin: 0;
+ overflow: hidden;
+ border: 1px solid #c8cbc7;
border-radius: 8px;
+ color: #121513;
+ background: linear-gradient(118deg, rgba(255,255,255,0.85), transparent 42%), linear-gradient(180deg, #fff 0%, #f7f7f3 72%, #e4e7e1 100%);
+ box-shadow: 0 3px 0 #aeb3ad, 0 7px 13px rgba(0, 21, 15, 0.28);
cursor: pointer;
- transition: all 0.2s ease;
user-select: none;
- box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
- display: inline-block;
- margin: 0 -10px;
+ transition: transform 0.16s ease, box-shadow 0.16s ease, border-color 0.16s ease;
}
-.card.small {
- width: 50px;
- height: 70px;
- margin: 0 -6px;
+.card::after { content: ''; position: absolute; inset: 1px; border: 1px solid rgba(255,255,255,0.8); border-radius: 6px; pointer-events: none; }
+.card.small { width: 50px; height: 70px; margin: 0; border-radius: 6px; }
+.card.micro { width: 28px; height: 40px; border-radius: 4px; box-shadow: 0 2px 0 #aeb3ad, 0 3px 7px rgba(0, 21, 15, 0.24); }
+.card:not(.disabled):hover { border-color: #f1ce5c; box-shadow: 0 3px 0 #b49231, 0 9px 16px rgba(0, 27, 18, 0.3), 0 0 0 2px rgba(247, 216, 105, 0.28); }
+.card.selected { transform: translateY(-20px); border-color: #f4cf58 !important; box-shadow: 0 3px 0 #9f7920, 0 10px 16px rgba(0, 25, 18, 0.34), 0 0 0 2px rgba(255, 222, 103, 0.3); }
+.card.disabled { cursor: default !important; }
+.card.disabled:hover { transform: none; }
+.card.virtualized {
+ opacity: 0.28;
+ filter: grayscale(0.82) saturate(0.25) blur(0.35px);
+ border-color: rgba(183, 202, 195, 0.55) !important;
+ box-shadow: 0 2px 0 rgba(91, 112, 104, 0.45), 0 5px 12px rgba(0, 21, 15, 0.12);
+ transform: translateY(5px) scale(0.98);
}
-
-.card:hover {
- transform: translateY(-10px);
- box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
+.card.virtualized::before {
+ content: '虚置';
+ position: absolute;
+ z-index: 20;
+ left: 50%;
+ top: 50%;
+ padding: 3px 6px;
+ border: 1px solid rgba(224, 244, 237, 0.85);
+ border-radius: 999px;
+ color: #f0fff9;
+ background: rgba(13, 71, 56, 0.78);
+ font-size: 10px;
+ font-weight: 900;
+ white-space: nowrap;
+ transform: translate(-50%, -50%);
}
-
-.card.selected {
- transform: translateY(-37px);
- border-color: #1890ff;
- border-width: 3px;
- box-shadow: 0 6px 12px rgba(24, 144, 255, 0.4);
+.card.virtualized.disabled:hover { transform: translateY(5px) scale(0.98); }
+.card.rule-disabled {
+ filter: grayscale(0.78) saturate(0.32);
+ opacity: 1;
+ border-color: #858b86;
+ background:
+ linear-gradient(118deg, rgba(232, 234, 231, 0.42), transparent 42%),
+ linear-gradient(180deg, #c7cbc6 0%, #adb2ad 72%, #959b96 100%);
+ box-shadow: 0 2px 0 #747a75, 0 5px 9px rgba(0, 21, 15, 0.24);
}
-
-.card.disabled {
- cursor: not-allowed;
+.card.rule-disabled::after { border-color: rgba(238, 240, 237, 0.34); }
+.card.rule-disabled .trump-badge { filter: grayscale(1); opacity: 0.62; }
+.card.joker-card { background: linear-gradient(118deg, rgba(255,255,255,0.9), transparent 42%), linear-gradient(180deg, #fff 0%, #f7f7f3 72%, #e4e7e1 100%); }
+.card.joker-card.big-joker { background-color: rgba(216, 32, 53, 0.035); }
+.card.joker-card.small-joker { background-color: rgba(18, 21, 19, 0.025); }
+.card.joker-card.county-prince-joker {
+ border-color: #9a76c7 !important;
+ background:
+ radial-gradient(circle at 50% 42%, rgba(135, 83, 190, 0.18), transparent 48%),
+ linear-gradient(145deg, #fff 0%, #f5eefc 62%, #d7c4eb 100%);
+}
+.card.joker-card.prince-joker {
+ border-color: #5b96c2 !important;
+ background:
+ radial-gradient(circle at 50% 42%, rgba(51, 126, 180, 0.2), transparent 48%),
+ linear-gradient(145deg, #fff 0%, #edf7fc 62%, #bcd9e9 100%);
+}
+.card.no-trump-minus {
+ border-color: #b99742;
+ background:
+ linear-gradient(118deg, rgba(255, 255, 255, 0.9), transparent 42%),
+ linear-gradient(180deg, #fffdf1 0%, #f6efd0 100%);
+}
+.card.joker-card.white-joker {
+ border-color: #d9ad3f !important;
+ background:
+ radial-gradient(circle at 50% 42%, rgba(255, 226, 121, 0.28), transparent 45%),
+ linear-gradient(145deg, #fffef5 0%, #fff9d8 57%, #ead79a 100%);
+ box-shadow: 0 2px 0 #a47b20, 0 5px 11px rgba(91, 57, 0, 0.34);
}
-.card.disabled:hover {
- transform: none;
- box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
+/* 铁证如山:手牌跟随当前轮模式;桌面牌携带出牌时的模式快照。 */
+[data-iron-evidence-mode="multiply"] .card.iron-evidence-card:not(.iron-evidence-multiply):not(.iron-evidence-zero):not(.face-down),
+.card.iron-evidence-card.iron-evidence-multiply:not(.face-down) {
+ border-color: #ff4d4f !important;
+ background:
+ radial-gradient(circle at 50% 42%, rgba(255, 91, 91, 0.28), transparent 52%),
+ linear-gradient(145deg, #fff2f0 0%, #ffc9c5 58%, #f28c88 100%) !important;
+ box-shadow:
+ 0 3px 0 #a63c3e,
+ 0 7px 14px rgba(111, 18, 22, 0.34),
+ 0 0 0 2px rgba(255, 77, 79, 0.26) !important;
}
-.card-corner {
- position: absolute;
- font-weight: bold;
+[data-iron-evidence-mode="zero"] .card.iron-evidence-card:not(.iron-evidence-multiply):not(.iron-evidence-zero):not(.face-down),
+.card.iron-evidence-card.iron-evidence-zero:not(.face-down) {
+ border-color: #858b86 !important;
+ color: #555b57 !important;
+ background:
+ linear-gradient(118deg, rgba(240, 242, 239, 0.56), transparent 42%),
+ linear-gradient(180deg, #d8dbd7 0%, #aeb3ae 100%) !important;
+ box-shadow:
+ 0 3px 0 #6f746f,
+ 0 6px 12px rgba(26, 32, 28, 0.3) !important;
+ filter: grayscale(1) saturate(0) brightness(0.88);
}
-.card-corner.top-left {
- top: 4px;
- left: 6px;
- text-align: left;
+.card-corner { position: absolute; z-index: 2; font-weight: 900; }
+.card-corner.top-left { top: 6px; left: 7px; text-align: left; }
+.card-corner.bottom-right { right: 7px; bottom: 6px; text-align: right; transform: rotate(180deg); opacity: 0.78; }
+.card-rank { margin-bottom: 1px; font-family: 'Times New Roman', 'Liberation Serif', Times, serif; font-size: 20px; line-height: 0.92; letter-spacing: -0.05em; font-variant-numeric: lining-nums tabular-nums; }
+.card-suit { font-size: 17px; line-height: 1; }
+.card.small .card-rank { font-size: 13px; }
+.card.small .card-suit { font-size: 11px; }
+.card.micro .card-corner.top-left { top: 3px; left: 4px; }
+.card.micro .card-corner.bottom-right,
+.card.micro .card-center { display: none; }
+.card.micro .card-rank { font-size: 10px; }
+.card.micro .card-suit { font-size: 9px; }
+
+.card-center { position: absolute; inset: 0; display: grid; place-items: center; text-align: center; }
+.suit-symbol { font-size: 35px; line-height: 1; opacity: 0.82; filter: saturate(1.08); }
+.card.small .suit-symbol { font-size: 24px; }
+.joker-center { display: grid; place-items: center; transform: none; }
+.joker-text { display: flex; flex-direction: column; align-items: center; font-family: Georgia, 'Times New Roman', serif; font-size: 14px; font-weight: 900; line-height: 0.92; text-shadow: 0 1px white; }
+.joker-letter { display: block; width: 1em; height: 0.92em; text-align: center; }
+.joker-card .card-rank { letter-spacing: 0; }
+.card.small .joker-text { font-size: 9px; }
+.white-joker .joker-text { color: #bf8810; text-shadow: 0 1px #fff5bf; }
+.county-prince-joker .joker-text { color: #7542aa; text-shadow: 0 1px #f9f0ff; }
+.prince-joker .joker-text { color: #236b9e; text-shadow: 0 1px #edfaff; }
+
+/*
+ * 落桌转化牌的实体原牌签。放在叠牌时仍会露出的左侧窄条内,
+ * 当前大牌面继续表示实际参与跟牌与比较的牌面。
+ */
+.original-face-badge {
+ position: absolute;
+ z-index: 18;
+ top: 43px;
+ left: 4px;
+ box-sizing: border-box;
+ max-width: 31px;
+ min-height: 17px;
+ display: inline-flex;
+ align-items: center;
+ gap: 1px;
+ padding: 2px 3px 2px 2px;
+ overflow: hidden;
+ border: 1px solid rgba(241, 210, 113, 0.82);
+ border-left-width: 2px;
+ border-radius: 3px 6px 6px 3px;
+ color: #f8f7ef;
+ background: rgba(7, 42, 34, 0.91);
+ box-shadow: 0 2px 5px rgba(0, 24, 17, 0.3);
+ font-size: 8px;
+ font-weight: 800;
+ line-height: 1.05;
+ white-space: nowrap;
+ pointer-events: none;
}
-.card-corner.bottom-right {
- bottom: 4px;
- right: 6px;
- text-align: right;
- transform: rotate(180deg);
+.original-face-badge > span {
+ color: #e7c55f;
+ font-size: 6px;
+ font-weight: 900;
}
-.card-rank {
- font-size: 18px;
+.original-face-badge > strong {
+ overflow: hidden;
+ text-overflow: clip;
+ font-size: 9px;
line-height: 1;
- margin-bottom: 2px;
}
-.card.small .card-rank {
- font-size: 12px;
+.original-face-badge.is-red > strong { color: #ff8989; }
+.original-face-badge.is-black > strong { color: #f5f7f4; }
+.original-face-badge.is-purple > strong { color: #d5a9ff; }
+.original-face-badge.is-blue > strong { color: #9dd7ff; }
+.original-face-badge.is-gold > strong { color: #ffe27a; }
+
+.card.small .original-face-badge {
+ top: 28px;
+ left: 2px;
+ max-width: 25px;
+ min-height: 12px;
+ gap: 0;
+ padding: 1px 2px 1px 1px;
+ border-left-width: 1px;
+ font-size: 6px;
}
-.card-suit {
+.card.small .original-face-badge > span { font-size: 5px; }
+.card.small .original-face-badge > strong { font-size: 6px; }
+.card.micro .original-face-badge { display: none; }
+
+.trump-badge { position: absolute; left: 5px; bottom: 5px; z-index: 10; width: 22px; height: 22px; display: grid; place-items: center; border: 1px solid #f6d65d; border-radius: 50%; background: linear-gradient(145deg, #ffdf62, #c68b18); box-shadow: 0 2px 5px rgba(91, 54, 0, 0.34); }
+.trump-star { color: #fff8c9; font-size: 13px; line-height: 1; text-shadow: 0 1px 2px #774000; animation: twinkle 2.2s ease-in-out infinite; }
+.card.small .trump-badge { left: 3px; bottom: 3px; width: 15px; height: 15px; }
+.card.small .trump-star { font-size: 9px; }
+.card.micro .trump-badge { right: 2px; bottom: 2px; left: auto; width: 8px; height: 8px; border-width: 0; }
+.card.micro .trump-star { font-size: 6px; }
+
+.inferior-badge {
+ display: none;
+ position: absolute;
+ left: 5px;
+ bottom: 5px;
+ z-index: 10;
+ width: 22px;
+ height: 22px;
+ place-items: center;
+ border: 1px solid #e2a48c;
+ border-radius: 50%;
+ color: #fff2e8;
+ background: linear-gradient(145deg, #a7634d, #6f372d);
+ box-shadow: 0 2px 5px rgba(77, 31, 21, 0.34);
font-size: 16px;
+ font-weight: 900;
line-height: 1;
}
-.card.small .card-suit {
+[data-inferior-suit="hearts"] .card.card-suit-hearts:not(.is-trump-card) .inferior-badge,
+[data-inferior-suit="diamonds"] .card.card-suit-diamonds:not(.is-trump-card) .inferior-badge,
+[data-inferior-suit="clubs"] .card.card-suit-clubs:not(.is-trump-card) .inferior-badge,
+[data-inferior-suit="spades"] .card.card-suit-spades:not(.is-trump-card) .inferior-badge {
+ display: grid;
+}
+
+/* 劣花色中的级牌仍是级牌:沿用主牌星标,普通劣花色牌才显示减号。 */
+
+.card.small .inferior-badge {
+ left: 3px;
+ bottom: 3px;
+ width: 15px;
+ height: 15px;
font-size: 10px;
}
-.card-center {
+.card.micro .inferior-badge {
+ left: 2px;
+ bottom: 2px;
+ width: 8px;
+ height: 8px;
+ border-width: 0;
+ font-size: 7px;
+}
+
+.converted-spade-badge {
position: absolute;
- top: 50%;
- left: 50%;
- transform: translate(-50%, -50%);
+ z-index: 11;
+ top: 5px;
+ right: 5px;
+ padding: 1px 4px;
+ border: 1px solid rgba(32, 35, 34, 0.28);
+ border-radius: 999px;
+ color: #202322;
+ background: rgba(255, 247, 219, 0.9);
+ box-shadow: 0 1px 3px rgba(0, 20, 12, 0.16);
+ font-size: 8px;
+ font-weight: 900;
+ line-height: 1.25;
+}
+
+.card.small .converted-spade-badge {
+ top: 3px;
+ right: 3px;
+ padding: 0 2px;
+ font-size: 6px;
+}
+
+.card.micro .converted-spade-badge { display: none; }
+.card.last-stand-trump { box-shadow: 0 3px 0 #a8791a, 0 7px 13px rgba(0, 21, 15, 0.28), inset 0 0 0 2px rgba(255, 214, 75, 0.35); }
+
+.card.divine-weapon-transformed {
+ border-color: #ffe070 !important;
+ background:
+ linear-gradient(118deg, rgba(255, 244, 171, 0.7), transparent 44%),
+ linear-gradient(180deg, #fffdf0 0%, #f7edbc 72%, #d7bd61 100%);
+ box-shadow:
+ 0 3px 0 #9d7516,
+ 0 8px 18px rgba(73, 44, 0, 0.34),
+ 0 0 0 2px rgba(255, 220, 91, 0.32),
+ 0 0 22px rgba(255, 207, 54, 0.34);
+}
+
+.card.divine-weapon-preview {
+ animation: divineWeaponPreviewPulse 1.05s ease-in-out infinite;
+}
+
+.divine-weapon-card-badge {
+ position: absolute;
+ z-index: 12;
+ top: 5px;
+ right: 5px;
+ padding: 2px 5px;
+ border: 1px solid rgba(119, 76, 0, 0.38);
+ border-radius: 999px;
+ color: #6d4300;
+ background: linear-gradient(180deg, #fff3a4, #eab938);
+ box-shadow: 0 2px 5px rgba(78, 45, 0, 0.24);
+ font-size: 8px;
+ font-weight: 900;
+ line-height: 1.2;
+}
+
+.card.small .divine-weapon-card-badge {
+ top: 3px;
+ right: 3px;
+ padding: 1px 3px;
+ font-size: 6px;
+}
+
+.card.micro .divine-weapon-card-badge { display: none; }
+
+.card.rice-to-mulberry-transformed {
+ border-color: #56b88b !important;
+ background:
+ linear-gradient(132deg, rgba(122, 220, 171, 0.28), transparent 48%),
+ linear-gradient(180deg, #fcfffd 0%, #e0f3e7 74%, #a8d1b7 100%);
+ box-shadow:
+ 0 3px 0 #377c5b,
+ 0 8px 17px rgba(13, 74, 47, 0.28),
+ 0 0 0 2px rgba(89, 192, 139, 0.24);
+}
+
+.rice-to-mulberry-card-badge {
+ position: absolute;
+ z-index: 13;
+ top: 5px;
+ right: 5px;
+ padding: 2px 4px;
+ border: 1px solid rgba(226, 255, 238, 0.92);
+ border-radius: 999px;
+ color: #effff5;
+ background: linear-gradient(145deg, #29976a, #176040);
+ box-shadow: 0 2px 5px rgba(8, 66, 40, 0.34);
+ font-size: 8px;
+ font-weight: 900;
+ line-height: 1.1;
+}
+
+.card.small .rice-to-mulberry-card-badge {
+ top: 3px;
+ right: 3px;
+ padding: 1px 3px;
+ font-size: 6px;
+}
+
+.card.micro .rice-to-mulberry-card-badge { display: none; }
+
+.card.cluster-analysis-transformed {
+ border-color: #6de3ff !important;
+ background:
+ linear-gradient(125deg, rgba(169, 240, 255, 0.42), transparent 48%),
+ linear-gradient(180deg, #fbffff 0%, #dff7f7 72%, #a8d8dd 100%);
+ box-shadow: 0 3px 0 #287b8b, 0 8px 17px rgba(4, 61, 75, 0.3), 0 0 0 2px rgba(99, 224, 255, 0.28);
+}
+
+.cluster-analysis-card-badge {
+ position: absolute;
+ z-index: 12;
+ top: 5px;
+ right: 5px;
+ display: grid;
+ place-items: center;
+ width: 19px;
+ height: 19px;
+ border: 1px solid rgba(14, 93, 112, 0.48);
+ border-radius: 50%;
+ color: #eaffff;
+ background: linear-gradient(145deg, #28a9c5, #13687b);
+ box-shadow: 0 2px 5px rgba(4, 53, 65, 0.3);
+ font-size: 9px;
+ font-weight: 900;
+}
+
+.card.small .cluster-analysis-card-badge {
+ top: 3px;
+ right: 3px;
+ width: 15px;
+ height: 15px;
+ font-size: 7px;
+}
+
+.card.micro .cluster-analysis-card-badge { display: none; }
+
+.joker-substitution-card-badge {
+ position: absolute;
+ z-index: 12;
+ top: 5px;
+ right: 5px;
+ display: grid;
+ place-items: center;
+ width: 19px;
+ height: 19px;
+ border-radius: 50%;
+ color: #fff4dc;
+ background: linear-gradient(145deg, #9a55c6, #54256f);
+ box-shadow: 0 2px 5px rgba(47, 12, 66, 0.34);
+ font-size: 9px;
+ font-weight: 900;
+}
+
+.card.small .joker-substitution-card-badge {
+ top: 3px;
+ right: 3px;
+ width: 15px;
+ height: 15px;
+ font-size: 7px;
+}
+
+.card.micro .joker-substitution-card-badge { display: none; }
+
+.strength-compensation-card-badge {
+ position: absolute;
+ z-index: 13;
+ top: 5px;
+ right: 5px;
+ min-width: 24px;
+ padding: 2px 4px;
+ border: 1px solid rgba(255, 255, 255, 0.76);
+ border-radius: 999px;
+ color: #fff;
+ background: #227447;
+ box-shadow: 0 2px 5px rgba(0, 40, 24, 0.35);
+ font-size: 9px;
+ font-weight: 900;
+ line-height: 1.1;
text-align: center;
}
-.suit-symbol {
- font-size: 48px;
- opacity: 0.2;
+.strength-compensation-card-badge.is-minus { background: #9a442f; }
+.card.small .strength-compensation-card-badge {
+ top: 3px;
+ right: 3px;
+ min-width: 18px;
+ padding: 1px 3px;
+ font-size: 7px;
}
+.card.micro .strength-compensation-card-badge { display: none; }
-.card.small .suit-symbol {
- font-size: 30px;
+.card.teammate-cheered {
+ border-color: #f0bd45 !important;
+ background:
+ linear-gradient(135deg, rgba(255, 226, 123, 0.24), transparent 48%),
+ linear-gradient(180deg, #fffef8 0%, #fff3c7 76%, #e6c96f 100%);
+ box-shadow:
+ 0 3px 0 #a87819,
+ 0 8px 17px rgba(91, 59, 3, 0.3),
+ 0 0 0 2px rgba(255, 207, 71, 0.28);
}
-.joker-text {
- font-size: 20px;
- font-weight: bold;
+.teammate-cheer-card-badge {
+ position: absolute;
+ z-index: 13;
+ top: 5px;
+ right: 5px;
+ padding: 2px 4px;
+ border: 1px solid rgba(255, 248, 202, 0.9);
+ border-radius: 999px;
+ color: #fff7ce;
+ background: linear-gradient(145deg, #d59b1d, #8b5d08);
+ box-shadow: 0 2px 5px rgba(82, 51, 0, 0.35);
+ font-size: 8px;
+ font-weight: 900;
+ line-height: 1.1;
}
-.card.small .joker-text {
- font-size: 14px;
+.card.small .teammate-cheer-card-badge {
+ top: 3px;
+ right: 3px;
+ padding: 1px 3px;
+ font-size: 6px;
}
-/* 主牌星标 */
-.trump-badge {
+.card.micro .teammate-cheer-card-badge { display: none; }
+
+.card.afterglow-boosted {
+ border-color: #e58a4d !important;
+ background:
+ linear-gradient(135deg, rgba(255, 145, 76, 0.19), transparent 48%),
+ linear-gradient(180deg, #fffdf8 0%, #ffe4c9 76%, #dca56d 100%);
+ box-shadow:
+ 0 3px 0 #9a4d20,
+ 0 8px 17px rgba(104, 42, 5, 0.31),
+ 0 0 0 2px rgba(255, 126, 58, 0.27);
+}
+
+.afterglow-card-badge {
position: absolute;
- bottom: 4px;
- left: 4px;
- z-index: 10;
+ z-index: 13;
+ top: 5px;
+ right: 5px;
+ padding: 2px 4px;
+ border: 1px solid rgba(255, 236, 211, 0.92);
+ border-radius: 999px;
+ color: #fff3df;
+ background: linear-gradient(145deg, #d86b2b, #873310);
+ box-shadow: 0 2px 5px rgba(82, 28, 0, 0.35);
+ font-size: 8px;
+ font-weight: 900;
+ line-height: 1.1;
}
-.trump-star {
- font-size: 16px;
- color: #ffd700;
- text-shadow: 0 0 2px rgba(0, 0, 0, 0.3);
- display: inline-block;
- animation: twinkle 2s ease-in-out infinite;
+.card.small .afterglow-card-badge {
+ top: 3px;
+ right: 3px;
+ padding: 1px 3px;
+ font-size: 6px;
+}
+
+.card.micro .afterglow-card-badge { display: none; }
+
+.card.three-tigers-transformed {
+ border-color: #e69635 !important;
+ background:
+ repeating-linear-gradient(135deg, rgba(196, 91, 21, 0.10) 0 7px, transparent 7px 15px),
+ linear-gradient(180deg, #fffdf6 0%, #f8e7c6 74%, #dbb36c 100%);
+ box-shadow:
+ 0 3px 0 #8c4b19,
+ 0 8px 17px rgba(75, 33, 5, 0.34),
+ 0 0 0 2px rgba(255, 175, 61, 0.28);
+}
+
+.three-tigers-card-badge {
+ position: absolute;
+ z-index: 13;
+ top: 5px;
+ right: 5px;
+ padding: 2px 4px;
+ border: 1px solid rgba(100, 42, 7, 0.55);
+ border-radius: 999px;
+ color: #fff6dc;
+ background: linear-gradient(145deg, #dd7924, #87370e);
+ box-shadow: 0 2px 5px rgba(75, 28, 3, 0.34);
+ font-size: 8px;
+ font-weight: 900;
+ line-height: 1.1;
+}
+
+.card.small .three-tigers-card-badge {
+ top: 3px;
+ right: 3px;
+ padding: 1px 3px;
+ font-size: 6px;
}
-.card.small .trump-star {
- font-size: 12px;
+.card.micro .three-tigers-card-badge { display: none; }
+
+.card.forbidden-magic-demoted {
+ border-color: #c89bff !important;
+ background:
+ linear-gradient(132deg, rgba(222, 198, 255, 0.38), transparent 48%),
+ linear-gradient(180deg, #fffefe 0%, #eee6f5 74%, #c9b7d8 100%);
+ box-shadow: 0 3px 0 #6b4a83, 0 8px 17px rgba(44, 20, 63, 0.28), 0 0 0 2px rgba(198, 145, 245, 0.22);
}
-@keyframes twinkle {
- 0%, 100% {
- opacity: 1;
- transform: scale(1);
- }
- 50% {
- opacity: 0.7;
- transform: scale(0.95);
- }
+.card.forbidden-magic-transformed {
+ box-shadow: 0 3px 0 #6b4a83, 0 8px 17px rgba(44, 20, 63, 0.34), 0 0 0 2px rgba(221, 169, 255, 0.4), 0 0 20px rgba(184, 108, 237, 0.3);
+}
+
+.forbidden-magic-card-badge {
+ position: absolute;
+ z-index: 12;
+ top: 5px;
+ right: 5px;
+ display: grid;
+ place-items: center;
+ width: 19px;
+ height: 19px;
+ border: 1px solid rgba(66, 28, 91, 0.5);
+ border-radius: 50%;
+ color: #fff5ff;
+ background: linear-gradient(145deg, #a05fc7, #58316f);
+ box-shadow: 0 2px 5px rgba(44, 17, 61, 0.35);
+ font-size: 9px;
+ font-weight: 900;
}
+
+.card.small .forbidden-magic-card-badge {
+ top: 3px;
+ right: 3px;
+ width: 15px;
+ height: 15px;
+ font-size: 7px;
+}
+
+.card.micro .forbidden-magic-card-badge { display: none; }
+
+.card-transformation-control {
+ position: absolute;
+ z-index: 24;
+ left: 2px;
+ top: 48px;
+ display: grid;
+ place-items: center;
+ width: 16px;
+ height: 24px;
+ padding: 0;
+ border: 1px solid rgba(255, 255, 255, 0.92);
+ border-radius: 5px;
+ color: #fff;
+ box-shadow: 0 2px 6px rgba(69, 11, 18, 0.42);
+ font-size: 11px;
+ font-weight: 900;
+ line-height: 1;
+ cursor: pointer;
+}
+
+.card-transformation-trigger {
+ background: linear-gradient(145deg, #8b63d8, #553091);
+}
+
+.card-transformation-cancel {
+ background: linear-gradient(145deg, #d94b4b, #8d1e2a);
+}
+
+.card-transformation-control:hover {
+ filter: brightness(1.12);
+ transform: scale(1.06);
+}
+
+@keyframes divineWeaponPreviewPulse {
+ 0%, 100% { filter: brightness(1); }
+ 50% { filter: brightness(1.12); }
+}
+
+.card.face-down {
+ border-color: #d5b548 !important;
+ color: #fff1a6 !important;
+ background: linear-gradient(145deg, #0d5b4b 0%, #073b33 52%, #062b27 100%);
+ box-shadow: 0 3px 0 #795f18, 0 7px 13px rgba(0, 21, 15, 0.38), inset 0 0 0 3px rgba(225, 190, 68, 0.34);
+}
+
+.card.face-down::after {
+ inset: 5px;
+ border: 1px solid rgba(255, 222, 106, 0.55);
+ background:
+ repeating-linear-gradient(45deg, transparent 0 7px, rgba(255, 220, 100, 0.11) 7px 9px),
+ repeating-linear-gradient(-45deg, transparent 0 7px, rgba(255, 220, 100, 0.08) 7px 9px);
+}
+
+.card-back-surface {
+ position: absolute;
+ inset: 0;
+ z-index: 3;
+ display: grid;
+ place-items: center;
+}
+
+.card-back-surface span {
+ display: grid;
+ width: 38px;
+ height: 38px;
+ place-items: center;
+ border: 1px solid rgba(255, 223, 108, 0.7);
+ border-radius: 50%;
+ color: #ffeba0;
+ background: rgba(52, 34, 5, 0.34);
+ box-shadow: 0 0 16px rgba(255, 214, 72, 0.2);
+ font-size: 18px;
+ font-weight: 900;
+}
+
+.card.small .card-back-surface span { width: 26px; height: 26px; font-size: 13px; }
+.card.micro .card-back-surface span { width: 16px; height: 16px; font-size: 8px; }
+
+@keyframes twinkle { 50% { filter: brightness(1.2); transform: rotate(8deg) scale(0.9); } }
+@media (prefers-reduced-motion: reduce) { .card, .trump-star { transition: none; animation: none; } }
diff --git a/tractor-game-simulator/client/src/components/Game/Card.jsx b/tractor-game-simulator/client/src/components/Game/Card.jsx
index ac7b2d6..507d1ab 100644
--- a/tractor-game-simulator/client/src/components/Game/Card.jsx
+++ b/tractor-game-simulator/client/src/components/Game/Card.jsx
@@ -1,5 +1,6 @@
import { useMemo } from 'react';
import { isTrumpCard } from '../../utils/cardUtils';
+import { isIronEvidenceSpecialCard } from '../../utils/ironEvidenceUtils';
import './Card.css';
const SUIT_SYMBOLS = {
@@ -14,12 +15,15 @@ const SUIT_COLORS = {
hearts: 'red',
diamonds: 'red',
clubs: 'black',
- spades: 'black',
- joker: 'purple'
+ spades: 'black'
};
const RANK_DISPLAY = {
A: 'A',
+ '-2': '-2',
+ '-1': '-1',
+ '0': '0',
+ '1': '1',
'2': '2',
'3': '3',
'4': '4',
@@ -32,16 +36,59 @@ const RANK_DISPLAY = {
J: 'J',
Q: 'Q',
K: 'K',
- small_joker: '小王',
- big_joker: '大王'
+ B: 'B',
+ C: 'C',
+ D: 'D',
+ M: 'M',
+ small_joker: 'JOKER',
+ big_joker: 'JOKER',
+ county_prince_joker: 'JOKER',
+ prince_joker: 'JOKER',
+ white_joker: 'JOKER'
+};
+
+const JOKER_META = {
+ small_joker: { className: 'small-joker', color: '#121513', label: '小王' },
+ big_joker: { className: 'big-joker', color: '#d82035', label: '大王' },
+ county_prince_joker: {
+ className: 'county-prince-joker',
+ color: '#7b4bb7',
+ label: '郡王'
+ },
+ prince_joker: { className: 'prince-joker', color: '#276fa8', label: '亲王' },
+ white_joker: { className: 'white-joker', color: '#c28b12', label: '白王(皇)' }
+};
+
+const getRankLabel = rank => JOKER_META[rank]?.label || (rank === 'M' ? 'M(Minus)' : rank);
+
+const getOriginalFaceLabel = (suit, rank) => {
+ if (!rank) return '';
+ if (JOKER_META[rank]) return JOKER_META[rank].label;
+ return `${SUIT_SYMBOLS[suit] || ''}${RANK_DISPLAY[rank] || rank}`;
+};
+
+const getOriginalFaceTone = (suit, rank) => {
+ if (rank === 'big_joker' || suit === 'hearts' || suit === 'diamonds') return 'is-red';
+ if (rank === 'county_prince_joker') return 'is-purple';
+ if (rank === 'prince_joker') return 'is-blue';
+ if (rank === 'white_joker') return 'is-gold';
+ return 'is-black';
};
export default function Card({
card,
selected = false,
onClick,
+ onRequestTransformation,
+ onCancelTransformation,
disabled = false,
+ ruleDisabled = false,
+ ruleDisabledReason = '',
+ virtualized = false,
+ faceDown = false,
small = false,
+ micro = false,
+ showOriginalFace = false,
draggable = false,
onDragStart,
onDragEnd,
@@ -51,19 +98,69 @@ export default function Card({
trumpRank = null
}) {
const isJoker = card.suit === 'joker';
+ const jokerMeta = isJoker ? JOKER_META[card.rank] || JOKER_META.small_joker : null;
+ const isNoTrumpMinus = card.rank === 'M';
const isTrump = isTrumpCard(card, trumpSuit, trumpRank);
+ const isDivineWeaponTransformed = Boolean(
+ card.isDivineWeaponTransformed || card.divineWeaponPreview
+ );
+ const isClusterAnalysisTransformed = Boolean(card.isClusterAnalysisTransformed);
+ const isJokerSubstitution = Boolean(card.isJokerSubstitution);
+ const isForbiddenMagicDemoted = Boolean(card.isForbiddenMagicDemoted);
+ const isStrengthCompensated = Boolean(card.isStrengthCompensated);
+ const isDefenseAsOffenseBoosted = Boolean(card.isDefenseAsOffenseBoosted);
+ const isTeammateCheered = Boolean(card.isTeammateCheered);
+ const isAfterglowBoosted = Boolean(card.isAfterglowBoosted);
+ const isThreeTigersTransformed = Boolean(card.isThreeTigersTransformed);
+ const isRiceToMulberryTransformed = Boolean(card.isRiceToMulberryTransformed);
+ const isIronEvidenceCard = isIronEvidenceSpecialCard(card);
+ const ironEvidenceModeClass = isIronEvidenceCard && ['multiply', 'zero'].includes(
+ card.ironEvidenceMode
+ )
+ ? `iron-evidence-${card.ironEvidenceMode}`
+ : '';
+ const isConvertedSpade = !isDivineWeaponTransformed
+ && !isStrengthCompensated
+ && !isDefenseAsOffenseBoosted
+ && !isTeammateCheered
+ && !isAfterglowBoosted
+ && card.originalSuit === 'spades'
+ && card.suit === 'hearts';
+ const originalSuit = card.originalSuit || card.suit;
+ const originalRank = card.originalRank || null;
+ const hasChangedFace = Boolean(
+ originalRank
+ && (
+ String(originalSuit) !== String(card.suit)
+ || String(originalRank) !== String(card.rank)
+ )
+ );
+ const originalFaceLabel = hasChangedFace
+ ? getOriginalFaceLabel(originalSuit, originalRank)
+ : '';
+ const currentFaceLabel = getOriginalFaceLabel(card.suit, card.rank);
const displayRank = useMemo(() => {
return RANK_DISPLAY[card.rank] || card.rank;
}, [card.rank]);
const suitSymbol = SUIT_SYMBOLS[card.suit] || '';
- const color = SUIT_COLORS[card.suit] || 'black';
+ const color = isJoker
+ ? jokerMeta.color
+ : (SUIT_COLORS[card.suit] || 'black');
+ const cornerRank = isJoker ? '♛' : displayRank;
return (
onDragStart && onDragStart(e, card)}
onDragEnd={onDragEnd}
@@ -72,33 +169,167 @@ export default function Card({
style={{
color: color,
borderColor: selected ? '#1890ff' : '#d9d9d9',
- cursor: draggable && !disabled ? 'move' : 'pointer'
+ // 系统 grab 是一只面积很大的张掌,叠在紧凑牌面上会遮挡点数;
+ // 普通 pointer 同样明确表示可点/可拖,同时视觉尺寸更克制。
+ cursor: disabled ? 'not-allowed' : 'pointer'
}}
>
+ {faceDown ? (
+
+ 梦
+
+ ) : <>
-
{displayRank}
+
{cornerRank}
{!isJoker &&
{suitSymbol}
}
{isJoker ? (
-
{displayRank}
+
+
+ {displayRank.split('').map((letter, index) => (
+
+ {letter}
+
+ ))}
+
+
) : (
{suitSymbol}
)}
-
{displayRank}
+
{cornerRank}
{!isJoker &&
{suitSymbol}
}
+ {showOriginalFace && hasChangedFace && (
+
+ 原
+ {originalFaceLabel}
+
+ )}
+
{/* 主牌星标 */}
{isTrump && (
★
)}
+ {!isJoker && (
+
−
+ )}
+ {isConvertedSpade && (
+
+ ♠→♥
+
+ )}
+ {isRiceToMulberryTransformed && (
+
+ 桑
+
+ )}
+ {isDivineWeaponTransformed && (
+
神兵
+ )}
+ {isClusterAnalysisTransformed && (
+
+ 聚
+
+ )}
+ {isJokerSubstitution && (
+
+ 换
+
+ )}
+ {isForbiddenMagicDemoted && (
+
+ 禁
+
+ )}
+ {isStrengthCompensated && (
+
0 ? 'is-plus' : 'is-minus'}`}
+ title={`取长补短:原 ${getRankLabel(card.originalRank || card.rank)} ${card.strengthCompensationDelta > 0 ? '升' : '降'}一级,当前为 ${getRankLabel(card.rank)}`}
+ >
+ {card.strengthCompensationDelta > 0 ? '+1' : '−1'}
+
+ )}
+ {isTeammateCheered && (
+
+ 油+1
+
+ )}
+ {isAfterglowBoosted && (
+
+ 返+1
+
+ )}
+ {isThreeTigersTransformed && (
+
+ 虎−4
+
+ )}
+ {onRequestTransformation && (
+
{
+ event.stopPropagation();
+ onRequestTransformation();
+ }}
+ >
+ 转
+
+ )}
+ {onCancelTransformation && (
+
{
+ event.stopPropagation();
+ onCancelTransformation();
+ }}
+ >
+ 还
+
+ )}
+ >}
);
}
diff --git a/tractor-game-simulator/client/src/components/Game/GameBackgroundMusic.jsx b/tractor-game-simulator/client/src/components/Game/GameBackgroundMusic.jsx
new file mode 100644
index 0000000..3120245
--- /dev/null
+++ b/tractor-game-simulator/client/src/components/Game/GameBackgroundMusic.jsx
@@ -0,0 +1,133 @@
+import { useEffect, useRef, useState } from 'react';
+
+const MUSIC_PREFERENCE_KEY = 'tractor-game-bgm-enabled';
+const MUSIC_SOURCE = '/audio/qingyuan-xu-peidong.mp3';
+
+const readMusicPreference = () => {
+ try {
+ const savedPreference = window.localStorage.getItem(MUSIC_PREFERENCE_KEY);
+ return savedPreference === null ? true : savedPreference === 'true';
+ } catch {
+ return true;
+ }
+};
+
+export default function GameBackgroundMusic() {
+ const audioRef = useRef(null);
+ const [isEnabled, setIsEnabled] = useState(readMusicPreference);
+ const [isPlaying, setIsPlaying] = useState(false);
+ const [isWaitingForInteraction, setIsWaitingForInteraction] = useState(false);
+
+ useEffect(() => {
+ try {
+ window.localStorage.setItem(MUSIC_PREFERENCE_KEY, String(isEnabled));
+ } catch {
+ // 浏览器禁用本地存储时仍允许本次牌局正常播放。
+ }
+ }, [isEnabled]);
+
+ useEffect(() => {
+ const audio = audioRef.current;
+ if (!audio) return undefined;
+ audio.volume = 0.28;
+
+ if (!isEnabled) {
+ audio.pause();
+ setIsPlaying(false);
+ setIsWaitingForInteraction(false);
+ return undefined;
+ }
+
+ let isActive = true;
+ const stopUnlockListeners = () => {
+ window.removeEventListener('pointerdown', attemptPlayback, true);
+ window.removeEventListener('keydown', attemptPlayback, true);
+ window.removeEventListener('touchstart', attemptPlayback, true);
+ };
+ const attemptPlayback = async () => {
+ if (!isActive || !isEnabled || !audio.paused) {
+ if (!audio.paused) stopUnlockListeners();
+ return;
+ }
+ try {
+ await audio.play();
+ if (!isActive) return;
+ setIsPlaying(true);
+ setIsWaitingForInteraction(false);
+ stopUnlockListeners();
+ } catch {
+ if (isActive) setIsWaitingForInteraction(true);
+ }
+ };
+
+ void attemptPlayback();
+ // 移动浏览器通常会拦截无手势的有声自动播放;玩家第一次触碰牌桌时立即补播。
+ window.addEventListener('pointerdown', attemptPlayback, true);
+ window.addEventListener('keydown', attemptPlayback, true);
+ window.addEventListener('touchstart', attemptPlayback, true);
+
+ return () => {
+ isActive = false;
+ stopUnlockListeners();
+ };
+ }, [isEnabled]);
+
+ const handleToggle = () => {
+ const audio = audioRef.current;
+ if (isEnabled) {
+ audio?.pause();
+ setIsEnabled(false);
+ return;
+ }
+
+ setIsEnabled(true);
+ setIsWaitingForInteraction(false);
+ // 此处处于按钮点击的用户手势中,直接播放可避开移动端自动播放限制。
+ audio?.play().catch(() => setIsWaitingForInteraction(true));
+ };
+
+ const label = !isEnabled
+ ? '音乐已关'
+ : isWaitingForInteraction
+ ? '播放音乐'
+ : '情缘';
+
+ return (
+
+
+ );
+}
diff --git a/tractor-game-simulator/client/src/components/Game/GameBoard.css b/tractor-game-simulator/client/src/components/Game/GameBoard.css
index 4c60064..e25efb6 100644
--- a/tractor-game-simulator/client/src/components/Game/GameBoard.css
+++ b/tractor-game-simulator/client/src/components/Game/GameBoard.css
@@ -3,10 +3,129 @@
flex-direction: column;
height: 100%;
flex: 1;
- background: linear-gradient(135deg, #1e3c72 0%, #2a5298 100%);
+ position: relative;
+ overflow: hidden;
+ background:
+ radial-gradient(circle at 50% -12%, rgba(193, 224, 104, 0.82) 0%, rgba(104, 167, 77, 0.38) 25%, transparent 44%),
+ radial-gradient(circle at -4% 30%, rgba(20, 91, 57, 0.92), transparent 33%),
+ radial-gradient(circle at 104% 24%, rgba(13, 76, 51, 0.94), transparent 32%),
+ linear-gradient(180deg, #80b64f 0%, #1e7352 31%, #073b35 100%);
color: white;
}
+.game-board::before,
+.game-board::after {
+ content: '';
+ position: absolute;
+ z-index: 0;
+ width: 34vw;
+ height: 34vw;
+ min-width: 360px;
+ min-height: 360px;
+ border-radius: 46% 54% 38% 62%;
+ opacity: 0.34;
+ pointer-events: none;
+ background:
+ radial-gradient(ellipse at 20% 25%, #194b35 0 9%, transparent 10%),
+ radial-gradient(ellipse at 44% 16%, #266344 0 12%, transparent 13%),
+ radial-gradient(ellipse at 66% 38%, #184a34 0 15%, transparent 16%),
+ radial-gradient(ellipse at 34% 57%, #0f3d2c 0 18%, transparent 19%);
+}
+
+.game-board::before { left: -12vw; top: -11vw; transform: rotate(18deg); }
+.game-board::after { right: -12vw; top: -13vw; transform: scaleX(-1) rotate(12deg); }
+.game-board > * { position: relative; z-index: 1; }
+
+.game-bgm-control {
+ position: absolute;
+ z-index: 180;
+ top: 40px;
+ right: calc(clamp(320px, 23vw, 390px) + 52px);
+ pointer-events: none;
+}
+
+.game-bgm-audio {
+ display: none;
+}
+
+.game-bgm-toggle {
+ height: 38px;
+ display: inline-flex;
+ align-items: center;
+ gap: 7px;
+ padding: 0 12px;
+ border: 1px solid rgba(255, 226, 117, 0.58);
+ border-radius: 999px;
+ color: #fff3bd;
+ background: linear-gradient(145deg, rgba(8, 69, 50, 0.96), rgba(5, 45, 37, 0.96));
+ box-shadow: 0 7px 18px rgba(0, 25, 18, 0.3), inset 0 1px rgba(255, 255, 255, 0.12);
+ font: inherit;
+ font-size: 13px;
+ font-weight: 800;
+ cursor: pointer;
+ pointer-events: auto;
+ transition: border-color 160ms ease, background 160ms ease, opacity 160ms ease, transform 160ms ease;
+}
+
+.game-bgm-toggle:hover {
+ border-color: rgba(255, 233, 145, 0.9);
+ background: linear-gradient(145deg, rgba(13, 91, 62, 0.98), rgba(7, 58, 45, 0.98));
+ transform: translateY(-1px);
+}
+
+.game-bgm-toggle:focus-visible {
+ outline: 2px solid #fff0a8;
+ outline-offset: 2px;
+}
+
+.game-bgm-toggle.is-disabled {
+ border-color: rgba(215, 231, 220, 0.28);
+ color: rgba(232, 241, 235, 0.72);
+ background: rgba(6, 43, 35, 0.9);
+}
+
+.game-bgm-note {
+ color: #ffe36f;
+ font-size: 20px;
+ line-height: 1;
+}
+
+.game-bgm-label {
+ min-width: 42px;
+ white-space: nowrap;
+}
+
+.game-bgm-levels {
+ height: 15px;
+ display: inline-flex;
+ align-items: flex-end;
+ gap: 2px;
+}
+
+.game-bgm-levels i {
+ width: 2px;
+ height: 5px;
+ border-radius: 2px;
+ background: currentColor;
+ opacity: 0.42;
+}
+
+.game-bgm-levels i:nth-child(2) { height: 10px; }
+.game-bgm-levels i:nth-child(3) { height: 14px; }
+
+.game-bgm-toggle.is-playing .game-bgm-levels i {
+ opacity: 0.9;
+ animation: gameBgmLevel 720ms ease-in-out infinite alternate;
+}
+
+.game-bgm-toggle.is-playing .game-bgm-levels i:nth-child(2) { animation-delay: -240ms; }
+.game-bgm-toggle.is-playing .game-bgm-levels i:nth-child(3) { animation-delay: -480ms; }
+
+@keyframes gameBgmLevel {
+ from { transform: scaleY(0.42); }
+ to { transform: scaleY(1); }
+}
+
.main-game-area {
flex: 1;
display: flex;
@@ -29,13 +148,715 @@
/* 出牌阶段特殊布局 */
.phase-content.playing-phase {
position: relative;
- padding: 10px;
+ padding: 12px 18px 16px;
background: transparent;
box-shadow: none;
height: 100%;
margin: 0;
}
+.card-exchange-controls {
+ display: flex;
+ align-items: center;
+ justify-content: flex-end;
+ gap: 10px;
+}
+
+.card-exchange-target {
+ max-width: 180px;
+ overflow: hidden;
+ color: #ffe38a !important;
+ font-size: 13px;
+ font-weight: 700;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.iceberg-selection-controls {
+ display: flex;
+ align-items: center;
+ justify-content: flex-end;
+ gap: 10px;
+}
+
+.iceberg-selection-hint {
+ color: #ffe38a !important;
+ font-size: 13px;
+ font-weight: 800;
+ white-space: nowrap;
+}
+
+.ten-sided-ambush-wait-controls {
+ display: flex;
+ align-items: center;
+ justify-content: flex-end;
+ gap: 10px;
+}
+
+.ten-sided-ambush-modal .ant-modal-content {
+ overflow: hidden;
+ border: 1px solid rgba(222, 178, 73, 0.72);
+ background:
+ radial-gradient(circle at 50% -15%, rgba(196, 154, 53, 0.24), transparent 45%),
+ linear-gradient(155deg, #133e33, #071f1c 72%);
+ box-shadow: 0 24px 70px rgba(0, 0, 0, 0.55), 0 0 36px rgba(221, 178, 64, 0.2);
+}
+
+.ten-sided-ambush-modal .ant-modal-title,
+.ten-sided-ambush-modal .ant-typography {
+ color: #fff4c5;
+}
+
+.ten-sided-ambush-modal .ant-modal-header {
+ background: transparent;
+}
+
+/* 出牌中的即时决策贴近底部主视角,内容保持短而紧凑。 */
+.player-decision-modal-wrap {
+ display: flex;
+ align-items: flex-end;
+ justify-content: center;
+ padding: 20px 20px clamp(142px, 18vh, 205px);
+ overflow: hidden;
+}
+
+.player-decision-modal-wrap .ant-modal {
+ top: 0;
+ margin: 0;
+ padding-bottom: 0;
+}
+
+.player-decision-modal-wrap .ant-modal-content {
+ padding: 15px 18px 13px;
+ border: 1px solid rgba(233, 196, 92, 0.56);
+ border-radius: 14px;
+ background: linear-gradient(155deg, rgba(20, 66, 53, 0.98), rgba(5, 34, 29, 0.99));
+ box-shadow: 0 15px 42px rgba(0, 0, 0, 0.44);
+}
+
+.player-decision-modal-wrap .ant-modal-header {
+ margin-bottom: 8px;
+ background: transparent;
+}
+
+.player-decision-modal-wrap .ant-modal-title {
+ color: #fff1b0;
+ font-size: 18px;
+ line-height: 1.3;
+}
+
+.player-decision-modal-wrap .ant-modal-body {
+ color: #f5fff7;
+}
+
+.player-decision-modal-wrap .ant-modal-footer {
+ margin-top: 12px;
+}
+
+.player-decision-modal-wrap .ant-modal-footer .ant-btn {
+ height: 34px;
+ padding-inline: 14px;
+}
+
+.player-decision-modal-wrap .ant-modal-footer .ant-btn:not(.ant-btn-primary) {
+ border-color: rgba(225, 239, 228, 0.36);
+ color: #edf9ef;
+ background: rgba(255, 255, 255, 0.08);
+}
+
+.player-decision-modal-wrap .ant-modal-footer .ant-btn:not(.ant-btn-primary):not(:disabled):hover {
+ border-color: rgba(255, 224, 126, 0.72);
+ color: #fff6c9;
+ background: rgba(255, 224, 126, 0.13);
+}
+
+/* 深色决策弹窗中的主操作统一使用高对比浅色字,避免金色主题把文字算成黑色。 */
+.player-decision-modal-wrap .ant-modal-footer .ant-btn-primary,
+.player-decision-modal-wrap .ant-modal-footer .ant-btn-primary:not(:disabled):hover,
+.player-decision-modal-wrap .ant-modal-footer .ant-btn-primary:not(:disabled):focus-visible {
+ border-color: #e6c35a;
+ color: #fffbed !important;
+ background: linear-gradient(180deg, #3b9667, #1e6848);
+ box-shadow: inset 0 1px rgba(255, 255, 255, 0.2), 0 4px 12px rgba(0, 22, 13, 0.28);
+ text-shadow: 0 1px 2px rgba(0, 0, 0, 0.38);
+}
+
+.player-decision-modal-wrap .ant-modal-footer .ant-btn-primary:disabled {
+ border-color: rgba(213, 226, 216, 0.2);
+ color: rgba(238, 247, 240, 0.42) !important;
+ background: rgba(104, 119, 110, 0.42);
+ box-shadow: none;
+ text-shadow: none;
+}
+
+.player-decision-primary-text {
+ margin: 0;
+ color: #f6fff8;
+ font-size: 15px;
+ line-height: 1.55;
+}
+
+.player-decision-secondary-text {
+ margin: 6px 0 0;
+ color: rgba(235, 247, 239, 0.66);
+ font-size: 12px;
+ line-height: 1.45;
+}
+
+.card-transformation-modal-wrap .ant-modal-body {
+ min-height: 128px;
+}
+
+.card-transformation-dialog {
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+}
+
+.card-transformation-option-grid {
+ display: grid;
+ gap: 9px;
+}
+
+.card-transformation-option-grid.suit-options {
+ grid-template-columns: repeat(4, minmax(0, 1fr));
+}
+
+.card-transformation-option-grid.rank-options {
+ grid-template-columns: repeat(7, minmax(0, 1fr));
+}
+
+.card-transformation-option-grid.cluster-rank-options {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+}
+
+.card-transformation-option-grid .ant-btn {
+ height: 42px;
+ border-color: rgba(234, 200, 103, 0.52);
+ color: #fff;
+ background: rgba(255, 255, 255, 0.08);
+ font-weight: 800;
+}
+
+.card-transformation-option-grid .ant-btn:hover {
+ border-color: #ffe58f !important;
+ color: #fff7cf !important;
+ background: rgba(207, 161, 51, 0.24) !important;
+}
+
+.cultural-revolution-type-grid {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 12px;
+}
+
+.cultural-revolution-type-grid .ant-btn {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ height: 78px;
+ border-color: rgba(234, 200, 103, 0.56);
+ color: #fff3b2;
+ background: rgba(255, 255, 255, 0.08);
+ font-size: 18px;
+ font-weight: 900;
+}
+
+.cultural-revolution-type-grid .ant-btn small {
+ margin-top: 4px;
+ color: rgba(239, 248, 241, 0.68);
+ font-size: 12px;
+ font-weight: 500;
+}
+
+.transformation-suit-option.suit-hearts,
+.transformation-suit-option.suit-diamonds {
+ color: #ffb0aa;
+}
+
+.card-transformation-step-heading {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ color: #fff5c7;
+ font-weight: 700;
+}
+
+@media (max-width: 680px) {
+ .card-transformation-option-grid.suit-options {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ }
+
+ .card-transformation-option-grid.rank-options {
+ grid-template-columns: repeat(5, minmax(0, 1fr));
+ }
+}
+
+.ten-sided-ambush-selector {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 18px;
+ padding: 8px 4px 4px;
+ text-align: center;
+}
+
+.ten-sided-ambush-secret-note {
+ max-width: 400px;
+ color: #f7df91 !important;
+ line-height: 1.7;
+}
+
+.ten-sided-ambush-rank-grid {
+ display: grid;
+ grid-template-columns: repeat(5, minmax(52px, 1fr));
+ gap: 10px;
+ width: 100%;
+}
+
+.ten-sided-ambush-rank-grid .ant-btn {
+ height: 54px;
+ border-color: rgba(240, 209, 116, 0.42);
+ color: #fff7d2;
+ background: rgba(255, 255, 255, 0.08);
+ font-size: 21px;
+ font-weight: 900;
+}
+
+.ten-sided-ambush-rank-grid .ant-btn:hover,
+.ten-sided-ambush-rank-grid .ant-btn.is-selected {
+ border-color: #ffd95e !important;
+ color: #4d3000 !important;
+ background: linear-gradient(180deg, #fff0a3, #e9ae2c) !important;
+ box-shadow: 0 0 0 2px rgba(255, 217, 94, 0.16), 0 8px 22px rgba(229, 170, 34, 0.28);
+ transform: translateY(-2px);
+}
+
+.hidden-dragon-selector .ten-sided-ambush-rank-grid .ant-btn {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ line-height: 1.05;
+}
+
+.hidden-dragon-selector .ten-sided-ambush-rank-grid .ant-btn small {
+ margin-top: 4px;
+ color: rgba(255, 255, 255, 0.64);
+ font-size: 10px;
+ font-weight: 600;
+}
+
+.hidden-dragon-selector .ten-sided-ambush-rank-grid .ant-btn:hover small,
+.hidden-dragon-selector .ten-sided-ambush-rank-grid .ant-btn.is-selected small {
+ color: rgba(77, 48, 0, 0.7);
+}
+
+.ten-sided-ambush-confirm.ant-btn,
+.ten-sided-ambush-confirm.ant-btn:disabled,
+.ten-sided-ambush-confirm.ant-btn.ant-btn-disabled {
+ color: #fff !important;
+}
+
+.ten-sided-ambush-confirm.ant-btn:disabled,
+.ten-sided-ambush-confirm.ant-btn.ant-btn-disabled {
+ border-color: rgba(255, 255, 255, 0.64) !important;
+ background: rgba(10, 46, 38, 0.72) !important;
+ text-shadow: 0 1px 2px rgba(0, 0, 0, 0.48);
+}
+
+.gentleman-promise-modal .ant-modal {
+ max-width: min(520px, calc(100vw - 28px));
+}
+
+.gentleman-promise-suit-grid {
+ display: grid;
+ grid-template-columns: repeat(3, minmax(96px, 1fr));
+ gap: 10px;
+ width: 100%;
+}
+
+.gentleman-promise-suit-grid .ant-btn {
+ display: flex;
+ height: 58px;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ border-color: rgba(240, 209, 116, 0.42);
+ color: #fff7d2;
+ background: rgba(255, 255, 255, 0.08);
+ font-size: 16px;
+ font-weight: 800;
+ line-height: 1.15;
+}
+
+.gentleman-promise-suit-grid .ant-btn small {
+ margin-top: 4px;
+ color: rgba(255, 255, 255, 0.66);
+ font-size: 11px;
+}
+
+.gentleman-promise-suit-grid .ant-btn:hover,
+.gentleman-promise-suit-grid .ant-btn.is-selected {
+ border-color: #ffd95e !important;
+ color: #4d3000 !important;
+ background: linear-gradient(180deg, #fff0a3, #e9ae2c) !important;
+ box-shadow: 0 8px 22px rgba(229, 170, 34, 0.28);
+}
+
+.gentleman-promise-suit-grid .ant-btn:hover small,
+.gentleman-promise-suit-grid .ant-btn.is-selected small {
+ color: rgba(77, 48, 0, 0.7);
+}
+
+.ten-sided-ambush-reveal-overlay {
+ position: fixed !important;
+ inset: 0;
+ z-index: 3000 !important;
+ display: grid;
+ place-items: center;
+ overflow: hidden;
+ pointer-events: none;
+ background: radial-gradient(circle, rgba(4, 23, 19, 0.12), rgba(2, 10, 9, 0.48));
+ animation: ambushOverlayFade 1.8s ease-out forwards;
+}
+
+.ten-sided-ambush-reveal-overlay::before,
+.ten-sided-ambush-reveal-overlay::after {
+ content: '';
+ position: absolute;
+ width: 46vmax;
+ height: 46vmax;
+ border: 2px solid rgba(248, 207, 80, 0.35);
+ transform: rotate(45deg);
+ animation: ambushLinesClose 1.4s cubic-bezier(.2, .8, .25, 1) forwards;
+}
+
+.ten-sided-ambush-reveal-overlay::after {
+ width: 31vmax;
+ height: 31vmax;
+ animation-delay: 80ms;
+}
+
+.ten-sided-ambush-reveal {
+ position: relative;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ min-width: 280px;
+ padding: 28px 56px 32px;
+ border: 1px solid rgba(255, 222, 114, 0.76);
+ border-radius: 24px;
+ color: #fff3bd;
+ background: linear-gradient(150deg, rgba(17, 61, 49, 0.97), rgba(5, 22, 19, 0.98));
+ box-shadow: 0 24px 70px rgba(0, 0, 0, 0.62), 0 0 60px rgba(235, 182, 44, 0.28);
+ animation: ambushRevealPop 1.8s cubic-bezier(.18, .8, .2, 1) forwards;
+}
+
+.ten-sided-ambush-reveal strong {
+ color: #ffd34d;
+ font-family: Georgia, 'Times New Roman', serif;
+ font-size: clamp(74px, 9vw, 120px);
+ line-height: 1;
+ text-shadow: 0 0 28px rgba(255, 207, 53, 0.58);
+}
+
+.three-powers-reveal strong {
+ font-size: clamp(34px, 5vw, 64px);
+ white-space: nowrap;
+}
+
+.ten-sided-ambush-reveal-title {
+ color: #fff9dc;
+ font-size: 28px;
+ font-weight: 900;
+ letter-spacing: 0.32em;
+ text-indent: 0.32em;
+}
+
+.ten-sided-ambush-reveal-subtitle {
+ margin-top: 10px;
+ color: rgba(255, 245, 202, 0.78);
+ font-size: 15px;
+ letter-spacing: 0.12em;
+}
+
+@keyframes ambushRevealPop {
+ 0% { opacity: 0; transform: scale(0.55) rotate(-4deg); filter: blur(8px); }
+ 18% { opacity: 1; transform: scale(1.08) rotate(1deg); filter: blur(0); }
+ 32%, 72% { opacity: 1; transform: scale(1) rotate(0); }
+ 100% { opacity: 0; transform: scale(1.04); }
+}
+
+@keyframes ambushOverlayFade {
+ 0%, 72% { opacity: 1; }
+ 100% { opacity: 0; }
+}
+
+@keyframes ambushLinesClose {
+ 0% { opacity: 0; transform: rotate(45deg) scale(1.8); }
+ 24%, 70% { opacity: 1; transform: rotate(45deg) scale(1); }
+ 100% { opacity: 0; transform: rotate(45deg) scale(0.92); }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .ten-sided-ambush-reveal-overlay,
+ .ten-sided-ambush-reveal-overlay::before,
+ .ten-sided-ambush-reveal-overlay::after,
+ .ten-sided-ambush-reveal {
+ animation-duration: 1ms;
+ }
+}
+
+.active-skill-activation-overlay {
+ position: fixed;
+ inset: 0;
+ z-index: 3050;
+ display: grid;
+ place-items: center;
+ pointer-events: none;
+ background: radial-gradient(circle, rgba(255, 207, 67, 0.08), rgba(2, 17, 13, 0.4));
+ animation: activeSkillOverlayFade 1.8s ease-out forwards;
+}
+
+.active-skill-activation {
+ position: relative;
+ display: flex;
+ min-width: 330px;
+ flex-direction: column;
+ align-items: center;
+ gap: 4px;
+ padding: 24px 50px 27px;
+ overflow: hidden;
+ border: 1px solid rgba(255, 225, 121, 0.82);
+ border-radius: 20px;
+ color: rgba(255, 249, 219, 0.8);
+ background: linear-gradient(145deg, rgba(17, 54, 44, 0.98), rgba(5, 22, 18, 0.99));
+ box-shadow: 0 24px 72px rgba(0, 0, 0, 0.58), 0 0 46px rgba(242, 190, 43, 0.24);
+ animation: activeSkillActivationPop 1.8s cubic-bezier(.18, .8, .2, 1) forwards;
+}
+
+.active-skill-activation > * {
+ position: relative;
+ z-index: 1;
+}
+
+.active-skill-activation > span {
+ font-size: 16px;
+ letter-spacing: 0.08em;
+}
+
+.active-skill-activation strong {
+ color: #ffda55;
+ font-family: Georgia, 'Noto Serif SC', serif;
+ font-size: 46px;
+ line-height: 1.25;
+ letter-spacing: 0.12em;
+ text-indent: 0.12em;
+ text-shadow: 0 0 22px rgba(255, 205, 52, 0.42);
+}
+
+.active-skill-activation small {
+ color: rgba(255, 244, 199, 0.72);
+ font-size: 14px;
+}
+
+.active-skill-activation-overlay.is-destroy-dyke {
+ background:
+ radial-gradient(circle at 50% 54%, rgba(237, 139, 67, 0.16), transparent 30%),
+ radial-gradient(circle at 50% 64%, rgba(24, 139, 128, 0.22), transparent 48%),
+ rgba(2, 17, 16, 0.52);
+}
+
+.active-skill-activation.is-destroy-dyke {
+ border-color: rgba(255, 183, 91, 0.88);
+ color: rgba(255, 239, 207, 0.88);
+ background:
+ linear-gradient(155deg, rgba(73, 54, 31, 0.98), rgba(6, 35, 33, 0.99) 68%),
+ rgba(5, 22, 18, 0.99);
+ box-shadow:
+ 0 24px 72px rgba(0, 0, 0, 0.62),
+ 0 0 54px rgba(32, 165, 149, 0.23),
+ inset 0 0 34px rgba(239, 129, 60, 0.075);
+}
+
+.active-skill-activation.is-destroy-dyke::before {
+ position: absolute;
+ right: -12%;
+ bottom: -44px;
+ left: -12%;
+ height: 92px;
+ border-radius: 48% 52% 0 0;
+ background:
+ radial-gradient(ellipse at 18% 12%, rgba(136, 235, 219, 0.36) 0 12%, transparent 13%),
+ radial-gradient(ellipse at 58% 2%, rgba(136, 235, 219, 0.24) 0 13%, transparent 14%),
+ linear-gradient(180deg, rgba(33, 152, 137, 0.7), rgba(5, 70, 66, 0.88));
+ content: '';
+ opacity: 0;
+ transform: translateY(42px) rotate(-1deg);
+ animation: destroyDykeFloodSweep 1.8s cubic-bezier(.2, .72, .18, 1) forwards;
+}
+
+.active-skill-activation.is-destroy-dyke::after {
+ position: absolute;
+ top: -35%;
+ left: 50%;
+ width: 2px;
+ height: 92%;
+ background: linear-gradient(
+ 180deg,
+ transparent,
+ rgba(255, 195, 107, 0.72) 34%,
+ rgba(255, 195, 107, 0.16) 76%,
+ transparent
+ );
+ box-shadow:
+ -11px 44px 0 -0.5px rgba(255, 195, 107, 0.28),
+ 9px 76px 0 -0.5px rgba(255, 195, 107, 0.2);
+ content: '';
+ opacity: 0;
+ transform: rotate(14deg) scaleY(0);
+ transform-origin: top;
+ animation: destroyDykeCrack 1.8s ease-out forwards;
+}
+
+.active-skill-activation.is-destroy-dyke strong {
+ color: #ffbd6c;
+ text-shadow:
+ 0 0 22px rgba(255, 143, 72, 0.45),
+ 0 8px 22px rgba(0, 0, 0, 0.34);
+}
+
+.active-skill-activation.is-destroy-dyke small {
+ color: rgba(189, 239, 226, 0.78);
+}
+
+@keyframes activeSkillActivationPop {
+ 0% { opacity: 0; transform: scale(0.72) translateY(16px); filter: blur(7px); }
+ 18% { opacity: 1; transform: scale(1.04) translateY(0); filter: blur(0); }
+ 32%, 74% { opacity: 1; transform: scale(1); }
+ 100% { opacity: 0; transform: scale(1.03) translateY(-5px); }
+}
+
+@keyframes activeSkillOverlayFade {
+ 0%, 72% { opacity: 1; }
+ 100% { opacity: 0; }
+}
+
+@keyframes destroyDykeFloodSweep {
+ 0%, 14% { opacity: 0; transform: translateY(48px) rotate(-1deg); }
+ 30% { opacity: 0.9; }
+ 64% { opacity: 0.72; transform: translateY(0) rotate(1deg); }
+ 100% { opacity: 0; transform: translateY(-20px) rotate(-1deg); }
+}
+
+@keyframes destroyDykeCrack {
+ 0%, 12% { opacity: 0; transform: rotate(14deg) scaleY(0); }
+ 22%, 66% { opacity: 0.78; transform: rotate(14deg) scaleY(1); }
+ 100% { opacity: 0; transform: rotate(14deg) scaleY(1); }
+}
+
+.equivalent-reciprocity-result {
+ position: fixed;
+ left: 50%;
+ top: 42%;
+ z-index: 1450;
+ min-width: 390px;
+ padding: 16px 24px 18px;
+ transform: translate(-50%, -50%);
+ border: 2px solid #f3c847;
+ border-radius: 18px;
+ background: rgba(5, 49, 38, 0.96);
+ box-shadow: 0 18px 55px rgba(0, 0, 0, 0.48), 0 0 28px rgba(243, 200, 71, 0.24);
+ color: #fff7d6;
+ text-align: center;
+ pointer-events: none;
+ animation: equivalentReciprocityReveal 360ms ease-out;
+}
+
+.equivalent-reciprocity-result-title {
+ margin-bottom: 10px;
+ color: #ffd666;
+ font-size: 20px;
+ font-weight: 800;
+}
+
+.equivalent-reciprocity-card-row {
+ display: flex;
+ align-items: flex-end;
+ justify-content: center;
+ gap: 54px;
+ margin-bottom: 10px;
+}
+
+.equivalent-reciprocity-card-side {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 6px;
+ color: rgba(255, 255, 255, 0.82);
+ font-weight: 700;
+}
+
+.equivalent-reciprocity-card-side.is-winner {
+ color: #ffd666;
+}
+
+.equivalent-reciprocity-card-side.is-winner .card {
+ box-shadow: 0 0 0 3px #f3c847, 0 0 22px rgba(243, 200, 71, 0.7);
+}
+
+.equivalent-reciprocity-hand-picker {
+ min-height: 122px;
+ padding: 8px 10px 4px;
+ overflow: hidden;
+ border: 1px solid rgba(243, 200, 71, 0.42);
+ border-radius: 12px;
+ background: rgba(0, 48, 38, 0.72);
+}
+
+.equivalent-reciprocity-hand-picker .hand {
+ width: 100%;
+ min-height: 112px;
+ justify-content: center;
+}
+
+.equivalent-reciprocity-confirm-button,
+.equivalent-reciprocity-confirm-button:disabled,
+.equivalent-reciprocity-confirm-button.ant-btn-disabled {
+ color: #fff !important;
+}
+
+@keyframes equivalentReciprocityReveal {
+ from { opacity: 0; transform: translate(-50%, -44%) scale(0.88); }
+ to { opacity: 1; transform: translate(-50%, -50%) scale(1); }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .active-skill-button.is-armed,
+ .active-skill-button.is-ready,
+ .active-skill-activation-overlay,
+ .active-skill-activation,
+ .active-skill-activation::before,
+ .active-skill-activation::after {
+ animation-duration: 1ms !important;
+ }
+}
+
+@media (max-width: 780px) {
+ .phase-content.playing-phase { padding: 6px; }
+ .active-skill-activation {
+ box-sizing: border-box;
+ width: calc(100vw - 32px);
+ min-width: 0;
+ padding: 20px 24px 23px;
+ }
+ .active-skill-activation strong { font-size: clamp(32px, 11vw, 44px); }
+ .ten-sided-ambush-rank-grid { grid-template-columns: repeat(3, minmax(52px, 1fr)); }
+ .player-decision-modal-wrap { padding: 12px 12px 118px; }
+}
+
/* 游戏控制面板 - 右下角 */
.game-controls {
position: absolute;
@@ -63,33 +884,305 @@
color: #333;
}
-/* 毙牌动画 */
-@keyframes trumpPop {
- 0% {
- transform: scale(0.3);
- opacity: 0;
- }
- 50% {
- transform: scale(1.2);
- opacity: 1;
- }
- 100% {
- transform: scale(1);
- opacity: 1;
+.bottom-cards-summary {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ justify-content: center;
+ gap: 10px 18px;
+ margin-bottom: 14px;
+}
+
+.bottom-cards-score {
+ display: inline-flex;
+ align-items: baseline;
+ gap: 3px;
+ padding: 5px 12px;
+ border: 1px solid #efd16d;
+ border-radius: 999px;
+ color: #76530b;
+ background: #fff8d9;
+ font-size: 14px;
+ white-space: nowrap;
+}
+
+.bottom-cards-score strong {
+ color: #c77b00;
+ font-size: 20px;
+ font-variant-numeric: tabular-nums;
+}
+.candle-initial-choice-modal .ant-modal-content {
+ border: 1px solid rgba(235, 190, 71, 0.7);
+ background:
+ radial-gradient(circle at 50% -18%, rgba(247, 187, 51, 0.22), transparent 48%),
+ linear-gradient(150deg, #16483a, #071f1b 76%);
+ box-shadow: 0 24px 72px rgba(0, 0, 0, 0.56), 0 0 36px rgba(239, 182, 48, 0.14);
+}
+
+.candle-initial-choice-modal .ant-modal-header {
+ background: transparent;
+}
+
+.candle-initial-choice-modal .ant-modal-title {
+ color: #ffe9a3;
+ font-size: 20px;
+}
+
+.candle-initial-choice-copy {
+ display: grid;
+ grid-template-columns: 54px minmax(0, 1fr);
+ gap: 6px 14px;
+ align-items: center;
+ padding: 8px 2px 2px;
+}
+
+.candle-choice-icon {
+ grid-row: 1 / span 2;
+ font-size: 48px;
+ filter: drop-shadow(0 0 9px rgba(255, 178, 54, 0.48));
+ text-align: center;
+}
+
+.candle-initial-choice-copy p {
+ margin: 0;
+ color: rgba(255, 255, 255, 0.78);
+ line-height: 1.65;
+}
+
+.candle-initial-choice-copy p:first-of-type {
+ color: #fff3bf;
+ font-size: 16px;
+ font-weight: 800;
+}
+
+.candle-initial-choice-actions {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 12px;
+}
+.ambiguous-choice-options {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 14px;
+ margin-top: 16px;
+}
+
+.ambiguous-choice-option {
+ min-width: 0;
+ min-height: 170px;
+ padding: 14px;
+ color: #fff4bf;
+ background: linear-gradient(145deg, rgba(20, 70, 55, .98), rgba(9, 45, 38, .98));
+ border: 1px solid rgba(235, 197, 79, .65);
+ border-radius: 14px;
+ cursor: pointer;
+ transition: transform .16s ease, border-color .16s ease, box-shadow .16s ease;
+}
+
+.ambiguous-choice-option:hover,
+.ambiguous-choice-option:focus-visible {
+ transform: translateY(-2px);
+ border-color: #ffe276;
+ box-shadow: 0 8px 22px rgba(0, 0, 0, .28);
+ outline: none;
+}
+
+.ambiguous-choice-option > strong {
+ display: block;
+ margin-bottom: 12px;
+ font-size: 18px;
+}
+
+.ambiguous-choice-cards {
+ display: flex;
+ flex-wrap: wrap;
+ justify-content: center;
+ gap: 6px;
+}
+
+@media (max-width: 720px) {
+ .ambiguous-choice-options {
+ grid-template-columns: 1fr;
}
}
+.nine-princes-inline-controls {
+ display: flex;
+ align-items: center;
+ justify-content: flex-end;
+ gap: 6px;
+}
-.trump-animation-overlay {
- animation: fadeIn 0.3s ease-out;
+.nine-princes-inline-hint {
+ max-width: 230px;
+ overflow: hidden;
+ color: #ffe38a !important;
+ font-size: 13px;
+ font-weight: 800;
+ text-overflow: ellipsis;
+ white-space: nowrap;
}
-.trump-animation {
- background: rgba(0, 0, 0, 0.7);
- padding: 30px 60px;
- border-radius: 16px;
- backdrop-filter: blur(10px);
+/* 普通网页无法可靠地强制 iOS/微信内置浏览器旋转;竖屏时先阻止进入残缺牌桌,
+ * 支持 Screen Orientation API 的浏览器可由按钮进入全屏横屏。 */
+.portrait-orientation-guard {
+ display: none;
}
-.trump-animation.overtrump {
- background: rgba(50, 0, 0, 0.8);
+@media (orientation: portrait) and (max-width: 900px) {
+ .portrait-orientation-guard {
+ position: fixed;
+ z-index: 10000;
+ inset: 0;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ box-sizing: border-box;
+ padding:
+ max(24px, env(safe-area-inset-top))
+ max(22px, env(safe-area-inset-right))
+ max(24px, env(safe-area-inset-bottom))
+ max(22px, env(safe-area-inset-left));
+ color: #f5fff7;
+ background:
+ radial-gradient(circle at 50% 28%, rgba(156, 218, 109, 0.25), transparent 34%),
+ linear-gradient(160deg, rgba(15, 103, 72, 0.99), rgba(3, 43, 37, 0.995));
+ backdrop-filter: blur(14px);
+ }
+
+ .portrait-orientation-card {
+ width: min(340px, 100%);
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 13px;
+ padding: 30px 24px 25px;
+ border: 1px solid rgba(255, 227, 128, 0.5);
+ border-radius: 24px;
+ background: linear-gradient(145deg, rgba(5, 58, 46, 0.94), rgba(8, 39, 34, 0.96));
+ box-shadow: 0 24px 70px rgba(0, 20, 15, 0.46), inset 0 1px rgba(255, 255, 255, 0.12);
+ text-align: center;
+ }
+
+ .portrait-orientation-card strong {
+ color: #ffe784;
+ font-size: 26px;
+ letter-spacing: 0.05em;
+ }
+
+ .portrait-orientation-card p {
+ margin: 0;
+ color: rgba(239, 250, 242, 0.8);
+ font-size: 15px;
+ line-height: 1.65;
+ }
+
+ .portrait-orientation-card button {
+ min-width: 154px;
+ min-height: 42px;
+ padding: 0 18px;
+ border: 1px solid #ffe58f;
+ border-radius: 999px;
+ color: #3d2b00;
+ background: linear-gradient(180deg, #fff09a, #dfb43a);
+ box-shadow: 0 7px 20px rgba(77, 48, 0, 0.32), inset 0 1px rgba(255, 255, 255, 0.72);
+ font: inherit;
+ font-weight: 850;
+ cursor: pointer;
+ }
+
+ .portrait-orientation-card small {
+ min-height: 34px;
+ color: rgba(222, 239, 226, 0.62);
+ font-size: 12px;
+ line-height: 1.45;
+ }
+
+ .portrait-orientation-icon {
+ position: relative;
+ width: 118px;
+ height: 86px;
+ }
+
+ .portrait-phone-shape {
+ position: absolute;
+ left: 50%;
+ top: 50%;
+ width: 48px;
+ height: 78px;
+ border: 4px solid #fff2ad;
+ border-radius: 12px;
+ box-shadow: inset 0 0 0 2px rgba(255, 255, 255, 0.12), 0 0 22px rgba(255, 224, 104, 0.2);
+ transform: translate(-50%, -50%) rotate(64deg);
+ }
+
+ .portrait-phone-shape::after {
+ content: '';
+ position: absolute;
+ left: 50%;
+ bottom: 5px;
+ width: 5px;
+ height: 5px;
+ border-radius: 50%;
+ background: #fff2ad;
+ transform: translateX(-50%);
+ }
+
+ .portrait-rotate-arrow {
+ position: absolute;
+ right: 0;
+ top: 2px;
+ color: #9be8b5;
+ font-size: 38px;
+ font-weight: 800;
+ line-height: 1;
+ }
+}
+
+@media (orientation: landscape) and (max-height: 620px) {
+ .game-bgm-control {
+ top: max(7px, env(safe-area-inset-top));
+ right: calc(clamp(188px, 31vw, 248px) + max(22px, env(safe-area-inset-right)));
+ }
+
+ .game-bgm-toggle {
+ width: 34px;
+ height: 32px;
+ justify-content: center;
+ padding: 0;
+ }
+
+ .game-bgm-label,
+ .game-bgm-levels {
+ display: none;
+ }
+
+ .game-board,
+ .main-game-area,
+ .phase-content.playing-phase {
+ width: 100%;
+ max-width: 100%;
+ overflow: hidden;
+ }
+
+ .phase-content.playing-phase {
+ padding:
+ max(2px, env(safe-area-inset-top))
+ max(4px, env(safe-area-inset-right))
+ max(2px, env(safe-area-inset-bottom))
+ max(4px, env(safe-area-inset-left));
+ }
+
+ .player-decision-modal-wrap {
+ align-items: center;
+ padding: 8px max(10px, env(safe-area-inset-right)) 8px max(10px, env(safe-area-inset-left));
+ }
+
+ .player-decision-modal-wrap .ant-modal {
+ max-height: calc(100dvh - 16px);
+ overflow-y: auto;
+ }
+
+ .nine-princes-inline-hint {
+ display: none;
+ }
}
diff --git a/tractor-game-simulator/client/src/components/Game/GameBoard.jsx b/tractor-game-simulator/client/src/components/Game/GameBoard.jsx
index ee7c09e..5a39782 100644
--- a/tractor-game-simulator/client/src/components/Game/GameBoard.jsx
+++ b/tractor-game-simulator/client/src/components/Game/GameBoard.jsx
@@ -1,19 +1,98 @@
-import { useState, useEffect } from 'react';
+import { useState, useEffect, useMemo, useRef } from 'react';
import { Button, Space, Typography, Modal, Select, InputNumber, Input, message, Divider, Tag } from 'antd';
import { useGameStore } from '../../store/gameStore';
import socketService from '../../services/socket';
-import { SOCKET_EVENTS, GamePhases, PlayModes } from '../../utils/constants';
-import { detectAvailableDeclarations } from '../../utils/trumpUtils';
-import { validateLeadingPlay, validateFollowingPlay } from '../../utils/cardPatternUtils';
+import { SOCKET_EVENTS, GamePhases } from '../../utils/constants';
+import {
+ detectAvailableDeclarations,
+ detectThreeSixNineDeclarations
+} from '../../utils/trumpUtils';
+import {
+ calculateMustPlayCards,
+ getClusterAnalysisTargetRanks,
+ isTrumpCard
+} from '../../utils/cardPatternUtils';
+import {
+ canPlayerViewBottomCards,
+ getActiveBuryingPlayerId,
+ getActiveSkillAvailability,
+ getFinalTrickAutoSelectedCardIds,
+ getPlayActionLabel,
+ getRuleDisabledCardIds,
+ getRuleDisabledCardReason,
+ isBurySelectionValid,
+ isCurrentPlayersTurn,
+ mapOneCountryCardsForCurrentPlayer,
+ validatePlaySelection
+} from '../../utils/actionAvailability';
+import { calculateCardPoints, getCardPoints, getMeticulousAccountingCardPoints } from '../../utils/scoringUtils';
+import {
+ formatLevel,
+ getCanonicalOpenHandCards,
+ getCurrentRoundPlayedCards,
+ getCurrentRoundPlayHistory,
+ getPendingPoliticalReviewDecision,
+ getRuleSelectionAccess,
+ getThrowFailedPreview,
+ mergeTransferredHandCards,
+ mergeLivePlayerCardCounts,
+ retainUnplayedCardTransformations,
+ THROW_FAILED_PREVIEW_DURATION_MS
+} from '../../utils/gameViewUtils';
+import { ruleIncludesId } from '../../utils/ruleCatalog';
+import { sortCards } from '../../utils/cardUtils';
import Hand from './Hand';
+import Card from './Card';
+import GameBackgroundMusic from './GameBackgroundMusic';
import GameTable from './GameTable';
+import MobileLandscapeGuard from './MobileLandscapeGuard';
import RuleSelector from './RuleSelector';
import TrumpDeclaration from './TrumpDeclaration';
import './GameBoard.css';
const { Title, Text } = Typography;
-
-export default function GameBoard() {
+const EFFECTIVE_SUIT_LABELS = Object.freeze({
+ hearts: '♥ 红桃',
+ diamonds: '♦ 方片',
+ clubs: '♣ 梅花',
+ spades: '♠ 黑桃',
+ trump: '★ 主'
+});
+const TRANSFORMATION_SUITS = Object.freeze([
+ { value: 'hearts', label: '♥ 红桃' },
+ { value: 'diamonds', label: '♦ 方片' },
+ { value: 'clubs', label: '♣ 梅花' },
+ { value: 'spades', label: '♠ 黑桃' }
+]);
+const TRANSFORMATION_RANKS = Object.freeze([
+ '2', '3', '4', '5', '6', '7', '8', '9', '10', 'J', 'Q', 'K', 'A'
+]);
+const INVITE_INTO_URN_SUITS = Object.freeze([
+ ...TRANSFORMATION_SUITS,
+ { value: 'joker', label: '🃏 王' }
+]);
+const INVITE_INTO_URN_JOKER_RANKS = Object.freeze([
+ { value: 'small_joker', label: '小王' },
+ { value: 'big_joker', label: '大王' },
+ { value: 'white_joker', label: '白王(皇)' }
+]);
+const CARD_SUIT_SYMBOLS = Object.freeze({
+ hearts: '♥',
+ diamonds: '♦',
+ clubs: '♣',
+ spades: '♠'
+});
+const formatPublicCard = card => {
+ if (!card) return '未知牌';
+ if (card.rank === 'small_joker') return '小王';
+ if (card.rank === 'big_joker') return '大王';
+ if (card.rank === 'county_prince_joker') return '郡王';
+ if (card.rank === 'prince_joker') return '亲王';
+ if (card.rank === 'white_joker') return '白王(皇)';
+ return `${CARD_SUIT_SYMBOLS[card.suit] || ''}${card.rank}`;
+};
+
+export default function GameBoard({ onReturnToRoom }) {
const {
currentRoom,
currentPlayer,
@@ -30,6 +109,7 @@ export default function GameBoard() {
} = useGameStore();
const [messageApi, contextHolder] = message.useMessage();
+ const [modalApi, modalContextHolder] = Modal.useModal();
const [buryingPlayerModal, setBuryingPlayerModal] = useState(false);
const [firstPlayerModal, setFirstPlayerModal] = useState(false);
const [selectedBuryingPlayer, setSelectedBuryingPlayer] = useState(null);
@@ -40,16 +120,25 @@ export default function GameBoard() {
const [adjustValue, setAdjustValue] = useState(0);
const [viewBottomModal, setViewBottomModal] = useState(false);
const [shownCards, setShownCards] = useState({}); // { [playerId]: { playerName, cards } }
- const [playedCards, setPlayedCards] = useState({}); // { [playerId]: { playerName, cards } }
- const [playHistory, setPlayHistory] = useState([]); // 出牌历史记录 [{ playerId, playerName, timestamp }, ...]
- const [revealedBottomCards, setRevealedBottomCards] = useState([]); // 展示的底牌
- const [myBottomCards, setMyBottomCards] = useState([]); // 我埋的底牌(仅埋底玩家可见)
+ const [playedCards, setPlayedCards] = useState(() => (
+ getCurrentRoundPlayedCards(currentRoom?.gameState, currentRoom?.players)
+ )); // { [playerId]: { playerName, cards } }
+ const [throwFailedPreviews, setThrowFailedPreviews] = useState({});
+ const [playHistory, setPlayHistory] = useState(() => (
+ getCurrentRoundPlayHistory(currentRoom?.gameState)
+ )); // 出牌历史记录 [{ playerId, playerName, timestamp }, ...]
+ const [revealedBottomCards, setRevealedBottomCards] = useState(() => (
+ currentRoom?.gameState?.revealedBottomCards
+ || currentRoom?.gameState?.bottomScoreResult?.bottomCards
+ || []
+ )); // 终局展示的底牌
+ const [publicBottomCards, setPublicBottomCards] = useState([]); // “昭然若揭”发牌开始即公开的底牌
+ const [myBottomCards, setMyBottomCards] = useState([]); // 查看底牌弹窗中的牌
const [trumpSuit, setTrumpSuit] = useState(null); // 主牌花色
const [trumpRank, setTrumpRank] = useState(null); // 主牌点数
- const [trumpModal, setTrumpModal] = useState(false); // 设置主牌弹窗
const [roomConfigModal, setRoomConfigModal] = useState(false); // 房间设置弹窗
- const [newBottomCardsCount, setNewBottomCardsCount] = useState(8); // 新的底牌数量
const [newDealInterval, setNewDealInterval] = useState(500); // 新的发牌间隔
+ const [newBotType, setNewBotType] = useState('who_designed');
const [renameModal, setRenameModal] = useState(false); // 修改昵称弹窗
const [newPlayerName, setNewPlayerName] = useState(''); // 新昵称
const [chatModal, setChatModal] = useState(false); // 聊天弹窗
@@ -66,24 +155,757 @@ export default function GameBoard() {
const [selectedRule, setSelectedRule] = useState(null); // 当前选择的规则 { name, content }
const [availableDeclarations, setAvailableDeclarations] = useState([]); // 可用的亮主选项
const [currentTrumpDeclaration, setCurrentTrumpDeclaration] = useState(null); // 当前主牌亮主信息
+ const [currentInferiorDeclaration, setCurrentInferiorDeclaration] = useState(null);
+ const [threeSixNineState, setThreeSixNineState] = useState(null);
+ const [oneCountryTwoSystemsState, setOneCountryTwoSystemsState] = useState(null);
+ const [woodenOxDecision, setWoodenOxDecision] = useState(null);
+ const [woodenOxPrivateState, setWoodenOxPrivateState] = useState(null);
+ const [woodenOxSelectedCardId, setWoodenOxSelectedCardId] = useState(null);
const [dealerCountdown, setDealerCountdown] = useState(null); // 庄家倒计时
- const [trumpAnimation, setTrumpAnimation] = useState(null); // 毙牌动画 { type: 'trump' | 'overtrump', playerName }
+ const [trumpAnimation, setTrumpAnimation] = useState(null); // 毙牌局部动画:攻击者标签 + 被压牌打击点
+ const [cardExchangeAnimation, setCardExchangeAnimation] = useState(null);
+ const [privateCardTransferReveal, setPrivateCardTransferReveal] = useState(null);
+ const [bottomPickup, setBottomPickup] = useState(null);
+ const [handArrivalHighlight, setHandArrivalHighlight] = useState(null);
+ const [ruleVisibleHands, setRuleVisibleHands] = useState([]);
+ const [icebergSelection, setIcebergSelection] = useState(null);
+ const [tenSidedAmbushSelection, setTenSidedAmbushSelection] = useState(null);
+ const [selectedTenSidedAmbushRank, setSelectedTenSidedAmbushRank] = useState(null);
+ const [tenSidedAmbushPrivateRank, setTenSidedAmbushPrivateRank] = useState(null);
+ const [tenSidedAmbushRevealAnimation, setTenSidedAmbushRevealAnimation] = useState(null);
+ const [waitingRabbitSelection, setWaitingRabbitSelection] = useState(null);
+ const [selectedWaitingRabbitSuit, setSelectedWaitingRabbitSuit] = useState(null);
+ const [selectedWaitingRabbitRank, setSelectedWaitingRabbitRank] = useState(null);
+ const [waitingRabbitPrivateTarget, setWaitingRabbitPrivateTarget] = useState(null);
+ const [waitingRabbitDecision, setWaitingRabbitDecision] = useState(null);
+ const [waitingRabbitDiscardCardId, setWaitingRabbitDiscardCardId] = useState(null);
+ const [threePowersSelection, setThreePowersSelection] = useState(null);
+ const [selectedThreePowersRank, setSelectedThreePowersRank] = useState(null);
+ const [threePowersPrivateRanks, setThreePowersPrivateRanks] = useState({});
+ const [threePowersRevealAnimation, setThreePowersRevealAnimation] = useState(null);
+ const [gentlemanPromiseSelection, setGentlemanPromiseSelection] = useState(null);
+ const [selectedGentlemanPromiseSuit, setSelectedGentlemanPromiseSuit] = useState(null);
+ const [hiddenDragonSelection, setHiddenDragonSelection] = useState(null);
+ const [selectedHiddenDragonRank, setSelectedHiddenDragonRank] = useState(null);
+ const [antinomySelection, setAntinomySelection] = useState(null);
+ const [selectedAntinomySuit, setSelectedAntinomySuit] = useState(null);
+ const [selectedAntinomyRank, setSelectedAntinomyRank] = useState(null);
+ const [riceToMulberrySelection, setRiceToMulberrySelection] = useState(null);
+ const [selectedRiceToMulberryCardIds, setSelectedRiceToMulberryCardIds] = useState([]);
+ const [destroyDykeDecision, setDestroyDykeDecision] = useState(null);
+ const [administrativeReviewSelection, setAdministrativeReviewSelection] = useState(null);
+ const [selectedAdministrativeReviewValue, setSelectedAdministrativeReviewValue] = useState(null);
+ const [politicalReviewDecision, setPoliticalReviewDecision] = useState(null);
+ const [focusFigureVote, setFocusFigureVote] = useState(null);
+ const [focusFigurePrivate, setFocusFigurePrivate] = useState(null);
+ const [armedActiveSkillId, setArmedActiveSkillId] = useState(null);
+ const [explicitCardTransformations, setExplicitCardTransformations] = useState({});
+ const [cardTransformationDialog, setCardTransformationDialog] = useState(null);
+ const [selectedDivineWeaponCardId, setSelectedDivineWeaponCardId] = useState(null);
+ const [divineWeaponSourceCardId, setDivineWeaponSourceCardId] = useState(null);
+ const [activeSkillAnimation, setActiveSkillAnimation] = useState(null);
+ const [lastStandDecision, setLastStandDecision] = useState(null);
+ const [teammateCheerDecision, setTeammateCheerDecision] = useState(null);
+ const [afterglowDecision, setAfterglowDecision] = useState(null);
+ const [removeFirewoodDecision, setRemoveFirewoodDecision] = useState(null);
+ const [mainstaySelectedCardIds, setMainstaySelectedCardIds] = useState([]);
+ const [ambiguousFirstOptionCardIds, setAmbiguousFirstOptionCardIds] = useState([]);
+ const [ambiguousChoice, setAmbiguousChoice] = useState(null);
+ const [lateMoverDecisionOpen, setLateMoverDecisionOpen] = useState(false);
+ const [bushGateDecisionOpen, setBushGateDecisionOpen] = useState(false);
+ const [timeReversalDecision, setTimeReversalDecision] = useState(null);
+ const [ninePrincesDecision, setNinePrincesDecision] = useState(null);
+ const [surrenderDecision, setSurrenderDecision] = useState(null);
+ const [forbiddenMagicDecision, setForbiddenMagicDecision] = useState(null);
+ const [lureTigerDecision, setLureTigerDecision] = useState(null);
+ const [equivalentReciprocityTarget, setEquivalentReciprocityTarget] = useState(null);
+ const [equivalentReciprocitySelection, setEquivalentReciprocitySelection] = useState(null);
+ const [equivalentReciprocityCardId, setEquivalentReciprocityCardId] = useState(null);
+ const [equivalentReciprocityResult, setEquivalentReciprocityResult] = useState(null);
+ const [mutualSupportDirectionOpen, setMutualSupportDirectionOpen] = useState(false);
+ const [mutualSupportSelection, setMutualSupportSelection] = useState(null);
+ const [mutualSupportSelectedCardIds, setMutualSupportSelectedCardIds] = useState([]);
+ const [strawBoatDecision, setStrawBoatDecision] = useState(null);
+ const [strawBoatDiscardCardId, setStrawBoatDiscardCardId] = useState(null);
+ const [culturalRevolutionSelection, setCulturalRevolutionSelection] = useState(null);
+ const [inviteIntoUrnSelection, setInviteIntoUrnSelection] = useState(null);
+ const [magicTrickTargetIds, setMagicTrickTargetIds] = useState([]);
+ const [magicTrickPreparedRound, setMagicTrickPreparedRound] = useState(null);
const [attackerScore, setAttackerScore] = useState(0); // 闲家当前得分
const [collectedPointCards, setCollectedPointCards] = useState([]); // 闲家收集的分数牌
- const [bottomScoreResult, setBottomScoreResult] = useState(null); // 底牌得分结果
- const [upgradeResult, setUpgradeResult] = useState(null); // 升级结果
- const [isReadyForNext, setIsReadyForNext] = useState(false); // 是否已准备下一局
+ const [bottomScoreResult, setBottomScoreResult] = useState(
+ () => currentRoom?.gameState?.bottomScoreResult || null
+ ); // 底牌得分结果
+ const [upgradeResult, setUpgradeResult] = useState(
+ () => currentRoom?.gameState?.upgradeResult || null
+ ); // 升级结果
+ const [isReadyForNext, setIsReadyForNext] = useState(() => Boolean(
+ currentRoom?.players?.find(player => player.id === currentPlayer?.id)?.isReadyForNext
+ )); // 是否已准备下一局
+ const [justPlayedCards, setJustPlayedCards] = useState(false); // 标记是否刚刚出过牌
+ // React state 与 Zustand 手牌更新可能落在不同批次。出牌后必须同步锁住自动选牌,
+ // 否则 removeCards 先触发重绘时,剩余的唯一对子/拖拉机会短暂被再次选中。
+ const suppressAutoSelectionRef = useRef(false);
+ const [lastRoundPlayedCards, setLastRoundPlayedCards] = useState({}); // 上一轮的出牌信息
+ const [livePlayerCardCounts, setLivePlayerCardCounts] = useState({}); // 发牌中的实时手牌数
+ const [currentWinningPlayerId, setCurrentWinningPlayerId] = useState(null);
+ const [lastRoundWinnerPlayerId, setLastRoundWinnerPlayerId] = useState(null);
+ const [viewingLastRound, setViewingLastRound] = useState(false); // 是否正在查看上轮
+ const [lastRoundTimer, setLastRoundTimer] = useState(null); // 查看上轮的定时器
+ const [isHoldingCompletedRound, setIsHoldingCompletedRound] = useState(false);
+ const [heldCompletedRoundNumber, setHeldCompletedRoundNumber] = useState(null);
+ // Socket 轮末事件中同步写入;不等 React state 提交,避免 room_updated
+ // 的下轮烛态在中央计分或右上状态中抢先闪现。
+ const heldCompletedRoundNumberRef = useRef(null);
+ const heldRoundCandleRef = useRef(null);
+ const playedCardsRef = useRef(playedCards);
+ const pendingOwnConcealedCardsRef = useRef(new Map());
+ const throwFailedPreviewTimersRef = useRef(new Map());
+ const roundClearTimerRef = useRef(null);
+ const awaitingRoundClearRef = useRef(false);
+ const exchangeAnimationTimerRef = useRef(null);
+ const exchangeHandUpdateTimerRef = useRef(null);
+ const privateCardRevealTimerRef = useRef(null);
+ const bottomCardsMergeTimerRef = useRef(null);
+ const handArrivalHighlightTimerRef = useRef(null);
+ const exchangeAnimationEndsAtRef = useRef(0);
+ const tenSidedAmbushAnimationTimerRef = useRef(null);
+ const threePowersAnimationTimerRef = useRef(null);
+ const activeSkillAnimationTimerRef = useRef(null);
+ const exchangeAnimationDurationRef = useRef(2200);
+ const equivalentReciprocityTimerRef = useRef(null);
+ const politicalReviewApprovalSubmittingRef = useRef(new Set());
+
+ const clearCardTransitionTimers = () => {
+ [
+ exchangeAnimationTimerRef,
+ exchangeHandUpdateTimerRef,
+ privateCardRevealTimerRef,
+ bottomCardsMergeTimerRef,
+ handArrivalHighlightTimerRef
+ ].forEach(timerRef => {
+ if (timerRef.current) clearTimeout(timerRef.current);
+ timerRef.current = null;
+ });
+ exchangeAnimationEndsAtRef.current = 0;
+ };
const socket = socketService.socket;
const isHost = currentPlayer?.socketId === currentRoom?.hostId;
const gameState = currentRoom?.gameState;
- const phase = gameState?.phase || GamePhases.WAITING;
+ const surrenderState = gameState?.surrender || null;
+ const hasRequestedSurrender = Boolean(
+ surrenderState?.requestedPlayerIds?.includes(currentPlayer?.id)
+ || surrenderState?.currentDecision?.initiatorPlayerId === currentPlayer?.id
+ || surrenderState?.queuedPlayerIds?.includes(currentPlayer?.id)
+ );
+ const isSurrenderFeatureVisible = Boolean(
+ [GamePhases.DRAWING, GamePhases.BURYING, GamePhases.PLAYING].includes(gameState?.phase)
+ && !ruleIncludesId(gameState?.selectedRule, 'burn_the_boats')
+ );
+ const canRequestSurrender = Boolean(
+ currentRoom
+ && currentPlayer
+ && isSurrenderFeatureVisible
+ && !surrenderState?.currentDecision
+ && !(surrenderState?.queuedPlayerIds?.length > 0)
+ );
+ const waitingRabbitState = gameState?.waitingRabbit || null;
+ const mainstayAction = gameState?.mainstay?.currentAction || null;
+ const ownMainstayAction = mainstayAction?.chooserPlayerId === currentPlayer?.id
+ ? mainstayAction
+ : null;
+ useEffect(() => {
+ if (!ownMainstayAction || ownMainstayAction.stage === 'decision') {
+ setMainstaySelectedCardIds([]);
+ return;
+ }
+ if (ownMainstayAction.stage === 'give') {
+ setMainstaySelectedCardIds(
+ myCards
+ .filter(card => isTrumpCard(card, gameState?.trumpSuit, gameState?.trumpRank))
+ .map(card => card.id)
+ );
+ return;
+ }
+ setMainstaySelectedCardIds([]);
+ }, [
+ ownMainstayAction?.id,
+ ownMainstayAction?.stage,
+ ownMainstayAction?.chooserPlayerId,
+ currentPlayer?.id,
+ gameState?.trumpSuit,
+ gameState?.trumpRank,
+ myCards
+ ]);
+ useEffect(() => {
+ const pending = surrenderState?.currentDecision;
+ if (pending?.teammatePlayerId === currentPlayer?.id) {
+ setSurrenderDecision(pending);
+ } else if (!pending) {
+ setSurrenderDecision(null);
+ }
+ }, [
+ surrenderState?.currentDecision?.id,
+ surrenderState?.currentDecision?.teammatePlayerId,
+ currentPlayer?.id
+ ]);
+ // 刷新或断线重连时,从冻结中的公共状态恢复不可关闭的交互框。
+ useEffect(() => {
+ if (waitingRabbitState?.pendingSelectionPlayerIds?.includes(currentPlayer?.id)) {
+ setWaitingRabbitSelection(previous => previous || {
+ eligibleSuits: TRANSFORMATION_SUITS.map(option => option.value),
+ eligibleRanks: TRANSFORMATION_RANKS.filter(rank => rank !== gameState?.trumpRank),
+ trumpRank: gameState?.trumpRank
+ });
+ }
+
+ const pendingDecision = waitingRabbitState?.pendingDecision;
+ if (pendingDecision?.chooserPlayerId === currentPlayer?.id) {
+ setWaitingRabbitDecision({
+ ...pendingDecision,
+ eligibleDiscardCardIds: myCards
+ .filter(card => getCardPoints(card) === 0)
+ .map(card => card.id)
+ });
+ } else if (!pendingDecision) {
+ setWaitingRabbitDecision(null);
+ setWaitingRabbitDiscardCardId(null);
+ }
+ }, [
+ waitingRabbitState?.pendingSelectionPlayerIds,
+ waitingRabbitState?.pendingDecision?.id,
+ waitingRabbitState?.pendingDecision?.chooserPlayerId,
+ currentPlayer?.id,
+ gameState?.trumpRank,
+ myCards
+ ]);
+ useEffect(() => {
+ const antinomy = gameState?.antinomy;
+ if (!antinomy?.pendingPlayerIds?.includes(currentPlayer?.id)) {
+ setAntinomySelection(null);
+ return;
+ }
+ setAntinomySelection(previous => previous || {
+ stage: antinomy.selectionStage || 'opening',
+ triggerRound: antinomy.triggerRound ?? null,
+ eligibleSuits: TRANSFORMATION_SUITS.map(option => option.value),
+ eligibleRanks: [...TRANSFORMATION_RANKS],
+ currentDeclaration: antinomy.declarationsByPlayerId?.[currentPlayer?.id] || null
+ });
+ }, [
+ gameState?.antinomy?.pendingPlayerIds,
+ gameState?.antinomy?.selectionStage,
+ gameState?.antinomy?.triggerRound,
+ currentPlayer?.id
+ ]);
+ useEffect(() => {
+ const isPending = gameState?.riceToMulberry?.pendingPlayerIds?.includes(currentPlayer?.id);
+ if (!isPending) {
+ setRiceToMulberrySelection(null);
+ setSelectedRiceToMulberryCardIds([]);
+ return;
+ }
+ const eligibleCardIds = myCards
+ .filter(card => !card.isRiceToMulberryTransformed && getCardPoints(card) > 0)
+ .map(card => card.id);
+ setRiceToMulberrySelection(previous => previous || {
+ requiredCount: Math.floor(eligibleCardIds.length / 2),
+ eligibleCardIds
+ });
+ }, [
+ gameState?.riceToMulberry?.pendingPlayerIds,
+ currentPlayer?.id,
+ myCards
+ ]);
+ useEffect(() => {
+ const pending = gameState?.destroyDyke?.pending;
+ setDestroyDykeDecision(
+ pending?.dealerPlayerId === currentPlayer?.id ? pending : null
+ );
+ }, [gameState?.destroyDyke?.pending, currentPlayer?.id]);
+ useEffect(() => {
+ const pending = getPendingPoliticalReviewDecision(gameState, currentPlayer?.id);
+ setPoliticalReviewDecision(previous => {
+ if (!pending) return null;
+ return previous?.id === pending.id ? previous : pending;
+ });
+ }, [gameState?.politicalReview?.pending, currentPlayer?.id]);
+ const ownAfterglowPending = gameState?.afterglow?.pending?.playerId === currentPlayer?.id
+ ? gameState.afterglow.pending
+ : null;
+ useEffect(() => {
+ if (ownAfterglowPending) setAfterglowDecision(ownAfterglowPending);
+ }, [ownAfterglowPending?.playerId, ownAfterglowPending?.triggerRound]);
+ const ownAmbiguousPending = gameState?.ambiguous?.pending?.currentPlayerId === currentPlayer?.id
+ ? gameState.ambiguous.pending.selections?.find(
+ selection => selection.playerId === currentPlayer?.id
+ )
+ : null;
+ useEffect(() => {
+ if (!ownAmbiguousPending) return;
+ setAmbiguousChoice({
+ round: gameState?.ambiguous?.pending?.round,
+ ...ownAmbiguousPending
+ });
+ }, [
+ ownAmbiguousPending?.playerId,
+ ownAmbiguousPending?.selectedOptionIndex,
+ gameState?.ambiguous?.pending?.round
+ ]);
+ const isOneCountryTwoSystemsRule = ruleIncludesId(gameState?.selectedRule, 'one_country_two_systems');
+ const isThreeSixNineRule = ruleIncludesId(gameState?.selectedRule, 'three_six_nine_grades');
+ const displayedInferiorSuit = isThreeSixNineRule
+ ? threeSixNineState?.inferiorSuit
+ || currentInferiorDeclaration?.suit
+ || gameState?.threeSixNine?.inferiorSuit
+ || null
+ : null;
+ const baseRuleRuntimeStatus = oneCountryTwoSystemsState
+ ? { ...gameState, oneCountryTwoSystems: oneCountryTwoSystemsState }
+ : gameState;
+ const ruleRuntimeStatus = ruleIncludesId(gameState?.selectedRule, 'waiting_rabbit')
+ ? {
+ ...baseRuleRuntimeStatus,
+ waitingRabbit: {
+ ...baseRuleRuntimeStatus?.waitingRabbit,
+ ownPrivateTarget: waitingRabbitPrivateTarget
+ }
+ }
+ : baseRuleRuntimeStatus;
+ const isLostInFogRule = ruleIncludesId(gameState?.selectedRule, 'lost_in_fog');
+ const isOpenlyRevealedRule = ruleIncludesId(selectedRule, 'openly_revealed')
+ || ruleIncludesId(gameState?.selectedRule, 'openly_revealed');
+ const isPeopleCommuneRule = ruleIncludesId(selectedRule, 'people_commune')
+ || ruleIncludesId(gameState?.selectedRule, 'people_commune');
+ const isReformAndOpeningUpRule = ruleIncludesId(selectedRule, 'reform_and_opening_up')
+ || ruleIncludesId(gameState?.selectedRule, 'reform_and_opening_up');
+ const displayedPublicBottomCards = isOpenlyRevealedRule
+ ? (gameState?.publicBottomCards?.length ? gameState.publicBottomCards : publicBottomCards)
+ : [];
+ const candleToDawn = gameState?.candleToDawn || null;
+ const mustChooseInitialCandleState = Boolean(
+ candleToDawn?.isSelectionPending
+ && candleToDawn?.selectorPlayerId === currentPlayer?.id
+ );
+ const activeBuryingPlayerId = getActiveBuryingPlayerId(gameState);
+ const isSecondaryBurying = Boolean(gameState?.secondaryBuryingPlayerId);
+ const activeBottomCardsCount = Number.isInteger(gameState?.peopleCommune?.requiredCards)
+ ? gameState.peopleCommune.requiredCards
+ : Number.isInteger(gameState?.bottomCardsCount)
+ ? gameState.bottomCardsCount
+ : 8;
+ const roomPhase = gameState?.phase || GamePhases.WAITING;
+ const phase = isHoldingCompletedRound && roomPhase === GamePhases.REVEALING
+ ? GamePhases.PLAYING
+ : roomPhase;
+
+ // 若瞬时断线期间漏掉 cards_played,一次 room_resumed / room_updated 也应能
+ // 补齐本墩桌面。正常实时事件已经画出的暗牌本人视图要保留,避免被公共牌背覆盖。
+ useEffect(() => {
+ if (
+ phase !== GamePhases.PLAYING
+ || !Array.isArray(gameState?.currentRoundTable)
+ || gameState.currentRoundTable.length === 0
+ ) {
+ return;
+ }
+
+ const restored = getCurrentRoundPlayedCards(gameState, currentRoom?.players);
+ setPlayedCards(previous => {
+ const merged = { ...restored };
+ Object.entries(previous).forEach(([playerId, localPlay]) => {
+ if (merged[playerId]?.concealed && localPlay?.ownConcealedCards) {
+ merged[playerId] = localPlay;
+ }
+ });
+ playedCardsRef.current = merged;
+ return merged;
+ });
+ setPlayHistory(getCurrentRoundPlayHistory(gameState));
+ }, [
+ phase,
+ gameState?.currentRound,
+ gameState?.currentRoundTable,
+ currentRoom?.players
+ ]);
+
+ const {
+ canView: canViewRuleSelection,
+ canChoose: isRuleChooser
+ } = getRuleSelectionAccess(gameState, currentPlayer?.id);
+ const isDoubleHappinessSelection = Boolean(
+ gameState?.isRuleSelectionPending
+ && gameState?.ruleSelectionMode === 'double_happiness'
+ );
+ const canRefreshDoubleHappiness = Boolean(isHost && isDoubleHappinessSelection);
+ const canOpenRuleSelector = canViewRuleSelection;
+ const cardExchange = gameState?.cardExchange || null;
+ const hasSubmittedCardExchange = Boolean(
+ cardExchange?.submittedPlayerIds?.includes(currentPlayer?.id)
+ );
+ const icebergPendingPlayerIds = gameState?.icebergPendingPlayerIds || [];
+ const hasPendingIcebergSelection = icebergPendingPlayerIds.length > 0;
+ const tenSidedAmbush = gameState?.tenSidedAmbush || null;
+ const isTenSidedAmbushSelector = Boolean(
+ tenSidedAmbush?.selectorPlayerId === currentPlayer?.id
+ );
+ const tenSidedAmbushView = tenSidedAmbush ? {
+ ...tenSidedAmbush,
+ rank: tenSidedAmbush.rank || (isTenSidedAmbushSelector ? tenSidedAmbushPrivateRank : null),
+ isPrivate: Boolean(!tenSidedAmbush.isRevealed && isTenSidedAmbushSelector && tenSidedAmbushPrivateRank)
+ } : null;
+ const threePowers = gameState?.threePowers || null;
+ const threePowersView = threePowers ? {
+ ...threePowers,
+ slots: (threePowers.slots || []).map(slot => {
+ const privateRank = slot.selectorPlayerId === currentPlayer?.id
+ ? threePowersPrivateRanks[slot.sourceRank]
+ : null;
+ return {
+ ...slot,
+ rank: slot.rank || privateRank || null,
+ isPrivate: Boolean(!slot.isRevealed && privateRank)
+ };
+ })
+ } : null;
+ const tablePlayers = mergeLivePlayerCardCounts(
+ currentRoom?.players,
+ livePlayerCardCounts,
+ phase === GamePhases.DRAWING
+ );
+ const openHand = gameState?.openHand || null;
+ const canonicalOpenHandCards = getCanonicalOpenHandCards(
+ gameState,
+ currentPlayer?.id
+ );
+
+ // 明手本人不实际操作自己的牌,始终用服务端公开快照校准本地牌架。
+ // 这也能清除旧版本在代打甩牌失败时留下的重复牌与重复ID。
+ useEffect(() => {
+ if (!canonicalOpenHandCards) return;
+ setMyCards(canonicalOpenHandCards);
+ }, [canonicalOpenHandCards, setMyCards]);
+
+ // 二鬼拍门的明置牌也写入公共房间快照,保证重连或切回页面时能立即恢复。
+ useEffect(() => {
+ if (!ruleIncludesId(selectedRule, 'two_ghosts_knock_door')) return;
+ setRuleVisibleHands(gameState?.twoGhosts?.revealedHands || []);
+ }, [selectedRule?.id, gameState?.twoGhosts?.revealedHands]);
+ const currentTurnOwner = Number.isInteger(gameState?.currentPlayerIndex)
+ ? currentRoom?.players?.[gameState.currentPlayerIndex]
+ : null;
+ const isProxyTurn = Boolean(
+ phase === GamePhases.PLAYING &&
+ currentTurnOwner?.id === openHand?.playerId &&
+ currentPlayer?.id === openHand?.controllerPlayerId
+ );
+ const isOpenHandSelf = Boolean(openHand?.playerId === currentPlayer?.id);
+ const woodenOxDisplayCard = woodenOxPrivateState?.storedCard
+ && woodenOxPrivateState?.holderPlayerId === currentPlayer?.id
+ ? { ...woodenOxPrivateState.storedCard, isWoodenOxCard: true }
+ : null;
+ const woodenOxPlayableCard = woodenOxDisplayCard
+ && currentTurnOwner?.id === currentPlayer?.id
+ ? woodenOxDisplayCard
+ : null;
+ const activePlayCards = useMemo(() => {
+ if (isProxyTurn) return openHand?.cards || [];
+ return woodenOxPlayableCard ? [...myCards, woodenOxPlayableCard] : myCards;
+ }, [isProxyTurn, openHand?.cards, myCards, woodenOxPlayableCard?.id]);
+ const ownPublicWoodenOxMule = gameState?.woodenOx?.mules?.find(
+ mule => mule.holderPlayerId === currentPlayer?.id
+ ) || null;
+ const ruleDisabledCardIds = getRuleDisabledCardIds({
+ gameState,
+ handCards: activePlayCards,
+ players: currentRoom?.players,
+ currentPlayerId: currentPlayer?.id
+ });
+ const ruleDisabledCardReason = getRuleDisabledCardReason(gameState);
+ const activeSkillAvailability = getActiveSkillAvailability({
+ gameState,
+ players: currentRoom?.players,
+ currentPlayerId: currentPlayer?.id,
+ roomConfig: currentRoom?.config,
+ handCards: activePlayCards
+ });
+ const activeSkill = activeSkillAvailability.skill;
+ const mutualSupportMaxGiveCount = Math.min(
+ 2,
+ Math.max(0, myCards.length - (gameState?.leadingPattern?.length || 1))
+ );
+ const isActiveSkillArmed = Boolean(
+ activeSkill && armedActiveSkillId === activeSkill.id
+ );
+ const virtualizedCardIds = isActiveSkillArmed
+ && activeSkill?.effect === 'ignore_odd_led_side_suit'
+ ? (activeSkillAvailability.virtualizedCardIds || [])
+ : [];
+ const forbiddenMagicState = gameState?.forbiddenMagic || null;
+ const lureTigerState = gameState?.lureTiger || null;
+ const removeFirewoodState = gameState?.removeFirewood || null;
+ const isForbiddenMagicActiveByMe = Boolean(
+ activeSkill?.effect === 'demote_trumps_and_transform'
+ && forbiddenMagicState?.activePlayerIds?.includes(currentPlayer?.id)
+ );
+ const isForbiddenMagicReservedByMe = Boolean(
+ activeSkill?.effect === 'demote_trumps_and_transform'
+ && forbiddenMagicState?.reservations?.some(
+ reservation => reservation.playerId === currentPlayer?.id
+ )
+ );
+ const isLureTigerReservedByMe = Boolean(
+ activeSkill?.effect === 'silence_non_leader_for_round'
+ && lureTigerState?.reservations?.some(
+ reservation => reservation.playerId === currentPlayer?.id
+ )
+ );
+ const isExplicitTransformationSkill = Boolean(
+ activeSkill && [
+ 'joker_wildcards',
+ 'adjacent_rank_transform',
+ 'demote_trumps_and_transform'
+ ].includes(activeSkill.effect)
+ );
+ const divineWeapon = gameState?.divineWeapon || null;
+ const selectedDivineWeaponCard = divineWeapon?.cards?.find(
+ card => card.id === selectedDivineWeaponCardId
+ ) || null;
+ // 禁术发动后,原主牌必须先由玩家明确转成一种副花色才能出。
+ // 未设置转化的牌保留原牌面,避免界面误导成“已经自动降为副牌”。
+ const forbiddenMagicPreviewCards = myCards;
+ const divineWeaponPreviewCards = selectedDivineWeaponCard && divineWeaponSourceCardId
+ ? forbiddenMagicPreviewCards.map(card => card.id === divineWeaponSourceCardId
+ ? {
+ ...card,
+ suit: selectedDivineWeaponCard.suit,
+ rank: selectedDivineWeaponCard.rank,
+ originalSuit: card.suit,
+ originalRank: card.rank,
+ isDivineWeaponTransformed: true,
+ divineWeaponPreview: true,
+ divineWeaponCardId: selectedDivineWeaponCard.id
+ }
+ : card)
+ : forbiddenMagicPreviewCards;
+ const explicitTransformationList = Object.values(explicitCardTransformations);
+ // 转化只是候选牌面:只有这次真正选中的牌才应参与校验和提交。
+ // 否则玩家先转换一张王、随后改出普通牌时,会被未选中的转化持续锁死,
+ // 只能靠刷新清掉本地状态。
+ const selectedTransformationList = explicitTransformationList
+ .filter(item => selectedCards.includes(item.cardId));
+ const jokerSubstitutions = selectedTransformationList
+ .filter(item => item.kind === 'joker')
+ .map(({ cardId, suit, rank }) => ({ cardId, suit, rank }));
+ const clusterAnalysisSubstitutions = selectedTransformationList
+ .filter(item => item.kind === 'cluster')
+ .map(({ cardId, suit, fromRank, toRank }) => ({ cardId, suit, fromRank, toRank }));
+ const forbiddenMagicSubstitutions = selectedTransformationList
+ .filter(item => item.kind === 'forbidden_magic')
+ .map(({ cardId, suit, rank }) => ({ cardId, suit, rank }));
+ const hasSelectedActiveSkillTransformation = Boolean(
+ activeSkill && (
+ (activeSkill.effect === 'joker_wildcards' && jokerSubstitutions.length > 0)
+ || (activeSkill.effect === 'adjacent_rank_transform' && clusterAnalysisSubstitutions.length > 0)
+ )
+ );
+ // 偷梁换柱/聚类分析只有在选中了已转化牌时才算发动技能;
+ // 编辑状态本身不能阻止玩家改为一次普通出牌。
+ const effectiveActiveSkillId = isExplicitTransformationSkill
+ ? (hasSelectedActiveSkillTransformation ? activeSkill?.id || null : null)
+ : (isActiveSkillArmed ? activeSkill?.id || null : null);
+ const transformableCardIds = (isActiveSkillArmed || isForbiddenMagicActiveByMe)
+ && isExplicitTransformationSkill
+ ? activePlayCards
+ .filter(card => {
+ if (explicitCardTransformations[card.id]) return false;
+ if (activeSkill.effect === 'joker_wildcards') return card.suit === 'joker';
+ if (activeSkill.effect === 'demote_trumps_and_transform') {
+ return isTrumpCard(card, trumpSuit, trumpRank);
+ }
+ return getClusterAnalysisTargetRanks(card, trumpRank).length > 0;
+ })
+ .map(card => card.id)
+ : [];
+ const unsortedTransformedPreviewCards = divineWeaponPreviewCards.map(card => {
+ const transformation = explicitCardTransformations[card.id];
+ if (!transformation) return card;
+ if (transformation.kind === 'joker') {
+ return {
+ ...card,
+ suit: transformation.suit,
+ rank: transformation.rank,
+ originalSuit: card.suit,
+ originalRank: card.rank,
+ isJokerSubstitution: true,
+ explicitTransformationPreview: true
+ };
+ }
+ if (transformation.kind === 'forbidden_magic') {
+ return {
+ ...card,
+ suit: transformation.suit,
+ rank: transformation.rank,
+ originalSuit: transformation.fromSuit,
+ originalRank: transformation.fromRank,
+ isForbiddenMagicDemoted: true,
+ isForbiddenMagicTransformed: true,
+ explicitTransformationPreview: true
+ };
+ }
+ return {
+ ...card,
+ rank: transformation.toRank,
+ originalRank: card.rank,
+ isClusterAnalysisTransformed: true,
+ clusterAnalysisSourceRank: card.rank,
+ explicitTransformationPreview: true
+ };
+ });
+ // 显式转化只改变本次出牌的预览牌面;按新牌面重新排序,让王转成 J 后
+ // 真正进入对应花色的 J 牌组,而不是继续占据原先的王牌位置。
+ const transformedPreviewCards = explicitTransformationList.length > 0
+ || isForbiddenMagicActiveByMe
+ ? sortCards(
+ unsortedTransformedPreviewCards,
+ trumpSuit,
+ trumpRank,
+ displayedInferiorSuit
+ )
+ : unsortedTransformedPreviewCards;
+ const isTimeReversalReservedByMe = Boolean(
+ activeSkill?.effect === 'rewind_completed_round'
+ && gameState?.timeReversal?.reservations?.some(
+ reservation => reservation.playerId === currentPlayer?.id
+ )
+ );
+ const hasTimeReversalDecisionPending = Boolean(
+ gameState?.timeReversal?.decisionState || timeReversalDecision
+ );
+ const ninePrincesPending = gameState?.ninePrinces?.pending || ninePrincesDecision || null;
+ const hasPendingNinePrincesDecision = Boolean(ninePrincesPending);
+ const isNinePrincesChooser = Boolean(
+ ninePrincesDecision?.playerId === currentPlayer?.id
+ );
+ const ninePrincesCandidates = ninePrincesDecision?.candidates || [];
+ const ninePrincesEligibleCardIdSet = useMemo(
+ () => new Set(ninePrincesCandidates.map(candidate => candidate.card.id)),
+ [ninePrincesCandidates]
+ );
+ const selectedNinePrincesCandidate = ninePrincesCandidates.find(
+ candidate => selectedCards.includes(candidate.card.id)
+ ) || null;
+ const ninePrincesDisabledCardIds = isNinePrincesChooser
+ ? myCards
+ .filter(card => !ninePrincesEligibleCardIdSet.has(card.id))
+ .map(card => card.id)
+ : [];
+ const isMagicTrickPrepared = Boolean(
+ activeSkill?.effect === 'swap_two_plays_at_round_end'
+ && magicTrickPreparedRound === gameState?.currentRound
+ );
+ const mutualSupportPendingAction = gameState?.mutualSupport?.pendingAction || null;
+ const strawBoatSnapshotDecision = gameState?.strawBoatBorrowingArrows?.pending || null;
+ const isStrawBoatChooser = Boolean(
+ strawBoatDecision?.playerId === currentPlayer?.id
+ );
+ const strawBoatDiscardOptions = useMemo(
+ () => myCards.filter(card => getCardPoints(card) === 0),
+ [myCards]
+ );
+
+ useEffect(() => {
+ setStrawBoatDecision(previous => {
+ if (!strawBoatSnapshotDecision) return null;
+ return previous?.id === strawBoatSnapshotDecision.id
+ ? previous
+ : strawBoatSnapshotDecision;
+ });
+ if (!strawBoatSnapshotDecision) setStrawBoatDiscardCardId(null);
+ }, [strawBoatSnapshotDecision]);
+
+ useEffect(() => {
+ if (
+ mutualSupportPendingAction?.chooserPlayerId === currentPlayer?.id
+ && mutualSupportPendingAction?.actionId
+ ) {
+ const otherPlayer = currentRoom?.players?.find(
+ player => player.id === mutualSupportPendingAction.otherPlayerId
+ );
+ setMutualSupportSelection(previous => (
+ previous?.actionId === mutualSupportPendingAction.actionId
+ ? previous
+ : {
+ ...mutualSupportPendingAction,
+ source: 'server',
+ otherPlayerName: otherPlayer?.name || '队友'
+ }
+ ));
+ setMutualSupportSelectedCardIds([]);
+ return;
+ }
+ setMutualSupportSelection(previous => previous?.source === 'server' ? null : previous);
+ }, [
+ mutualSupportPendingAction?.actionId,
+ mutualSupportPendingAction?.chooserPlayerId,
+ mutualSupportPendingAction?.otherPlayerId,
+ currentPlayer?.id
+ ]);
+
+ // 清理查看上轮的定时器
+ useEffect(() => {
+ return () => {
+ if (lastRoundTimer) {
+ clearTimeout(lastRoundTimer);
+ }
+ };
+ }, [lastRoundTimer]);
+
+ useEffect(() => {
+ return () => {
+ if (roundClearTimerRef.current) {
+ clearTimeout(roundClearTimerRef.current);
+ }
+ clearCardTransitionTimers();
+ if (activeSkillAnimationTimerRef.current) {
+ clearTimeout(activeSkillAnimationTimerRef.current);
+ }
+ if (equivalentReciprocityTimerRef.current) {
+ clearTimeout(equivalentReciprocityTimerRef.current);
+ }
+ throwFailedPreviewTimersRef.current.forEach(timer => clearTimeout(timer));
+ throwFailedPreviewTimersRef.current.clear();
+ };
+ }, []);
+
+ useEffect(() => {
+ setThrowFailedPreviews({});
+ return () => {
+ throwFailedPreviewTimersRef.current.forEach(timer => clearTimeout(timer));
+ throwFailedPreviewTimersRef.current.clear();
+ };
+ }, [currentRoom?.id]);
+
+ useEffect(() => {
+ if (!socket || !currentRoom?.id || !ownPublicWoodenOxMule) {
+ if (!ownPublicWoodenOxMule) setWoodenOxPrivateState(null);
+ return;
+ }
+ socket.emit('request_wooden_ox_private_state', { roomId: currentRoom.id });
+ }, [
+ socket,
+ currentRoom?.id,
+ ownPublicWoodenOxMule?.holderPlayerId,
+ ownPublicWoodenOxMule?.hasStoredCard
+ ]);
// 同步房间状态中的主牌信息到本地状态和store
useEffect(() => {
if (gameState) {
const roomTrumpSuit = gameState.trumpSuit;
const roomTrumpRank = gameState.trumpRank;
+ const roomInferiorSuit = gameState?.threeSixNine?.inferiorSuit || null;
console.log(`🔄 同步房间状态: trumpSuit=${roomTrumpSuit}, trumpRank=${roomTrumpRank}`);
@@ -91,48 +913,377 @@ export default function GameBoard() {
setTrumpSuit(roomTrumpSuit);
setTrumpRank(roomTrumpRank);
// 更新store,触发手牌重新排序
- setTrumpInfo(roomTrumpSuit, roomTrumpRank);
+ setTrumpInfo(roomTrumpSuit, roomTrumpRank, roomInferiorSuit);
+ }
+ }, [
+ gameState?.trumpSuit,
+ gameState?.trumpRank,
+ gameState?.threeSixNine?.inferiorSuit,
+ setTrumpInfo
+ ]);
+
+ useEffect(() => {
+ setOneCountryTwoSystemsState(
+ isOneCountryTwoSystemsRule ? gameState?.oneCountryTwoSystems || null : null
+ );
+ }, [isOneCountryTwoSystemsRule, gameState?.oneCountryTwoSystems]);
+
+ useEffect(() => {
+ if (isThreeSixNineRule) {
+ const state = gameState?.threeSixNine || null;
+ setThreeSixNineState(state);
+ setCurrentTrumpDeclaration(
+ state?.currentTrumpDeclaration || gameState?.currentTrumpDeclaration || null
+ );
+ setCurrentInferiorDeclaration(
+ state?.currentInferiorDeclaration || gameState?.currentInferiorDeclaration || null
+ );
+ return;
+ }
+ setThreeSixNineState(null);
+ setCurrentTrumpDeclaration(gameState?.currentTrumpDeclaration || null);
+ setCurrentInferiorDeclaration(null);
+ }, [
+ isThreeSixNineRule,
+ gameState?.currentTrumpDeclaration,
+ gameState?.currentInferiorDeclaration,
+ gameState?.threeSixNine
+ ]);
+
+ useEffect(() => {
+ if (Number.isFinite(gameState?.attackerScore)) {
+ setAttackerScore(gameState.attackerScore);
+ }
+ if (Array.isArray(gameState?.collectedPointCards)) {
+ setCollectedPointCards(gameState.collectedPointCards);
+ }
+ }, [gameState?.attackerScore, gameState?.collectedPointCards]);
+
+ // 终局结算属于可恢复的公开状态,不能只靠一次性 bottom_revealed 事件。
+ // 返回房间、刷新或断线重连后,直接用最新房间快照重建亮底、投降亮牌与升级结果。
+ useEffect(() => {
+ if (![GamePhases.REVEALING, GamePhases.FINISHED].includes(roomPhase)) return;
+
+ const snapshotResult = gameState?.bottomScoreResult;
+ if (!snapshotResult) return;
+
+ setBottomScoreResult(snapshotResult);
+ setUpgradeResult(gameState?.upgradeResult || null);
+ setRevealedBottomCards(
+ gameState?.revealedBottomCards
+ || snapshotResult.bottomCards
+ || []
+ );
+ if (Number.isFinite(snapshotResult.totalScore)) {
+ setAttackerScore(snapshotResult.totalScore);
+ }
+ if (Array.isArray(snapshotResult.collectedPointCards)) {
+ setCollectedPointCards(snapshotResult.collectedPointCards);
}
- }, [gameState?.trumpSuit, gameState?.trumpRank, setTrumpInfo]);
+ }, [
+ roomPhase,
+ gameState?.bottomScoreResult,
+ gameState?.upgradeResult,
+ gameState?.revealedBottomCards
+ ]);
+
+ // “下一局已准备”同样以房间玩家快照为准,其他人准备和本人重连后都会同步。
+ useEffect(() => {
+ if (roomPhase !== GamePhases.REVEALING) return;
+ const roomPlayer = currentRoom?.players?.find(player => player.id === currentPlayer?.id);
+ setIsReadyForNext(Boolean(roomPlayer?.isReadyForNext));
+ }, [roomPhase, currentRoom?.players, currentPlayer?.id]);
+
+ // 每次选规则时全房间自动展示候选,确认权仍只属于服务端指定的选择者。
+ useEffect(() => {
+ setRuleSelectorModal(canViewRuleSelection);
+ }, [
+ canViewRuleSelection,
+ gameState?.ruleSelectionMode
+ ]);
// 检测手牌变化,更新可亮主选项(仅在摸牌阶段)
useEffect(() => {
- if (phase !== GamePhases.DRAWING || !trumpRank) {
+ if (
+ phase !== GamePhases.DRAWING
+ || !trumpRank
+ || gameState?.isTrumpDeclarationLocked
+ || cardExchange
+ ) {
+ setAvailableDeclarations([]);
+ return;
+ }
+
+ const myPlayerIndex = currentRoom?.players?.findIndex(
+ player => player.id === currentPlayer?.id
+ );
+ const myTeamDeclaration = isOneCountryTwoSystemsRule && myPlayerIndex >= 0
+ ? oneCountryTwoSystemsState?.declarationsByTeam?.[myPlayerIndex % 2] || null
+ : currentTrumpDeclaration;
+ if (isOneCountryTwoSystemsRule && oneCountryTwoSystemsState?.hasJokerDeclaration) {
setAvailableDeclarations([]);
return;
}
- const declarations = detectAvailableDeclarations(myCards, trumpRank, currentTrumpDeclaration, currentPlayer?.id);
+ const declarations = (isThreeSixNineRule
+ ? detectThreeSixNineDeclarations(
+ myCards,
+ trumpRank,
+ {
+ currentTrumpDeclaration: threeSixNineState?.currentTrumpDeclaration
+ || currentTrumpDeclaration,
+ currentInferiorDeclaration: threeSixNineState?.currentInferiorDeclaration
+ || currentInferiorDeclaration,
+ claimedSuits: threeSixNineState?.claimedSuits || {}
+ },
+ currentPlayer?.id
+ )
+ : detectAvailableDeclarations(
+ myCards,
+ trumpRank,
+ myTeamDeclaration,
+ currentPlayer?.id
+ )).filter(declaration => !(
+ ruleIncludesId(gameState?.selectedRule, 'last_stand')
+ && declaration.suit === 'joker'
+ ));
setAvailableDeclarations(declarations);
console.log('🎯 可亮主选项更新:', declarations);
- }, [myCards, trumpRank, phase, currentTrumpDeclaration, currentPlayer?.id]);
+ }, [
+ myCards,
+ trumpRank,
+ phase,
+ currentTrumpDeclaration,
+ currentRoom?.players,
+ currentPlayer?.id,
+ gameState?.isTrumpDeclarationLocked,
+ gameState?.selectedRule?.id,
+ isOneCountryTwoSystemsRule,
+ isThreeSixNineRule,
+ oneCountryTwoSystemsState,
+ threeSixNineState,
+ currentInferiorDeclaration,
+ cardExchange
+ ]);
// 监听游戏事件
useEffect(() => {
if (!socket) return;
+ const resetRoundDisplay = () => {
+ if (roundClearTimerRef.current) {
+ clearTimeout(roundClearTimerRef.current);
+ roundClearTimerRef.current = null;
+ }
+ awaitingRoundClearRef.current = false;
+ heldCompletedRoundNumberRef.current = null;
+ heldRoundCandleRef.current = null;
+ playedCardsRef.current = {};
+ setPlayedCards({});
+ setCurrentWinningPlayerId(null);
+ setIsHoldingCompletedRound(false);
+ setHeldCompletedRoundNumber(null);
+ };
+
+ const showSkillActivation = ({
+ id = null,
+ name,
+ playerId = null,
+ playerName,
+ treatedAsSmall = false,
+ concealed = false,
+ variant = 'default',
+ actionLabel = '发动主动技能',
+ detail = null
+ }) => {
+ if (activeSkillAnimationTimerRef.current) {
+ clearTimeout(activeSkillAnimationTimerRef.current);
+ }
+ setArmedActiveSkillId(null);
+ setActiveSkillAnimation({
+ id,
+ name,
+ playerId,
+ playerName,
+ treatedAsSmall,
+ concealed,
+ variant,
+ actionLabel,
+ detail,
+ key: `${variant}-${Date.now()}`
+ });
+ activeSkillAnimationTimerRef.current = setTimeout(() => {
+ setActiveSkillAnimation(null);
+ activeSkillAnimationTimerRef.current = null;
+ }, 1800);
+ };
+
// 游戏开始
socket.on('game_started', ({ gameState }) => {
- messageApi.success('游戏开始!');
+ clearCardTransitionTimers();
setShownCards({}); // 清空展示的牌
+ setLivePlayerCardCounts({});
+ setCurrentWinningPlayerId(null);
+ setLastRoundWinnerPlayerId(null);
+ heldCompletedRoundNumberRef.current = null;
+ heldRoundCandleRef.current = null;
+ setHeldCompletedRoundNumber(null);
+ setPublicBottomCards([]);
+ setMyBottomCards([]);
+ setViewBottomModal(false);
+ setCardExchangeAnimation(null);
+ setPrivateCardTransferReveal(null);
+ setBottomPickup(null);
+ setHandArrivalHighlight(null);
+ setRuleVisibleHands([]);
+ setIcebergSelection(null);
+ setTenSidedAmbushSelection(null);
+ setSelectedTenSidedAmbushRank(null);
+ setTenSidedAmbushPrivateRank(null);
+ setTenSidedAmbushRevealAnimation(null);
+ setWaitingRabbitSelection(null);
+ setSelectedWaitingRabbitSuit(null);
+ setSelectedWaitingRabbitRank(null);
+ setWaitingRabbitPrivateTarget(null);
+ setWaitingRabbitDecision(null);
+ setWaitingRabbitDiscardCardId(null);
+ setThreePowersSelection(null);
+ setSelectedThreePowersRank(null);
+ setThreePowersPrivateRanks({});
+ setThreePowersRevealAnimation(null);
+ setGentlemanPromiseSelection(null);
+ setSelectedGentlemanPromiseSuit(null);
+ setHiddenDragonSelection(null);
+ setSelectedHiddenDragonRank(null);
+ setAdministrativeReviewSelection(null);
+ setSelectedAdministrativeReviewValue(null);
+ setPoliticalReviewDecision(null);
+ setFocusFigureVote(null);
+ setFocusFigurePrivate(null);
+ setArmedActiveSkillId(null);
+ setExplicitCardTransformations({});
+ setCardTransformationDialog(null);
+ setSelectedDivineWeaponCardId(null);
+ setDivineWeaponSourceCardId(null);
+ setActiveSkillAnimation(null);
+ setLastStandDecision(null);
+ setTeammateCheerDecision(null);
+ setAfterglowDecision(null);
+ setRemoveFirewoodDecision(null);
+ setMainstaySelectedCardIds([]);
+ setAmbiguousFirstOptionCardIds([]);
+ setAmbiguousChoice(null);
+ setBushGateDecisionOpen(false);
+ setWoodenOxDecision(null);
+ setWoodenOxPrivateState(null);
+ setWoodenOxSelectedCardId(null);
+ setForbiddenMagicDecision(null);
+ setLureTigerDecision(null);
+ setSurrenderDecision(null);
+ setEquivalentReciprocityTarget(null);
+ setEquivalentReciprocitySelection(null);
+ setEquivalentReciprocityCardId(null);
+ setEquivalentReciprocityResult(null);
+ setMutualSupportDirectionOpen(false);
+ setMutualSupportSelection(null);
+ setMutualSupportSelectedCardIds([]);
+ setStrawBoatDecision(null);
+ setStrawBoatDiscardCardId(null);
+ });
+
+ // 底牌在逐张摸牌前已经确定;本规则下从发牌动画开始便在牌桌中央明置。
+ socket.on('drawing_started', ({ publicBottomCards: openlyRevealedCards }) => {
+ if (Array.isArray(openlyRevealedCards)) {
+ setPublicBottomCards(openlyRevealedCards);
+ }
});
// 游戏重新开始
socket.on('game_restarted', () => {
- messageApi.success('游戏重新开始!');
+ clearCardTransitionTimers();
+ setSurrenderDecision(null);
setMyCards([]); // 清空手牌
+ setLivePlayerCardCounts({});
setShownCards({}); // 清空展示的牌
- setPlayedCards({}); // 清空已出的牌
+ resetRoundDisplay(); // 清空已出的牌
setPlayHistory([]); // 清空出牌历史
clearSelection(); // 清空选中的牌
setCurrentTrumpDeclaration(null); // 清空亮主信息
+ setCurrentInferiorDeclaration(null);
+ setThreeSixNineState(null);
setAvailableDeclarations([]); // 清空可用亮主选项
setAttackerScore(0); // 清空闲家得分
setCollectedPointCards([]); // 清空收集的分数牌
setBottomScoreResult(null); // 清空底牌得分结果
setUpgradeResult(null); // 清空升级结果
setRevealedBottomCards([]); // 清空底牌展示
+ setPublicBottomCards([]);
+ setMyBottomCards([]);
+ setViewBottomModal(false);
+ setCardExchangeAnimation(null);
+ setPrivateCardTransferReveal(null);
+ setBottomPickup(null);
+ setHandArrivalHighlight(null);
+ setRuleVisibleHands([]);
+ setIcebergSelection(null);
+ setTenSidedAmbushSelection(null);
+ setSelectedTenSidedAmbushRank(null);
+ setTenSidedAmbushPrivateRank(null);
+ setTenSidedAmbushRevealAnimation(null);
+ setWaitingRabbitSelection(null);
+ setSelectedWaitingRabbitSuit(null);
+ setSelectedWaitingRabbitRank(null);
+ setWaitingRabbitPrivateTarget(null);
+ setWaitingRabbitDecision(null);
+ setWaitingRabbitDiscardCardId(null);
+ setThreePowersSelection(null);
+ setSelectedThreePowersRank(null);
+ setThreePowersPrivateRanks({});
+ setThreePowersRevealAnimation(null);
+ setGentlemanPromiseSelection(null);
+ setSelectedGentlemanPromiseSuit(null);
+ setHiddenDragonSelection(null);
+ setSelectedHiddenDragonRank(null);
+ setAdministrativeReviewSelection(null);
+ setSelectedAdministrativeReviewValue(null);
+ setPoliticalReviewDecision(null);
+ setFocusFigureVote(null);
+ setFocusFigurePrivate(null);
+ setArmedActiveSkillId(null);
+ setExplicitCardTransformations({});
+ setCardTransformationDialog(null);
+ setSelectedDivineWeaponCardId(null);
+ setDivineWeaponSourceCardId(null);
+ setActiveSkillAnimation(null);
+ setLastStandDecision(null);
+ setTeammateCheerDecision(null);
+ setAfterglowDecision(null);
+ setRemoveFirewoodDecision(null);
+ setAmbiguousFirstOptionCardIds([]);
+ setAmbiguousChoice(null);
+ setBushGateDecisionOpen(false);
+ setWoodenOxDecision(null);
+ setWoodenOxPrivateState(null);
+ setWoodenOxSelectedCardId(null);
+ setForbiddenMagicDecision(null);
+ setLureTigerDecision(null);
+ setEquivalentReciprocityTarget(null);
+ setEquivalentReciprocitySelection(null);
+ setEquivalentReciprocityCardId(null);
+ setEquivalentReciprocityResult(null);
+ setMutualSupportDirectionOpen(false);
+ setMutualSupportSelection(null);
+ setMutualSupportSelectedCardIds([]);
+ setStrawBoatDecision(null);
+ setStrawBoatDiscardCardId(null);
setIsReadyForNext(false); // 重置准备状态
+ setLastRoundPlayedCards({}); // 清空上轮出牌记录
+ setLastRoundWinnerPlayerId(null);
+ setViewingLastRound(false); // 取消查看上轮状态
+ if (lastRoundTimer) {
+ clearTimeout(lastRoundTimer);
+ setLastRoundTimer(null);
+ }
});
// 收到手牌
@@ -140,1250 +1291,6979 @@ export default function GameBoard() {
addCard(card);
});
- // 玩家展示手牌
- socket.on('cards_shown', ({ playerId, playerName, cards }) => {
- messageApi.info(`${playerName} 展示了 ${cards.length} 张牌`);
- // 更新该玩家的展示牌区域(覆盖之前的牌)
- setShownCards(prev => ({
+ // 发牌时房间快照不会逐张广播;用不含牌面的公开进度实时更新每家的手牌数。
+ socket.on('deal_progress', ({ playerId, cardsCount }) => {
+ if (!playerId || !Number.isInteger(cardsCount)) return;
+ setLivePlayerCardCounts(prev => ({
...prev,
- [playerId]: { playerName, cards }
+ [playerId]: cardsCount
}));
});
- // 收到底牌(埋底玩家)
- socket.on('bottom_cards_received', ({ bottomCards, totalCards }) => {
- bottomCards.forEach(card => addCard(card));
- messageApi.success(`收到 ${bottomCards.length} 张底牌,当前共 ${totalCards} 张牌`);
+ socket.on('card_exchange_started', ({ ruleName, requiredCards, transfers, operation = 'exchange' }) => {
+ clearSelection();
+ const myTransfer = transfers?.find(transfer => transfer.fromPlayerId === currentPlayer?.id);
+ if (operation === 'discard') {
+ messageApi.info(`${ruleName}:请选择 ${requiredCards} 张牌暗中弃置`);
+ } else {
+ const targetText = myTransfer?.toPlayerName ? `交给 ${myTransfer.toPlayerName}` : '完成换牌';
+ messageApi.info(`${ruleName}:请选择 ${requiredCards} 张牌${targetText}`);
+ }
});
- // 埋底玩家设置
- socket.on('burying_player_set', ({ playerName }) => {
- messageApi.info(`${playerName} 被指定为埋底玩家`);
+ socket.on('card_exchange_submitted', ({
+ playerId,
+ playerName,
+ operation = 'exchange',
+ submittedCount,
+ totalCount
+ }) => {
+ const actionName = operation === 'discard' ? '弃牌' : '换牌';
+ if (playerId === currentPlayer?.id) {
+ clearSelection();
+ messageApi.success(`已确认${actionName},等待其他玩家 (${submittedCount}/${totalCount})`);
+ } else {
+ messageApi.info(`${playerName} 已确认${actionName} (${submittedCount}/${totalCount})`);
+ }
});
- // 埋底完成
- socket.on('cards_buried', ({ playerName, playerId }) => {
- messageApi.success(`${playerName} 完成埋底`);
- // 如果是我自己埋的底,清空我的底牌缓存(已经埋了)
- if (playerId === currentPlayer?.id) {
- // 底牌已经保存在后端,前端不需要再显示
+ socket.on('card_exchange_resolved', ({
+ ruleName,
+ operation = 'exchange',
+ transfers,
+ animationDuration = 2200
+ }) => {
+ if (exchangeAnimationTimerRef.current) {
+ clearTimeout(exchangeAnimationTimerRef.current);
}
+ if (privateCardRevealTimerRef.current) {
+ clearTimeout(privateCardRevealTimerRef.current);
+ privateCardRevealTimerRef.current = null;
+ }
+ setPrivateCardTransferReveal(null);
+ const prefersReducedMotion = window.matchMedia?.('(prefers-reduced-motion: reduce)').matches;
+ const effectiveDuration = prefersReducedMotion ? 0 : animationDuration;
+ exchangeAnimationDurationRef.current = effectiveDuration;
+ if (effectiveDuration > 0) {
+ const lastCardDelay = Math.max(0, ...((transfers || []).map((transfer, transferIndex) =>
+ transferIndex * 35 + Math.max(0, (transfer.cardsCount || 1) - 1) * 90
+ )));
+ const animationLifetime = animationDuration + lastCardDelay + 260;
+ exchangeAnimationEndsAtRef.current = Date.now() + animationLifetime;
+ setCardExchangeAnimation({
+ kind: operation === 'discard' ? 'discard' : 'exchange',
+ operation,
+ ruleName,
+ transfers,
+ animationDuration,
+ key: Date.now()
+ });
+ exchangeAnimationTimerRef.current = setTimeout(() => {
+ setCardExchangeAnimation(null);
+ exchangeAnimationTimerRef.current = null;
+ exchangeAnimationEndsAtRef.current = 0;
+ }, animationLifetime);
+ } else {
+ setCardExchangeAnimation(null);
+ exchangeAnimationTimerRef.current = null;
+ exchangeAnimationEndsAtRef.current = 0;
+ }
+ messageApi.info(
+ operation === 'discard'
+ ? `${ruleName}:正在暗中弃牌`
+ : `${ruleName}:换牌中,请留意牌的来源与落点`,
+ Math.max(2, Math.ceil((animationDuration + 500) / 1000))
+ );
});
- // 首发玩家设置
- socket.on('first_player_set', ({ playerName }) => {
- messageApi.info(`${playerName} 先出牌`);
+ socket.on('mainstay_started', () => {
+ clearSelection();
+ setMainstaySelectedCardIds([]);
+ messageApi.info('中流砥柱:从一号位开始依次检查每名玩家的当前主牌数', 4);
});
- // 玩家出牌
- socket.on('cards_played', ({ playerId, playerName, cards }) => {
- console.log('收到 cards_played 事件:', { playerId, playerName, cardsCount: cards.length });
- messageApi.info(`${playerName} 出了 ${cards.length} 张牌`);
- // 更新该玩家的出牌区域(覆盖之前的牌)
- setPlayedCards(prev => {
- const updated = {
- ...prev,
- [playerId]: { playerName, cards }
- };
- console.log('更新后的 playedCards:', Object.keys(updated));
- return updated;
- });
- // 添加到出牌历史
- setPlayHistory(prev => [...prev, { playerId, playerName, timestamp: Date.now() }]);
+ socket.on('mainstay_decision_required', ({ trumpCount = 0 }) => {
+ setMainstaySelectedCardIds([]);
+ messageApi.info(`中流砥柱:你当前有${trumpCount}张主牌,可以选择是否发动`, 4);
});
- // 玩家跳过
- socket.on('turn_passed', ({ playerName }) => {
- messageApi.info(`${playerName} 跳过了回合`);
+ socket.on('mainstay_cards_required', ({ stage, requiredCards = 5 }) => {
+ messageApi.info(
+ stage === 'return'
+ ? `中流砥柱:请选择${requiredCards}张牌返还给队友`
+ : `中流砥柱:请选择${requiredCards}张牌交给队友,必须包含全部主牌`,
+ 4
+ );
});
- // 展示底牌(修正事件名)
- socket.on('bottom_revealed', ({ bottomCards, bottomScoreResult, upgradeResult }) => {
- messageApi.info(`底牌已展示: ${bottomCards.length} 张`);
- setRevealedBottomCards(bottomCards);
- if (bottomScoreResult) {
- setBottomScoreResult(bottomScoreResult);
- setAttackerScore(bottomScoreResult.totalScore);
- setCollectedPointCards(bottomScoreResult.collectedPointCards || []);
- // 显示底牌得分结果
- const resultMsg = bottomScoreResult.attackerWonBottom
- ? `闲家拿底!底牌${bottomScoreResult.bottomPoints}分×${bottomScoreResult.bottomMultiplier}倍=${bottomScoreResult.bottomScoreGained}分,闲家总分:${bottomScoreResult.totalScore}分`
- : `庄家守底!闲家总分:${bottomScoreResult.totalScore}分`;
- messageApi.success(resultMsg, 5);
- }
- if (upgradeResult) {
- setUpgradeResult(upgradeResult);
- // 显示升级结果
- const winnerMsg = upgradeResult.attackerWon ? '闲家获胜' : '庄家获胜';
- const upgradeMsg = upgradeResult.attackerWon
- ? `闲家升${upgradeResult.attackerLevelUp}级`
- : `庄家升${upgradeResult.dealerLevelUp}级`;
- messageApi.success(`${winnerMsg}!${upgradeMsg}`, 5);
+ socket.on('mainstay_player_skipped', ({ playerName, reason }) => {
+ if (reason === 'too_many_trumps') {
+ messageApi.info(`中流砥柱:${playerName}的主牌超过5张,本次不能发动`, 3);
}
});
- // 收到我的底牌
- socket.on('my_bottom_cards', ({ bottomCards }) => {
- setMyBottomCards(bottomCards);
- setViewBottomModal(true);
+ socket.on('mainstay_decision_resolved', ({ playerName, accepted }) => {
+ if (!accepted) messageApi.info(`${playerName}放弃发动中流砥柱`, 3);
});
- // 玩家准备下一局
- socket.on('player_ready_for_next', ({ playerName, readyCount, totalCount }) => {
- messageApi.info(`${playerName} 已准备 (${readyCount}/${totalCount})`);
+ socket.on('mainstay_transfer_resolved', ({
+ actionId,
+ stage,
+ fromPlayerId,
+ fromPlayerName,
+ toPlayerId,
+ toPlayerName,
+ cardsCount = 5,
+ animationDuration = 1100
+ }) => {
+ setMainstaySelectedCardIds([]);
+ if (exchangeAnimationTimerRef.current) clearTimeout(exchangeAnimationTimerRef.current);
+ setCardExchangeAnimation({
+ kind: 'mainstay',
+ ruleName: '中流砥柱',
+ title: stage === 'return' ? '中流砥柱 · 队友返牌' : '中流砥柱 · 交出全部主牌',
+ transfers: [{ fromPlayerId, toPlayerId, cardsCount }],
+ animationDuration,
+ key: `${actionId}-${stage}`
+ });
+ exchangeAnimationTimerRef.current = setTimeout(() => {
+ setCardExchangeAnimation(null);
+ exchangeAnimationTimerRef.current = null;
+ }, animationDuration + 220);
+ messageApi.info(
+ `${fromPlayerName}${stage === 'return' ? '返还给' : '交给'}${toPlayerName}${cardsCount}张牌`,
+ 3
+ );
});
- // 下一局开始
- socket.on('next_game_started', () => {
- messageApi.success('开始下一局!');
- // 清空所有前端状态
- setMyCards([]);
- setShownCards({});
- setPlayedCards({});
- setPlayHistory([]);
+ socket.on('mainstay_hand_updated', ({ cards = [] }) => {
+ setMyCards(cards);
clearSelection();
- setCurrentTrumpDeclaration(null);
- setAvailableDeclarations([]);
- setAttackerScore(0);
- setCollectedPointCards([]);
- setBottomScoreResult(null);
- setUpgradeResult(null);
- setRevealedBottomCards([]);
- setIsReadyForNext(false); // 重置准备状态
- // 主牌信息会通过房间状态同步的useEffect自动更新
+ setMainstaySelectedCardIds([]);
});
- // 分数更新
- socket.on('score_updated', ({ playerId, newScore }) => {
- messageApi.success('分数已更新');
+ socket.on('mainstay_completed', ({ skipped, activatedCount = 0 }) => {
+ setMainstaySelectedCardIds([]);
+ messageApi.info(
+ skipped
+ ? '中流砥柱:本局无主,规则跳过'
+ : `中流砥柱处理完成,共发动${activatedCount}次`,
+ 4
+ );
});
- // 等级更新
- socket.on('level_updated', ({ playerId, newLevel }) => {
- messageApi.success('等级已更新');
+ socket.on('happy_twins_positions_swapped', ({
+ dealerPlayerName = '庄家',
+ upstreamPlayerName = '上家'
+ }) => {
+ messageApi.info(
+ `欢乐成双:${dealerPlayerName}与原上家${upstreamPlayerName}交换位置`,
+ 4
+ );
});
- // 撤回出牌
- socket.on('play_undone', ({ playerId, playerName, cards }) => {
- console.log('收到 play_undone 事件:', { playerId, playerName, cardsCount: cards.length });
- messageApi.info(`${playerName} 撤回了出牌`);
- // 清除该玩家的已出牌显示
- setPlayedCards(prev => {
- const updated = { ...prev };
- delete updated[playerId];
- console.log('撤回后的 playedCards:', Object.keys(updated));
- return updated;
- });
+ socket.on('happy_twins_positions_restored', ({ nextDealerPlayerName = '下一位玩家' }) => {
+ messageApi.success(
+ `欢乐成双:已恢复原座次,下一局由${nextDealerPlayerName}上庄`,
+ 5
+ );
+ });
- // 从出牌历史中移除该玩家的最后一次出牌
- setPlayHistory(prev => {
- const lastIndex = prev.map(p => p.playerId).lastIndexOf(playerId);
- if (lastIndex !== -1) {
- const updated = [...prev];
- updated.splice(lastIndex, 1);
- return updated;
- }
- return prev;
- });
+ socket.on('encircle_three_missing_one_transition', ({
+ missingSuit,
+ replaced = false,
+ effectiveRound
+ }) => {
+ if (replaced) return;
+ const suitSymbol = {
+ hearts: '♥',
+ diamonds: '♦',
+ clubs: '♣',
+ spades: '♠'
+ }[missingSuit] || missingSuit;
+ messageApi.info(
+ `围三阙一:缺少的 ${suitSymbol} 正是当前主花色,第${effectiveRound}轮主花色不变`,
+ 4
+ );
+ });
- // 如果是自己撤回,将牌添加回手牌
- if (playerId === currentPlayer?.id) {
- console.log('将牌添加回手牌:', cards.length, '张');
- cards.forEach(cardData => {
- addCard(cardData);
+ socket.on('planned_economy_cards_drawn', ({
+ round,
+ draws = [],
+ remainingCards = 0,
+ animationDuration = 1500
+ }) => {
+ if (exchangeAnimationTimerRef.current) clearTimeout(exchangeAnimationTimerRef.current);
+ const prefersReducedMotion = window.matchMedia?.('(prefers-reduced-motion: reduce)').matches;
+ const effectiveDuration = prefersReducedMotion ? 0 : animationDuration;
+ if (effectiveDuration > 0) {
+ setCardExchangeAnimation({
+ kind: 'planned_economy_draw',
+ ruleName: '计划经济',
+ title: `计划经济 · 第${round}轮补牌 · 剩余${remainingCards}张`,
+ transfers: draws.map(draw => ({
+ fromPlayerId: null,
+ toPlayerId: draw.playerId,
+ cardsCount: 1
+ })),
+ animationDuration,
+ key: `${round}-${Date.now()}`
});
+ exchangeAnimationTimerRef.current = setTimeout(() => {
+ setCardExchangeAnimation(null);
+ exchangeAnimationTimerRef.current = null;
+ }, animationDuration + 220);
+ } else {
+ setCardExchangeAnimation(null);
}
+ messageApi.info(`计划经济:第${round}轮结束,四家各摸1张;储备牌剩余${remainingCards}张`, 3);
});
- // 主牌更新
- socket.on('trump_updated', ({ trumpSuit, trumpRank }) => {
- console.log(`🃏 收到trump_updated事件: trumpSuit=${trumpSuit}, trumpRank=${trumpRank}`);
- setTrumpSuit(trumpSuit);
- setTrumpRank(trumpRank);
- // 更新store中的主牌信息,自动重排手牌
- setTrumpInfo(trumpSuit, trumpRank);
- if (trumpSuit && trumpRank) {
- messageApi.info(`主牌已设置: ${trumpSuit} ${trumpRank}`);
- } else if (trumpRank) {
- console.log(`📢 级牌已设置: ${trumpRank}`);
- }
+ socket.on('magic_trick_prepared', ({ round, targetPlayerNames = [] }) => {
+ setMagicTrickPreparedRound(round);
+ setMagicTrickTargetIds([]);
+ setArmedActiveSkillId(null);
+ setExplicitCardTransformations({});
+ setCardTransformationDialog(null);
+ clearSelection();
+ messageApi.success(`魔术戏法已暗中准备:本轮结算时交换 ${targetPlayerNames.join(' 与 ')} 的出牌`, 4);
});
- // 甩牌失败
- socket.on('throw_failed', ({ playerId, playerName, message: msg, attemptedCards, attemptedCardObjects, forcedCards }) => {
- messageApi.warning(`${playerName} ${msg},实际出牌 ${forcedCards.length} 张`, 3);
-
- // 如果是自己甩牌失败,恢复未被强制出的牌到手牌(因为前端在发送时进行了乐观移除)
- if (playerId === currentPlayer?.id && Array.isArray(attemptedCardObjects)) {
- const forcedIds = new Set((forcedCards || []).map(c => c.id));
- const toRestore = attemptedCardObjects.filter(c => !forcedIds.has(c.id));
- if (toRestore.length > 0) {
- // 将未被强制出的牌加回手牌
- toRestore.forEach(cardData => addCard(cardData));
- }
- }
+ socket.on('equivalent_reciprocity_started', ({
+ initiatorPlayerId,
+ initiatorPlayerName,
+ targetPlayerName
+ }) => {
+ setArmedActiveSkillId(null);
+ setEquivalentReciprocityTarget(null);
+ clearSelection();
+ messageApi.info(
+ initiatorPlayerId === currentPlayer?.id
+ ? `已向 ${targetPlayerName} 发起拼点,请秘密选择一张牌`
+ : `${initiatorPlayerName} 与 ${targetPlayerName} 开始拼点`,
+ 3
+ );
});
- // 毙牌动作
- socket.on('trump_action', ({ type, playerId, playerName }) => {
- const actionText = type === 'trump' ? '毙了' : '盖毙';
- messageApi.success(`${playerName} ${actionText}!`, 2);
- // 设置动画
- setTrumpAnimation({ type, playerName });
- // 3秒后清除动画
- setTimeout(() => {
- setTrumpAnimation(null);
- }, 3000);
+ socket.on('equivalent_reciprocity_card_required', ({
+ challengeId,
+ opponentPlayerId,
+ opponentPlayerName
+ }) => {
+ clearSelection();
+ setEquivalentReciprocityCardId(null);
+ setEquivalentReciprocitySelection({
+ challengeId,
+ opponentPlayerId,
+ opponentPlayerName,
+ submitted: false
+ });
});
- // 亮主成功
- socket.on('trump_declared', ({ playerId, playerName, suit, count, declarationType, strength, isCounter, cards }) => {
- const action = isCounter ? '反主' : '亮主';
- const suitMap = {
- 'spades': '♠',
- 'hearts': '♥',
- 'clubs': '♣',
- 'diamonds': '♦',
- 'joker': '王'
- };
- const suitSymbol = suitMap[suit] || suit;
-
- console.log(`🎺 ${action}成功: ${playerName} ${action}了 ${count} 张 ${suitSymbol}`);
- messageApi.success(`${playerName} ${action}: ${count === 2 ? '一对' : '单张'}${suitSymbol}`);
+ socket.on('equivalent_reciprocity_selection_recorded', ({
+ challengeId,
+ playerId,
+ playerName
+ }) => {
+ if (playerId === currentPlayer?.id) {
+ setEquivalentReciprocitySelection(previous => previous?.challengeId === challengeId
+ ? { ...previous, submitted: true }
+ : previous);
+ messageApi.success('拼点牌已暗置,等待对方选择');
+ } else {
+ messageApi.info(`${playerName} 已暗置拼点牌`);
+ }
+ });
- // 更新当前亮主信息
- setCurrentTrumpDeclaration({
- playerId: playerId,
- playerName: playerName,
- suit: suit,
- count: count,
- declarationType: declarationType,
- strength: strength,
- isCounter: isCounter,
- cards: cards || []
+ socket.on('equivalent_reciprocity_resolved', (result) => {
+ const animationDuration = result.animationDuration || 1600;
+ setEquivalentReciprocitySelection(null);
+ setEquivalentReciprocityCardId(null);
+ setEquivalentReciprocityResult(result);
+ setAttackerScore(result.attackerScore);
+ exchangeAnimationDurationRef.current = animationDuration;
+ if (exchangeAnimationTimerRef.current) clearTimeout(exchangeAnimationTimerRef.current);
+ setCardExchangeAnimation({
+ kind: 'equivalent_reciprocity',
+ ruleName: '等价互惠',
+ title: '等价互惠 · 拼点换牌',
+ transfers: [
+ {
+ fromPlayerId: result.initiatorPlayerId,
+ toPlayerId: result.targetPlayerId,
+ cardsCount: 1
+ },
+ {
+ fromPlayerId: result.targetPlayerId,
+ toPlayerId: result.initiatorPlayerId,
+ cardsCount: 1
+ }
+ ],
+ animationDuration,
+ key: result.challengeId
});
-
- // 立即同步主牌信息到本地并重排手牌,避免在网络延迟或缺少 trump_updated 事件前出现无主排序
- try {
- const immediateTrumpSuit = suit !== 'joker' ? suit : 'no_trump';
- console.log(`📡 即时更新主牌: ${immediateTrumpSuit}, trumpRank=${trumpRank}`);
- setTrumpSuit(immediateTrumpSuit);
- // trumpRank 保持不变(由房间配置决定),但也再次传入以保证排序正确
- setTrumpInfo(immediateTrumpSuit, trumpRank);
- } catch (e) {
- console.warn('同步主牌信息失败:', e);
+ exchangeAnimationTimerRef.current = setTimeout(() => {
+ setCardExchangeAnimation(null);
+ exchangeAnimationTimerRef.current = null;
+ }, animationDuration + 220);
+ if (equivalentReciprocityTimerRef.current) {
+ clearTimeout(equivalentReciprocityTimerRef.current);
}
+ equivalentReciprocityTimerRef.current = setTimeout(() => {
+ setEquivalentReciprocityResult(null);
+ equivalentReciprocityTimerRef.current = null;
+ }, animationDuration + 900);
+ messageApi[result.isTie ? 'info' : 'success'](
+ result.isTie
+ ? '拼点相同:无人失去5分,两张牌照常交换'
+ : `${result.winnerPlayerName} 拼点胜出,${result.loserPlayerName} 一方失去5分`,
+ 4
+ );
});
- // 房间配置更新
- socket.on('config_updated', ({ config }) => {
- messageApi.success('房间设置已更新,将在下一局游戏生效');
- setNewBottomCardsCount(config.bottomCardsCount);
- setNewDealInterval(config.dealInterval);
+ socket.on('equivalent_reciprocity_hand_updated', ({ cards, animationDuration = 1600 }) => {
+ if (exchangeHandUpdateTimerRef.current) clearTimeout(exchangeHandUpdateTimerRef.current);
+ const prefersReducedMotion = window.matchMedia?.('(prefers-reduced-motion: reduce)').matches;
+ const updateDelay = prefersReducedMotion ? 0 : Math.round(animationDuration * 0.68);
+ exchangeHandUpdateTimerRef.current = setTimeout(() => {
+ setMyCards(cards || []);
+ clearSelection();
+ exchangeHandUpdateTimerRef.current = null;
+ }, updateDelay);
});
- // 玩家昵称更新
- socket.on('player_name_updated', ({ playerId, oldName, newName }) => {
- if (playerId === currentPlayer?.id) {
- messageApi.success(`昵称已修改为: ${newName}`);
- } else {
- messageApi.info(`${oldName} 修改昵称为: ${newName}`);
+ socket.on('mutual_support_started', ({
+ direction,
+ initiatorPlayerId,
+ initiatorPlayerName,
+ teammatePlayerName,
+ pending
+ }) => {
+ setMutualSupportDirectionOpen(false);
+ setArmedActiveSkillId(null);
+ clearSelection();
+ if (initiatorPlayerId === currentPlayer?.id) {
+ messageApi.info(
+ direction === 'request'
+ ? `已向 ${teammatePlayerName} 请求手牌,等待队友选择0至2张`
+ : `已将所选手牌交给 ${teammatePlayerName},轮末由对方等量返还`,
+ 4
+ );
+ } else if (pending) {
+ messageApi.info(`${initiatorPlayerName} 向队友发起了同舟共济请求`, 3);
}
});
- // 接收聊天消息
- socket.on('chat_message_received', ({ playerName, message, timestamp }) => {
- setChatHistory(prev => [...prev, { playerName, message, timestamp }]);
- messageApi.info(`${playerName}: ${message}`);
+ socket.on('mutual_support_cards_required', payload => {
+ if (payload?.chooserPlayerId !== currentPlayer?.id) return;
+ setMutualSupportSelectedCardIds([]);
+ setMutualSupportSelection({ ...payload, source: 'server' });
});
- // Bot添加
- socket.on('bot_added', ({ player }) => {
- messageApi.success(`Bot ${player.name} 已加入房间`);
+ socket.on('mutual_support_transfer_resolved', ({
+ actionId,
+ stage,
+ fromPlayerId,
+ fromPlayerName,
+ toPlayerId,
+ toPlayerName,
+ cardsCount = 0,
+ animationDuration = 1100
+ }) => {
+ setMutualSupportSelection(previous => previous?.actionId === actionId ? null : previous);
+ setMutualSupportSelectedCardIds([]);
+ if (cardsCount > 0) {
+ if (exchangeAnimationTimerRef.current) clearTimeout(exchangeAnimationTimerRef.current);
+ setCardExchangeAnimation({
+ kind: 'mutual_support',
+ ruleName: '同舟共济',
+ title: stage === 'return' ? '同舟共济 · 轮末返还' : '同舟共济 · 临时交牌',
+ transfers: [{ fromPlayerId, toPlayerId, cardsCount }],
+ animationDuration,
+ key: actionId
+ });
+ exchangeAnimationTimerRef.current = setTimeout(() => {
+ setCardExchangeAnimation(null);
+ exchangeAnimationTimerRef.current = null;
+ }, animationDuration + 220);
+ }
+ const actionText = stage === 'return'
+ ? `${fromPlayerName} 向 ${toPlayerName} 返还${cardsCount}张牌`
+ : cardsCount > 0
+ ? `${fromPlayerName} 交给 ${toPlayerName} ${cardsCount}张牌`
+ : `${fromPlayerName} 选择不给牌`;
+ messageApi.info(`同舟共济:${actionText}`, 3);
});
- // Bot移除
- socket.on('bot_removed', ({ playerName }) => {
- messageApi.info(`Bot ${playerName} 已离开房间`);
+ socket.on('mutual_support_hand_updated', ({ cards = [] }) => {
+ setMyCards(cards);
+ clearSelection();
+ setMutualSupportSelectedCardIds([]);
});
- // 玩家准备状态更新
- socket.on('player_ready_status', ({ playerName, isReady }) => {
- messageApi.info(`${playerName} ${isReady ? '已准备' : '取消准备'}`);
+ socket.on('straw_boat_borrowing_arrows_required', payload => {
+ setStrawBoatDecision(payload || null);
+ setStrawBoatDiscardCardId(null);
+ if (payload?.playerId === currentPlayer?.id) {
+ messageApi.info('草船借箭:请选择一张非分数牌公开弃置,或放弃发动', 4);
+ } else if (payload?.playerName) {
+ messageApi.info(`${payload.playerName} 正在决定是否发动草船借箭`, 3);
+ }
});
- // 所有玩家准备完毕
- socket.on('all_players_ready', ({ message }) => {
- messageApi.success(message);
+ socket.on('straw_boat_borrowing_arrows_resolved', result => {
+ setStrawBoatDecision(null);
+ setStrawBoatDiscardCardId(null);
+ if (result?.accepted) {
+ messageApi.success(
+ `${result.playerName} 发动草船借箭:公开弃置 ${formatPublicCard(result.discardedCard)},获得 ${formatPublicCard(result.borrowedCard)}`,
+ 5
+ );
+ } else if (result?.playerName) {
+ messageApi.info(`${result.playerName} 放弃发动草船借箭`, 3);
+ }
});
- // 规则选择
- socket.on('rule_selected', ({ playerName, rule }) => {
- setSelectedRule(rule);
- messageApi.info(`${playerName} 选择了规则: ${rule.name}`);
+ socket.on('straw_boat_borrowing_arrows_hand_updated', ({ cards = [] }) => {
+ setMyCards(cards);
+ clearSelection();
});
- // 庄家倒计时开始/重置
- socket.on('dealer_countdown_start', ({ countdown }) => {
- setDealerCountdown(countdown);
+ socket.on('card_exchange_hand_updated', ({
+ sentCardIds,
+ receivedCards,
+ fromPlayerName,
+ ruleName = '换牌',
+ operation = 'exchange',
+ animationDuration
+ }) => {
+ if (exchangeHandUpdateTimerRef.current) {
+ clearTimeout(exchangeHandUpdateTimerRef.current);
+ }
+ if (privateCardRevealTimerRef.current) {
+ clearTimeout(privateCardRevealTimerRef.current);
+ privateCardRevealTimerRef.current = null;
+ }
+ const incomingCards = Array.isArray(receivedCards) ? receivedCards : [];
+ const prefersReducedMotion = window.matchMedia?.('(prefers-reduced-motion: reduce)').matches;
+ const effectiveDuration = Number.isFinite(animationDuration)
+ ? animationDuration
+ : exchangeAnimationDurationRef.current;
+ if (operation !== 'discard' && incomingCards.length > 0) {
+ setPrivateCardTransferReveal({
+ ruleName,
+ fromPlayerName,
+ cards: incomingCards,
+ revealDelay: prefersReducedMotion ? 0 : Math.round(effectiveDuration * 0.36),
+ revealDuration: prefersReducedMotion ? 1800 : Math.round(effectiveDuration * 0.8),
+ reducedMotion: Boolean(prefersReducedMotion),
+ key: `${ruleName}-${Date.now()}`
+ });
+ privateCardRevealTimerRef.current = setTimeout(() => {
+ setPrivateCardTransferReveal(null);
+ privateCardRevealTimerRef.current = null;
+ }, prefersReducedMotion ? 1800 : effectiveDuration + 680);
+ } else {
+ setPrivateCardTransferReveal(null);
+ }
+ // 先让牌背完成飞行、让接收者看清正面,再一次性更新和排序整手牌。
+ const updateDelay = prefersReducedMotion
+ ? 0
+ : operation === 'discard'
+ ? Math.max(
+ Math.round(effectiveDuration * 0.82),
+ exchangeAnimationEndsAtRef.current - Date.now() - 120
+ )
+ : Math.max(
+ effectiveDuration + 180,
+ exchangeAnimationEndsAtRef.current - Date.now() - 120
+ );
+ exchangeHandUpdateTimerRef.current = setTimeout(() => {
+ setMyCards(mergeTransferredHandCards(
+ useGameStore.getState().myCards,
+ sentCardIds,
+ incomingCards
+ ));
+ clearSelection();
+ exchangeHandUpdateTimerRef.current = null;
+ if (incomingCards.length > 0) {
+ if (handArrivalHighlightTimerRef.current) {
+ clearTimeout(handArrivalHighlightTimerRef.current);
+ }
+ setHandArrivalHighlight({
+ cardIds: incomingCards.map(card => card.id),
+ label: '收',
+ kind: 'exchange'
+ });
+ handArrivalHighlightTimerRef.current = setTimeout(() => {
+ setHandArrivalHighlight(null);
+ handArrivalHighlightTimerRef.current = null;
+ }, 1800);
+ }
+ if (fromPlayerName && incomingCards.length > 0) {
+ messageApi.success(`已收到 ${fromPlayerName} 的 ${incomingCards.length} 张牌`);
+ }
+ }, updateDelay);
});
- // 庄家倒计时结束
- socket.on('dealer_countdown_end', () => {
- setDealerCountdown(null);
+ socket.on('whole_hand_exchange_resolved', ({
+ ruleName,
+ transfers,
+ animationDuration = 1300,
+ exchangeKind,
+ actorPlayerName,
+ targetPlayerName
+ }) => {
+ if (exchangeAnimationTimerRef.current) clearTimeout(exchangeAnimationTimerRef.current);
+ const prefersReducedMotion = window.matchMedia?.('(prefers-reduced-motion: reduce)').matches;
+ const effectiveDuration = prefersReducedMotion ? 0 : animationDuration;
+ exchangeAnimationDurationRef.current = effectiveDuration;
+ if (effectiveDuration > 0) {
+ setCardExchangeAnimation({
+ kind: 'whole_hand',
+ ruleName,
+ transfers,
+ animationDuration,
+ key: Date.now()
+ });
+ exchangeAnimationTimerRef.current = setTimeout(() => {
+ setCardExchangeAnimation(null);
+ exchangeAnimationTimerRef.current = null;
+ }, animationDuration + 320);
+ }
+ messageApi.info(
+ exchangeKind === 'pair'
+ ? `${ruleName}:${actorPlayerName} 与 ${targetPlayerName} 交换全部手牌`
+ : `${ruleName}:全员整手交换`,
+ 3
+ );
});
- // 回合状态更新
- socket.on('round_updated', (roundUpdate) => {
- console.log('收到 round_updated 事件:', roundUpdate);
- if (roundUpdate.type === 'turn_changed') {
- const currentPlayer = currentRoom.players[roundUpdate.currentPlayerIndex];
- if (currentPlayer) {
- messageApi.info(`现在轮到 ${currentPlayer.name} 出牌`);
- }
- } else if (roundUpdate.type === 'round_started') {
- messageApi.success(roundUpdate.message || `轮次 ${roundUpdate.round} 开始`);
- // 新一轮开始,清空出牌历史(因为是新的一轮,之前的牌不能再撤回)
- setPlayHistory([]);
- // 同时清空已出牌显示
- setPlayedCards({});
- } else if (roundUpdate.type === 'round_ended') {
- // 轮次结束,显示获胜者信息
- if (roundUpdate.roundWinner) {
- messageApi.success(`第${roundUpdate.round}轮结束,${roundUpdate.roundWinner.playerName} 获胜,获得下一轮出牌权`);
- }
- // 处理得分信息
- if (roundUpdate.scoreInfo) {
- const { roundPoints, winnerIsAttacker, attackerScore: newScore, collectedPointCards: newCards } = roundUpdate.scoreInfo;
- if (winnerIsAttacker && roundPoints > 0) {
- messageApi.info(`闲家得${roundPoints}分,总分:${newScore}分`, 3);
- }
- setAttackerScore(newScore);
- setCollectedPointCards(newCards || []);
- }
- // 清空出牌历史和显示,准备下一轮
- setPlayHistory([]);
- setPlayedCards({});
+ socket.on('whole_hand_exchange_hand_updated', ({ cards, fromPlayerName, exchangeKind }) => {
+ if (exchangeHandUpdateTimerRef.current) {
+ clearTimeout(exchangeHandUpdateTimerRef.current);
+ exchangeHandUpdateTimerRef.current = null;
}
+ if (exchangeKind === 'pair') {
+ // 釜底抽薪结算后庄家会立刻收底牌;先同步整手,避免延迟动画覆盖新收到的底牌。
+ setMyCards(cards || []);
+ clearSelection();
+ messageApi.success(`收到 ${fromPlayerName || '反主者'} 的全部手牌`);
+ return;
+ }
+ const prefersReducedMotion = window.matchMedia?.('(prefers-reduced-motion: reduce)').matches;
+ const updateDelay = prefersReducedMotion
+ ? 0
+ : Math.max(0, Math.round(exchangeAnimationDurationRef.current * 0.72));
+ exchangeHandUpdateTimerRef.current = setTimeout(() => {
+ setMyCards(cards || []);
+ clearSelection();
+ exchangeHandUpdateTimerRef.current = null;
+ messageApi.success(`收到 ${fromPlayerName || '其他玩家'} 的全部手牌`);
+ }, updateDelay);
});
- return () => {
- socket.off('game_started');
- socket.off('card_dealt');
- socket.off('cards_shown');
- socket.off('bottom_cards_received');
- socket.off('burying_player_set');
- socket.off('cards_buried');
- socket.off('first_player_set');
- socket.off('cards_played');
- socket.off('turn_passed');
- socket.off('play_undone');
- socket.off('bottom_revealed');
- socket.off('my_bottom_cards');
- socket.off('player_ready_for_next');
- socket.off('next_game_started');
- socket.off('game_restarted');
- socket.off('score_updated');
- socket.off('level_updated');
- socket.off('trump_updated');
- socket.off('throw_failed');
- socket.off('trump_action');
- socket.off('trump_declared');
- socket.off('config_updated');
- socket.off('player_name_updated');
- socket.off('chat_message_received');
- socket.off('bot_added');
- socket.off('bot_removed');
- socket.off('player_ready_status');
- socket.off('all_players_ready');
- socket.off('rule_selected');
- socket.off('dealer_countdown_start');
- socket.off('dealer_countdown_end');
- socket.off('trump_action');
- socket.off('round_updated');
- };
- }, [socket, messageApi, clearSelection, addCard, removeCards, currentPlayer, currentRoom]);
-
- // 庄家倒计时递减
- useEffect(() => {
- if (dealerCountdown === null || dealerCountdown <= 0) return;
-
- const timer = setInterval(() => {
- setDealerCountdown(prev => {
- if (prev === null || prev <= 1) {
- return null;
- }
- return prev - 1;
- });
- }, 1000);
-
- return () => clearInterval(timer);
- }, [dealerCountdown]);
-
- // 同步主牌状态和规则
- useEffect(() => {
- if (gameState) {
- setTrumpSuit(gameState.trumpSuit);
- setTrumpRank(gameState.trumpRank);
- if (gameState.selectedRule) {
- setSelectedRule(gameState.selectedRule);
+ socket.on('remove_firewood_exchange_started', ({ totalCount }) => {
+ messageApi.info(`釜底抽薪:共有 ${totalCount} 次反主,开始由后向前询问`, 4);
+ });
+ socket.on('remove_firewood_decision_required', decision => {
+ clearSelection();
+ setRemoveFirewoodDecision(decision);
+ });
+ socket.on('remove_firewood_decision_resolved', ({
+ counteredPlayerId,
+ counteredPlayerName,
+ counteringPlayerName,
+ accepted
+ }) => {
+ if (counteredPlayerId === currentPlayer?.id) setRemoveFirewoodDecision(null);
+ if (!accepted) {
+ messageApi.info(`${counteredPlayerName} 选择不与 ${counteringPlayerName} 交换手牌`, 3);
}
- }
- }, [gameState]);
-
- // 同步房间配置
- useEffect(() => {
- if (currentRoom?.config) {
- setNewBottomCardsCount(currentRoom.config.bottomCardsCount);
- setNewDealInterval(currentRoom.config.dealInterval);
- }
- }, [currentRoom]);
+ });
+ socket.on('remove_firewood_exchange_completed', ({ exchangedCount, totalCount }) => {
+ setRemoveFirewoodDecision(null);
+ messageApi.info(`釜底抽薪结算完成:${totalCount} 次机会中交换 ${exchangedCount} 次`, 3);
+ });
- // 开始游戏
- const handleStartGame = () => {
- socket.emit(SOCKET_EVENTS.START_GAME, { roomId: currentRoom.id });
- };
+ socket.on('last_stand_decision_required', ({ cardsCount, suit }) => {
+ clearSelection();
+ setLastStandDecision({ cardsCount, suit });
+ });
+ socket.on('last_stand_hand_updated', ({ cards }) => {
+ setMyCards(cards || []);
+ setLastStandDecision(null);
+ clearSelection();
+ });
+ socket.on('last_stand_activated', ({ playerName, cardsCount }) => {
+ messageApi.success(`${playerName} 发动绝处逢生,${cardsCount} 张牌全部视为主牌`, 4);
+ });
- // 玩家准备
- const handlePlayerReady = () => {
- socket.emit('player_ready', { roomId: currentRoom.id });
- };
+ socket.on('teammate_cheer_decision_required', payload => {
+ clearSelection();
+ setTeammateCheerDecision(payload);
+ });
+ socket.on('teammate_cheer_hand_updated', ({ cards = [] }) => {
+ setMyCards(cards);
+ clearSelection();
+ });
+ socket.on('teammate_cheer_activated', ({ playerName, buffedPlayerName }) => {
+ setTeammateCheerDecision(null);
+ messageApi.success(
+ `${playerName} 发动队友加油:${buffedPlayerName} 获得永久牌面 +1 Buff`,
+ 5
+ );
+ });
+ socket.on('teammate_cheer_declined', ({ playerName }) => {
+ setTeammateCheerDecision(null);
+ messageApi.info(`${playerName} 暂不发动队友加油`, 3);
+ });
+ socket.on('teammate_cheer_reverted', ({ playerName, buffedPlayerName }) => {
+ setTeammateCheerDecision(null);
+ messageApi.info(
+ `${playerName} 撤回出牌,${buffedPlayerName} 的队友加油 Buff 已同步撤销`,
+ 4
+ );
+ });
- // 亮主处理
- const handleDeclare = (suitType, count) => {
- // 映射suitType到实际的花色
- const suitMap = {
- 'spades': 'spades',
- 'hearts': 'hearts',
- 'clubs': 'clubs',
- 'diamonds': 'diamonds',
- 'joker': 'joker'
- };
+ socket.on('afterglow_decision_required', payload => {
+ clearSelection();
+ setAfterglowDecision(payload);
+ });
+ socket.on('afterglow_hand_updated', ({ cards = [] }) => {
+ setMyCards(cards);
+ clearSelection();
+ });
+ socket.on('afterglow_activated', ({ playerName }) => {
+ setAfterglowDecision(null);
+ messageApi.success(
+ `${playerName} 发动回光返照:剩余主牌立即 +1,只要仍有主牌,每次只能出主牌`,
+ 5
+ );
+ });
+ socket.on('afterglow_declined', ({ playerName }) => {
+ setAfterglowDecision(null);
+ messageApi.info(`${playerName} 暂不发动回光返照`, 3);
+ });
+ socket.on('afterglow_expired', ({ playerName }) => {
+ messageApi.info(`${playerName} 的主牌已经出尽,回光返照效果结束`, 3);
+ });
+ socket.on('afterglow_reverted', ({ playerName, activationReverted, effectRestored }) => {
+ setAfterglowDecision(null);
+ messageApi.info(
+ activationReverted
+ ? `${playerName} 撤回出牌,回光返照的发动已同步撤销`
+ : effectRestored
+ ? `${playerName} 撤回出牌,回光返照效果已恢复`
+ : `${playerName} 的回光返照状态已同步`,
+ 4
+ );
+ });
- const suit = suitMap[suitType];
- if (!suit) {
- messageApi.error('无效的花色');
- return;
- }
+ socket.on('wooden_ox_decision_required', payload => {
+ setWoodenOxDecision(payload);
+ setWoodenOxSelectedCardId(null);
+ });
+ socket.on('wooden_ox_private_state', payload => {
+ setWoodenOxPrivateState(payload);
+ });
+ socket.on('wooden_ox_hand_updated', ({ cards = [] }) => {
+ setMyCards(cards);
+ });
+ socket.on('wooden_ox_action_recorded', () => {
+ setWoodenOxDecision(null);
+ setWoodenOxSelectedCardId(null);
+ });
+ socket.on('wooden_ox_round_ready', () => {
+ setWoodenOxDecision(null);
+ setWoodenOxSelectedCardId(null);
+ });
+ socket.on('wooden_ox_transferred', ({ fromPlayerName, toPlayerName, transfersUsed }) => {
+ messageApi.info(
+ `木牛流马:${fromPlayerName}交给${toPlayerName}(第${transfersUsed}次单程传递)`,
+ 3
+ );
+ });
- console.log(`🎺 尝试亮主: ${suitType}, 数量: ${count}`);
+ socket.on('strength_compensation_hand_updated', ({ cards = [] }) => {
+ clearSelection();
+ setMyCards(cards);
+ });
- // 发送亮主请求到服务器
- socket.emit(SOCKET_EVENTS.DECLARE_TRUMP, {
- roomId: currentRoom.id,
- suit: suit,
- count: count
+ socket.on('defense_as_offense_hand_updated', ({ cards = [] }) => {
+ clearSelection();
+ setMyCards(cards);
});
- };
- // 一键选中所有手牌
- const handleSelectAllCards = () => {
- if (myCards.length === 0) {
- messageApi.warning('没有手牌可选择');
- return;
- }
- const allCardIds = myCards.map(card => card.id);
- // 如果已经全选,则取消全选
- if (selectedCards.length === myCards.length) {
+ socket.on('time_reversal_activated', ({ playerId, playerName, round }) => {
clearSelection();
- } else {
- // 选中所有牌
- setSelectedCards(allCardIds);
- }
- };
+ messageApi.info(
+ playerId === currentPlayer?.id
+ ? `已预备第${round}轮时间倒流,轮末再确认是否发动`
+ : `${playerName} 已预备第${round}轮时间倒流`,
+ 4
+ );
+ });
+ socket.on('time_reversal_decision_required', ({ round }) => {
+ clearSelection();
+ setTimeReversalDecision({ round });
+ });
+ socket.on('time_reversal_hand_restored', ({ cards }) => {
+ setMyCards(cards || []);
+ clearSelection();
+ });
+ socket.on('time_reversal_response_recorded', ({ playerName, pendingPlayerIds = [] }) => {
+ messageApi.info(
+ `${playerName} 选择保留本轮结果,仍等待 ${pendingPlayerIds.length} 名预备者决定`,
+ 4
+ );
+ });
+ socket.on('time_reversal_resolved', ({ accepted, playerName, round, reason }) => {
+ setTimeReversalDecision(null);
+ if (!accepted) {
+ if (reason === 'all_declined') {
+ messageApi.info(`所有预备者均保留第${round}轮结果,未发动时间倒流`, 4);
+ }
+ return;
+ }
+ if (roundClearTimerRef.current) {
+ clearTimeout(roundClearTimerRef.current);
+ roundClearTimerRef.current = null;
+ }
+ awaitingRoundClearRef.current = false;
+ heldCompletedRoundNumberRef.current = null;
+ heldRoundCandleRef.current = null;
+ playedCardsRef.current = {};
+ setPlayedCards({});
+ setLastRoundPlayedCards({});
+ setPlayHistory([]);
+ setCurrentWinningPlayerId(null);
+ setLastRoundWinnerPlayerId(null);
+ setViewingLastRound(false);
+ setIsHoldingCompletedRound(false);
+ setHeldCompletedRoundNumber(null);
+ messageApi.success(`${playerName} 发动时间倒流:重新开始第${round}轮`, 4);
+ });
- // 设置埋底玩家
- const handleSetBuryingPlayer = () => {
- if (!selectedBuryingPlayer) {
- messageApi.warning('请选择埋底玩家');
- return;
- }
- socket.emit(SOCKET_EVENTS.SET_BURYING_PLAYER, {
- roomId: currentRoom.id,
- playerId: selectedBuryingPlayer
+ socket.on('nine_princes_selection_required', payload => {
+ clearSelection();
+ setNinePrincesDecision(payload);
+ });
+ socket.on('nine_princes_decision_pending', ({ playerId, playerName }) => {
+ if (playerId !== currentPlayer?.id) {
+ messageApi.info(`九子夺嫡:等待 ${playerName} 选择是否晋升手牌`, 3);
+ }
+ });
+ socket.on('nine_princes_hand_updated', ({
+ cards = [],
+ previousFace,
+ promotedFace,
+ becameWhite
+ }) => {
+ clearSelection();
+ setMyCards(cards);
+ messageApi.success(
+ `九子夺嫡:${formatPublicCard(previousFace)} 晋升为 ${formatPublicCard(promotedFace)}` +
+ `${becameWhite ? ',己方获得10分,本局九子夺嫡结算结束' : ''}`,
+ 4
+ );
+ });
+ socket.on('nine_princes_resolved', ({
+ playerId,
+ playerName,
+ promoted,
+ becameWhite
+ }) => {
+ setNinePrincesDecision(current => (
+ current?.playerId === playerId ? null : current
+ ));
+ if (playerId === currentPlayer?.id) clearSelection();
+ if (playerId === currentPlayer?.id && promoted) return;
+ messageApi.info(
+ promoted
+ ? `${playerName} 完成九子夺嫡晋升${becameWhite ? '并得到白王,其阵营获得10分' : ''}`
+ : `${playerName} 放弃本轮九子夺嫡晋升`,
+ 4
+ );
});
- setBuryingPlayerModal(false);
- };
- // 埋底
- const handleBuryCards = () => {
- if (selectedCards.length !== currentRoom.config.bottomCardsCount) {
- messageApi.warning(`请选择 ${currentRoom.config.bottomCardsCount} 张牌进行埋底`);
- return;
- }
- const cardsToRemove = [...selectedCards];
- socket.emit(SOCKET_EVENTS.BURY_CARDS, {
- roomId: currentRoom.id,
- cardIds: cardsToRemove
+ socket.on('forbidden_magic_reserved', ({ playerId, playerName, targetRound }) => {
+ messageApi.info(
+ playerId === currentPlayer?.id
+ ? `已预备禁术秘法,将在第${targetRound}轮开始时确认`
+ : `${playerName} 已预备禁术秘法`,
+ 4
+ );
+ });
+ socket.on('forbidden_magic_decision_required', ({ round }) => {
+ clearSelection();
+ setForbiddenMagicDecision({ round });
+ });
+ socket.on('forbidden_magic_activated', ({ playerId, playerName }) => {
+ if (playerId === currentPlayer?.id) {
+ setForbiddenMagicDecision(null);
+ clearSelection();
+ setExplicitCardTransformations({});
+ setCardTransformationDialog(null);
+ }
+ messageApi.success(`${playerName} 发动禁术秘法,本局永久生效`, 4);
+ });
+ socket.on('forbidden_magic_declined', ({ playerId, playerName }) => {
+ if (playerId === currentPlayer?.id) setForbiddenMagicDecision(null);
+ messageApi.info(`${playerName} 暂不发动禁术秘法,未消耗技能`, 3);
+ });
+ socket.on('forbidden_magic_decisions_completed', () => {
+ setForbiddenMagicDecision(null);
});
- // 立即从手牌中移除(乐观更新)
- removeCards(cardsToRemove);
- clearSelection();
- };
- // 设置首发玩家
- const handleSetFirstPlayer = () => {
- if (!selectedFirstPlayer) {
- messageApi.warning('请选择首发玩家');
- return;
- }
- socket.emit(SOCKET_EVENTS.SET_FIRST_PLAYER, {
- roomId: currentRoom.id,
- playerId: selectedFirstPlayer
+ socket.on('lure_tiger_reserved', ({ playerId, playerName, targetRound }) => {
+ messageApi.info(
+ playerId === currentPlayer?.id
+ ? `已预备调虎离山,将在第${targetRound}轮开始时确认`
+ : `${playerName} 已预备调虎离山`,
+ 4
+ );
+ });
+ socket.on('lure_tiger_decision_required', decision => {
+ clearSelection();
+ setLureTigerDecision(decision);
+ });
+ socket.on('lure_tiger_target_required', decision => {
+ clearSelection();
+ setLureTigerDecision(decision);
+ });
+ socket.on('lure_tiger_activated', ({
+ playerId,
+ playerName,
+ targetPlayerName
+ }) => {
+ if (playerId === currentPlayer?.id) setLureTigerDecision(null);
+ messageApi.warning(
+ `${playerName} 发动调虎离山:${targetPlayerName} 本轮沉默,出牌不计大小与分数`,
+ 5
+ );
+ });
+ socket.on('lure_tiger_declined', ({ playerId, playerName }) => {
+ if (playerId === currentPlayer?.id) setLureTigerDecision(null);
+ messageApi.info(`${playerName} 暂不发动调虎离山,未占用本方次数`, 3);
+ });
+ socket.on('lure_tiger_decisions_completed', () => {
+ setLureTigerDecision(null);
});
- setFirstPlayerModal(false);
- };
- // 出牌
- const handlePlayCards = () => {
- if (selectedCards.length === 0) {
- messageApi.warning('请先选择要出的牌');
- return;
- }
- const cardsToPlay = [...selectedCards];
- console.log('发送 PLAY_CARDS 事件:', { cardIds: cardsToPlay });
- socket.emit(SOCKET_EVENTS.PLAY_CARDS, {
- roomId: currentRoom.id,
- cardIds: cardsToPlay
+ // 玩家展示手牌
+ socket.on('cards_shown', ({ playerId, playerName, cards }) => {
+ messageApi.info(`${playerName} 展示了 ${cards.length} 张牌`);
+ // 更新该玩家的展示牌区域(覆盖之前的牌)
+ setShownCards(prev => ({
+ ...prev,
+ [playerId]: { playerName, cards }
+ }));
});
- // 立即从手牌中移除(乐观更新)
- removeCards(cardsToPlay);
- clearSelection();
- };
- // 跳过
- const handlePass = () => {
- socket.emit(SOCKET_EVENTS.PASS_TURN, {
- roomId: currentRoom.id
+ // 收到底牌(埋底玩家)
+ socket.on('bottom_cards_received', ({
+ bottomCards,
+ totalCards,
+ administrativeReview = false
+ }) => {
+ const receivedBottomCards = Array.isArray(bottomCards) ? bottomCards : [];
+ if (bottomCardsMergeTimerRef.current) {
+ clearTimeout(bottomCardsMergeTimerRef.current);
+ }
+ if (handArrivalHighlightTimerRef.current) {
+ clearTimeout(handArrivalHighlightTimerRef.current);
+ handArrivalHighlightTimerRef.current = null;
+ }
+ if (receivedBottomCards.length === 0) {
+ setBottomPickup(null);
+ messageApi.info('本局没有底牌');
+ } else {
+ const prefersReducedMotion = window.matchMedia?.('(prefers-reduced-motion: reduce)').matches;
+ const pickupKey = `bottom-${Date.now()}`;
+ const pickupTitle = administrativeReview ? '行政审查 · 解封底牌' : '庄家拿起底牌';
+ const revealDelay = prefersReducedMotion
+ ? 0
+ : Math.max(0, exchangeAnimationEndsAtRef.current - Date.now() + 100);
+ setBottomPickup({
+ cards: receivedBottomCards,
+ title: pickupTitle,
+ visible: revealDelay === 0,
+ merged: false,
+ key: pickupKey
+ });
+ bottomCardsMergeTimerRef.current = setTimeout(() => {
+ setBottomPickup(previous => previous?.key === pickupKey
+ ? { ...previous, visible: true }
+ : previous);
+ bottomCardsMergeTimerRef.current = setTimeout(() => {
+ setMyCards(mergeTransferredHandCards(
+ useGameStore.getState().myCards,
+ [],
+ receivedBottomCards
+ ));
+ setHandArrivalHighlight({
+ cardIds: receivedBottomCards.map(card => card.id),
+ kind: 'bottom'
+ });
+ setBottomPickup(previous => previous?.key === pickupKey
+ ? { ...previous, merged: true }
+ : previous);
+ clearSelection();
+ bottomCardsMergeTimerRef.current = null;
+ messageApi.success(
+ `收到 ${receivedBottomCards.length} 张底牌,牌面已换色 · 当前共 ${totalCards} 张牌`,
+ 4
+ );
+ }, prefersReducedMotion ? 0 : 520);
+ }, revealDelay);
+ }
});
- };
- // 撤回出牌
- const handleUndoPlay = () => {
- socket.emit(SOCKET_EVENTS.UNDO_PLAY, {
- roomId: currentRoom.id
+ socket.on('secondary_burying_started', ({
+ dealerPlayerId,
+ secondaryPlayerId,
+ secondaryPlayerName,
+ cardsCount = 8,
+ animationDuration = 1400
+ }) => {
+ clearSelection();
+ if (exchangeAnimationTimerRef.current) {
+ clearTimeout(exchangeAnimationTimerRef.current);
+ }
+ const prefersReducedMotion = window.matchMedia?.('(prefers-reduced-motion: reduce)').matches;
+ const effectiveDuration = prefersReducedMotion ? 0 : animationDuration;
+ exchangeAnimationDurationRef.current = effectiveDuration;
+ if (effectiveDuration > 0) {
+ setCardExchangeAnimation({
+ kind: 'secondary_bury',
+ ruleName: '改革开放 · 底牌交接',
+ transfers: [{
+ fromPlayerId: dealerPlayerId,
+ toPlayerId: secondaryPlayerId,
+ cardsCount
+ }],
+ animationDuration,
+ key: Date.now()
+ });
+ const delayedCardsDuration = Math.max(0, cardsCount - 1) * 90;
+ exchangeAnimationEndsAtRef.current = Date.now()
+ + animationDuration + delayedCardsDuration + 220;
+ exchangeAnimationTimerRef.current = setTimeout(() => {
+ setCardExchangeAnimation(null);
+ exchangeAnimationTimerRef.current = null;
+ exchangeAnimationEndsAtRef.current = 0;
+ }, animationDuration + delayedCardsDuration + 220);
+ } else {
+ setCardExchangeAnimation(null);
+ exchangeAnimationEndsAtRef.current = 0;
+ }
+ messageApi.info(`改革开放:底牌交给 ${secondaryPlayerName} 重新埋底`, 4);
});
- };
- // 查看我的底牌(埋底玩家)
- const handleViewMyBottomCards = () => {
- socket.emit(SOCKET_EVENTS.VIEW_MY_BOTTOM_CARDS, {
- roomId: currentRoom.id
+ socket.on('secondary_bottom_cards_received', ({ bottomCards, totalCards }) => {
+ if (exchangeHandUpdateTimerRef.current) {
+ clearTimeout(exchangeHandUpdateTimerRef.current);
+ }
+ const prefersReducedMotion = window.matchMedia?.('(prefers-reduced-motion: reduce)').matches;
+ const updateDelay = prefersReducedMotion
+ ? 0
+ : Math.max(0, exchangeAnimationEndsAtRef.current - Date.now() + 100);
+ exchangeHandUpdateTimerRef.current = setTimeout(() => {
+ const receivedBottomCards = Array.isArray(bottomCards) ? bottomCards : [];
+ setBottomPickup(receivedBottomCards.length > 0 ? {
+ cards: receivedBottomCards,
+ title: '改革开放 · 接过底牌',
+ visible: true,
+ merged: true,
+ key: `secondary-bottom-${Date.now()}`
+ } : null);
+ setMyCards(mergeTransferredHandCards(
+ useGameStore.getState().myCards,
+ [],
+ receivedBottomCards
+ ));
+ if (handArrivalHighlightTimerRef.current) {
+ clearTimeout(handArrivalHighlightTimerRef.current);
+ handArrivalHighlightTimerRef.current = null;
+ }
+ setHandArrivalHighlight(receivedBottomCards.length > 0 ? {
+ cardIds: receivedBottomCards.map(card => card.id),
+ kind: 'bottom'
+ } : null);
+ clearSelection();
+ exchangeHandUpdateTimerRef.current = null;
+ messageApi.success(`拿起 ${receivedBottomCards.length} 张底牌,牌面已换色 · 当前共 ${totalCards} 张牌`, 4);
+ }, updateDelay);
});
- };
- const handleReadyForNext = () => {
- if (isReadyForNext) {
- return; // 已经准备过了,防止重复点击
- }
- setIsReadyForNext(true);
- socket.emit('ready_for_next_game', {
- roomId: currentRoom.id
+ // 埋底玩家设置
+ socket.on('burying_player_set', ({ playerName }) => {
+ messageApi.info(`${playerName} 被指定为庄家`);
});
- };
- // 调整分数
- const handleUpdateScore = () => {
- if (!selectedPlayerId) {
- messageApi.warning('请选择玩家');
- return;
- }
- socket.emit(SOCKET_EVENTS.UPDATE_SCORE, {
- roomId: currentRoom.id,
- playerId: selectedPlayerId,
- newScore: adjustValue
+ socket.on('dealer_selected', ({ playerName }) => {
+ messageApi.info(`主牌与庄家已锁定:${playerName}`);
});
- setScoreAdjustModal(false);
- };
- // 调整等级
- const handleUpdateLevel = () => {
- if (!selectedPlayerId) {
- messageApi.warning('请选择玩家');
- return;
- }
- socket.emit(SOCKET_EVENTS.UPDATE_LEVEL, {
- roomId: currentRoom.id,
- playerId: selectedPlayerId,
- newLevel: adjustValue
+ socket.on('open_hand_revealed', ({ playerName, controllerPlayerName }) => {
+ clearSelection();
+ messageApi.info(`算无遗策:${playerName} 明牌,由 ${controllerPlayerName} 代打`, 4);
});
- setLevelAdjustModal(false);
- };
- // 重新开始
- const handleRestartGame = () => {
- socket.emit(SOCKET_EVENTS.RESTART_GAME, {
- roomId: currentRoom.id
+ socket.on('iceberg_reveal_selection_required', ({
+ reason,
+ requiredCount,
+ targetCount,
+ currentlyRevealedCardIds = []
+ }) => {
+ clearSelection();
+ setIcebergSelection({
+ reason,
+ requiredCount,
+ targetCount,
+ currentlyRevealedCardIds,
+ isSubmitted: false
+ });
+ messageApi.info(reason === 'initial'
+ ? `冰山一角:请选择 ${requiredCount} 张牌明置`
+ : `请补选 ${requiredCount} 张牌明置`);
});
- };
- // 快速调整分数(快捷按钮)
- const handleQuickAdjustScore = (amount) => {
- socket.emit(SOCKET_EVENTS.UPDATE_SCORE, {
- roomId: currentRoom.id,
- amount
+ socket.on('iceberg_reveal_selection_confirmed', () => {
+ setIcebergSelection(previous => previous
+ ? { ...previous, isSubmitted: true }
+ : previous);
+ messageApi.success('明牌选择已确认');
});
- };
- // 快速调整等级(快捷按钮)
- const handleQuickAdjustLevel = (amount) => {
- socket.emit(SOCKET_EVENTS.UPDATE_LEVEL, {
- roomId: currentRoom.id,
- amount
+ socket.on('waiting_rabbit_selection_started', () => {
+ clearSelection();
+ messageApi.info('守株待兔:四家分别暗中指定一张目标牌');
});
- };
- // 设置主牌
- const handleSetTrump = (suit, rank) => {
- socket.emit(SOCKET_EVENTS.SET_TRUMP, {
- roomId: currentRoom.id,
- suit,
- rank
+ socket.on('waiting_rabbit_selection_required', ({
+ eligibleSuits = [],
+ eligibleRanks = [],
+ trumpRank: levelRank
+ }) => {
+ setSelectedWaitingRabbitSuit(null);
+ setSelectedWaitingRabbitRank(null);
+ setWaitingRabbitSelection({ eligibleSuits, eligibleRanks, trumpRank: levelRank });
});
- setTrumpModal(false);
- };
- // 更新房间配置
- const handleUpdateRoomConfig = () => {
- if (newBottomCardsCount < 1 || newBottomCardsCount > 20) {
- messageApi.warning('底牌数量必须在1-20之间');
- return;
- }
- if (newDealInterval < 10 || newDealInterval > 5000) {
- messageApi.warning('发牌间隔必须在10-5000毫秒之间');
- return;
- }
- socket.emit(SOCKET_EVENTS.UPDATE_CONFIG, {
- roomId: currentRoom.id,
- config: {
- bottomCardsCount: newBottomCardsCount,
- dealInterval: newDealInterval
+ socket.on('waiting_rabbit_target_selected', ({ suit, rank }) => {
+ setWaitingRabbitPrivateTarget({ suit, rank });
+ setWaitingRabbitSelection(null);
+ setSelectedWaitingRabbitSuit(null);
+ setSelectedWaitingRabbitRank(null);
+ messageApi.success(`守株待兔目标已暗定为 ${formatPublicCard({ suit, rank })}`);
+ });
+
+ socket.on('waiting_rabbit_target_locked', ({ playerId, playerName, remainingCount }) => {
+ if (playerId !== currentPlayer?.id) {
+ messageApi.info(`${playerName} 已完成守株待兔暗选(剩余${remainingCount}人)`);
}
});
- setRoomConfigModal(false);
- };
- // 修改玩家昵称
- const handleUpdatePlayerName = () => {
- const trimmedName = newPlayerName.trim();
- if (!trimmedName) {
- messageApi.warning('昵称不能为空');
- return;
- }
- if (trimmedName.length > 20) {
- messageApi.warning('昵称长度不能超过20个字符');
- return;
- }
- socket.emit(SOCKET_EVENTS.UPDATE_PLAYER_NAME, {
- roomId: currentRoom.id,
- newName: trimmedName
+ socket.on('waiting_rabbit_triggered', ({ sourcePlayerName, chooserPlayerName, targetCard }) => {
+ messageApi.warning(
+ `守株待兔轮末:${sourcePlayerName} 打出了 ${formatPublicCard(targetCard)},等待 ${chooserPlayerName} 决定是否交换`,
+ 5
+ );
});
- setRenameModal(false);
- setNewPlayerName('');
- };
- // 打开修改昵称对话框
- const handleOpenRenameModal = () => {
- setNewPlayerName(currentPlayer?.name || '');
- setRenameModal(true);
- };
+ socket.on('waiting_rabbit_exchange_required', payload => {
+ clearSelection();
+ setWaitingRabbitDiscardCardId(null);
+ setWaitingRabbitDecision(payload);
+ });
- // 处理规则选择
- const handleRuleSelected = (rule) => {
- // 发送规则选择事件到服务器
- socket.emit(SOCKET_EVENTS.SELECT_RULE, {
- roomId: currentRoom.id,
- rule: rule
+ socket.on('waiting_rabbit_resolved', ({
+ chooserPlayerId,
+ chooserPlayerName,
+ sourcePlayerId,
+ sourcePlayerName,
+ targetCard,
+ discardedCard,
+ tableCards,
+ accepted
+ }) => {
+ if (chooserPlayerId === currentPlayer?.id) {
+ setWaitingRabbitDecision(null);
+ setWaitingRabbitDiscardCardId(null);
+ }
+ if (accepted && Array.isArray(tableCards)) {
+ setPlayedCards(previous => {
+ const sourcePlay = previous[sourcePlayerId];
+ if (!sourcePlay) return previous;
+ const updated = {
+ ...previous,
+ [sourcePlayerId]: {
+ ...sourcePlay,
+ cards: tableCards
+ }
+ };
+ playedCardsRef.current = updated;
+ return updated;
+ });
+ }
+ messageApi.info(accepted
+ ? `守株待兔轮末:${chooserPlayerName} 用 ${formatPublicCard(discardedCard)} 换走了 ${sourcePlayerName} 的 ${formatPublicCard(targetCard)}`
+ : `守株待兔轮末:${chooserPlayerName} 放弃交换 ${formatPublicCard(targetCard)}`,
+ 5);
});
- setRuleSelectorModal(false);
- };
- // 发送聊天消息
- const handleSendChatMessage = (msg) => {
- const messageToSend = msg || chatMessage.trim();
- if (!messageToSend) {
- messageApi.warning('消息不能为空');
- return;
- }
- if (messageToSend.length > 200) {
- messageApi.warning('消息长度不能超过200个字符');
- return;
- }
- socket.emit(SOCKET_EVENTS.SEND_CHAT_MESSAGE, {
- roomId: currentRoom.id,
- message: messageToSend
+ socket.on('waiting_rabbit_hand_updated', ({ cards = [] }) => {
+ setMyCards(cards);
+ clearSelection();
});
- setChatMessage('');
- };
- // 添加快捷短语
- const handleAddQuickPhrase = () => {
- const trimmed = newQuickPhrase.trim();
- if (!trimmed) {
- messageApi.warning('快捷短语不能为空');
- return;
- }
- if (trimmed.length > 50) {
- messageApi.warning('快捷短语长度不能超过50个字符');
- return;
- }
- if (quickPhrases.includes(trimmed)) {
- messageApi.warning('该快捷短语已存在');
- return;
- }
- const newPhrases = [...quickPhrases, trimmed];
- setQuickPhrases(newPhrases);
- localStorage.setItem('tractorQuickPhrases', JSON.stringify(newPhrases));
- setNewQuickPhrase('');
- messageApi.success('快捷短语已添加');
- };
+ socket.on('ten_sided_ambush_selection_started', ({ selectorPlayerId, selectorPlayerName }) => {
+ clearSelection();
+ if (selectorPlayerId !== currentPlayer?.id) {
+ messageApi.info(`十面埋伏:等待 ${selectorPlayerName} 暗中指定点数`);
+ }
+ });
- // 删除快捷短语
- const handleDeleteQuickPhrase = (phrase) => {
- const newPhrases = quickPhrases.filter(p => p !== phrase);
- setQuickPhrases(newPhrases);
- localStorage.setItem('tractorQuickPhrases', JSON.stringify(newPhrases));
- messageApi.success('快捷短语已删除');
- };
+ socket.on('ten_sided_ambush_selection_required', ({ eligibleRanks = [], trumpRank }) => {
+ clearSelection();
+ setSelectedTenSidedAmbushRank(null);
+ setTenSidedAmbushSelection({ eligibleRanks, trumpRank });
+ messageApi.info('十面埋伏:请暗中指定一个点数');
+ });
- // 添加Bot
- const handleAddBot = () => {
- const botCount = currentRoom.players.filter(p => p.isBot).length;
- socket.emit(SOCKET_EVENTS.ADD_BOT, {
- roomId: currentRoom.id,
- botName: `AI Bot ${botCount + 1}`
+ socket.on('ten_sided_ambush_rank_selected', ({ rank }) => {
+ setTenSidedAmbushPrivateRank(rank);
+ setTenSidedAmbushSelection(null);
+ setSelectedTenSidedAmbushRank(null);
+ messageApi.success(`已暗中指定 ${rank},首次出现前仅你可见`, 4);
});
- };
- // 移除Bot
- const handleRemoveBot = (playerId) => {
- socket.emit(SOCKET_EVENTS.REMOVE_BOT, {
- roomId: currentRoom.id,
- playerId
+ socket.on('ten_sided_ambush_rank_locked', ({ selectorPlayerId, selectorPlayerName }) => {
+ if (selectorPlayerId !== currentPlayer?.id) {
+ messageApi.info(`${selectorPlayerName} 已完成十面埋伏布置,点数仍未揭晓`);
+ }
});
- };
- const isBuryingPlayer = gameState?.buryingPlayerId === currentPlayer?.id;
+ socket.on('ten_sided_ambush_revealed', ({ rank, source = 'play', playerName = null }) => {
+ if (tenSidedAmbushAnimationTimerRef.current) {
+ clearTimeout(tenSidedAmbushAnimationTimerRef.current);
+ }
+ setTenSidedAmbushPrivateRank(rank);
+ setTenSidedAmbushRevealAnimation({ rank, source, playerName, key: Date.now() });
+ tenSidedAmbushAnimationTimerRef.current = setTimeout(() => {
+ setTenSidedAmbushRevealAnimation(null);
+ tenSidedAmbushAnimationTimerRef.current = null;
+ }, 1800);
+ messageApi.warning(source === 'bottom'
+ ? `底牌揭示十面埋伏点数:${rank}`
+ : `十面埋伏揭晓:${rank}`, 4);
+ });
- // 渲染控制按钮区域 - 简化版,只保留核心功能按钮
- const renderControlButtons = () => {
- const buttonStyle = { width: '100px', fontSize: '13px' };
+ socket.on('three_powers_selection_started', ({ slots = [] }) => {
+ clearSelection();
+ const ownSlot = slots.find(slot => slot.selectorPlayerId === currentPlayer?.id);
+ if (!ownSlot) {
+ messageApi.info('三权分立:等待2、3、4号位暗选重载点数');
+ }
+ });
- switch (phase) {
- case GamePhases.WAITING:
- if (!currentRoom) return null;
+ socket.on('three_powers_selection_required', ({
+ sourceRank,
+ pointValue,
+ selectorPosition,
+ eligibleRanks = [],
+ trumpRank: levelRank
+ }) => {
+ clearSelection();
+ setSelectedThreePowersRank(null);
+ setThreePowersSelection({
+ sourceRank,
+ pointValue,
+ selectorPosition,
+ eligibleRanks,
+ trumpRank: levelRank
+ });
+ messageApi.info(`三权分立:请暗选重载原${sourceRank}分牌的点数`);
+ });
- const isWaitingForReady = gameState?.isWaitingForReady || false;
+ socket.on('three_powers_rank_selected', ({ sourceRank, pointValue, rank }) => {
+ setThreePowersPrivateRanks(previous => ({ ...previous, [sourceRank]: rank }));
+ setThreePowersSelection(null);
+ setSelectedThreePowersRank(null);
+ messageApi.success(`已暗选 ${rank} 作为新的${pointValue}分牌,首次出现前仅你可见`, 4);
+ });
- if (isWaitingForReady) {
- const buttons = [];
+ socket.on('three_powers_rank_locked', ({ selectorPlayerId, sourceRank, selectorPlayerName }) => {
+ if (selectorPlayerId !== currentPlayer?.id) {
+ messageApi.info(`${selectorPlayerName} 已完成原${sourceRank}分牌重载,点数仍未揭晓`);
+ }
+ });
- if (!currentPlayer?.isBot) {
- buttons.push(
-
- {currentPlayer?.isReady ? '取消准备' : '准备'}
-
- );
- }
+ socket.on('three_powers_revealed', ({ slots = [], source = 'play', playerName = null }) => {
+ if (threePowersAnimationTimerRef.current) {
+ clearTimeout(threePowersAnimationTimerRef.current);
+ }
+ setThreePowersPrivateRanks(previous => ({
+ ...previous,
+ ...Object.fromEntries(slots.map(slot => [slot.sourceRank, slot.rank]))
+ }));
+ setThreePowersRevealAnimation({ slots, source, playerName, key: Date.now() });
+ threePowersAnimationTimerRef.current = setTimeout(() => {
+ setThreePowersRevealAnimation(null);
+ threePowersAnimationTimerRef.current = null;
+ }, 1800);
+ const revealText = slots.map(slot => `${slot.sourceRank}→${slot.rank}`).join(',');
+ messageApi.warning(
+ source === 'bottom'
+ ? `底牌揭晓三权分立:${revealText}`
+ : `三权分立揭晓:${revealText}`,
+ 4
+ );
+ });
- return (
-
- {buttons}
-
- );
- }
- return null;
+ socket.on('gentleman_promise_selection_started', () => {
+ clearSelection();
+ messageApi.info('君子一言:正在声明各自最少的有效花色');
+ });
- case GamePhases.DRAWING:
- return (
-
-
- 全选
-
-
- );
+ socket.on('gentleman_promise_selection_required', ({
+ eligibleSuits = [],
+ suitCounts = {},
+ minimumCount = 0
+ }) => {
+ clearSelection();
+ setSelectedGentlemanPromiseSuit(null);
+ setGentlemanPromiseSelection({ eligibleSuits, suitCounts, minimumCount });
+ messageApi.info('君子一言:请选择一个并列最短的有效花色');
+ });
- case GamePhases.BURYING:
- const buryingButtons = [];
+ socket.on('gentleman_promise_declared', ({ playerId, playerName, suit, source }) => {
+ if (playerId === currentPlayer?.id) {
+ setGentlemanPromiseSelection(null);
+ setSelectedGentlemanPromiseSuit(null);
+ }
+ const suitLabel = EFFECTIVE_SUIT_LABELS[suit] || suit;
+ messageApi.success(
+ source === 'system'
+ ? `君子一言:${playerName} 的唯一最短花色为 ${suitLabel},系统已代为声明`
+ : `君子一言:${playerName} 声明 ${suitLabel}`,
+ 3
+ );
+ });
- if (isBuryingPlayer) {
- buryingButtons.push(
-
- 埋底({selectedCards.length}/{currentRoom.config.bottomCardsCount})
-
- );
- } else {
- buryingButtons.push(
-
- 等待庄家埋底
-
- );
- }
+ socket.on('gentleman_promise_completed', () => {
+ setGentlemanPromiseSelection(null);
+ setSelectedGentlemanPromiseSuit(null);
+ messageApi.success('君子一言:四家声明完成,开始出牌');
+ });
- return (
-
- {buryingButtons}
-
- );
+ socket.on('hidden_dragon_selection_started', () => {
+ clearSelection();
+ messageApi.info('潜龙在渊:正在声明各自最多的非级牌点数');
+ });
- case GamePhases.PLAYING:
- // 检查是否轮到当前玩家出牌
- const isMyTurn = gameState?.playMode === PlayModes.FREE ||
- (gameState?.currentPlayerIndex !== null &&
- gameState?.currentPlayerIndex !== undefined &&
- currentRoom?.players[gameState.currentPlayerIndex]?.id === currentPlayer?.id);
+ socket.on('hidden_dragon_selection_required', ({
+ eligibleRanks = [],
+ rankCounts = {},
+ maximumCount = 0,
+ trumpRank = null
+ }) => {
+ clearSelection();
+ setSelectedHiddenDragonRank(null);
+ setHiddenDragonSelection({ eligibleRanks, rankCounts, maximumCount, trumpRank });
+ messageApi.info('潜龙在渊:请选择一个并列最多的点数');
+ });
- // 检查是否可以撤回
- const canUndo = (() => {
- if (!currentPlayer?.id) return false;
- const lastPlayIndex = playHistory.map(p => p.playerId).lastIndexOf(currentPlayer.id);
- if (lastPlayIndex === -1) {
- return false;
- }
- const hasSubsequentPlays = playHistory
- .slice(lastPlayIndex + 1)
- .some(p => p.playerId !== currentPlayer.id);
- return !hasSubsequentPlays;
- })();
+ socket.on('hidden_dragon_declared', ({ playerId, playerName, rank, source }) => {
+ if (playerId === currentPlayer?.id) {
+ setHiddenDragonSelection(null);
+ setSelectedHiddenDragonRank(null);
+ }
+ messageApi.success(
+ source === 'system'
+ ? `潜龙在渊:${playerName} 的唯一最多点数为 ${rank},系统已代为声明`
+ : `潜龙在渊:${playerName} 声明 ${rank}`,
+ 3
+ );
+ });
- // 验证选中的牌是否合法
- const validateSelectedCards = (() => {
- if (selectedCards.length === 0) {
- return { valid: false, message: '请选择要出的牌' };
- }
+ socket.on('hidden_dragon_completed', () => {
+ setHiddenDragonSelection(null);
+ setSelectedHiddenDragonRank(null);
+ messageApi.success('潜龙在渊:四家声明完成,开始出牌');
+ });
- const selectedCardObjects = myCards.filter(card => selectedCards.includes(card.id));
+ socket.on('hidden_dragon_resolved', ({ playerName, declaredRank, success }) => {
+ if (success) {
+ messageApi.success(`潜龙在渊:${playerName} 从未打出 ${declaredRank},所属阵营获得10分`, 4);
+ } else {
+ messageApi.warning(`潜龙在渊:${playerName} 已经打出过 ${declaredRank},本次未得分`, 4);
+ }
+ });
- const isLeading = gameState?.currentRoundPlays === 0 ||
- gameState?.playersPlayedThisRound?.length === 0 ||
- (Array.isArray(gameState?.playersPlayedThisRound) && gameState.playersPlayedThisRound.length === 0);
+ socket.on('antinomy_selection_started', ({ stage }) => {
+ clearSelection();
+ messageApi.info(
+ stage === 'opening'
+ ? '二律背反:庄家已埋底,四家开始选择牌面'
+ : '二律背反:命中声明牌面的玩家正在重新选择'
+ );
+ });
- if (isLeading) {
- return validateLeadingPlay(selectedCardObjects, trumpSuit, trumpRank);
- } else {
- const leadingPattern = gameState?.leadingPattern;
- if (!leadingPattern) {
- return validateLeadingPlay(selectedCardObjects, trumpSuit, trumpRank);
- }
- return validateFollowingPlay(selectedCardObjects, myCards, leadingPattern, trumpSuit, trumpRank);
- }
- })();
+ socket.on('antinomy_selection_required', ({
+ stage = 'opening',
+ triggerRound = null,
+ eligibleSuits = [],
+ eligibleRanks = [],
+ currentDeclaration = null
+ }) => {
+ clearSelection();
+ setSelectedAntinomySuit(null);
+ setSelectedAntinomyRank(null);
+ setAntinomySelection({
+ stage,
+ triggerRound,
+ eligibleSuits,
+ eligibleRanks,
+ currentDeclaration
+ });
+ });
- const canPlay = isMyTurn && validateSelectedCards.valid;
- const playButtonTitle = !isMyTurn ? '还没轮到你出牌' :
- !validateSelectedCards.valid ? validateSelectedCards.message : '';
+ socket.on('antinomy_selection_submitted', ({ playerId, playerName, pendingPlayerIds = [] }) => {
+ if (playerId === currentPlayer?.id) {
+ setAntinomySelection(null);
+ setSelectedAntinomySuit(null);
+ setSelectedAntinomyRank(null);
+ }
+ messageApi.info(
+ `二律背反:${playerName} 已提交选择,仍待 ${pendingPlayerIds.length} 人`,
+ 3
+ );
+ });
- // 右上角只保留: 出牌、撤回、聊天、全选
- return (
-
-
- 出牌({selectedCards.length})
-
-
- 撤回
-
- setChatModal(true)} style={buttonStyle}>
- 聊天
-
-
- 全选
-
-
- );
+ socket.on('antinomy_declarations_revealed', ({ stage }) => {
+ setAntinomySelection(null);
+ setSelectedAntinomySuit(null);
+ setSelectedAntinomyRank(null);
+ messageApi.success(
+ stage === 'opening'
+ ? '二律背反:四家声明同时亮出,现在可以开始出牌'
+ : '二律背反:重选声明同时更新,现在可以开始下一轮',
+ 4
+ );
+ });
- case GamePhases.REVEALING:
- return (
-
-
- {isReadyForNext ? '已准备' : '开始下一局'}
-
-
- );
+ socket.on('rice_to_mulberry_selection_started', () => {
+ clearSelection();
+ messageApi.info('改稻为桑:两名闲家正在选择要改造的分牌');
+ });
- case GamePhases.FINISHED:
- return null;
+ socket.on('rice_to_mulberry_selection_required', ({
+ requiredCount = 0,
+ eligibleCardIds = []
+ }) => {
+ clearSelection();
+ setSelectedRiceToMulberryCardIds([]);
+ setRiceToMulberrySelection({ requiredCount, eligibleCardIds });
+ });
- default:
- return null;
- }
- };
+ socket.on('rice_to_mulberry_hand_updated', ({ cards = [] }) => {
+ setMyCards(cards);
+ clearSelection();
+ setRiceToMulberrySelection(null);
+ setSelectedRiceToMulberryCardIds([]);
+ });
- // 渲染游戏阶段内容
- const renderPhaseContent = () => {
- switch (phase) {
- case GamePhases.WAITING:
- if (!currentRoom) {
- return
等待加入房间...
;
- }
+ socket.on('rice_to_mulberry_transformed', ({
+ playerId,
+ playerName,
+ transformedCount = 0,
+ pendingPlayerIds = []
+ }) => {
+ if (playerId === currentPlayer?.id) {
+ setRiceToMulberrySelection(null);
+ setSelectedRiceToMulberryCardIds([]);
+ }
+ messageApi.info(
+ `改稻为桑:${playerName}已改造${transformedCount}张分牌,仍待${pendingPlayerIds.length}人`,
+ 3
+ );
+ });
- const isWaitingForReady = gameState?.isWaitingForReady || false;
+ socket.on('rice_to_mulberry_completed', () => {
+ setRiceToMulberrySelection(null);
+ setSelectedRiceToMulberryCardIds([]);
+ messageApi.success('改稻为桑:两名闲家均已完成改造,现在可以开始出牌', 4);
+ });
- // 如果在准备等待阶段
- if (isWaitingForReady) {
- return (
-
- {/* 游戏桌面 */}
- {}}
- onReorder={() => {}}
- currentTurnPlayerId={null}
- trumpSuit={trumpSuit}
- trumpRank={trumpRank}
- isHost={isHost}
- onSetTrump={() => setTrumpModal(true)}
- selectedRule={selectedRule}
- onSelectRule={() => setRuleSelectorModal(true)}
- renderControls={renderControlButtons()}
- isWaitingForReady={isWaitingForReady}
- team1Level={gameState?.team1Level}
- team2Level={gameState?.team2Level}
- dealerPlayerIndex={gameState?.dealerPlayerIndex}
- onRename={handleOpenRenameModal}
- />
-
- );
- }
+ socket.on('surrender_requested', ({ initiatorPlayerId, initiatorPlayerName }) => {
+ messageApi.warning(
+ initiatorPlayerId === currentPlayer?.id
+ ? '已发起投降,本墩完整结束后将询问你的队友'
+ : `${initiatorPlayerName}发起了投降,本墩结束后处理`,
+ 4
+ );
+ });
- // 正常的房间等待界面
- return (
-
-
等待开始
-
当前玩家: {currentRoom.playerCount} / {currentRoom.maxPlayers}
-
-
底牌数量: {currentRoom.config?.bottomCardsCount || 8} | 发牌间隔: {currentRoom.config?.dealInterval || 500}ms
-
-
+ socket.on('surrender_decision_pending', ({ initiatorPlayerName, teammatePlayerName }) => {
+ messageApi.info(
+ `投降表决:正在询问${teammatePlayerName}是否同意${initiatorPlayerName}投降`,
+ 4
+ );
+ });
-
- {/* 房主操作按钮 */}
- {isHost && (
-
- {currentRoom.playerCount >= 2 && (
-
- 开始游戏
-
- )}
- setRoomConfigModal(true)}>
- 房间设置
-
- = currentRoom.maxPlayers}>
- 添加Bot
+ socket.on('surrender_decision_required', decision => {
+ clearSelection();
+ setSurrenderDecision(decision);
+ });
+
+ socket.on('surrender_rejected', ({ initiatorPlayerName, teammatePlayerName }) => {
+ setSurrenderDecision(null);
+ messageApi.success(
+ `${teammatePlayerName}不同意${initiatorPlayerName}投降,牌局继续`,
+ 4
+ );
+ });
+
+ socket.on('game_surrendered', ({ initiatorPlayerName, winningSide }) => {
+ setSurrenderDecision(null);
+ messageApi.warning(
+ `${initiatorPlayerName}一方投降,${winningSide === 'attacker' ? '闲家方' : '庄家方'}获胜`,
+ 5
+ );
+ });
+
+ socket.on('destroy_dyke_decision_pending', ({ dealerPlayerName, roundPoints }) => {
+ clearSelection();
+ messageApi.info(`毁堤淹田:闲家赢得${roundPoints}分,等待${dealerPlayerName}决定`, 4);
+ });
+
+ socket.on('destroy_dyke_decision_required', (decision) => {
+ clearSelection();
+ setDestroyDykeDecision(decision);
+ });
+
+ socket.on('destroy_dyke_activated', ({
+ dealerPlayerId,
+ dealerPlayerName,
+ voidedPoints
+ }) => {
+ setDestroyDykeDecision(null);
+ showSkillActivation({
+ id: 'destroy_dyke_flood_fields',
+ name: '毁堤淹田',
+ playerId: dealerPlayerId,
+ playerName: dealerPlayerName,
+ variant: 'destroy-dyke',
+ actionLabel: '发动规则',
+ detail: `本轮 ${voidedPoints} 分封存 · 三轮灾期开始`
+ });
+ messageApi.warning(
+ `毁堤淹田:${dealerPlayerName}令本轮${voidedPoints}分作废,三轮灾期开始`,
+ 5
+ );
+ });
+
+ socket.on('destroy_dyke_declined', () => {
+ setDestroyDykeDecision(null);
+ messageApi.info('毁堤淹田:庄家本轮不发动,分数照常结算', 3);
+ });
+
+ socket.on('destroy_dyke_disaster_updated', ({
+ roundsElapsed,
+ disasterAttackerPoints
+ }) => {
+ messageApi.info(
+ `毁堤淹田:灾期${roundsElapsed}/3,闲家已累计${disasterAttackerPoints}/20分`,
+ 4
+ );
+ });
+
+ socket.on('destroy_dyke_disaster_resolved', ({
+ status,
+ returnedPoints = 0,
+ incidentBonus = 0,
+ voidedPoints = 0
+ }) => {
+ if (status === 'incident') {
+ messageApi.error(
+ `毁堤淹田事发:返还${returnedPoints}分并额外获得${incidentBonus}分`,
+ 6
+ );
+ } else {
+ messageApi.success(`毁堤淹田:灾期结束,${voidedPoints}分永久作废`, 5);
+ }
+ });
+
+ socket.on('hidden_dragon_reverted', ({ playerName }) => {
+ messageApi.info(`潜龙在渊:${playerName} 撤回出牌,本次判定已回退`);
+ });
+
+ socket.on('administrative_review_selection_started', ({
+ suitSelectorPlayerName,
+ rankSelectorPlayerName
+ }) => {
+ clearSelection();
+ messageApi.info(
+ `行政审查:${suitSelectorPlayerName}公开指定副花色,${rankSelectorPlayerName}公开指定点数`,
+ 4
+ );
+ });
+
+ socket.on('administrative_review_selection_required', ({
+ type,
+ eligibleOptions = [],
+ trumpSuit: reviewTrumpSuit,
+ trumpRank: reviewTrumpRank
+ }) => {
+ clearSelection();
+ setSelectedAdministrativeReviewValue(null);
+ setAdministrativeReviewSelection({
+ type,
+ eligibleOptions,
+ trumpSuit: reviewTrumpSuit,
+ trumpRank: reviewTrumpRank
+ });
+ });
+
+ socket.on('administrative_review_declared', ({ playerId, playerName, type, value }) => {
+ if (playerId === currentPlayer?.id) {
+ setAdministrativeReviewSelection(null);
+ setSelectedAdministrativeReviewValue(null);
+ }
+ const valueLabel = type === 'suit'
+ ? (EFFECTIVE_SUIT_LABELS[value] || value)
+ : value;
+ messageApi.success(
+ `行政审查:${playerName}公开指定${type === 'suit' ? '副花色' : '点数'} ${valueLabel}`,
+ 4
+ );
+ });
+
+ socket.on('administrative_review_completed', ({ dealerPlayerName }) => {
+ setAdministrativeReviewSelection(null);
+ setSelectedAdministrativeReviewValue(null);
+ messageApi.success(`行政审查声明完成,${dealerPlayerName}先出;底牌仍不可查看`, 4);
+ });
+
+ socket.on('administrative_review_progressed', ({
+ playerName,
+ suitMatched,
+ rankMatched
+ }) => {
+ messageApi.info(
+ `行政审查:${playerName}推进审查(副花色${suitMatched ? '已满足' : '未满足'},点数${rankMatched ? '已满足' : '未满足'})`,
+ 3
+ );
+ });
+
+ socket.on('administrative_review_burying_unlocked', ({ dealerPlayerName }) => {
+ clearSelection();
+ messageApi.success(`行政审查条件全部满足,${dealerPlayerName}现在查看底牌并埋12张`, 5);
+ });
+
+ socket.on('administrative_review_buried', ({ dealerPlayerName, currentRound }) => {
+ messageApi.success(`行政审查:${dealerPlayerName}埋底完成,继续第${currentRound}轮`, 4);
+ });
+
+ socket.on('political_review_play_pending', ({
+ teammatePlayerId,
+ teammatePlayerName,
+ cards = []
+ }) => {
+ const startsNewRound = awaitingRoundClearRef.current;
+ if (startsNewRound) {
+ if (roundClearTimerRef.current) {
+ clearTimeout(roundClearTimerRef.current);
+ roundClearTimerRef.current = null;
+ }
+ awaitingRoundClearRef.current = false;
+ heldCompletedRoundNumberRef.current = null;
+ heldRoundCandleRef.current = null;
+ setIsHoldingCompletedRound(false);
+ setHeldCompletedRoundNumber(null);
+ }
+ const updated = {
+ ...(startsNewRound ? {} : playedCardsRef.current),
+ [teammatePlayerId]: {
+ playerName: teammatePlayerName,
+ cards,
+ cardsCount: cards.length,
+ politicalReviewPending: true
+ }
+ };
+ playedCardsRef.current = updated;
+ setPlayedCards(updated);
+ });
+
+ socket.on('political_review_play_held', ({ teammatePlayerName }) => {
+ clearSelection();
+ messageApi.info(`政治审查:${teammatePlayerName}的出牌正在等待队友表态`, 3);
+ });
+
+ socket.on('political_review_decision_required', request => {
+ setPoliticalReviewDecision(request);
+ });
+
+ socket.on('political_review_play_approved', ({
+ id,
+ cardIds = [],
+ controlledPlayerId = null,
+ activeSkillId = null,
+ jokerSubstitutions = [],
+ clusterAnalysisSubstitutions = [],
+ forbiddenMagicSubstitutions = [],
+ divineWeaponCardId = null,
+ divineWeaponSourceCardId = null,
+ ambiguousAlternativeCardIds = []
+ }) => {
+ if (!id || politicalReviewApprovalSubmittingRef.current.has(id)) return;
+ politicalReviewApprovalSubmittingRef.current.add(id);
+ socket.emit(SOCKET_EVENTS.PLAY_CARDS, {
+ roomId: currentRoom.id,
+ cardIds,
+ controlledPlayerId,
+ activeSkillId,
+ jokerSubstitutions,
+ clusterAnalysisSubstitutions,
+ forbiddenMagicSubstitutions,
+ divineWeaponCardId,
+ divineWeaponSourceCardId,
+ ambiguousAlternativeCardIds,
+ politicalReviewApprovalId: id
+ });
+ });
+
+ socket.on('political_review_resolved', ({
+ reviewerPlayerId,
+ reviewerPlayerName,
+ teammatePlayerId,
+ teammatePlayerName,
+ cards = [],
+ returned
+ }) => {
+ if (reviewerPlayerId === currentPlayer?.id) setPoliticalReviewDecision(null);
+ const cardsLabel = cards.map(formatPublicCard).join('、');
+ if (returned) {
+ const updated = { ...playedCardsRef.current };
+ delete updated[teammatePlayerId];
+ playedCardsRef.current = updated;
+ setPlayedCards(updated);
+ if (teammatePlayerId === currentPlayer?.id) {
+ suppressAutoSelectionRef.current = false;
+ setJustPlayedCards(false);
+ clearSelection();
+ }
+ messageApi.warning(
+ `${reviewerPlayerName}令${teammatePlayerName}收回 ${cardsLabel || '本次出牌'};没有禁出限制,可以原样再出`,
+ 5
+ );
+ } else {
+ messageApi.success(`${reviewerPlayerName}放行${teammatePlayerName}的本次出牌`, 3);
+ }
+ });
+
+ socket.on('focus_figure_voting_started', () => {
+ clearSelection();
+ setFocusFigurePrivate(null);
+ messageApi.info('焦点人物:两队开始秘密表决本队焦点');
+ });
+
+ socket.on('focus_figure_vote_required', ({
+ team,
+ attempt,
+ nomineePlayerId,
+ nomineePlayerName
+ }) => {
+ clearSelection();
+ setFocusFigureVote({ team, attempt, nomineePlayerId, nomineePlayerName });
+ });
+
+ socket.on('focus_figure_vote_recorded', ({
+ voterPlayerName,
+ agree,
+ voteCount
+ }) => {
+ messageApi.info(`${voterPlayerName}${agree ? '同意' : '反对'}当前候选(${voteCount}/2)`);
+ });
+
+ socket.on('focus_figure_nominee_changed', ({ nomineePlayerName }) => {
+ messageApi.warning(`本队未能一致通过,焦点候选改为 ${nomineePlayerName},请重新表决`, 4);
+ });
+
+ socket.on('focus_figure_team_finalized', ({ team, focusPlayerId, focusPlayerName }) => {
+ setFocusFigureVote(null);
+ setFocusFigurePrivate({ team, focusPlayerId, focusPlayerName });
+ messageApi.success(`本队已一致确定 ${focusPlayerName} 为焦点;本队持续可见,终局向所有人公开`, 4);
+ });
+
+ socket.on('focus_figure_voting_completed', ({ message: completedMessage }) => {
+ setFocusFigureVote(null);
+ messageApi.success(completedMessage || '两队焦点人物表决完成;己方焦点已标在玩家身边', 4);
+ });
+
+ socket.on('rule_visible_hands_updated', ({ hands = [], announcement = null }) => {
+ setRuleVisibleHands(hands);
+ const ownVisibleHand = hands.find(hand =>
+ hand.playerId === currentPlayer?.id && hand.kind === 'partial'
+ );
+ if (ownVisibleHand) {
+ setIcebergSelection(previous => {
+ if (!previous || ownVisibleHand.cards.length < previous.targetCount) return previous;
+ clearSelection();
+ return null;
+ });
+ }
+ if (announcement) messageApi.info(announcement, 4);
+ });
+
+ // 埋底完成
+ socket.on('cards_buried', ({
+ playerName,
+ playerId,
+ skipped = false,
+ isSecondary = false,
+ completed = true,
+ peopleCommune = false,
+ submittedCount = 0,
+ totalCount = 4
+ }) => {
+ const buryMessage = peopleCommune
+ ? completed
+ ? '人民公社:四家均已埋好两张牌'
+ : `${playerName} 已埋两张牌(${submittedCount}/${totalCount})`
+ : skipped
+ ? `本局无底牌,${playerName} 直接先出`
+ : isSecondary
+ ? `${playerName} 完成再埋底`
+ : completed
+ ? `${playerName} 完成埋底`
+ : `${playerName} 完成首次埋底`;
+ messageApi.success(buryMessage);
+ // 如果是我自己埋的底,清空我的底牌缓存(已经埋了)
+ if (playerId === currentPlayer?.id) {
+ setBottomPickup(null);
+ setHandArrivalHighlight(previous => previous?.kind === 'bottom' ? null : previous);
+ }
+ });
+
+ // 首发玩家设置
+ socket.on('first_player_set', ({ playerName }) => {
+ messageApi.info(`${playerName} 先出牌`);
+ });
+
+ socket.on('active_skill_activated', ({
+ id,
+ name,
+ playerId,
+ playerName,
+ treatedAsSmall = false,
+ concealed = false
+ }) => {
+ showSkillActivation({
+ id,
+ name,
+ playerId,
+ playerName,
+ treatedAsSmall,
+ concealed
+ });
+ const effectText = treatedAsSmall
+ ? ',本次垫牌视为小'
+ : concealed
+ ? ',牌面将在本轮结束时公开'
+ : '';
+ messageApi.warning(`${playerName} 发动了${name}${effectText}`, 3);
+ });
+
+ socket.on('invite_into_urn_activated', ({
+ sourcePlayerName,
+ targetPlayerName,
+ suit,
+ rank
+ }) => {
+ const suitLabel = INVITE_INTO_URN_SUITS.find(option => option.value === suit)?.label || suit;
+ const rankLabel = INVITE_INTO_URN_JOKER_RANKS.find(option => option.value === rank)?.label || rank;
+ messageApi.warning(
+ `${sourcePlayerName} 请 ${targetPlayerName} 入瓮:本轮打出 ${rankLabel}${suit === 'joker' ? '' : suitLabel} 即扣5分`,
+ 4
+ );
+ });
+
+ socket.on('bush_gate_activated', ({
+ activatorPlayerName,
+ leaderPlayerId,
+ leaderPlayerName,
+ returnedCards = []
+ }) => {
+ setBushGateDecisionOpen(false);
+ setCurrentWinningPlayerId(null);
+ setPlayedCards(previous => {
+ const updated = { ...previous };
+ delete updated[leaderPlayerId];
+ playedCardsRef.current = updated;
+ return updated;
+ });
+ setPlayHistory(previous => {
+ const lastIndex = previous.map(play => play.playerId).lastIndexOf(leaderPlayerId);
+ if (lastIndex < 0) return previous;
+ const updated = [...previous];
+ updated.splice(lastIndex, 1);
+ return updated;
+ });
+ if (leaderPlayerId === currentPlayer?.id) {
+ returnedCards.forEach(cardData => addCard(cardData));
+ clearSelection();
+ }
+ const returnedLabel = returnedCards.map(formatPublicCard).join('、');
+ messageApi.warning(
+ `${activatorPlayerName} 发动布什戈门:${leaderPlayerName} 收回 ${returnedLabel || `${returnedCards.length}张牌`},须改用其他牌重新首发`,
+ 5
+ );
+ });
+
+ socket.on('dream_killing_started', ({ playerName }) => {
+ messageApi.info(`${playerName} 进入梦中:手牌暗置,之后由系统随机出牌`, 3);
+ });
+
+ socket.on('dream_killing_awakened', ({ playerName, matchedSuit, matchedRank }) => {
+ const reason = matchedSuit && matchedRank
+ ? '花色与点数均命中'
+ : matchedSuit
+ ? '命中首家花色'
+ : '命中首家点数';
+ messageApi.success(`${playerName} 梦中杀人成功(${reason}),本轮视为最大并醒来`, 4);
+ });
+
+ // 玩家出牌
+ socket.on('cards_played', ({
+ playerId,
+ playerName,
+ cards,
+ removedCardIds = cards.map(card => card.id),
+ currentWinningPlayerId: winningPlayerId,
+ controllerPlayerId,
+ controllerPlayerName,
+ isProxy = false,
+ treatedAsSmall = false,
+ activeSkillId = null,
+ activeSkillName = null,
+ concealed = false,
+ cardsCount = cards.length,
+ jokerSubstitutions = [],
+ clusterAnalysisSubstitutions = [],
+ forbiddenMagicSubstitutions = [],
+ enduringInheritance = null,
+ dreamKilling = null,
+ oldHorseAbsolute = false,
+ lureTigerSilenced = false,
+ ironEvidenceMode = null,
+ ambiguousOptions = null
+ }) => {
+ const cachedOwnCards = concealed && playerId === currentPlayer?.id
+ ? pendingOwnConcealedCardsRef.current.get(playerId) || null
+ : null;
+ const ownConcealedCards = Array.isArray(cachedOwnCards)
+ && cachedOwnCards.length === cardsCount
+ ? cachedOwnCards
+ : null;
+ if (cachedOwnCards) pendingOwnConcealedCardsRef.current.delete(playerId);
+ const displayedCards = ownConcealedCards || cards;
+ console.log('收到 cards_played 事件:', { playerId, playerName, cardsCount });
+ if (treatedAsSmall) {
+ messageApi.info(`${playerName} 垫了 ${cards.length} 张牌(视为小)`);
+ } else {
+ messageApi.info(isProxy
+ ? `${controllerPlayerName} 代 ${playerName} 出了 ${cards.length} 张牌`
+ : concealed
+ ? ownConcealedCards
+ ? `${playerName} 出了 ${cardsCount} 张牌`
+ : `${playerName} 暗置了 ${cardsCount} 张牌`
+ : `${playerName} 出了 ${cards.length} 张牌`);
+ }
+ if (enduringInheritance) {
+ messageApi.success(`${playerName} 触发经久不衰,本次按上轮较高牌力结算`, 2);
+ }
+ if (oldHorseAbsolute) {
+ messageApi.warning(`老骥伏枥:${playerName} 本次合法首发绝对最大`, 4);
+ }
+ if (lureTigerSilenced) {
+ messageApi.info(`调虎离山:${playerName} 的本次出牌不计大小与分数`, 3);
+ }
+ // 只在服务端确认出牌后更新本地手牌,避免拒绝时出现“牌先消失、后恢复”。
+ if (playerId === currentPlayer?.id) {
+ suppressAutoSelectionRef.current = true;
+ setJustPlayedCards(true);
+ removeCards(removedCardIds);
+ }
+ if (controllerPlayerId === currentPlayer?.id) {
+ suppressAutoSelectionRef.current = true;
+ setJustPlayedCards(true);
+ }
+ if (
+ playerId === currentPlayer?.id
+ || controllerPlayerId === currentPlayer?.id
+ ) {
+ setExplicitCardTransformations(previous =>
+ retainUnplayedCardTransformations(previous, {
+ removedCardIds,
+ consumedActiveSkillId: activeSkillId
+ })
+ );
+ }
+ // 如果玩家在清桌延迟期间已经开始下一墩,直接切换到新墩,避免新旧牌混在一起。
+ const startsNewRound = awaitingRoundClearRef.current;
+ if (startsNewRound) {
+ if (roundClearTimerRef.current) {
+ clearTimeout(roundClearTimerRef.current);
+ roundClearTimerRef.current = null;
+ }
+ awaitingRoundClearRef.current = false;
+ heldCompletedRoundNumberRef.current = null;
+ heldRoundCandleRef.current = null;
+ setIsHoldingCompletedRound(false);
+ setHeldCompletedRoundNumber(null);
+ }
+ const visualCards = ironEvidenceMode
+ ? displayedCards.map(card => ({ ...card, ironEvidenceMode }))
+ : displayedCards;
+ const playedCardView = { playerName, cards: visualCards, cardsCount, concealed, ownConcealedCards: Boolean(ownConcealedCards), treatedAsSmall, activeSkillId, activeSkillName, jokerSubstitutions, clusterAnalysisSubstitutions, forbiddenMagicSubstitutions, enduringInheritance, dreamKilling, oldHorseAbsolute, lureTigerSilenced, ironEvidenceMode, ambiguousOptions };
+ const updated = startsNewRound
+ ? { [playerId]: playedCardView }
+ : {
+ ...playedCardsRef.current,
+ [playerId]: playedCardView
+ };
+ playedCardsRef.current = updated;
+ if (
+ Object.keys(updated).length >= (currentRoom?.players?.length || 4)
+ && ruleIncludesId(gameState?.selectedRule, 'candle_to_dawn')
+ && typeof gameState?.candleToDawn?.isLit === 'boolean'
+ ) {
+ // 第四手一到便冻结本轮烛态;无需等待随后到达的 round_updated,
+ // 防止 room_updated 的下一轮状态抢先渲染一帧。
+ heldRoundCandleRef.current = {
+ round: Number(gameState?.currentRound) || 1,
+ isLit: gameState.candleToDawn.isLit
+ };
+ }
+ setPlayedCards(updated);
+ setCurrentWinningPlayerId(concealed ? (winningPlayerId || null) : (winningPlayerId ?? playerId));
+ console.log('更新后的 playedCards:', Object.keys(updated));
+ // 添加到出牌历史
+ setPlayHistory(prev => startsNewRound
+ ? [{ playerId, playerName, controllerPlayerId, controllerPlayerName, isProxy, treatedAsSmall, lureTigerSilenced, activeSkillId, activeSkillName, enduringInheritance, timestamp: Date.now() }]
+ : [...prev, { playerId, playerName, controllerPlayerId, controllerPlayerName, isProxy, treatedAsSmall, lureTigerSilenced, activeSkillId, activeSkillName, enduringInheritance, timestamp: Date.now() }]);
+ });
+
+ socket.on('ambiguous_choice_required', request => {
+ setAmbiguousChoice(request);
+ });
+
+ socket.on('ambiguous_choice_pending', ({ playerName, position }) => {
+ setAmbiguousChoice(previous => (
+ previous?.playerId === currentPlayer?.id ? previous : null
+ ));
+ messageApi.info(`模棱两可:等待${position || ''}号位 ${playerName || ''} 选择结算方案`, 3);
+ });
+
+ socket.on('ambiguous_choice_resolved', ({ playerId, playerName, optionIndex, cards }) => {
+ const updated = { ...playedCardsRef.current };
+ if (updated[playerId]) {
+ updated[playerId] = {
+ ...updated[playerId],
+ cards,
+ cardsCount: cards.length,
+ ambiguousSelectedOptionIndex: optionIndex
+ };
+ playedCardsRef.current = updated;
+ setPlayedCards(updated);
+ }
+ if (playerId === currentPlayer?.id) setAmbiguousChoice(null);
+ messageApi.success(`${playerName} 选择方案${optionIndex === 0 ? 'A' : 'B'}`, 2);
+ });
+
+ socket.on('ambiguous_hand_updated', ({ cards }) => {
+ setMyCards(cards);
+ });
+
+ socket.on('ambiguous_round_resolved', () => {
+ setAmbiguousChoice(null);
+ });
+
+ socket.on('three_tigers_transformed', ({
+ active,
+ triggeredNow,
+ reverted,
+ triggeredSuit,
+ plays = [],
+ currentWinningPlayerId: winningPlayerId
+ }) => {
+ const updated = { ...playedCardsRef.current };
+ plays.forEach(play => {
+ const current = updated[play.playerId];
+ if (!current || current.concealed) return;
+ updated[play.playerId] = {
+ ...current,
+ cards: play.cards,
+ cardsCount: play.cards.length,
+ threeTigersTransformed: Boolean(active)
+ };
+ });
+ playedCardsRef.current = updated;
+ setPlayedCards(updated);
+ setCurrentWinningPlayerId(winningPlayerId || null);
+
+ const suitLabel = TRANSFORMATION_SUITS.find(option => option.value === triggeredSuit)?.label
+ || triggeredSuit
+ || '';
+ if (triggeredNow) {
+ messageApi.warning(`三人成虎:${suitLabel}已有三人打出,相关牌立即降低四级并视为主牌`, 4);
+ } else if (reverted) {
+ messageApi.info('三人成虎已撤销:第三人的出牌被撤回,桌面牌恢复原状', 3);
+ }
+ });
+
+ socket.on('concealed_cards_played_private', ({ playerId, cards }) => {
+ const current = playedCardsRef.current[playerId];
+ if (!current || awaitingRoundClearRef.current) {
+ pendingOwnConcealedCardsRef.current.set(playerId, cards);
+ return;
+ }
+ const visualCards = current.ironEvidenceMode
+ ? cards.map(card => ({ ...card, ironEvidenceMode: current.ironEvidenceMode }))
+ : cards;
+ const updated = {
+ ...playedCardsRef.current,
+ [playerId]: { ...current, cards: visualCards, ownConcealedCards: true }
+ };
+ playedCardsRef.current = updated;
+ setPlayedCards(updated);
+ });
+
+ socket.on('concealed_plays_revealed', ({ plays = [] }) => {
+ const updated = { ...playedCardsRef.current };
+ plays.forEach(play => {
+ if (!updated[play.playerId]) return;
+ const wasOwnConcealed = Boolean(updated[play.playerId].ownConcealedCards);
+ const ironEvidenceMode = play.ironEvidenceMode
+ || updated[play.playerId].ironEvidenceMode
+ || null;
+ const visualCards = ironEvidenceMode
+ ? play.cards.map(card => ({ ...card, ironEvidenceMode }))
+ : play.cards;
+ updated[play.playerId] = {
+ ...updated[play.playerId],
+ cards: visualCards,
+ cardsCount: play.cards.length,
+ concealed: false,
+ ownConcealedCards: false,
+ justRevealed: Boolean(play.concealed && !wasOwnConcealed),
+ ironEvidenceMode,
+ jokerSubstitutions: play.jokerSubstitutions || []
+ };
+ });
+ playedCardsRef.current = updated;
+ setPlayedCards(updated);
+ messageApi.info('暗置牌同时公开,开始结算本轮', 2);
+ });
+
+ // 玩家跳过
+ socket.on('turn_passed', ({ playerName }) => {
+ messageApi.info(`${playerName} 跳过了回合`);
+ });
+
+ // 展示底牌(修正事件名)
+ socket.on('bottom_revealed', ({ bottomCards, bottomScoreResult, upgradeResult }) => {
+ setSurrenderDecision(null);
+ messageApi.info(`底牌已展示: ${bottomCards.length} 张`);
+ setRevealedBottomCards(bottomCards);
+ if (bottomScoreResult) {
+ setBottomScoreResult(bottomScoreResult);
+ setAttackerScore(bottomScoreResult.totalScore);
+ setCollectedPointCards(bottomScoreResult.collectedPointCards || []);
+ // 显示底牌得分结果
+ if (bottomScoreResult.surrender) {
+ messageApi.warning(bottomScoreResult.resultText || '投降结算完成', 6);
+ } else if (bottomScoreResult.focusFigure) {
+ const focusNames = bottomScoreResult.focusFigure.teams
+ .map(team => `${team.side === 'dealer' ? '庄家方' : '闲家方'}:${team.focusPlayerName}`)
+ .join(',');
+ messageApi.success(
+ `焦点人物揭晓(${focusNames}):逐墩焦点分${bottomScoreResult.focusFigure.focusTrickScore}分,` +
+ `正常底牌分${bottomScoreResult.focusFigure.normalBottomScore}分,最终${bottomScoreResult.totalScore}分`,
+ 6
+ );
+ } else {
+ const hasAmbushBottomCards = bottomScoreResult.ambushCardCount > 0;
+ const scoreAfterBottom = bottomScoreResult.scoreBeforeMistyFog
+ ?? bottomScoreResult.scoreBeforeLingeringDiscard
+ ?? bottomScoreResult.totalScore;
+ const terminalRevealMessage = bottomScoreResult.mistyFogCards?.length > 0
+ ? `;随后公开迷雾牌${bottomScoreResult.mistyFogPoints}分,闲家补${bottomScoreResult.mistyFogBonus}分,最终${bottomScoreResult.totalScore}分`
+ : bottomScoreResult.lingeringDiscardCards?.length > 0
+ ? `;随后公开庄家方弃出的分牌${bottomScoreResult.lingeringDiscardPoints}分,闲家补${bottomScoreResult.lingeringDiscardBonus}分,最终${bottomScoreResult.totalScore}分`
+ : '';
+ const resultMsg = bottomScoreResult.peopleCommune
+ ? bottomScoreResult.attackerWonBottom
+ ? `闲家抄庄家底!庄家方埋分${bottomScoreResult.peopleCommune.dealerBuriedPoints}分×${bottomScoreResult.bottomMultiplier}倍,闲家获得${bottomScoreResult.bottomScoreGained}分,总分:${scoreAfterBottom}分`
+ : `庄家方抄闲家底!闲家方埋分${bottomScoreResult.peopleCommune.attackerBuriedPoints}分×${bottomScoreResult.bottomMultiplier}倍,闲家扣${Math.abs(bottomScoreResult.bottomScoreGained)}分,总分:${scoreAfterBottom}分`
+ : hasAmbushBottomCards
+ ? bottomScoreResult.attackerWonBottom
+ ? `闲家拿底!常规底分获得${bottomScoreResult.bottomPoints * bottomScoreResult.bottomMultiplier}分,伏击牌${bottomScoreResult.ambushRank}×${bottomScoreResult.ambushCardCount}扣${Math.abs(bottomScoreResult.ambushScoreDelta)}分;底牌净变化${bottomScoreResult.bottomScoreGained > 0 ? '+' : ''}${bottomScoreResult.bottomScoreGained}分,闲家总分:${scoreAfterBottom}分${terminalRevealMessage}`
+ : `庄家守底!底中伏击牌${bottomScoreResult.ambushRank}×${bottomScoreResult.ambushCardCount}按${bottomScoreResult.bottomMultiplier}倍给闲家加${bottomScoreResult.ambushScoreDelta}分,闲家总分:${scoreAfterBottom}分${terminalRevealMessage}`
+ : bottomScoreResult.attackerWonBottom
+ ? `闲家拿底!底牌${bottomScoreResult.bottomPoints}分×${bottomScoreResult.bottomMultiplier}倍=${bottomScoreResult.bottomScoreGained}分,闲家总分:${scoreAfterBottom}分${terminalRevealMessage}`
+ : `庄家守底!闲家总分:${scoreAfterBottom}分${terminalRevealMessage}`;
+ messageApi.success(resultMsg, 5);
+ }
+ }
+ if (upgradeResult) {
+ setUpgradeResult(upgradeResult);
+ // 显示升级结果
+ const winnerMsg = upgradeResult.attackerWon ? '闲家获胜' : '庄家获胜';
+ const upgradeMsg = upgradeResult.attackerWon
+ ? `闲家升${upgradeResult.attackerLevelUp}级`
+ : `庄家升${upgradeResult.dealerLevelUp}级`;
+ const continuationMsg = upgradeResult.dealerContinues ? ',势如破竹继续连庄' : '';
+ messageApi.success(`${winnerMsg}!${upgradeMsg}${continuationMsg}`, 5);
+ }
+ });
+
+ // 收到我的底牌
+ socket.on('my_bottom_cards', ({ bottomCards }) => {
+ setMyBottomCards(bottomCards);
+ setViewBottomModal(true);
+ });
+
+ // 玩家准备下一局
+ socket.on('player_ready_for_next', ({ playerName, readyCount, totalCount }) => {
+ messageApi.info(`${playerName} 已准备 (${readyCount}/${totalCount})`);
+ });
+
+ // 下一局开始
+ socket.on('next_game_started', () => {
+ clearCardTransitionTimers();
+ messageApi.success('开始下一局!');
+ // 清空所有前端状态
+ setSelectedRule(null);
+ setMyCards([]);
+ setLivePlayerCardCounts({});
+ setShownCards({});
+ resetRoundDisplay();
+ setPlayHistory([]);
+ clearSelection();
+ setCurrentTrumpDeclaration(null);
+ setCurrentInferiorDeclaration(null);
+ setThreeSixNineState(null);
+ setAvailableDeclarations([]);
+ setAttackerScore(0);
+ setCollectedPointCards([]);
+ setBottomScoreResult(null);
+ setUpgradeResult(null);
+ setRevealedBottomCards([]);
+ setPublicBottomCards([]);
+ setMyBottomCards([]);
+ setViewBottomModal(false);
+ setCardExchangeAnimation(null);
+ setPrivateCardTransferReveal(null);
+ setBottomPickup(null);
+ setHandArrivalHighlight(null);
+ setRuleVisibleHands([]);
+ setIcebergSelection(null);
+ setTenSidedAmbushSelection(null);
+ setSelectedTenSidedAmbushRank(null);
+ setTenSidedAmbushPrivateRank(null);
+ setTenSidedAmbushRevealAnimation(null);
+ setWaitingRabbitSelection(null);
+ setSelectedWaitingRabbitSuit(null);
+ setSelectedWaitingRabbitRank(null);
+ setWaitingRabbitPrivateTarget(null);
+ setWaitingRabbitDecision(null);
+ setWaitingRabbitDiscardCardId(null);
+ setGentlemanPromiseSelection(null);
+ setSelectedGentlemanPromiseSuit(null);
+ setHiddenDragonSelection(null);
+ setSelectedHiddenDragonRank(null);
+ setAdministrativeReviewSelection(null);
+ setSelectedAdministrativeReviewValue(null);
+ setPoliticalReviewDecision(null);
+ setFocusFigureVote(null);
+ setFocusFigurePrivate(null);
+ setArmedActiveSkillId(null);
+ setActiveSkillAnimation(null);
+ setTeammateCheerDecision(null);
+ setAfterglowDecision(null);
+ setRemoveFirewoodDecision(null);
+ setAmbiguousFirstOptionCardIds([]);
+ setAmbiguousChoice(null);
+ setBushGateDecisionOpen(false);
+ setMutualSupportDirectionOpen(false);
+ setMutualSupportSelection(null);
+ setMutualSupportSelectedCardIds([]);
+ setStrawBoatDecision(null);
+ setStrawBoatDiscardCardId(null);
+ setIsReadyForNext(false); // 重置准备状态
+ setLastRoundPlayedCards({}); // 清空上轮出牌记录
+ setLastRoundWinnerPlayerId(null);
+ setViewingLastRound(false); // 取消查看上轮状态
+ if (lastRoundTimer) {
+ clearTimeout(lastRoundTimer);
+ setLastRoundTimer(null);
+ }
+ // 主牌信息会通过房间状态同步的useEffect自动更新
+ });
+
+ // 分数更新
+ socket.on('score_updated', ({ playerId, newScore }) => {
+ messageApi.success('分数已更新');
+ });
+
+ // 等级更新
+ socket.on('level_updated', ({ playerId, newLevel }) => {
+ messageApi.success('等级已更新');
+ });
+
+ // 撤回出牌
+ socket.on('play_undone', ({
+ playerId,
+ playerName,
+ cards,
+ controllerPlayerName,
+ isProxy = false,
+ restoredActiveSkillId = null,
+ restoredActiveSkillName = null
+ }) => {
+ console.log('收到 play_undone 事件:', { playerId, playerName, cardsCount: cards.length });
+ const restoredSuffix = restoredActiveSkillName
+ ? `,${restoredActiveSkillName}次数已返还`
+ : '';
+ messageApi.info(`${isProxy ? `${controllerPlayerName} 撤回了代 ${playerName} 的出牌` : `${playerName} 撤回了出牌`}${restoredSuffix}`);
+ if (restoredActiveSkillId && playerId === currentPlayer?.id) {
+ setArmedActiveSkillId(null);
+ }
+ // 撤回可能改变当前最大者;先释放事件态,随后以 room_updated 中重算的赢家为准。
+ setCurrentWinningPlayerId(null);
+ // 清除该玩家的已出牌显示
+ setPlayedCards(prev => {
+ const updated = { ...prev };
+ delete updated[playerId];
+ playedCardsRef.current = updated;
+ console.log('撤回后的 playedCards:', Object.keys(updated));
+ return updated;
+ });
+
+ // 从出牌历史中移除该玩家的最后一次出牌
+ setPlayHistory(prev => {
+ const lastIndex = prev.map(p => p.playerId).lastIndexOf(playerId);
+ if (lastIndex !== -1) {
+ const updated = [...prev];
+ updated.splice(lastIndex, 1);
+ return updated;
+ }
+ return prev;
+ });
+
+ // 如果是自己撤回,将牌添加回手牌
+ if (playerId === currentPlayer?.id) {
+ console.log('将牌添加回手牌:', cards.length, '张');
+ cards.forEach(cardData => {
+ addCard(cardData);
+ });
+ }
+ });
+
+ socket.on('concealed_play_undone_private', ({ cards = [] }) => {
+ cards.forEach(cardData => addCard(cardData));
+ });
+
+ // 主牌更新
+ socket.on('trump_updated', ({
+ trumpSuit,
+ trumpRank,
+ inferiorSuit,
+ systemSelected = false,
+ oneCountryTwoSystems = null,
+ culturalRevolution = null,
+ culturalRevolutionExpired = null,
+ encircleThreeMissingOne = null
+ }) => {
+ console.log(`🃏 收到trump_updated事件: trumpSuit=${trumpSuit}, trumpRank=${trumpRank}`);
+ setTrumpSuit(trumpSuit);
+ setTrumpRank(trumpRank);
+ // 更新store中的主牌信息,自动重排手牌
+ setTrumpInfo(trumpSuit, trumpRank, inferiorSuit);
+ if (oneCountryTwoSystems) {
+ setOneCountryTwoSystemsState(oneCountryTwoSystems);
+ }
+ if (culturalRevolution) {
+ const actionLabel = culturalRevolution.declarationType === 'suit' ? '革花色' : '革点数';
+ const valueLabel = culturalRevolution.declarationType === 'suit'
+ ? TRANSFORMATION_SUITS.find(option => option.value === culturalRevolution.value)?.label
+ || culturalRevolution.value
+ : culturalRevolution.value;
+ messageApi.success(
+ `文化革命:${actionLabel} ${valueLabel},持续至第${culturalRevolution.expiresAfterRound}轮结束`,
+ 4
+ );
+ } else if (culturalRevolutionExpired) {
+ messageApi.info('文化革命效果结束,已恢复本局原主', 4);
+ } else if (encircleThreeMissingOne) {
+ const suitSymbol = {
+ hearts: '♥',
+ diamonds: '♦',
+ clubs: '♣',
+ spades: '♠'
+ }[encircleThreeMissingOne.nextTrumpSuit] || encircleThreeMissingOne.nextTrumpSuit;
+ messageApi.success(
+ `围三阙一:第${encircleThreeMissingOne.effectiveRound}轮起改为 ${suitSymbol} 主`,
+ 4
+ );
+ } else if (systemSelected && trumpSuit) {
+ const suitSymbol = {
+ hearts: '♥',
+ diamonds: '♦',
+ clubs: '♣',
+ spades: '♠'
+ }[trumpSuit] || trumpSuit;
+ messageApi.warning(`无人亮主,系统随机选择 ${suitSymbol} 为主花色`, 4);
+ } else if (oneCountryTwoSystems?.resolved) {
+ const resolved = oneCountryTwoSystems.resolved;
+ messageApi.info(
+ resolved.isNoTrump
+ ? '一国两制:双方无主'
+ : resolved.hasDistinctTeamSuits
+ ? '一国两制:双方主花色已分别锁定'
+ : '一国两制:双方共用主花色'
+ );
+ } else if (!oneCountryTwoSystems && trumpSuit && trumpRank) {
+ // 普通亮主已由玩家框内的牌标和右上角主牌区表达。
+ // 不再弹出第二条全局消息,避免发牌时遮住对家的亮牌。
+ console.log(`🃏 主牌已设置: ${trumpSuit} ${trumpRank}`);
+ } else if (trumpRank) {
+ console.log(`📢 级牌已设置: ${trumpRank}`);
+ }
+ });
+
+ // 甩牌失败
+ socket.on('throw_failed', ({
+ playerId,
+ playerName,
+ message: msg,
+ attemptedCardObjects,
+ forcedCards
+ }) => {
+ messageApi.warning(`${playerName} ${msg},实际出牌 ${forcedCards.length} 张`, 3);
+
+ // 先把完整的甩牌尝试留在牌桌上一秒;实际强制出牌由紧随其后的
+ // cards_played 在底层更新,预览退场后自然显露。
+ const previewKey = `${Date.now()}-${playerId}`;
+ const preview = getThrowFailedPreview(playerName, attemptedCardObjects, previewKey);
+ if (preview) {
+ setThrowFailedPreviews(previous => ({
+ ...previous,
+ [playerId]: preview
+ }));
+ }
+
+ const previewTimer = setTimeout(() => {
+ setThrowFailedPreviews(previous => {
+ if (previous[playerId]?.previewKey !== previewKey) return previous;
+ const next = { ...previous };
+ delete next[playerId];
+ return next;
+ });
+ throwFailedPreviewTimersRef.current.delete(previewKey);
+ }, THROW_FAILED_PREVIEW_DURATION_MS);
+ throwFailedPreviewTimersRef.current.set(previewKey, previewTimer);
+ });
+
+ // 毙牌动作
+ socket.on('trump_action', ({
+ type,
+ playerId,
+ playerName,
+ targetPlayerId,
+ targetPlayerName
+ }) => {
+ const key = `${Date.now()}-${playerId}-${type}`;
+ setTrumpAnimation({
+ key,
+ type,
+ playerId,
+ playerName,
+ targetPlayerId,
+ targetPlayerName
+ });
+ // 参考视频使用短促局部反馈:打击先结束,金字稍后退场。
+ setTimeout(() => {
+ setTrumpAnimation(current => current?.key === key ? null : current);
+ }, 1650);
+ });
+
+ // 亮主成功
+ socket.on('trump_declared', ({
+ playerId,
+ playerName,
+ suit,
+ count,
+ declarationType,
+ strength,
+ isCounter,
+ cards,
+ teamIndex,
+ declarationRole = 'trump',
+ oneCountryTwoSystems = false
+ }) => {
+ const action = declarationRole === 'inferior'
+ ? (isCounter ? '反劣' : '亮劣')
+ : (isCounter ? '反主' : '亮主');
+ const suitMap = {
+ 'spades': '♠',
+ 'hearts': '♥',
+ 'clubs': '♣',
+ 'diamonds': '♦',
+ 'joker': '王'
+ };
+ const suitSymbol = suitMap[suit] || suit;
+
+ console.log(`🎺 ${action}成功: ${playerName} ${action}了 ${count} 张 ${suitSymbol}`);
+ // 亮牌本身就是持续可见的结果;全局 message 会直接遮住顶部玩家和牌标。
+
+ const nextDeclaration = {
+ playerId: playerId,
+ playerName: playerName,
+ suit: suit,
+ count: count,
+ declarationType: declarationType,
+ strength: strength,
+ isCounter: isCounter,
+ declarationRole,
+ cards: cards || []
+ };
+ if (declarationRole === 'inferior') {
+ setCurrentInferiorDeclaration(nextDeclaration);
+ } else {
+ setCurrentTrumpDeclaration(nextDeclaration);
+ }
+
+ if (oneCountryTwoSystems && Number.isInteger(teamIndex)) {
+ setOneCountryTwoSystemsState(previous => ({
+ declarationsByTeam: {
+ ...(previous?.declarationsByTeam || {}),
+ [teamIndex]: {
+ playerId,
+ playerName,
+ suit,
+ count,
+ declarationType,
+ strength,
+ isCounter,
+ cards: cards || []
+ }
+ },
+ resolved: null,
+ hasJokerDeclaration: previous?.hasJokerDeclaration || suit === 'joker'
+ }));
+ }
+
+ // 立即同步主牌信息到本地并重排手牌,避免在网络延迟或缺少 trump_updated 事件前出现无主排序
+ try {
+ if (oneCountryTwoSystems || declarationRole === 'inferior') return;
+ const immediateTrumpSuit = suit !== 'joker' ? suit : 'no_trump';
+ console.log(`📡 即时更新主牌: ${immediateTrumpSuit}, trumpRank=${trumpRank}`);
+ setTrumpSuit(immediateTrumpSuit);
+ // trumpRank 保持不变(由房间配置决定),但也再次传入以保证排序正确
+ setTrumpInfo(
+ immediateTrumpSuit,
+ trumpRank,
+ suit === 'joker' ? null : threeSixNineState?.inferiorSuit || null
+ );
+ } catch (e) {
+ console.warn('同步主牌信息失败:', e);
+ }
+ });
+
+ socket.on('three_six_nine_updated', (state) => {
+ setThreeSixNineState(state);
+ setCurrentTrumpDeclaration(state?.currentTrumpDeclaration || null);
+ setCurrentInferiorDeclaration(state?.currentInferiorDeclaration || null);
+ const nextTrumpSuit = state?.trumpSuit ?? trumpSuit;
+ const nextTrumpRank = state?.trumpRank ?? trumpRank;
+ setTrumpSuit(nextTrumpSuit);
+ setTrumpRank(nextTrumpRank);
+ setTrumpInfo(nextTrumpSuit, nextTrumpRank, state?.inferiorSuit || null);
+ });
+
+ // 房间配置更新
+ socket.on('config_updated', ({ config }) => {
+ messageApi.success('房间设置已更新,将在下一局游戏生效');
+ setNewDealInterval(config.dealInterval);
+ });
+
+ socket.on('bot_type_updated', ({ botType, botTypeName }) => {
+ setNewBotType(botType);
+ messageApi.success(`Bot策略已切换为 ${botTypeName}`);
+ });
+
+ // 玩家昵称更新
+ socket.on('player_name_updated', ({ playerId, oldName, newName }) => {
+ if (playerId === currentPlayer?.id) {
+ messageApi.success(`昵称已修改为: ${newName}`);
+ } else {
+ messageApi.info(`${oldName} 修改昵称为: ${newName}`);
+ }
+ });
+
+ // 接收聊天消息
+ socket.on('chat_message_received', ({ playerName, message, timestamp }) => {
+ setChatHistory(prev => [...prev, { playerName, message, timestamp }]);
+ messageApi.info(`${playerName}: ${message}`);
+ });
+
+ // Bot添加
+ socket.on('bot_added', ({ player }) => {
+ messageApi.success(`Bot ${player.name} 已加入房间`);
+ });
+
+ // Bot移除
+ socket.on('bot_removed', ({ playerName }) => {
+ messageApi.info(`Bot ${playerName} 已离开房间`);
+ });
+
+ // 玩家准备状态更新
+ socket.on('player_ready_status', ({ playerName, isReady }) => {
+ messageApi.info(`${playerName} ${isReady ? '已准备' : '取消准备'}`);
+ });
+
+ // 所有玩家准备完毕
+ socket.on('all_players_ready', ({ message }) => {
+ messageApi.success(message);
+ });
+
+ // 规则选择
+ socket.on('rule_selected', ({ playerName, rule }) => {
+ setSelectedRule(rule);
+ setRuleSelectorModal(false);
+ const selectedNames = Array.isArray(rule.rules)
+ ? rule.rules.map(childRule => childRule.name).join(' + ')
+ : rule.name;
+ messageApi.info(`${playerName} 选择了规则: ${selectedNames}`);
+ });
+
+ socket.on('double_happiness_selection_started', () => {
+ messageApi.info('双喜临门:请从新的三条规则中选择两条');
+ });
+
+ socket.on('double_happiness_option_refreshed', ({
+ oldRule,
+ rule,
+ refreshedByPlayerName
+ }) => {
+ messageApi.info(
+ `${refreshedByPlayerName || '房主'}将“${oldRule?.name || '原候选'}”换成了“${rule?.name || '新候选'}”`
+ );
+ });
+
+ socket.on('candle_initial_state_selected', ({ selectorPlayerName, isLit }) => {
+ messageApi.info(`${selectorPlayerName}将烛${isLit ? '点燃' : '熄灭'},第1轮按该状态计分`);
+ });
+
+ // 庄家倒计时开始/重置
+ socket.on('dealer_countdown_start', ({ countdown }) => {
+ setDealerCountdown(countdown);
+ });
+
+ // 庄家倒计时结束
+ socket.on('dealer_countdown_end', () => {
+ setDealerCountdown(null);
+ });
+
+ // 回合状态更新
+ socket.on('round_updated', (roundUpdate) => {
+ console.log('收到 round_updated 事件:', roundUpdate);
+ if (roundUpdate.type === 'turn_changed') {
+ const currentPlayer = currentRoom.players[roundUpdate.currentPlayerIndex];
+ if (currentPlayer) {
+ messageApi.info(`现在轮到 ${currentPlayer.name} 出牌`);
+ }
+ } else if (roundUpdate.type === 'round_started') {
+ heldCompletedRoundNumberRef.current = null;
+ heldRoundCandleRef.current = null;
+ setHeldCompletedRoundNumber(null);
+ messageApi.success(roundUpdate.message || `轮次 ${roundUpdate.round} 开始`);
+ // 新一轮开始,清空出牌历史(因为是新的一轮,之前的牌不能再撤回)
+ setPlayHistory([]);
+ // 取消查看上轮状态
+ setViewingLastRound(false);
+ // 清除定时器
+ if (lastRoundTimer) {
+ clearTimeout(lastRoundTimer);
+ setLastRoundTimer(null);
+ }
+ } else if (roundUpdate.type === 'round_ended') {
+ // 必须在读取下轮公开快照前同步锁住刚结算的轮次。
+ heldCompletedRoundNumberRef.current = roundUpdate.round;
+ heldRoundCandleRef.current = typeof roundUpdate.candleTransition?.previousLit === 'boolean'
+ ? {
+ round: roundUpdate.round,
+ isLit: roundUpdate.candleTransition.previousLit
+ }
+ : heldRoundCandleRef.current;
+ if (roundUpdate.threeTigers?.triggeredSuit && roundUpdate.threeTigers.plays?.length) {
+ const updated = { ...playedCardsRef.current };
+ roundUpdate.threeTigers.plays.forEach(play => {
+ const current = updated[play.playerId];
+ if (!current || current.concealed) return;
+ updated[play.playerId] = {
+ ...current,
+ cards: play.cards,
+ cardsCount: play.cards.length,
+ threeTigersTransformed: true
+ };
+ });
+ playedCardsRef.current = updated;
+ setPlayedCards(updated);
+ }
+ if (roundUpdate.magicTrick?.triggered) {
+ const targetIds = new Set(roundUpdate.magicTrick.targetPlayerIds || []);
+ const updated = { ...playedCardsRef.current };
+ (roundUpdate.magicTrick.plays || []).forEach(play => {
+ if (!updated[play.playerId]) return;
+ updated[play.playerId] = {
+ ...updated[play.playerId],
+ cards: play.cards,
+ cardsCount: play.cards.length,
+ magicTrickSwapped: targetIds.has(play.playerId)
+ };
+ });
+ playedCardsRef.current = updated;
+ setPlayedCards(updated);
+ setMagicTrickPreparedRound(null);
+ setMagicTrickTargetIds([]);
+ messageApi.warning(
+ `魔术戏法揭晓:交换 ${roundUpdate.magicTrick.targetPlayerNames.join(' 与 ')} 的结算出牌`,
+ 4
+ );
+ }
+ if (roundUpdate.averagePooling?.triggered || roundUpdate.jointHarmony?.triggered) {
+ const updated = { ...playedCardsRef.current };
+ (roundUpdate.averagePooling?.teams || []).forEach(team => {
+ team.playerIds.forEach(playerId => {
+ if (updated[playerId]) updated[playerId] = { ...updated[playerId], averagePooling: true };
+ });
+ });
+ (roundUpdate.jointHarmony?.teams || []).forEach(team => {
+ team.playerIds.forEach(playerId => {
+ if (updated[playerId]) updated[playerId] = { ...updated[playerId], jointHarmony: true };
+ });
+ });
+ playedCardsRef.current = updated;
+ setPlayedCards(updated);
+ if (roundUpdate.averagePooling?.triggered) {
+ messageApi.info('平均池化:按队内有效单牌/对子的平均牌力重新结算本轮', 3);
+ }
+ if (roundUpdate.jointHarmony?.triggered) {
+ messageApi.success(
+ roundUpdate.jointHarmony.bothTeams
+ ? '双方同时珠联璧合,按牌面大小正常结算'
+ : '珠联璧合:达成一方视为最大,由该方后出者获得牌权',
+ 4
+ );
+ }
+ }
+ // 轮次结束,显示获胜者信息
+ if (roundUpdate.roundWinner) {
+ setCurrentWinningPlayerId(roundUpdate.roundWinner.playerId);
+ setLastRoundWinnerPlayerId(roundUpdate.roundWinner.playerId);
+ const nextLeader = roundUpdate.nextRoundLeader || roundUpdate.roundWinner;
+ const leaderChanged = nextLeader.playerId !== roundUpdate.roundWinner.playerId;
+ const resultMessage = leaderChanged
+ ? `第${roundUpdate.round}轮结束,${roundUpdate.roundWinner.playerName} 获胜;` +
+ `${nextLeader.playerName} 获得下一轮出牌权`
+ : `第${roundUpdate.round}轮结束,${roundUpdate.roundWinner.playerName} 获胜,获得下一轮出牌权`;
+ messageApi.success(resultMessage);
+ }
+ if (roundUpdate.trumpWins?.leaderPlayerId) {
+ const tiedCount = (roundUpdate.trumpWins.players || []).filter(
+ player => player.points === roundUpdate.trumpWins.highestPoints
+ ).length;
+ messageApi.info(
+ `Trump wins:${roundUpdate.trumpWins.leaderPlayerName} 以 ${roundUpdate.trumpWins.highestPoints} 分获得牌权` +
+ (tiedCount > 1 ? '(并列中最先出牌)' : ''),
+ 4
+ );
+ }
+ if (roundUpdate.oldHorse?.armedNow) {
+ const protectedPlayer = currentRoom.players.find(
+ player => player.id === roundUpdate.oldHorse.protectedPlayerId
+ );
+ messageApi.warning(
+ `老骥伏枥:${protectedPlayer?.name || '最后获得牌权者'} 下次合法首发绝对最大`,
+ 4
+ );
+ }
+ if (roundUpdate.turnDirectionChange) {
+ const directionName = roundUpdate.turnDirectionChange.nextDirection === 'clockwise'
+ ? '顺时针'
+ : '逆时针';
+ messageApi.warning(`路线摇摆:下一轮改为${directionName}出牌`, 3);
+ }
+ if (roundUpdate.abruptStop?.triggered) {
+ messageApi.warning('戛然而止:本轮完整结算后结束牌局,底牌仍按本轮胜负结算', 5);
+ }
+ if (roundUpdate.secondBattlefield?.triggered) {
+ const battle = roundUpdate.secondBattlefield;
+ const awardText = battle.scoreDelta > 0
+ ? '闲家阵营获得5分'
+ : battle.scoreDelta < 0
+ ? '庄家阵营获得5分'
+ : '跨阵营并列,双方各得5分';
+ messageApi.success(
+ `第二战场第${battle.showdownNumber}场:系统判定${battle.winnerPlayerNames.join('、')}` +
+ `以${battle.winningCategoryName}胜出,${awardText}`,
+ 5
+ );
+ }
+ // 处理得分信息
+ if (roundUpdate.scoreInfo && !roundUpdate.scoreInfo.hidden) {
+ const {
+ roundPoints,
+ roundPointCards = [],
+ winnerIsAttacker,
+ attackerScore: newScore,
+ collectedPointCards: newCards,
+ attackerRoundPointsAwarded = winnerIsAttacker ? roundPoints : 0,
+ accidentInsuranceWithheld = 0,
+ accidentInsuranceBonus = 0,
+ ambushRank,
+ ambushCardCount = 0,
+ ambushScoreDelta = 0,
+ repeatedExhaustion = null,
+ inviteIntoUrn = null,
+ outwardHarmonyInnerDivision = null,
+ fearOfBreakingVase = null,
+ ironEvidence = null,
+ destroyDyke = null,
+ weighingThousandJin = null,
+ focusFigureScoringPending = false
+ } = roundUpdate.scoreInfo;
+ if (weighingThousandJin) {
+ const {
+ mode,
+ outrankedAttackerCount = 0,
+ originalRoundPoints = 0,
+ adjustedRoundPoints = 0
+ } = weighingThousandJin;
+ messageApi.info(
+ mode === 'subtract_five'
+ ? `上称千斤:庄家压过${outrankedAttackerCount}名闲家,每张分牌−5;` +
+ `本轮${originalRoundPoints}分调整为${adjustedRoundPoints}分`
+ : `上称千斤:庄家未压过闲家,每张分牌×2;` +
+ `本轮${originalRoundPoints}分调整为${adjustedRoundPoints}分`,
+ 4
+ );
+ }
+ if (destroyDyke?.status === 'activated' && winnerIsAttacker) {
+ messageApi.warning(
+ `毁堤淹田:本轮${roundPoints}分作废,闲家总分仍为${newScore}分`,
+ 4
+ );
+ } else if (focusFigureScoringPending && winnerIsAttacker && roundPoints > 0) {
+ messageApi.info(
+ `闲家收下 ${roundPointCards.length} 张分牌;焦点身份与实际得分将在终局揭晓`,
+ 4
+ );
+ } else if (winnerIsAttacker && roundPoints > 0) {
+ if (accidentInsuranceWithheld > 0) {
+ messageApi.warning(
+ `意外保险:本轮牌面 ${roundPoints} 分,闲家只计 ${attackerRoundPointsAwarded} 分,总分:${newScore}分`,
+ 4
+ );
+ } else {
+ messageApi.info(`闲家得${attackerRoundPointsAwarded}分,总分:${newScore}分`, 3);
+ }
+ } else if (accidentInsuranceBonus > 0) {
+ messageApi.warning(
+ `意外保险:庄家方赢得 ${roundPoints} 分,超出的 ${accidentInsuranceBonus} 分补给闲家,总分:${newScore}分`,
+ 4
+ );
+ }
+ if (ambushCardCount > 0) {
+ messageApi.warning(
+ `十面埋伏 ${ambushRank} × ${ambushCardCount}:闲家${ambushScoreDelta > 0 ? '+' : ''}${ambushScoreDelta}分,总分:${newScore}分`,
+ 4
+ );
+ }
+ if (repeatedExhaustion?.penalty > 0) {
+ messageApi.warning(
+ `再衰三竭:${repeatedExhaustion.playerName}连续第${repeatedExhaustion.streak}轮最大,` +
+ `失去${repeatedExhaustion.penalty}分;闲家总分${repeatedExhaustion.scoreDelta > 0 ? '+' : ''}` +
+ `${repeatedExhaustion.scoreDelta},当前${newScore}分`,
+ 5
+ );
+ }
+ if (fearOfBreakingVase?.triggered) {
+ const triggerText = fearOfBreakingVase.triggerType === 'leader_over_protected_teammate'
+ ? `${fearOfBreakingVase.winnerPlayerName}从首家守到最大,但队友` +
+ `${fearOfBreakingVase.vesselPlayerName}打出了至少两对或两张王`
+ : `${fearOfBreakingVase.winnerPlayerName}作为第二家独自毙牌,而队友` +
+ `${fearOfBreakingVase.vesselPlayerName}本是其余三家最大`;
+ messageApi.warning(
+ `投鼠忌器:${triggerText};` +
+ `${fearOfBreakingVase.penalizedSide === 'attacker' ? '闲家方' : '庄家方'}` +
+ `失去${fearOfBreakingVase.penalty}分,闲家当前${newScore}分`,
+ 6
+ );
+ }
+ if (inviteIntoUrn?.triggeredCount > 0) {
+ const triggeredNames = inviteIntoUrn.declarations
+ .filter(declaration => declaration.triggered)
+ .map(declaration => declaration.targetPlayerName)
+ .join('、');
+ messageApi.warning(
+ `请君入瓮:${triggeredNames} 命中指定牌面;闲家总分` +
+ `${inviteIntoUrn.scoreDelta > 0 ? '+' : ''}${inviteIntoUrn.scoreDelta},` +
+ `当前${inviteIntoUrn.attackerScore}分`,
+ 5
+ );
+ }
+ if (outwardHarmonyInnerDivision?.triggered) {
+ const descriptions = [];
+ if (outwardHarmonyInnerDivision.dealerTeam?.mismatched) {
+ const patterns = outwardHarmonyInnerDivision.dealerTeam.players
+ .map(player => `${player.playerName}:${player.patternLabel}`)
+ .join('、');
+ descriptions.push(`庄家方牌型不一致(${patterns}),闲家获得5分`);
+ }
+ if (outwardHarmonyInnerDivision.attackerTeam?.mismatched) {
+ const patterns = outwardHarmonyInnerDivision.attackerTeam.players
+ .map(player => `${player.playerName}:${player.patternLabel}`)
+ .join('、');
+ descriptions.push(`闲家方牌型不一致(${patterns}),庄家获得5分`);
+ }
+ messageApi.warning(
+ `貌合神离:${descriptions.join(';')}。闲家当前${outwardHarmonyInnerDivision.attackerScore}分`,
+ 6
+ );
+ }
+ if (ironEvidence?.mode === 'zero' && ironEvidence.specialCardCount > 0) {
+ messageApi.warning(
+ `铁证如山:本轮出现 ${ironEvidence.specialCardCount} 张铁证牌,牌面 ${ironEvidence.baseRoundPoints} 分清零`,
+ 4
+ );
+ } else if (ironEvidence?.specialCardCount > 0) {
+ messageApi.info(
+ `铁证如山:本轮出现 ${ironEvidence.specialCardCount} 张铁证牌,` +
+ `${ironEvidence.baseRoundPoints} × ${ironEvidence.multiplier} = ${ironEvidence.roundPoints} 分`,
+ 4
+ );
+ }
+ if (Number.isFinite(newScore)) setAttackerScore(newScore);
+ setCollectedPointCards(newCards || []);
+ }
+ if (roundUpdate.candleTransition?.changed) {
+ messageApi.info(
+ `第四手为纯${roundUpdate.candleTransition.triggerColor === 'red' ? '红' : '黑'}色;` +
+ `本轮结算完成,下轮烛将${roundUpdate.candleTransition.nextLit ? '点燃' : '熄灭'}`,
+ 3
+ );
+ }
+ // 轮末短暂保留完整牌面和赢家提示;如堕云雾只是不保存可回看的上轮历史。
+ const completedRoundCards = playedCardsRef.current;
+ if (roundClearTimerRef.current) {
+ clearTimeout(roundClearTimerRef.current);
+ roundClearTimerRef.current = null;
+ }
+ if (isLostInFogRule) {
+ setLastRoundPlayedCards({});
+ setViewingLastRound(false);
+ } else {
+ console.log('保存上轮出牌,玩家数量:', Object.keys(completedRoundCards).length, completedRoundCards);
+ setLastRoundPlayedCards(completedRoundCards);
+ }
+ awaitingRoundClearRef.current = true;
+ setHeldCompletedRoundNumber(roundUpdate.round);
+ setIsHoldingCompletedRound(true);
+ const completedRoundHoldMs = ruleIncludesId(
+ currentRoom?.gameState?.selectedRule,
+ 'time_reversal'
+ )
+ ? 2000
+ : roundUpdate.secondBattlefield?.triggered
+ ? 2600
+ : roundUpdate.magicTrick?.triggered
+ ? 1800
+ : 1000;
+ roundClearTimerRef.current = setTimeout(() => {
+ if (!awaitingRoundClearRef.current) return;
+ awaitingRoundClearRef.current = false;
+ heldCompletedRoundNumberRef.current = null;
+ heldRoundCandleRef.current = null;
+ roundClearTimerRef.current = null;
+ playedCardsRef.current = {};
+ setPlayedCards({});
+ setCurrentWinningPlayerId(null);
+ setIsHoldingCompletedRound(false);
+ setHeldCompletedRoundNumber(null);
+ }, completedRoundHoldMs);
+ // 清空出牌历史
+ setPlayHistory([]);
+ }
+ });
+
+ return () => {
+ socket.off('game_started');
+ socket.off('drawing_started');
+ socket.off('candle_initial_state_selected');
+ socket.off('card_dealt');
+ socket.off('deal_progress');
+ socket.off('card_exchange_started');
+ socket.off('card_exchange_submitted');
+ socket.off('card_exchange_resolved');
+ socket.off('mainstay_started');
+ socket.off('mainstay_decision_required');
+ socket.off('mainstay_cards_required');
+ socket.off('mainstay_player_skipped');
+ socket.off('mainstay_decision_resolved');
+ socket.off('mainstay_transfer_resolved');
+ socket.off('mainstay_hand_updated');
+ socket.off('mainstay_completed');
+ socket.off('happy_twins_positions_swapped');
+ socket.off('happy_twins_positions_restored');
+ socket.off('encircle_three_missing_one_transition');
+ socket.off('planned_economy_cards_drawn');
+ socket.off('magic_trick_prepared');
+ socket.off('equivalent_reciprocity_started');
+ socket.off('equivalent_reciprocity_card_required');
+ socket.off('equivalent_reciprocity_selection_recorded');
+ socket.off('equivalent_reciprocity_resolved');
+ socket.off('equivalent_reciprocity_hand_updated');
+ socket.off('mutual_support_started');
+ socket.off('mutual_support_cards_required');
+ socket.off('mutual_support_transfer_resolved');
+ socket.off('mutual_support_hand_updated');
+ socket.off('straw_boat_borrowing_arrows_required');
+ socket.off('straw_boat_borrowing_arrows_resolved');
+ socket.off('straw_boat_borrowing_arrows_hand_updated');
+ socket.off('card_exchange_hand_updated');
+ socket.off('whole_hand_exchange_resolved');
+ socket.off('whole_hand_exchange_hand_updated');
+ socket.off('remove_firewood_exchange_started');
+ socket.off('remove_firewood_decision_required');
+ socket.off('remove_firewood_decision_resolved');
+ socket.off('remove_firewood_exchange_completed');
+ socket.off('last_stand_decision_required');
+ socket.off('last_stand_hand_updated');
+ socket.off('last_stand_activated');
+ socket.off('teammate_cheer_decision_required');
+ socket.off('teammate_cheer_hand_updated');
+ socket.off('teammate_cheer_activated');
+ socket.off('teammate_cheer_declined');
+ socket.off('teammate_cheer_reverted');
+ socket.off('afterglow_decision_required');
+ socket.off('afterglow_hand_updated');
+ socket.off('afterglow_activated');
+ socket.off('afterglow_declined');
+ socket.off('afterglow_expired');
+ socket.off('afterglow_reverted');
+ socket.off('wooden_ox_decision_required');
+ socket.off('wooden_ox_private_state');
+ socket.off('wooden_ox_hand_updated');
+ socket.off('wooden_ox_action_recorded');
+ socket.off('wooden_ox_round_ready');
+ socket.off('wooden_ox_transferred');
+ socket.off('strength_compensation_hand_updated');
+ socket.off('defense_as_offense_hand_updated');
+ socket.off('time_reversal_activated');
+ socket.off('time_reversal_decision_required');
+ socket.off('time_reversal_hand_restored');
+ socket.off('time_reversal_response_recorded');
+ socket.off('time_reversal_resolved');
+ socket.off('nine_princes_selection_required');
+ socket.off('nine_princes_decision_pending');
+ socket.off('nine_princes_hand_updated');
+ socket.off('nine_princes_resolved');
+ socket.off('forbidden_magic_reserved');
+ socket.off('forbidden_magic_decision_required');
+ socket.off('forbidden_magic_activated');
+ socket.off('forbidden_magic_declined');
+ socket.off('forbidden_magic_decisions_completed');
+ socket.off('lure_tiger_reserved');
+ socket.off('lure_tiger_decision_required');
+ socket.off('lure_tiger_target_required');
+ socket.off('lure_tiger_activated');
+ socket.off('lure_tiger_declined');
+ socket.off('lure_tiger_decisions_completed');
+ socket.off('cards_shown');
+ socket.off('bottom_cards_received');
+ socket.off('secondary_burying_started');
+ socket.off('secondary_bottom_cards_received');
+ socket.off('burying_player_set');
+ socket.off('dealer_selected');
+ socket.off('open_hand_revealed');
+ socket.off('iceberg_reveal_selection_required');
+ socket.off('iceberg_reveal_selection_confirmed');
+ socket.off('waiting_rabbit_selection_started');
+ socket.off('waiting_rabbit_selection_required');
+ socket.off('waiting_rabbit_target_selected');
+ socket.off('waiting_rabbit_target_locked');
+ socket.off('waiting_rabbit_triggered');
+ socket.off('waiting_rabbit_exchange_required');
+ socket.off('waiting_rabbit_resolved');
+ socket.off('waiting_rabbit_hand_updated');
+ socket.off('ten_sided_ambush_selection_started');
+ socket.off('ten_sided_ambush_selection_required');
+ socket.off('ten_sided_ambush_rank_selected');
+ socket.off('ten_sided_ambush_rank_locked');
+ socket.off('ten_sided_ambush_revealed');
+ socket.off('three_powers_selection_started');
+ socket.off('three_powers_selection_required');
+ socket.off('three_powers_rank_selected');
+ socket.off('three_powers_rank_locked');
+ socket.off('three_powers_revealed');
+ socket.off('gentleman_promise_selection_started');
+ socket.off('gentleman_promise_selection_required');
+ socket.off('gentleman_promise_declared');
+ socket.off('gentleman_promise_completed');
+ socket.off('hidden_dragon_selection_started');
+ socket.off('hidden_dragon_selection_required');
+ socket.off('hidden_dragon_declared');
+ socket.off('hidden_dragon_completed');
+ socket.off('hidden_dragon_resolved');
+ socket.off('hidden_dragon_reverted');
+ socket.off('antinomy_selection_started');
+ socket.off('antinomy_selection_required');
+ socket.off('antinomy_selection_submitted');
+ socket.off('antinomy_declarations_revealed');
+ socket.off('rice_to_mulberry_selection_started');
+ socket.off('rice_to_mulberry_selection_required');
+ socket.off('rice_to_mulberry_hand_updated');
+ socket.off('rice_to_mulberry_transformed');
+ socket.off('rice_to_mulberry_completed');
+ socket.off('surrender_requested');
+ socket.off('surrender_decision_pending');
+ socket.off('surrender_decision_required');
+ socket.off('surrender_rejected');
+ socket.off('game_surrendered');
+ socket.off('destroy_dyke_decision_pending');
+ socket.off('destroy_dyke_decision_required');
+ socket.off('destroy_dyke_activated');
+ socket.off('destroy_dyke_declined');
+ socket.off('destroy_dyke_disaster_updated');
+ socket.off('destroy_dyke_disaster_resolved');
+ socket.off('administrative_review_selection_started');
+ socket.off('administrative_review_selection_required');
+ socket.off('administrative_review_declared');
+ socket.off('administrative_review_completed');
+ socket.off('administrative_review_progressed');
+ socket.off('administrative_review_burying_unlocked');
+ socket.off('administrative_review_buried');
+ socket.off('political_review_play_pending');
+ socket.off('political_review_play_held');
+ socket.off('political_review_decision_required');
+ socket.off('political_review_play_approved');
+ socket.off('political_review_resolved');
+ socket.off('focus_figure_voting_started');
+ socket.off('focus_figure_vote_required');
+ socket.off('focus_figure_vote_recorded');
+ socket.off('focus_figure_nominee_changed');
+ socket.off('focus_figure_team_finalized');
+ socket.off('focus_figure_voting_completed');
+ socket.off('rule_visible_hands_updated');
+ socket.off('cards_buried');
+ socket.off('first_player_set');
+ socket.off('active_skill_activated');
+ socket.off('invite_into_urn_activated');
+ socket.off('bush_gate_activated');
+ socket.off('dream_killing_started');
+ socket.off('dream_killing_awakened');
+ socket.off('cards_played');
+ socket.off('ambiguous_choice_required');
+ socket.off('ambiguous_choice_pending');
+ socket.off('ambiguous_choice_resolved');
+ socket.off('ambiguous_hand_updated');
+ socket.off('ambiguous_round_resolved');
+ socket.off('three_tigers_transformed');
+ socket.off('concealed_cards_played_private');
+ socket.off('concealed_plays_revealed');
+ socket.off('turn_passed');
+ socket.off('play_undone');
+ socket.off('concealed_play_undone_private');
+ socket.off('bottom_revealed');
+ socket.off('my_bottom_cards');
+ socket.off('player_ready_for_next');
+ socket.off('next_game_started');
+ socket.off('game_restarted');
+ socket.off('score_updated');
+ socket.off('level_updated');
+ socket.off('trump_updated');
+ socket.off('throw_failed');
+ socket.off('trump_action');
+ socket.off('trump_declared');
+ socket.off('three_six_nine_updated');
+ socket.off('config_updated');
+ socket.off('bot_type_updated');
+ socket.off('player_name_updated');
+ socket.off('chat_message_received');
+ socket.off('bot_added');
+ socket.off('bot_removed');
+ socket.off('player_ready_status');
+ socket.off('all_players_ready');
+ socket.off('rule_selected');
+ socket.off('double_happiness_selection_started');
+ socket.off('double_happiness_option_refreshed');
+ socket.off('dealer_countdown_start');
+ socket.off('dealer_countdown_end');
+ socket.off('trump_action');
+ socket.off('round_updated');
+ if (tenSidedAmbushAnimationTimerRef.current) {
+ clearTimeout(tenSidedAmbushAnimationTimerRef.current);
+ tenSidedAmbushAnimationTimerRef.current = null;
+ }
+ if (threePowersAnimationTimerRef.current) {
+ clearTimeout(threePowersAnimationTimerRef.current);
+ threePowersAnimationTimerRef.current = null;
+ }
+ };
+ }, [socket, messageApi, clearSelection, addCard, removeCards, currentPlayer, currentRoom]);
+
+ // GameBoard 的私密事件监听器全部挂载后再主动拉取一次个人待办。
+ // 依赖 socketId 可同时覆盖整页刷新和 Socket 断线后换连接恢复,且不会随普通 room_updated 重复请求。
+ useEffect(() => {
+ if (!socket || !currentRoom?.id || !currentPlayer?.id) return;
+ // 同一连接内拦截 StrictMode 等造成的重复重放;一旦换了连接,则必须允许
+ // 尚未送达服务端的政治审查放行凭证重新提交。
+ politicalReviewApprovalSubmittingRef.current.clear();
+ socket.emit(SOCKET_EVENTS.REQUEST_PRIVATE_GAME_STATE_SYNC, {
+ roomId: currentRoom.id
+ });
+ }, [
+ socket,
+ currentRoom?.id,
+ currentPlayer?.id,
+ currentPlayer?.socketId
+ ]);
+
+ // 庄家倒计时递减
+ useEffect(() => {
+ if (dealerCountdown === null || dealerCountdown <= 0) return;
+
+ const timer = setInterval(() => {
+ setDealerCountdown(prev => {
+ if (prev === null || prev <= 1) {
+ return null;
+ }
+ return prev - 1;
+ });
+ }, 1000);
+
+ return () => clearInterval(timer);
+ }, [dealerCountdown]);
+
+ // 断线重连或刷新后,当前被轮询的玩家仍能恢复不可关闭的确认框。
+ useEffect(() => {
+ if (forbiddenMagicState?.decisionPlayerId === currentPlayer?.id) {
+ setForbiddenMagicDecision({ round: forbiddenMagicState.decisionRound });
+ } else {
+ setForbiddenMagicDecision(null);
+ }
+ }, [
+ forbiddenMagicState?.decisionPlayerId,
+ forbiddenMagicState?.decisionRound,
+ currentPlayer?.id
+ ]);
+
+ // 调虎离山分“确认发动”和“选择目标”两步;刷新后按服务端阶段恢复同一个强制窗口。
+ useEffect(() => {
+ const decision = lureTigerState?.currentDecision;
+ if (decision?.playerId === currentPlayer?.id) {
+ setLureTigerDecision(decision);
+ } else {
+ setLureTigerDecision(null);
+ }
+ }, [
+ lureTigerState?.currentDecision?.playerId,
+ lureTigerState?.currentDecision?.stage,
+ lureTigerState?.currentDecision?.round,
+ currentPlayer?.id
+ ]);
+
+ // 釜底抽薪发生在亮主结束后的冻结阶段;刷新后仍应恢复到当前被询问者的可选确认框。
+ useEffect(() => {
+ const decision = removeFirewoodState?.currentDecision;
+ if (decision?.counteredPlayerId === currentPlayer?.id) {
+ setRemoveFirewoodDecision(decision);
+ } else {
+ setRemoveFirewoodDecision(null);
+ }
+ }, [
+ removeFirewoodState?.currentDecision?.sequence,
+ removeFirewoodState?.currentDecision?.counteredPlayerId,
+ currentPlayer?.id
+ ]);
+
+ // 只有权威房间快照确认出牌人或轮次发生变化后,才解除上一手的同步保护。
+ // round_updated 只是先到达的提示事件,不能提前解锁。
+ useEffect(() => {
+ clearSelection();
+ suppressAutoSelectionRef.current = false;
+ setJustPlayedCards(false);
+ setArmedActiveSkillId(null);
+ setLateMoverDecisionOpen(false);
+ setCulturalRevolutionSelection(null);
+ }, [gameState?.currentPlayerIndex, gameState?.currentRound, clearSelection]);
+
+ // 自动选中必须出的牌
+ useEffect(() => {
+ // 只在出牌阶段且轮到当前玩家时执行
+ if (phase !== GamePhases.PLAYING || !gameState || !currentPlayer) return;
+ if (icebergSelection || hasPendingIcebergSelection) return;
+ if (isActiveSkillArmed) return;
+
+ const isMyTurn = isCurrentPlayersTurn(gameState, currentRoom?.players, currentPlayer?.id);
+
+ if (!isMyTurn) return;
+
+ // 如果刚刚出过牌,不要自动选中
+ if (suppressAutoSelectionRef.current || justPlayedCards) return;
+
+ // 检查当前玩家是否已经在本轮出过牌了
+ const hasPlayedThisRound = playedCards[currentTurnOwner?.id] !== undefined;
+ if (hasPlayedThisRound) return; // 已经出过牌了,不要再自动选中
+
+ // 检查是否是首发
+ const isLeading = gameState?.currentRoundPlays === 0 ||
+ gameState?.playersPlayedThisRound?.length === 0 ||
+ (Array.isArray(gameState?.playersPlayedThisRound) && gameState.playersPlayedThisRound.length === 0);
+
+ // 如果是首发,不自动选中(让用户自己决定出什么牌)
+ if (isLeading) return;
+
+ // 如果是跟牌,使用辅助函数计算必须出的牌
+ const leadingPattern = gameState?.leadingPattern;
+ if (!leadingPattern || activePlayCards.length === 0) return;
+
+ const regularHandCards = activePlayCards.filter(card => !card.isWoodenOxCard);
+ const finalTrickCardIds = getFinalTrickAutoSelectedCardIds({
+ gameState,
+ players: currentRoom?.players,
+ handCards: regularHandCards
+ });
+ const cardsToAutoSelect = finalTrickCardIds ? null : calculateMustPlayCards(
+ mapOneCountryCardsForCurrentPlayer(
+ regularHandCards,
+ gameState
+ ),
+ leadingPattern,
+ trumpSuit,
+ trumpRank,
+ gameState?.selectedRule
+ ? {
+ ...gameState.selectedRule,
+ currentRound: gameState.currentRound,
+ inferiorSuit: gameState?.threeSixNine?.inferiorSuit || null,
+ antinomySplitFaceKeys: Object.values(
+ gameState?.antinomy?.declarationsByPlayerId || {}
+ )
+ .filter(declaration => declaration?.effective)
+ .map(declaration => declaration.faceKey)
+ }
+ : null
+ );
+
+ // 如果有需要自动选中的牌,进行选中
+ const cardIdsToSelect = finalTrickCardIds
+ || cardsToAutoSelect?.map(card => card.id)
+ || [];
+ if (cardIdsToSelect.length > 0) {
+
+ // 检查当前选中的牌是否已经包含了所有必须出的牌
+ const mustPlayCardIds = new Set(cardIdsToSelect);
+ const currentlySelectedMustCards = selectedCards.filter(id => mustPlayCardIds.has(id));
+
+ // 如果用户已经选中了所有必须出的牌,并且还选了其他牌(在手动凑数),不要重置选中状态
+ if (currentlySelectedMustCards.length === cardIdsToSelect.length && selectedCards.length > cardIdsToSelect.length) {
+ return; // 用户正在手动添加其他牌,不干扰
+ }
+
+ // 只在选中的牌不同时才更新(避免无限循环)
+ const currentSorted = [...selectedCards].sort().join(',');
+ const newSorted = [...cardIdsToSelect].sort().join(',');
+ if (currentSorted !== newSorted) {
+ setSelectedCards(cardIdsToSelect);
+ }
+ }
+ }, [phase, gameState?.currentPlayerIndex, gameState?.currentRoundPlays, gameState?.playersPlayedThisRound,
+ gameState?.leadingPattern, gameState?.selectedRule?.id, gameState?.oneCountryTwoSystems?.resolved,
+ gameState?.antinomy?.declarationsByPlayerId,
+ activePlayCards, trumpSuit, trumpRank,
+ currentPlayer, currentRoom,
+ gameState?.playMode, selectedCards, setSelectedCards, playedCards, justPlayedCards,
+ icebergSelection, hasPendingIcebergSelection, isActiveSkillArmed]);
+
+ useEffect(() => {
+ if (!armedActiveSkillId) return;
+ if (activeSkill?.id !== armedActiveSkillId || !activeSkillAvailability.canActivate) {
+ setArmedActiveSkillId(null);
+ }
+ }, [
+ armedActiveSkillId,
+ activeSkill?.id,
+ activeSkillAvailability.canActivate
+ ]);
+
+ useEffect(() => {
+ if (!isActiveSkillArmed || activeSkill?.effect !== 'two_legal_plays_choose_at_round_end') {
+ setAmbiguousFirstOptionCardIds([]);
+ }
+ }, [isActiveSkillArmed, activeSkill?.effect]);
+
+ useEffect(() => {
+ const isDivineWeaponArmed = isActiveSkillArmed
+ && activeSkill?.effect === 'transform_matching_card';
+ const targetStillExists = divineWeapon?.cards?.some(
+ card => card.id === selectedDivineWeaponCardId
+ );
+ if (!isDivineWeaponArmed || (selectedDivineWeaponCardId && !targetStillExists)) {
+ setSelectedDivineWeaponCardId(null);
+ setDivineWeaponSourceCardId(null);
+ }
+ }, [
+ isActiveSkillArmed,
+ activeSkill?.effect,
+ divineWeapon?.generation,
+ divineWeapon?.cards,
+ selectedDivineWeaponCardId
+ ]);
+
+ // 同步主牌状态和规则
+ useEffect(() => {
+ if (gameState) {
+ setTrumpSuit(gameState.trumpSuit);
+ setTrumpRank(gameState.trumpRank);
+ setSelectedRule(gameState.selectedRule || null);
+ }
+ }, [gameState]);
+
+ // 同步房间配置
+ useEffect(() => {
+ if (currentRoom?.config) {
+ setNewDealInterval(currentRoom.config.dealInterval);
+ setNewBotType(currentRoom.config.botType || 'who_designed');
+ }
+ }, [currentRoom]);
+
+ // 开始游戏
+ const handleStartGame = () => {
+ socket.emit(SOCKET_EVENTS.START_GAME, { roomId: currentRoom.id });
+ };
+
+ // 玩家准备
+ const handlePlayerReady = () => {
+ socket.emit('player_ready', { roomId: currentRoom.id });
+ };
+
+ // 亮主处理
+ const handleDeclare = (suitType, count, declarationRole = 'trump') => {
+ // 映射suitType到实际的花色
+ const suitMap = {
+ 'spades': 'spades',
+ 'hearts': 'hearts',
+ 'clubs': 'clubs',
+ 'diamonds': 'diamonds',
+ 'joker': 'joker'
+ };
+
+ const suit = suitMap[suitType];
+ if (!suit) {
+ messageApi.error('无效的花色');
+ return;
+ }
+
+ console.log(`🎺 尝试亮${declarationRole === 'inferior' ? '劣' : '主'}: ${suitType}, 数量: ${count}`);
+
+ // 发送亮主请求到服务器
+ socket.emit(SOCKET_EVENTS.DECLARE_TRUMP, {
+ roomId: currentRoom.id,
+ suit: suit,
+ count: count,
+ declarationRole
+ });
+ };
+
+ // 设置埋底玩家
+ const handleSetBuryingPlayer = () => {
+ if (!selectedBuryingPlayer) {
+ messageApi.warning('请选择埋底玩家');
+ return;
+ }
+ socket.emit(SOCKET_EVENTS.SET_BURYING_PLAYER, {
+ roomId: currentRoom.id,
+ playerId: selectedBuryingPlayer
+ });
+ setBuryingPlayerModal(false);
+ };
+
+ // 埋底
+ const handleBuryCards = () => {
+ if (!isBurySelectionValid(selectedCards, activeBottomCardsCount)) {
+ messageApi.warning(`请选择 ${activeBottomCardsCount} 张牌进行埋底`);
+ return;
+ }
+ const cardsToRemove = [...selectedCards];
+ socket.emit(SOCKET_EVENTS.BURY_CARDS, {
+ roomId: currentRoom.id,
+ cardIds: cardsToRemove
+ });
+ // 立即从手牌中移除(乐观更新)
+ removeCards(cardsToRemove);
+ clearSelection();
+ };
+
+ // 设置首发玩家
+ const handleSetFirstPlayer = () => {
+ if (!selectedFirstPlayer) {
+ messageApi.warning('请选择首发玩家');
+ return;
+ }
+ socket.emit(SOCKET_EVENTS.SET_FIRST_PLAYER, {
+ roomId: currentRoom.id,
+ playerId: selectedFirstPlayer
+ });
+ setFirstPlayerModal(false);
+ };
+
+ // 出牌
+ const handleEquivalentReciprocityTarget = (player) => {
+ if (
+ activeSkill?.effect === 'swap_two_plays_at_round_end'
+ && isActiveSkillArmed
+ && player
+ ) {
+ if (player.id === currentPlayer?.id) {
+ messageApi.warning('魔术戏法不能选择自己');
+ return;
+ }
+ setMagicTrickTargetIds(previous => previous.includes(player.id)
+ ? previous.filter(playerId => playerId !== player.id)
+ : previous.length < 2
+ ? [...previous, player.id]
+ : previous
+ );
+ return;
+ }
+ if (
+ activeSkill?.effect !== 'compare_and_exchange'
+ || !isActiveSkillArmed
+ || !player
+ || player.id === currentPlayer?.id
+ ) return;
+ if ((player.cardsCount || 0) < 1) {
+ messageApi.warning('对方已经没有手牌,不能与其拼点');
+ return;
+ }
+ setEquivalentReciprocityTarget(player);
+ };
+
+ const handleSubmitEquivalentReciprocityCard = () => {
+ if (!equivalentReciprocitySelection || !equivalentReciprocityCardId) {
+ messageApi.warning('请选择一张拼点牌');
+ return;
+ }
+ socket.emit(SOCKET_EVENTS.SUBMIT_EQUIVALENT_RECIPROCITY_CARD, {
+ roomId: currentRoom.id,
+ challengeId: equivalentReciprocitySelection.challengeId,
+ cardId: equivalentReciprocityCardId
+ });
+ };
+
+ const handleRespondMainstay = (accept) => {
+ if (!ownMainstayAction || ownMainstayAction.stage !== 'decision') return;
+ socket.emit(SOCKET_EVENTS.RESPOND_MAINSTAY, {
+ roomId: currentRoom.id,
+ accept
+ });
+ };
+
+ const handleToggleMainstayCard = (cardId) => {
+ if (!ownMainstayAction || !['give', 'return'].includes(ownMainstayAction.stage)) return;
+ const card = myCards.find(value => value.id === cardId);
+ setMainstaySelectedCardIds(previous => {
+ if (previous.includes(cardId)) {
+ if (
+ ownMainstayAction.stage === 'give'
+ && card
+ && isTrumpCard(card, gameState?.trumpSuit, gameState?.trumpRank)
+ ) {
+ messageApi.warning('发动中流砥柱时必须交出当前全部主牌');
+ return previous;
+ }
+ return previous.filter(id => id !== cardId);
+ }
+ if (previous.length >= (ownMainstayAction.requiredCards || 5)) return previous;
+ return [...previous, cardId];
+ });
+ };
+
+ const handleSubmitMainstayCards = () => {
+ if (!ownMainstayAction || !['give', 'return'].includes(ownMainstayAction.stage)) return;
+ const requiredCards = ownMainstayAction.requiredCards || 5;
+ if (mainstaySelectedCardIds.length !== requiredCards) {
+ messageApi.warning(`必须选择${requiredCards}张牌`);
+ return;
+ }
+ if (ownMainstayAction.stage === 'give') {
+ const selectedIds = new Set(mainstaySelectedCardIds);
+ const missingTrump = myCards.some(card => (
+ isTrumpCard(card, gameState?.trumpSuit, gameState?.trumpRank)
+ && !selectedIds.has(card.id)
+ ));
+ if (missingTrump) {
+ messageApi.warning('交出的5张牌必须包含当前全部主牌');
+ return;
+ }
+ }
+ socket.emit(SOCKET_EVENTS.SUBMIT_MAINSTAY_CARDS, {
+ roomId: currentRoom.id,
+ actionId: ownMainstayAction.id,
+ cardIds: mainstaySelectedCardIds
+ });
+ };
+
+ const getMutualSupportTeammate = () => {
+ const playerIndex = currentRoom?.players?.findIndex(player => player.id === currentPlayer?.id);
+ if (!Number.isInteger(playerIndex) || playerIndex < 0 || currentRoom?.players?.length !== 4) {
+ return null;
+ }
+ return currentRoom.players[(playerIndex + 2) % 4] || null;
+ };
+
+ const handleMutualSupportRequest = () => {
+ socket.emit(SOCKET_EVENTS.ACTIVATE_MUTUAL_SUPPORT, {
+ roomId: currentRoom.id,
+ direction: 'request',
+ cardIds: []
+ });
+ setMutualSupportDirectionOpen(false);
+ };
+
+ const handleMutualSupportGive = () => {
+ const teammate = getMutualSupportTeammate();
+ const maxCards = mutualSupportMaxGiveCount;
+ if (maxCards < 1) {
+ messageApi.warning('必须先保留本轮出牌所需的手牌,现在没有牌可以交给队友');
+ return;
+ }
+ setMutualSupportSelectedCardIds([]);
+ setMutualSupportSelection({
+ source: 'activation',
+ stage: 'initial',
+ otherPlayerId: teammate?.id || null,
+ otherPlayerName: teammate?.name || '队友',
+ minCards: 1,
+ maxCards
+ });
+ setMutualSupportDirectionOpen(false);
+ };
+
+ const handleToggleMutualSupportCard = (cardId) => {
+ setMutualSupportSelectedCardIds(previous => {
+ if (previous.includes(cardId)) return previous.filter(id => id !== cardId);
+ if (previous.length >= (mutualSupportSelection?.maxCards || 0)) return previous;
+ return [...previous, cardId];
+ });
+ };
+
+ const handleSubmitMutualSupportCards = () => {
+ if (!mutualSupportSelection) return;
+ const selectedCount = mutualSupportSelectedCardIds.length;
+ if (
+ selectedCount < mutualSupportSelection.minCards
+ || selectedCount > mutualSupportSelection.maxCards
+ ) {
+ messageApi.warning(
+ mutualSupportSelection.minCards === mutualSupportSelection.maxCards
+ ? `必须选择${mutualSupportSelection.minCards}张牌`
+ : `请选择${mutualSupportSelection.minCards}至${mutualSupportSelection.maxCards}张牌`
+ );
+ return;
+ }
+ if (mutualSupportSelection.source === 'activation') {
+ socket.emit(SOCKET_EVENTS.ACTIVATE_MUTUAL_SUPPORT, {
+ roomId: currentRoom.id,
+ direction: 'give',
+ cardIds: mutualSupportSelectedCardIds
+ });
+ } else {
+ socket.emit(SOCKET_EVENTS.SUBMIT_MUTUAL_SUPPORT_CARDS, {
+ roomId: currentRoom.id,
+ actionId: mutualSupportSelection.actionId,
+ cardIds: mutualSupportSelectedCardIds
+ });
+ }
+ setMutualSupportSelection(null);
+ setMutualSupportSelectedCardIds([]);
+ };
+
+ const handleConfirmCulturalRevolution = () => {
+ const declarationType = culturalRevolutionSelection?.declarationType;
+ const value = culturalRevolutionSelection?.value;
+ if (!declarationType || !value) {
+ messageApi.warning(declarationType ? '请选择要声明的目标' : '请先选择革花色或革点数');
+ return;
+ }
+ socket.emit(SOCKET_EVENTS.ACTIVATE_CULTURAL_REVOLUTION, {
+ roomId: currentRoom.id,
+ declarationType,
+ value
+ });
+ setCulturalRevolutionSelection(null);
+ };
+
+ const handleConfirmInviteIntoUrn = () => {
+ const { targetPlayerId, suit, rank } = inviteIntoUrnSelection || {};
+ if (!targetPlayerId || !suit || !rank) {
+ messageApi.warning('请依次选择玩家、花色和点数');
+ return;
+ }
+ socket.emit(SOCKET_EVENTS.ACTIVATE_INVITE_INTO_URN, {
+ roomId: currentRoom.id,
+ targetPlayerId,
+ suit,
+ rank
+ });
+ setInviteIntoUrnSelection(null);
+ };
+
+ const handleToggleActiveSkill = () => {
+ if (!activeSkill) return;
+ if (activeSkill.effect === 'declare_target_card') {
+ if (!activeSkillAvailability.canActivate) {
+ messageApi.warning(activeSkillAvailability.reason || '现在不能发动这个技能');
+ return;
+ }
+ clearSelection();
+ setInviteIntoUrnSelection({ targetPlayerId: null, suit: null, rank: null });
+ return;
+ }
+ if (activeSkill.effect === 'temporarily_replace_trump') {
+ if (!activeSkillAvailability.canActivate) {
+ messageApi.warning(activeSkillAvailability.reason || '现在不能发动这个技能');
+ return;
+ }
+ clearSelection();
+ setCulturalRevolutionSelection({ declarationType: null, value: null });
+ return;
+ }
+ if (activeSkill.effect === 'silence_non_leader_for_round') {
+ if (isLureTigerReservedByMe) {
+ messageApi.info(activeSkillAvailability.reason || '已经预备调虎离山,请等待轮首确认');
+ return;
+ }
+ if (!activeSkillAvailability.canActivate) {
+ messageApi.warning(activeSkillAvailability.reason || '现在不能预备这个技能');
+ return;
+ }
+ clearSelection();
+ socket.emit(SOCKET_EVENTS.ACTIVATE_LURE_TIGER, {
+ roomId: currentRoom.id
+ });
+ return;
+ }
+ if (activeSkill.effect === 'demote_trumps_and_transform') {
+ if (isForbiddenMagicActiveByMe) {
+ messageApi.info('禁术秘法已在本局永久生效,可点击原主牌上的“转”改变牌面');
+ return;
+ }
+ if (isForbiddenMagicReservedByMe) {
+ messageApi.info(activeSkillAvailability.reason || '已经预备禁术秘法,请等待轮首确认');
+ return;
+ }
+ if (!activeSkillAvailability.canActivate) {
+ messageApi.warning(activeSkillAvailability.reason || '现在不能预备这个技能');
+ return;
+ }
+ clearSelection();
+ setExplicitCardTransformations({});
+ setCardTransformationDialog(null);
+ socket.emit(SOCKET_EVENTS.ACTIVATE_FORBIDDEN_MAGIC, {
+ roomId: currentRoom.id
+ });
+ return;
+ }
+ if (activeSkill.effect === 'rewind_completed_round') {
+ if (!activeSkillAvailability.canActivate) {
+ messageApi.warning(activeSkillAvailability.reason || '现在不能预备这个技能');
+ return;
+ }
+ clearSelection();
+ socket.emit(SOCKET_EVENTS.ACTIVATE_TIME_REVERSAL, {
+ roomId: currentRoom.id
+ });
+ return;
+ }
+ if (activeSkill.effect === 'sleep_random_play') {
+ if (!activeSkillAvailability.canActivate) {
+ messageApi.warning(activeSkillAvailability.reason || '现在不能发动这个技能');
+ return;
+ }
+ clearSelection();
+ socket.emit(SOCKET_EVENTS.ACTIVATE_DREAM_KILLING, {
+ roomId: currentRoom.id
+ });
+ return;
+ }
+ if (activeSkill.effect === 'temporary_teammate_card_transfer') {
+ if (!activeSkillAvailability.canActivate) {
+ messageApi.warning(activeSkillAvailability.reason || '现在不能发动这个技能');
+ return;
+ }
+ clearSelection();
+ setMutualSupportSelectedCardIds([]);
+ setMutualSupportDirectionOpen(true);
+ return;
+ }
+ if (activeSkill.effect === 'force_leader_replay') {
+ if (!activeSkillAvailability.canActivate) {
+ messageApi.warning(activeSkillAvailability.reason || '现在不能发动这个技能');
+ return;
+ }
+ clearSelection();
+ setBushGateDecisionOpen(true);
+ return;
+ }
+ if (activeSkill.effect === 'yield_turn_to_next_player') {
+ if (!activeSkillAvailability.canActivate) {
+ messageApi.warning(activeSkillAvailability.reason || '现在不能发动这个技能');
+ return;
+ }
+ clearSelection();
+ setLateMoverDecisionOpen(true);
+ return;
+ }
+ if (activeSkill.effect === 'compare_and_exchange') {
+ if (isActiveSkillArmed) {
+ setArmedActiveSkillId(null);
+ setEquivalentReciprocityTarget(null);
+ return;
+ }
+ if (!activeSkillAvailability.canActivate) {
+ messageApi.warning(activeSkillAvailability.reason || '现在不能发动这个技能');
+ return;
+ }
+ clearSelection();
+ setEquivalentReciprocityTarget(null);
+ setArmedActiveSkillId(activeSkill.id);
+ messageApi.info('等价互惠已就绪:请点击另一名玩家的玩家框');
+ return;
+ }
+ if (activeSkill.effect === 'swap_two_plays_at_round_end') {
+ if (isMagicTrickPrepared) {
+ messageApi.info('本轮魔术戏法已经暗中准备');
+ return;
+ }
+ if (isActiveSkillArmed) {
+ setArmedActiveSkillId(null);
+ setMagicTrickTargetIds([]);
+ return;
+ }
+ if (!activeSkillAvailability.canActivate) {
+ messageApi.warning(activeSkillAvailability.reason || '现在不能发动这个技能');
+ return;
+ }
+ clearSelection();
+ setMagicTrickTargetIds([]);
+ setArmedActiveSkillId(activeSkill.id);
+ messageApi.info('魔术戏法已就绪:请依次点击两名其他玩家');
+ return;
+ }
+ if (activeSkill.effect === 'two_legal_plays_choose_at_round_end') {
+ if (isActiveSkillArmed) {
+ setArmedActiveSkillId(null);
+ setAmbiguousFirstOptionCardIds([]);
+ clearSelection();
+ messageApi.info('已取消模棱两可');
+ return;
+ }
+ if (!activeSkillAvailability.canActivate) {
+ messageApi.warning(activeSkillAvailability.reason || '现在不能发动这个技能');
+ return;
+ }
+ clearSelection();
+ setAmbiguousFirstOptionCardIds([]);
+ setArmedActiveSkillId(activeSkill.id);
+ messageApi.info('模棱两可已就绪:请先选择并保存合法的方案A');
+ return;
+ }
+ if (isActiveSkillArmed) {
+ setArmedActiveSkillId(null);
+ setCardTransformationDialog(null);
+ if (isExplicitTransformationSkill) {
+ messageApi.info('已关闭转化编辑;完成的转化仍会保留,可点牌面选牌或点“还”恢复原牌');
+ return;
+ }
+ setExplicitCardTransformations({});
+ if (activeSkill.effect === 'transform_matching_card') {
+ setSelectedDivineWeaponCardId(null);
+ setDivineWeaponSourceCardId(null);
+ }
+ clearSelection();
+ return;
+ }
+ if (!activeSkillAvailability.canActivate) {
+ messageApi.warning(activeSkillAvailability.reason || '现在不能发动这个技能');
+ return;
+ }
+ if (!isExplicitTransformationSkill) {
+ clearSelection();
+ setExplicitCardTransformations({});
+ }
+ setCardTransformationDialog(null);
+ setArmedActiveSkillId(activeSkill.id);
+ const prompt = activeSkill.effect === 'concealed_until_round_end'
+ ? '请选择要暗置打出的牌'
+ : activeSkill.effect === 'ignore_odd_led_side_suit'
+ ? `已虚置${virtualizedCardIds.length || activeSkillAvailability.virtualizedCardIds?.length || 0}张对应副牌,请从其余手牌中完成本次出牌`
+ : activeSkill.effect === 'joker_wildcards'
+ ? '请点王牌左侧的“转”,先选花色、再选点数;点牌面仍是正常选牌'
+ : activeSkill.effect === 'adjacent_rank_transform'
+ ? '请点普通牌左侧的“转”选择相邻点数;点牌面仍是正常选牌'
+ : activeSkill.effect === 'transform_matching_card'
+ ? '请先选择牌桌中央的一张神兵牌'
+ : activeSkill.effect === 'belt_and_road_lead'
+ ? '请选择同一有效花色的两张非对子单牌'
+ : '请选择要垫出的牌';
+ messageApi.info(`${activeSkill.name}已就绪:${prompt}`);
+ };
+
+ const handlePlayCards = () => {
+ if (hasTimeReversalDecisionPending) {
+ messageApi.warning('本轮正在等待时间倒流决定');
+ return;
+ }
+ if (
+ ['compare_and_exchange', 'swap_two_plays_at_round_end'].includes(activeSkill?.effect)
+ && isActiveSkillArmed
+ ) {
+ messageApi.warning('请先完成玩家选择,或再次点击技能取消');
+ return;
+ }
+ if (!isCurrentPlayersTurn(gameState, currentRoom?.players, currentPlayer?.id)) {
+ messageApi.warning('现在还没有轮到你出牌');
+ return;
+ }
+ const selectionValidation = validatePlaySelection({
+ selectedCardIds: selectedCards,
+ handCards: activePlayCards,
+ gameState,
+ trumpSuit,
+ trumpRank,
+ activeSkillId: effectiveActiveSkillId,
+ currentPlayerId: isProxyTurn ? openHand?.playerId : currentPlayer?.id,
+ jokerSubstitutions,
+ clusterAnalysisSubstitutions,
+ forbiddenMagicSubstitutions,
+ divineWeaponCardId: selectedDivineWeaponCardId,
+ divineWeaponSourceCardId
+ });
+ if (!selectionValidation.valid) {
+ messageApi.warning(selectionValidation.message);
+ return;
+ }
+ const cardsToPlay = [...selectedCards];
+ const isAmbiguousPlay = isActiveSkillArmed
+ && activeSkill?.effect === 'two_legal_plays_choose_at_round_end';
+ if (isAmbiguousPlay && ambiguousFirstOptionCardIds.length === 0) {
+ setAmbiguousFirstOptionCardIds(cardsToPlay);
+ clearSelection();
+ messageApi.success('方案A已保存;请选择一套不同且同样合法的方案B');
+ return;
+ }
+ if (isAmbiguousPlay) {
+ const firstKey = [...ambiguousFirstOptionCardIds].sort().join('|');
+ const secondKey = [...cardsToPlay].sort().join('|');
+ if (firstKey === secondKey) {
+ messageApi.warning('方案B必须与方案A不同');
+ return;
+ }
+ }
+ console.log('发送 PLAY_CARDS 事件:', { cardIds: cardsToPlay });
+
+ // 先于清空选择标记“已提交”,避免服务器确认前仍沿用旧行动权的快照,
+ // 触发自动跟牌 effect 把剩余手牌瞬间重新选中。
+ suppressAutoSelectionRef.current = true;
+ setJustPlayedCards(true);
+ socket.emit(SOCKET_EVENTS.PLAY_CARDS, {
+ roomId: currentRoom.id,
+ cardIds: isAmbiguousPlay ? ambiguousFirstOptionCardIds : cardsToPlay,
+ controlledPlayerId: isProxyTurn ? openHand.playerId : null,
+ activeSkillId: effectiveActiveSkillId,
+ jokerSubstitutions,
+ clusterAnalysisSubstitutions,
+ forbiddenMagicSubstitutions,
+ divineWeaponCardId: selectedDivineWeaponCardId,
+ divineWeaponSourceCardId,
+ ambiguousAlternativeCardIds: isAmbiguousPlay ? cardsToPlay : []
+ }, acknowledgement => {
+ // 出牌被服务端拒绝时恢复自动选牌能力;成功后要一直等到
+ // 权威房间快照确认行动权或轮次已经变化。
+ if (acknowledgement?.ok === false) {
+ suppressAutoSelectionRef.current = false;
+ setJustPlayedCards(false);
+ }
+ });
+ setArmedActiveSkillId(null);
+ setAmbiguousFirstOptionCardIds([]);
+ setCardTransformationDialog(null);
+ clearSelection();
+ };
+
+ // 跳过
+ const handlePass = () => {
+ socket.emit(SOCKET_EVENTS.PASS_TURN, {
+ roomId: currentRoom.id
+ });
+ };
+
+ // 撤回出牌
+ const handleUndoPlay = () => {
+ socket.emit(SOCKET_EVENTS.UNDO_PLAY, {
+ roomId: currentRoom.id
+ });
+ };
+
+ const handleRespondStrawBoatBorrowingArrows = accept => {
+ if (accept && !strawBoatDiscardCardId) {
+ messageApi.warning('请先选择一张非分数牌公开弃置');
+ return;
+ }
+ socket.emit(SOCKET_EVENTS.RESPOND_STRAW_BOAT_BORROWING_ARROWS, {
+ roomId: currentRoom.id,
+ accept,
+ cardId: accept ? strawBoatDiscardCardId : null
+ });
+ };
+
+ // 普通规则仅庄家可查看;特殊规则由服务端按请求者身份返回可见的底牌。
+ const handleViewMyBottomCards = () => {
+ socket.emit(SOCKET_EVENTS.VIEW_MY_BOTTOM_CARDS, {
+ roomId: currentRoom.id
+ });
+ };
+
+ const handleReadyForNext = () => {
+ if (isReadyForNext) {
+ return; // 已经准备过了,防止重复点击
+ }
+ setIsReadyForNext(true);
+ socket.emit('ready_for_next_game', {
+ roomId: currentRoom.id
+ });
+ };
+
+ // 查看上轮出牌
+ const handleViewLastRound = () => {
+ // 如果正在查看,重复点击则刷新计时
+ if (lastRoundTimer) {
+ clearTimeout(lastRoundTimer);
+ }
+
+ // 设置为查看模式
+ setViewingLastRound(true);
+
+ // 2秒后自动恢复
+ const timer = setTimeout(() => {
+ setViewingLastRound(false);
+ setLastRoundTimer(null);
+ }, 2000);
+
+ setLastRoundTimer(timer);
+ };
+
+ // 调整分数
+ const handleUpdateScore = () => {
+ if (!selectedPlayerId) {
+ messageApi.warning('请选择玩家');
+ return;
+ }
+ socket.emit(SOCKET_EVENTS.UPDATE_SCORE, {
+ roomId: currentRoom.id,
+ playerId: selectedPlayerId,
+ newScore: adjustValue
+ });
+ setScoreAdjustModal(false);
+ };
+
+ // 调整等级
+ const handleUpdateLevel = () => {
+ if (!selectedPlayerId) {
+ messageApi.warning('请选择玩家');
+ return;
+ }
+ socket.emit(SOCKET_EVENTS.UPDATE_LEVEL, {
+ roomId: currentRoom.id,
+ playerId: selectedPlayerId,
+ newLevel: adjustValue
+ });
+ setLevelAdjustModal(false);
+ };
+
+ // 重新开始
+ const handleRestartGame = () => {
+ socket.emit(SOCKET_EVENTS.RESTART_GAME, {
+ roomId: currentRoom.id
+ });
+ };
+
+ // 快速调整分数(快捷按钮)
+ const handleQuickAdjustScore = (amount) => {
+ socket.emit(SOCKET_EVENTS.UPDATE_SCORE, {
+ roomId: currentRoom.id,
+ amount
+ });
+ };
+
+ // 快速调整等级(快捷按钮)
+ const handleQuickAdjustLevel = (amount) => {
+ socket.emit(SOCKET_EVENTS.UPDATE_LEVEL, {
+ roomId: currentRoom.id,
+ amount
+ });
+ };
+
+ // 更新房间配置
+ const handleUpdateRoomConfig = () => {
+ if (newDealInterval < 10 || newDealInterval > 5000) {
+ messageApi.warning('发牌间隔必须在10-5000毫秒之间');
+ return;
+ }
+ socket.emit(SOCKET_EVENTS.UPDATE_CONFIG, {
+ roomId: currentRoom.id,
+ config: {
+ dealInterval: newDealInterval
+ }
+ });
+ if (newBotType !== currentRoom.config?.botType) {
+ socket.emit(SOCKET_EVENTS.SET_BOT_TYPE, {
+ roomId: currentRoom.id,
+ botType: newBotType
+ });
+ }
+ setRoomConfigModal(false);
+ };
+
+ // 修改玩家昵称
+ const handleUpdatePlayerName = () => {
+ const trimmedName = newPlayerName.trim();
+ if (!trimmedName) {
+ messageApi.warning('昵称不能为空');
+ return;
+ }
+ if (trimmedName.length > 20) {
+ messageApi.warning('昵称长度不能超过20个字符');
+ return;
+ }
+ socket.emit(SOCKET_EVENTS.UPDATE_PLAYER_NAME, {
+ roomId: currentRoom.id,
+ newName: trimmedName
+ });
+ setRenameModal(false);
+ setNewPlayerName('');
+ };
+
+ // 打开修改昵称对话框
+ const handleOpenRenameModal = () => {
+ setNewPlayerName(currentPlayer?.name || '');
+ setRenameModal(true);
+ };
+
+ const handleRequestSurrender = () => {
+ if (!canRequestSurrender || hasRequestedSurrender) return;
+ modalApi.confirm({
+ title: '发起投降?',
+ content: '申请会等本墩完整结束,再询问你的队友;只有队友同意后投降才会生效。',
+ okText: '确认发起',
+ cancelText: '继续游戏',
+ okButtonProps: { danger: true },
+ centered: true,
+ onOk: () => socket.emit(SOCKET_EVENTS.REQUEST_SURRENDER, {
+ roomId: currentRoom.id
+ })
+ });
+ };
+
+ const handleRespondSurrender = accept => {
+ if (!surrenderDecision) return;
+ socket.emit(SOCKET_EVENTS.RESPOND_SURRENDER, {
+ roomId: currentRoom.id,
+ accept
+ });
+ };
+
+ // 处理规则选择
+ const handleRuleSelected = (ruleOrRuleIds) => {
+ // 发送规则选择事件到服务器
+ socket.emit(SOCKET_EVENTS.SELECT_RULE, {
+ roomId: currentRoom.id,
+ rule: Array.isArray(ruleOrRuleIds)
+ ? { ids: ruleOrRuleIds }
+ : { id: ruleOrRuleIds.id }
+ });
+ };
+
+ const handleRefreshDoubleHappinessOption = optionIndex => {
+ socket.emit(SOCKET_EVENTS.REFRESH_DOUBLE_HAPPINESS_OPTION, {
+ roomId: currentRoom.id,
+ optionIndex
+ });
+ };
+
+ const handleManageWoodenOx = action => {
+ if (action === 'load_and_pass' && !woodenOxSelectedCardId) {
+ messageApi.warning('请先选择一张要放入木牛流马的手牌');
+ return;
+ }
+ socket.emit('manage_wooden_ox', {
+ roomId: currentRoom.id,
+ action,
+ cardId: action === 'load_and_pass' ? woodenOxSelectedCardId : null
+ });
+ };
+
+ const handleDrawingCardClick = (cardId) => {
+ if (cardExchangeAnimation) return;
+ if (!cardExchange) {
+ toggleCardSelection(cardId);
+ return;
+ }
+ if (hasSubmittedCardExchange) return;
+ const isSelected = selectedCards.includes(cardId);
+ if (!isSelected && selectedCards.length >= cardExchange.requiredCards) {
+ messageApi.warning(`只能选择 ${cardExchange.requiredCards} 张牌`);
+ return;
+ }
+ toggleCardSelection(cardId);
+ };
+
+ const handleNinePrincesCardClick = (cardId) => {
+ if (!isNinePrincesChooser) return;
+ const candidate = ninePrincesCandidates.find(item => item.card.id === cardId);
+ if (!candidate) {
+ messageApi.warning('这张牌已经无法继续晋升');
+ return;
+ }
+ if (selectedCards.length === 1 && selectedCards[0] === cardId) {
+ clearSelection();
+ return;
+ }
+ setSelectedCards([cardId]);
+ messageApi.info(
+ `已选择 ${formatPublicCard(candidate.card)},将晋升为 ${formatPublicCard(candidate.promotedFace)}`,
+ 2
+ );
+ };
+
+ const handleConfirmNinePrinces = () => {
+ if (!selectedNinePrincesCandidate) {
+ messageApi.warning('请先在手牌中选择一张要晋升的牌');
+ return;
+ }
+ socket.emit(SOCKET_EVENTS.RESPOND_NINE_PRINCES, {
+ roomId: currentRoom.id,
+ cardId: selectedNinePrincesCandidate.card.id
+ });
+ };
+
+ const handleSkipNinePrinces = () => {
+ clearSelection();
+ socket.emit(SOCKET_EVENTS.RESPOND_NINE_PRINCES, {
+ roomId: currentRoom.id,
+ cardId: null
+ });
+ };
+
+ const handleSubmitCardExchange = () => {
+ if (!cardExchange || selectedCards.length !== cardExchange.requiredCards) {
+ messageApi.warning(
+ `请选择 ${cardExchange?.requiredCards || 2} 张牌进行${cardExchange?.operation === 'discard' ? '弃置' : '交换'}`
+ );
+ return;
+ }
+ socket.emit(SOCKET_EVENTS.SUBMIT_CARD_EXCHANGE, {
+ roomId: currentRoom.id,
+ cardIds: [...selectedCards]
+ });
+ };
+
+ const handleCancelExplicitTransformation = (cardId) => {
+ setExplicitCardTransformations(previous => {
+ const next = { ...previous };
+ delete next[cardId];
+ return next;
+ });
+ if (selectedCards.includes(cardId)) toggleCardSelection(cardId);
+ setCardTransformationDialog(null);
+ };
+
+ const handleCommitJokerTransformation = (rank) => {
+ const cardId = cardTransformationDialog?.cardId;
+ const suit = cardTransformationDialog?.selectedSuit;
+ const sourceCard = activePlayCards.find(card => card.id === cardId);
+ if (!sourceCard || sourceCard.suit !== 'joker' || !suit || !rank) return;
+ setExplicitCardTransformations(previous => ({
+ ...previous,
+ [cardId]: {
+ kind: 'joker',
+ cardId,
+ suit,
+ rank,
+ fromSuit: sourceCard.suit,
+ fromRank: sourceCard.rank
+ }
+ }));
+ if (selectedCards.includes(cardId)) toggleCardSelection(cardId);
+ setCardTransformationDialog(null);
+ messageApi.success(`已将王牌临时转换为 ${EFFECTIVE_SUIT_LABELS[suit]?.replace(/\s/g, '') || suit}${rank}`);
+ };
+
+ const handleCommitClusterTransformation = (toRank) => {
+ const cardId = cardTransformationDialog?.cardId;
+ const sourceCard = activePlayCards.find(card => card.id === cardId);
+ if (!sourceCard || !toRank) return;
+ setExplicitCardTransformations(previous => ({
+ ...previous,
+ [cardId]: {
+ kind: 'cluster',
+ cardId,
+ suit: sourceCard.suit,
+ fromRank: sourceCard.rank,
+ toRank
+ }
+ }));
+ if (selectedCards.includes(cardId)) toggleCardSelection(cardId);
+ setCardTransformationDialog(null);
+ messageApi.success(`聚类分析:${sourceCard.rank} 已临时视为 ${toRank},可继续转换其他牌`);
+ };
+
+ const handleCommitForbiddenMagicTransformation = (suit, rank = null) => {
+ const cardId = cardTransformationDialog?.cardId;
+ const sourceCard = activePlayCards.find(card => card.id === cardId);
+ const targetRank = sourceCard?.suit === 'joker' ? rank : sourceCard?.rank;
+ if (
+ !sourceCard
+ || !isTrumpCard(sourceCard, trumpSuit, trumpRank)
+ || !suit
+ || !targetRank
+ ) return;
+ if (
+ trumpSuit
+ && trumpSuit !== 'no_trump'
+ && suit === trumpSuit
+ ) {
+ messageApi.warning('禁术秘法只能转化为副牌花色,不能选择当前主花色');
+ return;
+ }
+ setExplicitCardTransformations(previous => ({
+ ...previous,
+ [cardId]: {
+ kind: 'forbidden_magic',
+ cardId,
+ suit,
+ rank: targetRank,
+ fromSuit: sourceCard.suit,
+ fromRank: sourceCard.rank
+ }
+ }));
+ if (selectedCards.includes(cardId)) toggleCardSelection(cardId);
+ setCardTransformationDialog(null);
+ messageApi.success(`禁术秘法:已临时视为 ${EFFECTIVE_SUIT_LABELS[suit]?.replace(/\s/g, '') || suit}${targetRank}`);
+ };
+
+ const handleRequestCardTransformation = (cardId) => {
+ if ((!isActiveSkillArmed && !isForbiddenMagicActiveByMe) || !isExplicitTransformationSkill) return;
+ if (explicitCardTransformations[cardId]) return;
+ const sourceCard = activePlayCards.find(card => card.id === cardId);
+ if (!sourceCard) return;
+
+ if (activeSkill.effect === 'demote_trumps_and_transform') {
+ if (!isTrumpCard(sourceCard, trumpSuit, trumpRank)) return;
+ setCardTransformationDialog({
+ kind: 'forbidden_magic',
+ cardId,
+ isJoker: sourceCard.suit === 'joker',
+ sourceRank: sourceCard.rank,
+ selectedSuit: null
+ });
+ return;
+ }
+
+ if (activeSkill.effect === 'joker_wildcards') {
+ if (sourceCard.suit !== 'joker') return;
+ setCardTransformationDialog({ kind: 'joker', cardId, selectedSuit: null });
+ return;
+ }
+
+ const targetRanks = getClusterAnalysisTargetRanks(sourceCard, trumpRank);
+ if (targetRanks.length > 0) {
+ setCardTransformationDialog({ kind: 'cluster', cardId, targetRanks });
+ }
+ };
+
+ const handlePlayingCardClick = (cardId) => {
+ if (!icebergSelection) {
+ if (isActiveSkillArmed && activeSkill?.effect === 'transform_matching_card') {
+ if (!selectedDivineWeaponCard) {
+ messageApi.warning('请先选择牌桌中央的一张神兵牌');
+ return;
+ }
+ if (cardId === divineWeaponSourceCardId) {
+ setDivineWeaponSourceCardId(null);
+ if (selectedCards.includes(cardId)) toggleCardSelection(cardId);
+ return;
+ }
+ if (!divineWeaponSourceCardId) {
+ const sourceCard = activePlayCards.find(card => card.id === cardId);
+ if (
+ !sourceCard
+ || (sourceCard.suit !== selectedDivineWeaponCard.suit
+ && sourceCard.rank !== selectedDivineWeaponCard.rank)
+ ) {
+ messageApi.warning('请选择与神兵牌花色或点数相同的手牌');
+ return;
+ }
+ setDivineWeaponSourceCardId(cardId);
+ if (!selectedCards.includes(cardId)) toggleCardSelection(cardId);
+ return;
+ }
+ }
+ toggleCardSelection(cardId);
+ return;
+ }
+ if (icebergSelection.isSubmitted) return;
+ if (icebergSelection.currentlyRevealedCardIds.includes(cardId)) {
+ messageApi.info('这张牌已经明置');
+ return;
+ }
+ const isSelected = selectedCards.includes(cardId);
+ if (!isSelected && selectedCards.length >= icebergSelection.requiredCount) {
+ messageApi.warning(`只能再选择 ${icebergSelection.requiredCount} 张明牌`);
+ return;
+ }
+ toggleCardSelection(cardId);
+ };
+
+ const handleDivineWeaponCardClick = (cardId) => {
+ if (!isActiveSkillArmed || activeSkill?.effect !== 'transform_matching_card') return;
+ const nextCardId = selectedDivineWeaponCardId === cardId ? null : cardId;
+ setSelectedDivineWeaponCardId(nextCardId);
+ setDivineWeaponSourceCardId(null);
+ clearSelection();
+ if (nextCardId) {
+ messageApi.info('神兵已选定:请再选择一张同花色或同点数手牌');
+ }
+ };
+
+ const handleSubmitIcebergReveals = () => {
+ if (!icebergSelection || icebergSelection.isSubmitted) return;
+ if (selectedCards.length !== icebergSelection.requiredCount) {
+ messageApi.warning(`请选择 ${icebergSelection.requiredCount} 张牌明置`);
+ return;
+ }
+ socket.emit(SOCKET_EVENTS.SUBMIT_ICEBERG_REVEALS, {
+ roomId: currentRoom.id,
+ cardIds: [...selectedCards]
+ });
+ };
+
+ const handleSelectWaitingRabbitTarget = () => {
+ if (!selectedWaitingRabbitSuit || !selectedWaitingRabbitRank) {
+ messageApi.warning('请选择目标牌的花色和点数');
+ return;
+ }
+ socket.emit(SOCKET_EVENTS.SELECT_WAITING_RABBIT_TARGET, {
+ roomId: currentRoom.id,
+ suit: selectedWaitingRabbitSuit,
+ rank: selectedWaitingRabbitRank
+ });
+ };
+
+ const handleRespondWaitingRabbit = (accept) => {
+ if (accept && !waitingRabbitDiscardCardId) {
+ messageApi.warning('请选择一张非分牌交换');
+ return;
+ }
+ socket.emit(SOCKET_EVENTS.RESPOND_WAITING_RABBIT, {
+ roomId: currentRoom.id,
+ accept,
+ discardCardId: accept ? waitingRabbitDiscardCardId : null
+ });
+ };
+
+ const handleSelectTenSidedAmbushRank = () => {
+ if (!selectedTenSidedAmbushRank) {
+ messageApi.warning('请选择一个伏击点数');
+ return;
+ }
+ socket.emit(SOCKET_EVENTS.SELECT_TEN_SIDED_AMBUSH_RANK, {
+ roomId: currentRoom.id,
+ rank: selectedTenSidedAmbushRank
+ });
+ };
+
+ const handleSelectThreePowersRank = () => {
+ if (!threePowersSelection || !selectedThreePowersRank) {
+ messageApi.warning('请选择一个重载点数');
+ return;
+ }
+ socket.emit(SOCKET_EVENTS.SELECT_THREE_POWERS_RANK, {
+ roomId: currentRoom.id,
+ sourceRank: threePowersSelection.sourceRank,
+ rank: selectedThreePowersRank
+ });
+ };
+
+ const handleSelectGentlemanPromiseSuit = () => {
+ if (!gentlemanPromiseSelection || !selectedGentlemanPromiseSuit) {
+ messageApi.warning('请选择一个并列最短的有效花色');
+ return;
+ }
+ socket.emit(SOCKET_EVENTS.SELECT_GENTLEMAN_PROMISE_SUIT, {
+ roomId: currentRoom.id,
+ suit: selectedGentlemanPromiseSuit
+ });
+ };
+
+ const handleSelectHiddenDragonRank = () => {
+ if (!hiddenDragonSelection || !selectedHiddenDragonRank) {
+ messageApi.warning('请选择一个并列最多的非级牌点数');
+ return;
+ }
+ socket.emit(SOCKET_EVENTS.SELECT_HIDDEN_DRAGON_RANK, {
+ roomId: currentRoom.id,
+ rank: selectedHiddenDragonRank
+ });
+ };
+
+ const handleSelectAntinomyCard = () => {
+ if (!antinomySelection || !selectedAntinomySuit || !selectedAntinomyRank) {
+ messageApi.warning('请选择花色和点数');
+ return;
+ }
+ socket.emit(SOCKET_EVENTS.SELECT_ANTINOMY_CARD, {
+ roomId: currentRoom.id,
+ suit: selectedAntinomySuit,
+ rank: selectedAntinomyRank
+ });
+ };
+
+ const handleToggleRiceToMulberryCard = (cardId) => {
+ const requiredCount = riceToMulberrySelection?.requiredCount || 0;
+ setSelectedRiceToMulberryCardIds(previous => {
+ if (previous.includes(cardId)) {
+ return previous.filter(id => id !== cardId);
+ }
+ if (previous.length >= requiredCount) return previous;
+ return [...previous, cardId];
+ });
+ };
+
+ const handleSelectRiceToMulberryCards = () => {
+ const requiredCount = riceToMulberrySelection?.requiredCount || 0;
+ if (selectedRiceToMulberryCardIds.length !== requiredCount) {
+ messageApi.warning(`请选择恰好${requiredCount}张分牌`);
+ return;
+ }
+ socket.emit(SOCKET_EVENTS.SELECT_RICE_TO_MULBERRY_CARDS, {
+ roomId: currentRoom.id,
+ cardIds: selectedRiceToMulberryCardIds
+ });
+ };
+
+ const handleRespondDestroyDyke = (accept) => {
+ if (!destroyDykeDecision) return;
+ socket.emit(SOCKET_EVENTS.RESPOND_DESTROY_DYKE, {
+ roomId: currentRoom.id,
+ accept: accept === true
+ });
+ };
+
+ const handleSelectAdministrativeReview = () => {
+ if (!administrativeReviewSelection || !selectedAdministrativeReviewValue) {
+ messageApi.warning('请选择一项行政审查条件');
+ return;
+ }
+ socket.emit(SOCKET_EVENTS.SELECT_ADMINISTRATIVE_REVIEW, {
+ roomId: currentRoom.id,
+ type: administrativeReviewSelection.type,
+ value: selectedAdministrativeReviewValue
+ });
+ };
+
+ // 发送聊天消息
+ const handleSendChatMessage = (msg) => {
+ const messageToSend = msg || chatMessage.trim();
+ if (!messageToSend) {
+ messageApi.warning('消息不能为空');
+ return;
+ }
+ if (messageToSend.length > 200) {
+ messageApi.warning('消息长度不能超过200个字符');
+ return;
+ }
+ socket.emit(SOCKET_EVENTS.SEND_CHAT_MESSAGE, {
+ roomId: currentRoom.id,
+ message: messageToSend
+ });
+ setChatMessage('');
+ };
+
+ // 添加快捷短语
+ const handleAddQuickPhrase = () => {
+ const trimmed = newQuickPhrase.trim();
+ if (!trimmed) {
+ messageApi.warning('快捷短语不能为空');
+ return;
+ }
+ if (trimmed.length > 50) {
+ messageApi.warning('快捷短语长度不能超过50个字符');
+ return;
+ }
+ if (quickPhrases.includes(trimmed)) {
+ messageApi.warning('该快捷短语已存在');
+ return;
+ }
+ const newPhrases = [...quickPhrases, trimmed];
+ setQuickPhrases(newPhrases);
+ localStorage.setItem('tractorQuickPhrases', JSON.stringify(newPhrases));
+ setNewQuickPhrase('');
+ messageApi.success('快捷短语已添加');
+ };
+
+ // 删除快捷短语
+ const handleDeleteQuickPhrase = (phrase) => {
+ const newPhrases = quickPhrases.filter(p => p !== phrase);
+ setQuickPhrases(newPhrases);
+ localStorage.setItem('tractorQuickPhrases', JSON.stringify(newPhrases));
+ messageApi.success('快捷短语已删除');
+ };
+
+ // 添加Bot
+ const handleAddBot = () => {
+ const botCount = currentRoom.players.filter(p => p.isBot).length;
+ socket.emit(SOCKET_EVENTS.ADD_BOT, {
+ roomId: currentRoom.id,
+ botName: `AI Bot ${botCount + 1}`
+ });
+ };
+
+ // 移除Bot
+ const handleRemoveBot = (playerId) => {
+ socket.emit(SOCKET_EVENTS.REMOVE_BOT, {
+ roomId: currentRoom.id,
+ playerId
+ });
+ };
+
+ const isActiveBuryingPlayer = activeBuryingPlayerId === currentPlayer?.id;
+ const canViewBottomCards = canPlayerViewBottomCards({
+ gameState,
+ currentPlayerId: currentPlayer?.id,
+ selectedRule,
+ bottomCardsCount: activeBottomCardsCount
+ });
+ const knownThreePowersSlots = ruleIncludesId(selectedRule, 'three_powers')
+ ? (threePowersView?.slots || []).filter(slot => slot.rank)
+ : [];
+ const isThreePowersBottomScoreHidden = ruleIncludesId(selectedRule, 'three_powers') && (
+ knownThreePowersSlots.length !== (threePowersView?.slots || []).length ||
+ knownThreePowersSlots.length === 0
+ );
+ const bottomCardsScore = isThreePowersBottomScoreHidden
+ ? null
+ : ruleIncludesId(selectedRule, 'three_powers')
+ ? myBottomCards.reduce((total, card) => total + knownThreePowersSlots.reduce(
+ (cardPoints, slot) => cardPoints + (slot.rank === card.rank ? slot.pointValue : 0),
+ 0
+ ), 0)
+ : calculateCardPoints(
+ myBottomCards,
+ ruleIncludesId(selectedRule, 'meticulous_accounting')
+ ? getMeticulousAccountingCardPoints
+ : undefined
+ );
+
+ // 渲染控制按钮区域 - 简化版,只保留核心功能按钮
+ const renderControlButtons = () => {
+ const buttonStyle = { width: '100px', fontSize: '13px' };
+
+ switch (phase) {
+ case GamePhases.WAITING:
+ if (!currentRoom) return null;
+
+ const isWaitingForReady = gameState?.isWaitingForReady || false;
+
+ if (isWaitingForReady) {
+ const buttons = [];
+
+ if (!currentPlayer?.isBot) {
+ buttons.push(
+
+ {currentPlayer?.isReady ? '取消准备' : '准备'}
+
+ );
+ }
+
+ return (
+
+ {buttons}
+
+ );
+ }
+ return null;
+
+ case GamePhases.DRAWING:
+ if (cardExchangeAnimation) {
+ return (
+
+
+ 正在交换手牌…
+
+
+ );
+ }
+ if (cardExchange) {
+ const targetId = cardExchange.targetByPlayerId?.[currentPlayer?.id];
+ const target = currentRoom.players.find(player => player.id === targetId);
+ const submittedCount = cardExchange.submittedPlayerIds?.length || 0;
+ return (
+
+
+ {hasSubmittedCardExchange
+ ? `等待其他玩家 (${submittedCount}/${currentRoom.players.length})`
+ : `交给 ${target?.name || '目标玩家'}`}
+
+
+ {hasSubmittedCardExchange
+ ? '已确认换牌'
+ : `确认换牌(${selectedCards.length}/${cardExchange.requiredCards})`}
+
+
+ );
+ }
+ if (!canViewBottomCards) return null;
+ return (
+
+
+ 查看底牌
+
+
+ );
+
+ case GamePhases.BURYING:
+ const buryingButtons = [];
+ const canBury = isBurySelectionValid(
+ selectedCards,
+ activeBottomCardsCount
+ );
+
+ if (cardExchangeAnimation?.kind === 'secondary_bury') {
+ buryingButtons.push(
+
+ 底牌交接中…
+
+ );
+ } else if (isActiveBuryingPlayer) {
+ buryingButtons.push(
+
+ {isSecondaryBurying ? '再埋底' : '埋底'}({selectedCards.length}/{activeBottomCardsCount})
+
+ );
+ } else {
+ buryingButtons.push(
+
+ {isSecondaryBurying
+ ? `等待 ${currentRoom.players.find(player => player.id === activeBuryingPlayerId)?.name || '庄家队友'} 再埋底`
+ : gameState?.peopleCommune
+ ? `等待 ${currentRoom.players.find(player => player.id === activeBuryingPlayerId)?.name || '其他玩家'} 埋底`
+ : '等待庄家埋底'}
+
+ );
+ }
+
+ return (
+
+ {buryingButtons}
+ {canViewBottomCards && (
+
+ 查看底牌
+
+ )}
+
+ );
+
+ case GamePhases.PLAYING:
+ if (hasPendingNinePrincesDecision) {
+ if (isNinePrincesChooser) {
+ return (
+
+
+ {selectedNinePrincesCandidate
+ ? `${formatPublicCard(selectedNinePrincesCandidate.card)} → ${formatPublicCard(selectedNinePrincesCandidate.promotedFace)}`
+ : '九子夺嫡 · 点选一张手牌'}
+
+
+ 确认晋升
+
+
+ 放弃晋升
+
+
+ );
+ }
+ return (
+
+
+ 等待 {ninePrincesPending?.playerName || '本轮赢家'} 选择晋升牌
+
+
+ );
+ }
+ if (cardExchangeAnimation) {
+ return (
+
+
+ {`${cardExchangeAnimation.ruleName} · ${
+ cardExchangeAnimation.kind === 'whole_hand'
+ ? '整手交换中…'
+ : cardExchangeAnimation.kind === 'discard'
+ ? '暗弃中…'
+ : '交换中…'
+ }`}
+
+
+ );
+ }
+ if (strawBoatDecision) {
+ return (
+
+
+ {isStrawBoatChooser
+ ? '请完成草船借箭选择'
+ : `等待 ${strawBoatDecision.playerName || '首置位玩家'} 草船借箭`}
+
+
+ );
+ }
+ if (gameState?.equivalentReciprocity) {
+ const selectedCount = gameState.equivalentReciprocity.selectedPlayerIds?.length || 0;
+ return (
+
+
+ 等价互惠 · 等待拼点 ({selectedCount}/2)
+
+
+ );
+ }
+ if (cardExchange) {
+ const isDiscard = cardExchange.operation === 'discard';
+ const targetId = cardExchange.targetByPlayerId?.[currentPlayer?.id];
+ const target = currentRoom.players.find(player => player.id === targetId);
+ const submittedCount = cardExchange.submittedPlayerIds?.length || 0;
+ return (
+
+
+ {hasSubmittedCardExchange
+ ? `等待其他玩家 (${submittedCount}/${currentRoom.players.length})`
+ : isDiscard
+ ? '本轮结束 · 暗中弃置 1 张牌'
+ : `本轮结束 · 交给 ${target?.name || '目标玩家'}`}
+
+
+ {hasSubmittedCardExchange
+ ? (isDiscard ? '已确认弃牌' : '已确认交牌')
+ : `${isDiscard ? '确认弃牌' : '确认交牌'}(${selectedCards.length}/${cardExchange.requiredCards})`}
+
+
+ );
+ }
+ if (gameState?.focusFigure?.isVotingPending) {
+ return (
+
+
+ {focusFigureVote ? '请表决本队焦点候选' : '等待两队完成焦点表决'}
+
+ {canViewBottomCards && (
+
+ 查看底牌
+
+ )}
+
+ );
+ }
+ if (tenSidedAmbush?.isSelectionPending) {
+ const selectorName = currentRoom.players.find(
+ player => player.id === tenSidedAmbush.selectorPlayerId
+ )?.name;
+ return (
+
+
+ {isTenSidedAmbushSelector
+ ? '请暗中选择伏击点数'
+ : `等待 ${selectorName || '庄家队友'} 布置`}
+
+ {canViewBottomCards && (
+
+ 查看底牌
+
+ )}
+
+ );
+ }
+ if (icebergSelection) {
+ return (
+
+
+ {icebergSelection.reason === 'initial' ? '选择两张牌明置' : '补选明牌'}
+
+
+ {icebergSelection.isSubmitted
+ ? '等待其他玩家'
+ : `确认明牌(${selectedCards.length}/${icebergSelection.requiredCount})`}
+
+ {canViewBottomCards && (
+
+ 查看底牌
+
+ )}
+
+ );
+ }
+ if (hasPendingIcebergSelection) {
+ const pendingNames = icebergPendingPlayerIds
+ .map(playerId => currentRoom.players.find(player => player.id === playerId)?.name)
+ .filter(Boolean)
+ .join('、');
+ return (
+
+
+ 等待 {pendingNames || '玩家'} 选择明牌
+
+ {canViewBottomCards && (
+
+ 查看底牌
+
+ )}
+
+ );
+ }
+ if (
+ waitingRabbitState?.pendingSelectionPlayerIds?.length > 0
+ || waitingRabbitState?.pendingDecision
+ ) {
+ const pendingDecision = waitingRabbitState.pendingDecision;
+ const pendingNames = (waitingRabbitState.pendingSelectionPlayerIds || [])
+ .map(playerId => currentRoom.players.find(player => player.id === playerId)?.name)
+ .filter(Boolean)
+ .join('、');
+ return (
+
+
+ {pendingDecision
+ ? `等待 ${pendingDecision.chooserPlayerName} 决定换牌`
+ : `等待 ${pendingNames || '玩家'} 暗选目标牌`}
+
+ {canViewBottomCards && (
+
+ 查看底牌
+
+ )}
+
+ );
+ }
+
+ // 检查是否轮到当前玩家出牌
+ const isMyTurn = isCurrentPlayersTurn(
+ gameState,
+ currentRoom?.players,
+ currentPlayer?.id
+ );
+
+ // 检查是否可以撤回
+ const lastPlay = playHistory[playHistory.length - 1];
+ const hasForbiddenMagicDecisionPending = Boolean(
+ forbiddenMagicState?.decisionPlayerId
+ );
+ const hasLureTigerDecisionPending = Boolean(
+ lureTigerState?.currentDecision?.playerId
+ );
+ const hasPoliticalReviewDecisionPending = Boolean(
+ gameState?.politicalReview?.pending
+ );
+ const hasAntinomySelectionPending = Boolean(
+ gameState?.antinomy?.pendingPlayerIds?.length
+ );
+ const hasRiceToMulberrySelectionPending = Boolean(
+ gameState?.riceToMulberry?.pendingPlayerIds?.length
+ );
+ const hasDestroyDykeDecisionPending = Boolean(
+ gameState?.destroyDyke?.pending
+ );
+ const hasSurrenderDecisionPending = Boolean(
+ gameState?.surrender?.currentDecision
+ || gameState?.surrender?.queuedPlayerIds?.length
+ || surrenderDecision
+ );
+ const canUndo = Boolean(lastPlay &&
+ (lastPlay.controllerPlayerId || lastPlay.playerId) === currentPlayer?.id &&
+ !isOpenHandSelf &&
+ !hasForbiddenMagicDecisionPending &&
+ !hasLureTigerDecisionPending &&
+ !hasPoliticalReviewDecisionPending &&
+ !hasAntinomySelectionPending &&
+ !hasRiceToMulberrySelectionPending &&
+ !hasDestroyDykeDecisionPending &&
+ !hasSurrenderDecisionPending &&
+ !hasTimeReversalDecisionPending);
+
+ // 验证选中的牌是否合法
+ const validateSelectedCards = validatePlaySelection({
+ selectedCardIds: selectedCards,
+ handCards: activePlayCards,
+ gameState,
+ trumpSuit,
+ trumpRank,
+ activeSkillId: effectiveActiveSkillId,
+ currentPlayerId: isProxyTurn ? openHand?.playerId : currentPlayer?.id,
+ jokerSubstitutions,
+ clusterAnalysisSubstitutions,
+ forbiddenMagicSubstitutions,
+ divineWeaponCardId: selectedDivineWeaponCardId,
+ divineWeaponSourceCardId
+ });
+
+ const isPlayerTargeting = Boolean(
+ ['compare_and_exchange', 'swap_two_plays_at_round_end'].includes(activeSkill?.effect)
+ && isActiveSkillArmed
+ );
+ const canPlay = isMyTurn
+ && validateSelectedCards.valid
+ && !isPlayerTargeting
+ && !hasForbiddenMagicDecisionPending
+ && !hasLureTigerDecisionPending
+ && !hasPoliticalReviewDecisionPending
+ && !hasAntinomySelectionPending
+ && !hasRiceToMulberrySelectionPending
+ && !hasDestroyDykeDecisionPending
+ && !hasSurrenderDecisionPending
+ && !hasTimeReversalDecisionPending;
+ const playActionLabel = isActiveSkillArmed
+ && activeSkill?.effect === 'two_legal_plays_choose_at_round_end'
+ ? (ambiguousFirstOptionCardIds.length > 0 ? '公开A/B' : '保存方案A')
+ : getPlayActionLabel({
+ isActiveSkillArmed: Boolean(effectiveActiveSkillId),
+ activeSkill
+ });
+ const isDecisionSkillReady = Boolean(
+ ['yield_turn_to_next_player', 'force_leader_replay', 'compare_and_exchange', 'swap_two_plays_at_round_end', 'declare_target_card', 'ignore_odd_led_side_suit', 'two_legal_plays_choose_at_round_end', 'silence_non_leader_for_round'].includes(activeSkill?.effect)
+ && activeSkillAvailability.canActivate
+ );
+ const playButtonTitle = hasSurrenderDecisionPending
+ ? '正在处理本轮投降表决' :
+ hasTimeReversalDecisionPending
+ ? '本轮正在等待时间倒流决定' :
+ hasPoliticalReviewDecisionPending
+ ? '等待队友完成政治审查' :
+ hasAntinomySelectionPending
+ ? '等待二律背反选择同时公开' :
+ hasRiceToMulberrySelectionPending
+ ? '等待两名闲家完成改稻为桑' :
+ hasDestroyDykeDecisionPending
+ ? '等待庄家决定是否发动毁堤淹田' :
+ hasForbiddenMagicDecisionPending
+ ? '请先完成轮首的禁术秘法确认' :
+ hasLureTigerDecisionPending
+ ? '请先完成轮首的调虎离山决定' :
+ !isMyTurn ? '还没轮到你出牌' :
+ !validateSelectedCards.valid ? validateSelectedCards.message : '';
+
+ // 检查是否有上轮出牌记录
+ const hasLastRound = Object.keys(lastRoundPlayedCards).length > 0;
+
+ // 右上角操作区:出牌、撤回、庄家查看底牌、查看上轮、聊天
+ return (
+
+ {activeSkillAvailability.visible && activeSkill && (
+
+ {activeSkill.name}
+
+ )}
+ 4 ? '112px' : '88px' }}
+ title={playButtonTitle}
+ >
+ {playActionLabel}({selectedCards.length})
+
+
+ 撤回
+
+ {!isLostInFogRule && (
+
+ 查看上轮
+
+ )}
+ {canViewBottomCards && (
+
+ 查看底牌
+
+ )}
+ setChatModal(true)} style={buttonStyle}>
+ 聊天
+
+
+ );
+
+ case GamePhases.REVEALING:
+ return (
+
+ {canViewBottomCards && (
+
+ 查看底牌
+
+ )}
+
+ {isReadyForNext ? '已准备' : '开始下一局'}
+
+
+ );
+
+ case GamePhases.FINISHED:
+ return null;
+
+ default:
+ return null;
+ }
+ };
+
+ // 渲染游戏阶段内容
+ const renderPhaseContent = () => {
+ switch (phase) {
+ case GamePhases.WAITING:
+ if (!currentRoom) {
+ return 等待加入房间...
;
+ }
+
+ const isWaitingForReady = gameState?.isWaitingForReady || false;
+ const isWaitingForRule = gameState?.isRuleSelectionPending || false;
+
+ // 准备或选规则都属于牌桌内流程;下一局选规则时继续保留牌桌。
+ if (isWaitingForReady || isWaitingForRule) {
+ return (
+
+ {/* 游戏桌面 */}
+ {}}
+ onReorder={() => {}}
+ currentTurnPlayerId={null}
+ trumpSuit={trumpSuit}
+ trumpRank={trumpRank}
+ publicBottomCards={displayedPublicBottomCards}
+ selectedRule={selectedRule}
+ ruleRuntimeStatus={ruleRuntimeStatus}
+ ownFocusFigurePlayerId={focusFigurePrivate?.focusPlayerId || null}
+ onSelectRule={canOpenRuleSelector ? () => setRuleSelectorModal(true) : undefined}
+ ruleChooserPlayerId={gameState?.ruleChooserPlayerId}
+ isRuleSelectionPending={gameState?.isRuleSelectionPending}
+ renderControls={renderControlButtons()}
+ isWaitingForReady={isWaitingForReady || isWaitingForRule}
+ team1Level={gameState?.team1Level}
+ team2Level={gameState?.team2Level}
+ dealerPlayerIndex={gameState?.dealerPlayerIndex}
+ onRename={handleOpenRenameModal}
+ />
+
+ );
+ }
+
+ // 正常的房间等待界面
+ return (
+
+
等待开始
+
当前玩家: {currentRoom.playerCount} / {currentRoom.maxPlayers}
+
+
默认底牌: 8 张(特殊规则可能调整) | 发牌间隔: {currentRoom.config?.dealInterval || 500}ms
+
+
+
+
+ {/* 房主操作按钮 */}
+ {isHost && (
+
+ {currentRoom.playerCount === currentRoom.maxPlayers && (
+
+ 开始游戏
+
+ )}
+ setRoomConfigModal(true)}>
+ 房间设置
+
+ = currentRoom.maxPlayers}>
+ 添加Bot
)}
- {/* Bot列表 */}
- {isHost && currentRoom.players.some(p => p.isBot) && (
-
-
房间内的Bot
-
- {currentRoom.players.filter(p => p.isBot).map(bot => (
- handleRemoveBot(bot.id)}
- color="blue"
- >
- 🤖 {bot.name}
-
- ))}
-
-
+ {/* Bot列表 */}
+ {isHost && currentRoom.players.some(p => p.isBot) && (
+
+
房间内的Bot
+
+ {currentRoom.players.filter(p => p.isBot).map(bot => (
+ handleRemoveBot(bot.id)}
+ color="blue"
+ >
+ 🤖 {bot.name}
+
+ ))}
+
+
+ )}
+
+ {/* 所有玩家可用按钮 */}
+
+ 修改昵称
+
+
+
+ );
+
+ case GamePhases.DRAWING:
+ return (
+
+ {/* 游戏桌面 - 摸牌阶段显示展示的牌 */}
+
+ }
+ currentTrumpDeclaration={currentTrumpDeclaration}
+ currentInferiorDeclaration={currentInferiorDeclaration}
+ players={tablePlayers}
+ currentPlayer={currentPlayer}
+ onReturnToRoom={onReturnToRoom}
+ onRequestSurrender={isSurrenderFeatureVisible ? handleRequestSurrender : undefined}
+ canRequestSurrender={canRequestSurrender}
+ hasRequestedSurrender={hasRequestedSurrender}
+ playedCards={{}}
+ shownCards={shownCards}
+ myCards={myCards}
+ selectedCards={selectedCards}
+ onCardClick={handleDrawingCardClick}
+ onReorder={hasSubmittedCardExchange || cardExchangeAnimation ? undefined : reorderCards}
+ currentTurnPlayerId={null}
+ trumpSuit={trumpSuit}
+ trumpRank={trumpRank}
+ publicBottomCards={displayedPublicBottomCards}
+ selectedRule={selectedRule}
+ ruleRuntimeStatus={ruleRuntimeStatus}
+ ownFocusFigurePlayerId={focusFigurePrivate?.focusPlayerId || null}
+ onSelectRule={canOpenRuleSelector ? () => setRuleSelectorModal(true) : undefined}
+ ruleChooserPlayerId={gameState?.ruleChooserPlayerId}
+ isRuleSelectionPending={gameState?.isRuleSelectionPending}
+ renderControls={renderControlButtons()}
+ isWaitingForReady={false}
+ buryingPlayerId={gameState?.buryingPlayerId}
+ dealerCountdown={dealerCountdown}
+ attackerScore={attackerScore}
+ collectedPointCards={collectedPointCards}
+ team1Level={gameState?.team1Level}
+ team2Level={gameState?.team2Level}
+ dealerPlayerIndex={gameState?.dealerPlayerIndex}
+ onRename={handleOpenRenameModal}
+ cardExchange={cardExchange}
+ mainstay={gameState?.mainstay}
+ cardExchangeAnimation={cardExchangeAnimation}
+ privateCardTransferReveal={privateCardTransferReveal}
+ highlightedCardIds={handArrivalHighlight?.cardIds || []}
+ highlightedCardLabel={handArrivalHighlight?.kind === 'exchange' ? '收' : ''}
+ highlightedCardTone={handArrivalHighlight?.kind === 'bottom' ? 'bottom' : 'arrival'}
+ ruleVisibleHands={ruleVisibleHands}
+ />
+
+ );
+
+ case GamePhases.BURYING:
+ return (
+
+ {/* 游戏桌面 - 埋底阶段 */}
+ setRuleSelectorModal(true) : undefined}
+ ruleChooserPlayerId={gameState?.ruleChooserPlayerId}
+ isRuleSelectionPending={gameState?.isRuleSelectionPending}
+ renderControls={renderControlButtons()}
+ isWaitingForReady={false}
+ currentTrumpDeclaration={currentTrumpDeclaration}
+ currentInferiorDeclaration={currentInferiorDeclaration}
+ buryingPlayerId={gameState?.buryingPlayerId}
+ secondaryBuryingPlayerId={gameState?.secondaryBuryingPlayerId}
+ dealerCountdown={dealerCountdown}
+ attackerScore={attackerScore}
+ collectedPointCards={collectedPointCards}
+ team1Level={gameState?.team1Level}
+ team2Level={gameState?.team2Level}
+ dealerPlayerIndex={gameState?.dealerPlayerIndex}
+ onRename={handleOpenRenameModal}
+ cardExchangeAnimation={cardExchangeAnimation}
+ privateCardTransferReveal={privateCardTransferReveal}
+ highlightedCardIds={handArrivalHighlight?.cardIds || []}
+ highlightedCardLabel={handArrivalHighlight?.kind === 'exchange' ? '收' : ''}
+ highlightedCardTone={handArrivalHighlight?.kind === 'bottom' ? 'bottom' : 'arrival'}
+ ruleVisibleHands={ruleVisibleHands}
+ />
+
+ );
+
+ case GamePhases.PLAYING:
+ // 获取当前轮到出牌的玩家ID
+ const currentTurnPlayerId = gameState?.currentPlayerIndex !== null && gameState?.currentPlayerIndex !== undefined
+ ? currentRoom.players[gameState.currentPlayerIndex]?.id
+ : null;
+
+ // 根据是否查看上轮来决定显示哪些出牌
+ const displayedPlayedCards = !isLostInFogRule && viewingLastRound
+ ? lastRoundPlayedCards
+ : playedCards;
+
+ return (
+
+ {/* 游戏桌面 - 出牌阶段不显示展示的牌 */}
+ card.id)
+ : ruleDisabledCardIds}
+ disabledCardReason={hasPendingNinePrincesDecision
+ ? '九子夺嫡只能选择仍可晋升的手牌'
+ : ruleDisabledCardReason}
+ virtualizedCardIds={virtualizedCardIds}
+ transformableCardIds={hasPendingNinePrincesDecision ? [] : transformableCardIds}
+ onCardClick={hasPendingNinePrincesDecision
+ ? (isNinePrincesChooser ? handleNinePrincesCardClick : undefined)
+ : cardExchangeAnimation || isProxyTurn || isOpenHandSelf
+ ? undefined
+ : (cardExchange ? handleDrawingCardClick : handlePlayingCardClick)}
+ onRequestCardTransformation={hasPendingNinePrincesDecision
+ ? undefined
+ : handleRequestCardTransformation}
+ onCancelCardTransformation={hasPendingNinePrincesDecision
+ ? undefined
+ : handleCancelExplicitTransformation}
+ onReorder={cardExchangeAnimation || hasSubmittedCardExchange || icebergSelection || isProxyTurn || isOpenHandSelf || hasTimeReversalDecisionPending || hasPendingNinePrincesDecision || explicitTransformationList.length > 0
+ ? undefined
+ : reorderCards}
+ disableMyHand={hasPendingNinePrincesDecision
+ ? !isNinePrincesChooser
+ : cardExchangeAnimation
+ ? true
+ : strawBoatDecision
+ ? true
+ : forbiddenMagicState?.decisionPlayerId
+ ? true
+ : gameState?.dreamKilling?.sleepingPlayerIds?.includes(currentPlayer?.id)
+ ? true
+ : hasTimeReversalDecisionPending
+ ? true
+ : gameState?.equivalentReciprocity || (
+ ['compare_and_exchange', 'swap_two_plays_at_round_end'].includes(activeSkill?.effect)
+ && isActiveSkillArmed
+ )
+ ? true
+ : gameState?.antinomy?.pendingPlayerIds?.length
+ ? true
+ : cardExchange
+ ? hasSubmittedCardExchange
+ : gameState?.focusFigure?.isVotingPending
+ ? true
+ : tenSidedAmbush?.isSelectionPending
+ ? true
+ : icebergSelection
+ ? false
+ : hasPendingIcebergSelection
+ ? true
+ : !isCurrentPlayersTurn(gameState, currentRoom?.players, currentPlayer?.id) || isProxyTurn || isOpenHandSelf}
+ openHand={openHand}
+ ruleVisibleHands={ruleVisibleHands}
+ playerTargeting={{
+ active: !hasPendingNinePrincesDecision
+ && ['compare_and_exchange', 'swap_two_plays_at_round_end'].includes(activeSkill?.effect)
+ && isActiveSkillArmed,
+ targetPlayerId: equivalentReciprocityTarget?.id || null,
+ selectedPlayerIds: magicTrickTargetIds,
+ allowSelf: false,
+ requireCards: activeSkill?.effect !== 'swap_two_plays_at_round_end',
+ label: activeSkill?.effect === 'swap_two_plays_at_round_end'
+ ? '选择交换结算出牌的玩家'
+ : '选择进行拼点的玩家'
+ }}
+ onPlayerTargetClick={handleEquivalentReciprocityTarget}
+ openHandSelectedCards={selectedCards}
+ onOpenHandCardClick={isProxyTurn
+ && !hasTimeReversalDecisionPending
+ && !hasPendingNinePrincesDecision
+ ? toggleCardSelection
+ : undefined}
+ canControlOpenHand={isProxyTurn
+ && !hasTimeReversalDecisionPending
+ && !hasPendingNinePrincesDecision}
+ currentTurnPlayerId={currentTurnPlayerId}
+ currentWinningPlayerId={viewingLastRound
+ ? lastRoundWinnerPlayerId
+ : (currentWinningPlayerId ?? currentRoom.players[gameState?.currentWinnerIndex]?.id)}
+ trumpAnimation={viewingLastRound ? null : trumpAnimation}
+ trumpSuit={trumpSuit}
+ trumpRank={trumpRank}
+ publicBottomCards={displayedPublicBottomCards}
+ selectedRule={selectedRule}
+ ruleRuntimeStatus={ruleRuntimeStatus}
+ displayRoundNumber={heldCompletedRoundNumberRef.current
+ ?? heldCompletedRoundNumber
+ ?? gameState?.currentRound}
+ heldRoundCandle={heldRoundCandleRef.current}
+ ownFocusFigurePlayerId={focusFigurePrivate?.focusPlayerId || null}
+ tenSidedAmbush={tenSidedAmbushView}
+ threePowers={threePowersView}
+ divineWeapon={divineWeapon}
+ selectedDivineWeaponCardId={selectedDivineWeaponCardId}
+ onDivineWeaponCardClick={handleDivineWeaponCardClick}
+ canSelectDivineWeapon={Boolean(
+ !hasPendingNinePrincesDecision
+ && isActiveSkillArmed
+ && activeSkill?.effect === 'transform_matching_card'
+ && !divineWeapon?.usedThisRound
)}
+ onSelectRule={canOpenRuleSelector ? () => setRuleSelectorModal(true) : undefined}
+ ruleChooserPlayerId={gameState?.ruleChooserPlayerId}
+ isRuleSelectionPending={gameState?.isRuleSelectionPending}
+ renderControls={renderControlButtons()}
+ isWaitingForReady={false}
+ currentTrumpDeclaration={currentTrumpDeclaration}
+ currentInferiorDeclaration={currentInferiorDeclaration}
+ buryingPlayerId={gameState?.buryingPlayerId}
+ dealerCountdown={dealerCountdown}
+ attackerScore={attackerScore}
+ collectedPointCards={collectedPointCards}
+ team1Level={gameState?.team1Level}
+ team2Level={gameState?.team2Level}
+ dealerPlayerIndex={gameState?.dealerPlayerIndex}
+ onRename={handleOpenRenameModal}
+ cardExchange={cardExchange}
+ cardExchangeAnimation={cardExchangeAnimation}
+ privateCardTransferReveal={privateCardTransferReveal}
+ highlightedCardIds={handArrivalHighlight?.cardIds || []}
+ highlightedCardLabel={handArrivalHighlight?.kind === 'exchange' ? '收' : ''}
+ highlightedCardTone={handArrivalHighlight?.kind === 'bottom' ? 'bottom' : 'arrival'}
+ />
+
+ );
+
+ case GamePhases.REVEALING:
+ // 揭示底牌阶段不需要高亮边框,传递 null
+ return (
+
+ {/* 游戏桌面 - 展示底牌,保留所有人的出牌 */}
+ setRuleSelectorModal(true) : undefined}
+ ruleChooserPlayerId={gameState?.ruleChooserPlayerId}
+ isRuleSelectionPending={gameState?.isRuleSelectionPending}
+ renderControls={renderControlButtons()}
+ isWaitingForReady={false}
+ isWaitingForNextGame
+ currentTrumpDeclaration={currentTrumpDeclaration}
+ currentInferiorDeclaration={currentInferiorDeclaration}
+ buryingPlayerId={gameState?.buryingPlayerId}
+ dealerCountdown={dealerCountdown}
+ attackerScore={attackerScore}
+ collectedPointCards={collectedPointCards}
+ bottomScoreResult={bottomScoreResult}
+ upgradeResult={upgradeResult}
+ team1Level={gameState?.team1Level}
+ team2Level={gameState?.team2Level}
+ dealerPlayerIndex={gameState?.dealerPlayerIndex}
+ />
+
+ );
+
+ case GamePhases.FINISHED:
+ // 游戏结束阶段不需要高亮边框,传递 null
+ return (
+
+ {/* 游戏桌面 - 游戏结束,保留所有人的出牌和底牌 */}
+ setRuleSelectorModal(true) : undefined}
+ ruleChooserPlayerId={gameState?.ruleChooserPlayerId}
+ isRuleSelectionPending={gameState?.isRuleSelectionPending}
+ renderControls={renderControlButtons()}
+ isWaitingForReady={false}
+ currentTrumpDeclaration={currentTrumpDeclaration}
+ currentInferiorDeclaration={currentInferiorDeclaration}
+ buryingPlayerId={gameState?.buryingPlayerId}
+ dealerCountdown={dealerCountdown}
+ attackerScore={attackerScore}
+ collectedPointCards={collectedPointCards}
+ bottomScoreResult={bottomScoreResult}
+ upgradeResult={upgradeResult}
+ team1Level={gameState?.team1Level}
+ team2Level={gameState?.team2Level}
+ dealerPlayerIndex={gameState?.dealerPlayerIndex}
+ onRename={handleOpenRenameModal}
+ />
+
+ );
+
+ default:
+ return null;
+ }
+ };
+
+ return (
+
+ {contextHolder}
+ {modalContextHolder}
+
+
+
+
+ socket.emit('select_initial_candle_state', {
+ roomId: currentRoom.id,
+ isLit: false
+ })}
+ >
+ 熄灭烛
+
+ socket.emit('select_initial_candle_state', {
+ roomId: currentRoom.id,
+ isLit: true
+ })}
+ >
+ 点燃烛
+
+
+ )}
+ >
+
+
🕯️
+
你是庄家队友,请选择第1轮的烛态。
+
+ 点燃:红色分牌每张 +5,黑色分牌每张 −5;
+ 熄灭则相反。小王是黑色,大王是红色。
+
+
+
+
+ {equivalentReciprocityResult && (
+
+
等价互惠 · 拼点
+
+ {(equivalentReciprocityResult.cards || []).map(entry => (
+
+ {entry.playerName}
+
+
+ ))}
+
+
+ {equivalentReciprocityResult.isTie
+ ? '平局 · 不失分 · 交换拼点牌'
+ : `${equivalentReciprocityResult.winnerPlayerName} 胜 · ${equivalentReciprocityResult.loserPlayerName}一方失去5分`}
+
+
+ )}
+
+ {tenSidedAmbushRevealAnimation && (
+
+
+ 十面埋伏
+ {tenSidedAmbushRevealAnimation.rank}
+
+ {tenSidedAmbushRevealAnimation.source === 'bottom'
+ ? '伏兵现于底牌'
+ : '伏击点数首次现身'}
+
+
+
+ )}
+
+ {threePowersRevealAnimation && (
+
+
+ 三权分立
+
+ {threePowersRevealAnimation.slots
+ .map(slot => `${slot.sourceRank}→${slot.rank}`)
+ .join(' ')}
+
+
+ {threePowersRevealAnimation.source === 'bottom'
+ ? '重载分牌现于底牌'
+ : '重载点数首次现身'}
+
+
+
+ )}
+
+ {activeSkillAnimation && (
+
+
+
+ {activeSkillAnimation.playerName} {activeSkillAnimation.actionLabel || '发动主动技能'}
+
+ {activeSkillAnimation.name}
+
+ {activeSkillAnimation.detail
+ || (activeSkillAnimation.treatedAsSmall
+ ? '本次垫牌始终视为小'
+ : activeSkillAnimation.concealed
+ ? '本轮结束时同时公开'
+ : '技能已发动')}
+
+
+
+ )}
+
+ {/* 主游戏区域 */}
+
+ {renderPhaseContent()}
+
+
+
+
+
+ 本窗口关闭后才能打出本轮第一张牌。放入或替换新牌时,
+ 木牛流马会立即交给队友。
+
+
+ 已传递 {woodenOxDecision?.transfersUsed || 0} / {woodenOxDecision?.maxTransfers || 4} 次
+ (每2次为一个完整往返)
+
+ {woodenOxDecision?.mustTransfer && (
+
+ 队友已无牌可出,本轮必须交出木牛流马,不能继续保留。
+
+ )}
+ {woodenOxDecision?.storedCard && (
+
+ )}
+ 选择要放入的新牌
+
+ {myCards.map(card => (
+ setWoodenOxSelectedCardId(previous => (
+ previous === card.id ? null : card.id
+ ))}
+ trumpSuit={trumpSuit}
+ trumpRank={trumpRank}
+ />
+ ))}
+
+
+ handleManageWoodenOx('load_and_pass')}>
+ {woodenOxDecision?.hasStoredCard ? '替换并交给队友' : '放入并交给队友'}
+
+ {woodenOxDecision?.hasStoredCard && (
+ handleManageWoodenOx('pass')}>
+ 原牌不变,直接交给队友
+
+ )}
+ handleManageWoodenOx('skip')}
+ >
+ 本轮不操作
+
+
+
+
+
+ setCardTransformationDialog(null)}
+ >
+ {cardTransformationDialog?.kind === 'joker' && (
+
+ {!cardTransformationDialog.selectedSuit ? <>
+
第一步:选择这张王要变成的花色
+
+ {TRANSFORMATION_SUITS.map(option => (
+ setCardTransformationDialog(previous => ({
+ ...previous,
+ selectedSuit: option.value
+ }))}
+ >
+ {option.label}
+
+ ))}
+
+
+
{
+ const cardId = cardTransformationDialog.cardId;
+ setCardTransformationDialog(null);
+ if (!selectedCards.includes(cardId)) toggleCardSelection(cardId);
+ }}
+ >
+ 保持王牌并选中
+
+ > : <>
+
+ setCardTransformationDialog(previous => ({
+ ...previous,
+ selectedSuit: null
+ }))}
+ >
+ 返回选花色
+
+
+ 第二步:选择 {TRANSFORMATION_SUITS.find(
+ option => option.value === cardTransformationDialog.selectedSuit
+ )?.label} 的点数
+
+
+
+ {TRANSFORMATION_RANKS.map(rank => (
+ handleCommitJokerTransformation(rank)}
+ >
+ {rank}
+
+ ))}
+
+ >}
+
+ )}
+ {cardTransformationDialog?.kind === 'cluster' && (
+
+
+ 请选择这张牌要临时视为的相邻点数。分牌和级牌已自动排除。
+
+
+ {(cardTransformationDialog.targetRanks || []).map(rank => (
+ handleCommitClusterTransformation(rank)}
+ >
+ {rank}
+
+ ))}
+
+
+ )}
+ {cardTransformationDialog?.kind === 'forbidden_magic' && (
+
+ {!cardTransformationDialog.selectedSuit ? <>
+
+ {cardTransformationDialog.isJoker
+ ? '第一步:选择王要变成的副牌花色。'
+ : `选择这张 ${cardTransformationDialog.sourceRank} 要变成的副牌花色;点数保持不变。`}
+
+
+ {TRANSFORMATION_SUITS
+ .filter(option => !(
+ trumpSuit
+ && trumpSuit !== 'no_trump'
+ && option.value === trumpSuit
+ ))
+ .map(option => (
+ {
+ if (cardTransformationDialog.isJoker) {
+ setCardTransformationDialog(previous => ({
+ ...previous,
+ selectedSuit: option.value
+ }));
+ return;
+ }
+ handleCommitForbiddenMagicTransformation(option.value);
+ }}
+ >
+ {option.label}
+
+ ))}
+
+
+ 禁术生效后,原主牌不能直接打出;每张要出的原主牌都必须先转成一种副花色。未打出的转化预设会保留,可继续为后续出牌准备。
+
+ > : <>
+
+ setCardTransformationDialog(previous => ({
+ ...previous,
+ selectedSuit: null
+ }))}
+ >
+ 返回选花色
+
+
+ 第二步:选择 {TRANSFORMATION_SUITS.find(
+ option => option.value === cardTransformationDialog.selectedSuit
+ )?.label} 的点数
+
+
+
+ {TRANSFORMATION_RANKS.map(rank => (
+ handleCommitForbiddenMagicTransformation(
+ cardTransformationDialog.selectedSuit,
+ rank
+ )}
+ >
+ {rank}
+
+ ))}
+
+ >}
+
+ )}
+
+
+ {
+ socket.emit(SOCKET_EVENTS.VOTE_FOCUS_FIGURE, {
+ roomId: currentRoom.id,
+ team: focusFigureVote?.team,
+ attempt: focusFigureVote?.attempt,
+ agree: true
+ });
+ setFocusFigureVote(null);
+ }}
+ onCancel={() => {
+ socket.emit(SOCKET_EVENTS.VOTE_FOCUS_FIGURE, {
+ roomId: currentRoom.id,
+ team: focusFigureVote?.team,
+ attempt: focusFigureVote?.attempt,
+ agree: false
+ });
+ setFocusFigureVote(null);
+ }}
+ >
+
+ 是否同意由 {focusFigureVote?.nomineePlayerName || ''} 担任本队焦点人物?
+
+
+ 选择不同意不会结束表决;系统会把候选切换给本队另一名玩家,再由两人重新投票。
+ 候选与票型只在本队内部可见。
+
+
+
+ {
+ socket.emit('respond_last_stand', { roomId: currentRoom.id, accept: true });
+ setLastStandDecision(null);
+ }}
+ onCancel={() => {
+ socket.emit('respond_last_stand', { roomId: currentRoom.id, accept: false });
+ setLastStandDecision(null);
+ }}
+ >
+
+ 你目前有 {lastStandDecision?.cardsCount || 0} 张同花色手牌且没有主牌,
+ 是否令这些牌全部视为主牌?
+
+
+ 暂不发动不会消耗机会;下次手牌变化后若仍满足条件,会再次询问。
+
+
+
+ {
+ socket.emit(SOCKET_EVENTS.RESPOND_TEAMMATE_CHEER, {
+ roomId: currentRoom.id,
+ accept: true
+ });
+ setTeammateCheerDecision(null);
+ }}
+ onCancel={() => {
+ socket.emit(SOCKET_EVENTS.RESPOND_TEAMMATE_CHEER, {
+ roomId: currentRoom.id,
+ accept: false
+ });
+ setTeammateCheerDecision(null);
+ }}
+ >
+
+ 你出牌后已经没有主牌。是否为队友{' '}
+ {teammateCheerDecision?.teammatePlayerName || ''} 加油,
+ 令其余下牌面永久全部提升一级?
+
+
+ 副牌不会跨入主牌链;副A升为B,大王升为郡王,实体牌原有分值不变。
+ 暂不发动不会消耗机会,之后仍满足条件时会再次询问。
+
+
+
+ {
+ socket.emit(SOCKET_EVENTS.RESPOND_POLITICAL_REVIEW, {
+ roomId: currentRoom.id,
+ returnPlay: true
+ });
+ }}
+ onCancel={() => {
+ socket.emit(SOCKET_EVENTS.RESPOND_POLITICAL_REVIEW, {
+ roomId: currentRoom.id,
+ returnPlay: false
+ });
+ }}
+ >
+
+ 队友 {politicalReviewDecision?.teammatePlayerName || ''} 打出了:
+
+
+ {(politicalReviewDecision?.cards || []).map(card => (
+ {formatPublicCard(card)}
+ ))}
+
+
+ 选择收回才会消耗你本局唯一一次政治审查。收回仅表示你不赞成这手牌,
+ 不会禁用其中任何牌;队友仍可立刻把完全相同的牌再出一次。选择放行不消耗次数,
+ 以后队友出牌时仍会继续询问。
+
+
+
+ {
+ socket.emit(SOCKET_EVENTS.RESPOND_AFTERGLOW, {
+ roomId: currentRoom.id,
+ accept: true
+ });
+ setAfterglowDecision(null);
+ }}
+ onCancel={() => {
+ socket.emit(SOCKET_EVENTS.RESPOND_AFTERGLOW, {
+ roomId: currentRoom.id,
+ accept: false
+ });
+ setAfterglowDecision(null);
+ }}
+ >
+
+ {afterglowDecision?.triggerTiming === 'before_first_play'
+ ? `你是开局一号位,手中有 ${afterglowDecision?.trumpCount || 0} 张主牌,是否在首次出牌前直接发动回光返照?`
+ : `你出牌后还剩 ${afterglowDecision?.trumpCount || 0} 张主牌,是否发动回光返照?`}
+
+
+ 回光返照仅在非无主局生效。确认后剩余主牌会立即沿完整主牌序列提升一级。
+ 从下一次出牌起,只要手中仍有主牌
+ 便无视通常的跟牌要求,但整次出牌只能由主牌组成,不能混入任何副牌;实体牌原有分值不变。
+ 主牌出尽后效果结束,
+ 暂不发动不会消耗机会。
+
+
+
+
+
+ 四家已经出完。请选择本轮最终采用的出牌方案;确认后不能更改。
+
+
+ {ambiguousChoice?.usageConsumed
+ ? '本次发动会消耗你每局一次的机会。'
+ : '你是本轮后续发动者,本次不消耗发动次数。'}
+
+
+ {(ambiguousChoice?.options || []).map(option => (
+ {
+ socket.emit(SOCKET_EVENTS.RESPOND_AMBIGUOUS_CHOICE, {
+ roomId: currentRoom.id,
+ optionIndex: option.index
+ });
+ setAmbiguousChoice(null);
+ }}
+ >
+ 采用方案{option.index === 0 ? 'A' : 'B'}
+
+ {(option.cards || []).map(card => (
+
+ ))}
+
+
+ ))}
+
+
- {/* 所有玩家可用按钮 */}
-
- 修改昵称
+ {
+ socket.emit(SOCKET_EVENTS.ACTIVATE_BUSH_GATE, {
+ roomId: currentRoom.id
+ });
+ setBushGateDecisionOpen(false);
+ }}
+ onCancel={() => setBushGateDecisionOpen(false)}
+ >
+
+ 是否令本轮一号位收回刚才的全部首发牌,并重新合法首发?
+
+
+ 被收回的每一张牌都不能用于紧接着的重新首发;一号位成功改出其他牌后,限制立即解除。
+ 选择暂不发动不会消耗每局一次的机会。
+
+
+
+ {
+ const eventName = activeSkill?.id === 'recommend_talent'
+ ? SOCKET_EVENTS.ACTIVATE_RECOMMEND_TALENT
+ : SOCKET_EVENTS.ACTIVATE_LATE_MOVER_ADVANTAGE;
+ socket.emit(eventName, {
+ roomId: currentRoom.id
+ });
+ setLateMoverDecisionOpen(false);
+ }}
+ onCancel={() => setLateMoverDecisionOpen(false)}
+ >
+
+ 是否令下家先出牌,并把自己改为本轮最后出牌?
+
+
+ 选择“否”不会消耗本局唯一一次发动机会;本技能只改变牌序,不改变牌的大小。
+
+
+
+
+ setInviteIntoUrnSelection(null)}>暂不发动
+
+ 确认发动
+
+
+ )}
+ >
+
+
+ 指定玩家
+ 本轮命中指定实体牌面即失去5分
+
+
+
- case GamePhases.DRAWING:
- return (
-
- {/* 游戏桌面 - 摸牌阶段显示展示的牌 */}
-
- }
- currentTrumpDeclaration={currentTrumpDeclaration}
- players={currentRoom.players}
- currentPlayer={currentPlayer}
- playedCards={{}}
- shownCards={shownCards}
- myCards={myCards}
- selectedCards={selectedCards}
- onCardClick={toggleCardSelection}
- onReorder={reorderCards}
- currentTurnPlayerId={null}
- trumpSuit={trumpSuit}
- trumpRank={trumpRank}
- isHost={isHost}
- onSetTrump={() => setTrumpModal(true)}
- selectedRule={selectedRule}
- onSelectRule={() => setRuleSelectorModal(true)}
- renderControls={renderControlButtons()}
- isWaitingForReady={false}
- buryingPlayerId={gameState?.buryingPlayerId}
- dealerCountdown={dealerCountdown}
- team1Level={gameState?.team1Level}
- team2Level={gameState?.team2Level}
- dealerPlayerIndex={gameState?.dealerPlayerIndex}
- onRename={handleOpenRenameModal}
- />
+
+ setCulturalRevolutionSelection(null)}>暂不发动
+ setCulturalRevolutionSelection({ declarationType: null, value: null })}>
+ 返回二选一
+
+
+ 确认发动
+
+
+ ) : (
+ setCulturalRevolutionSelection(null)}>暂不发动
+ )}
+ >
+ {!culturalRevolutionSelection?.declarationType ? (
+
+
+ 先选择本次革命的性质。两种效果二选一,不会同时改变主花色和级牌点数。
+
+
+ setCulturalRevolutionSelection({ declarationType: 'suit', value: null })}
+ >
+ 革花色
+ 替换原主花色
+
+ setCulturalRevolutionSelection({ declarationType: 'rank', value: null })}
+ >
+ 革点数
+ 替换原级牌点数
+
+
- );
-
- case GamePhases.BURYING:
- return (
-
- {/* 游戏桌面 - 埋底阶段 */}
-
setTrumpModal(true)}
- selectedRule={selectedRule}
- onSelectRule={() => setRuleSelectorModal(true)}
- renderControls={renderControlButtons()}
- isWaitingForReady={false}
- currentTrumpDeclaration={currentTrumpDeclaration}
- buryingPlayerId={gameState?.buryingPlayerId}
- dealerCountdown={dealerCountdown}
- team1Level={gameState?.team1Level}
- team2Level={gameState?.team2Level}
- dealerPlayerIndex={gameState?.dealerPlayerIndex}
- onRename={handleOpenRenameModal}
- />
+ ) : (
+
+
+
+ {culturalRevolutionSelection.declarationType === 'suit' ? '革花色' : '革点数'}
+
+
+ {culturalRevolutionSelection.declarationType === 'suit'
+ ? '选择新的主花色'
+ : '选择新的级牌点数(10、K均可)'}
+
+
+
+ {(culturalRevolutionSelection.declarationType === 'suit'
+ ? TRANSFORMATION_SUITS
+ : TRANSFORMATION_RANKS.map(rank => ({ value: rank, label: rank })))
+ .map(option => (
+ setCulturalRevolutionSelection(previous => ({
+ ...previous,
+ value: option.value
+ }))}
+ >
+ {option.label}
+
+ ))}
+
+
+ 发动当轮和下一轮生效;原主对应项暂时变回副牌。若期间另一名玩家发动,新声明会覆盖旧声明并重新计两轮。
+
- );
+ )}
+
- case GamePhases.PLAYING:
- // 获取当前轮到出牌的玩家ID
- const currentTurnPlayerId = gameState?.currentPlayerIndex !== null && gameState?.currentPlayerIndex !== undefined
- ? currentRoom.players[gameState.currentPlayerIndex]?.id
- : null;
+
+ handleRespondMainstay(false)}>暂不发动
+ handleRespondMainstay(true)}>
+ 发动中流砥柱
+
+
+ ) : (
+
+ {ownMainstayAction?.stage === 'return' ? '确认返还' : '确认交牌'}
+ ({mainstaySelectedCardIds.length}/{ownMainstayAction?.requiredCards || 5})
+
+ )
+ }
+ >
+ {ownMainstayAction?.stage === 'decision' ? (
+ <>
+
+ 你当前的主牌不超过5张,可以发动中流砥柱。
+
+
+ 发动后须将包含当前全部主牌的5张牌交给队友,再由队友选择5张牌返还。
+ 队友稍后轮到自己时仍会按当时的手牌重新判断,也可以再次发动并把主牌交回来。
+
+ >
+ ) : (
+ <>
+
+ {ownMainstayAction?.stage === 'return'
+ ? `请选择5张牌返还给 ${currentRoom?.players?.find(player => player.id === ownMainstayAction?.actorPlayerId)?.name || '队友'}。`
+ : `请选择5张牌交给 ${currentRoom?.players?.find(player => player.id === ownMainstayAction?.teammatePlayerId)?.name || '队友'};当前全部主牌已预先选中且不可取消。`}
+
+
+
+
+
+ 已选择 {mainstaySelectedCardIds.length} 张,本次必须正好选择5张。
+
+ >
+ )}
+
- return (
-
- {/* 游戏桌面 - 出牌阶段不显示展示的牌 */}
- setTrumpModal(true)}
- selectedRule={selectedRule}
- onSelectRule={() => setRuleSelectorModal(true)}
- renderControls={renderControlButtons()}
- isWaitingForReady={false}
- currentTrumpDeclaration={currentTrumpDeclaration}
- buryingPlayerId={gameState?.buryingPlayerId}
- dealerCountdown={dealerCountdown}
- attackerScore={attackerScore}
- collectedPointCards={collectedPointCards}
- team1Level={gameState?.team1Level}
- team2Level={gameState?.team2Level}
- dealerPlayerIndex={gameState?.dealerPlayerIndex}
- onRename={handleOpenRenameModal}
- />
-
- );
+
+ setMutualSupportDirectionOpen(false)}>暂不发动
+ 向队友要牌
+
+ 给队友牌
+
+
+ )}
+ >
+
+ 选择本次同舟共济的方向。向队友要牌时,队友可以交给你0至2张,也可以不给;
+ 主动给牌时,你必须交给队友1至2张。
+
+
+ 无论哪个方向,本轮结束时都由当前收牌的一方选择等量手牌返还给原持有者。
+ 只有实际确认发动才会消耗每局一次的机会。
+
+ {mutualSupportMaxGiveCount < 1 && (
+
+ 你目前必须保留全部手牌完成本轮出牌,因此暂时只能向队友要牌。
+
+ )}
+
- case GamePhases.REVEALING:
- // 揭示底牌阶段不需要高亮边框,传递 null
- return (
-
- {/* 游戏桌面 - 展示底牌,保留所有人的出牌 */}
- setTrumpModal(true)}
- revealedBottomCards={revealedBottomCards}
- selectedRule={selectedRule}
- onSelectRule={() => setRuleSelectorModal(true)}
- renderControls={renderControlButtons()}
- isWaitingForReady={false}
- currentTrumpDeclaration={currentTrumpDeclaration}
- buryingPlayerId={gameState?.buryingPlayerId}
- dealerCountdown={dealerCountdown}
- attackerScore={attackerScore}
- collectedPointCards={collectedPointCards}
- bottomScoreResult={bottomScoreResult}
- upgradeResult={upgradeResult}
- team1Level={gameState?.team1Level}
- team2Level={gameState?.team2Level}
- dealerPlayerIndex={gameState?.dealerPlayerIndex}
- />
-
- );
+
+ {mutualSupportSelection?.source === 'activation' && (
+ {
+ setMutualSupportSelection(null);
+ setMutualSupportSelectedCardIds([]);
+ setMutualSupportDirectionOpen(true);
+ }}>
+ 返回
+
+ )}
+ (mutualSupportSelection?.maxCards || 0)
+ }
+ onClick={handleSubmitMutualSupportCards}
+ >
+ {mutualSupportSelection?.stage === 'request'
+ && mutualSupportSelectedCardIds.length === 0
+ ? '不给牌'
+ : mutualSupportSelection?.stage === 'return'
+ ? `返还${mutualSupportSelectedCardIds.length}张`
+ : `交出${mutualSupportSelectedCardIds.length}张`}
+
+
+ )}
+ >
+
+ {mutualSupportSelection?.stage === 'return'
+ ? `本轮结束,请选择${mutualSupportSelection?.requiredCards || 0}张手牌返还给 ${mutualSupportSelection?.otherPlayerName || '队友'}。`
+ : mutualSupportSelection?.stage === 'request'
+ ? `${mutualSupportSelection?.otherPlayerName || '队友'} 请求你交牌;你可以选择0至${mutualSupportSelection?.maxCards || 0}张。`
+ : `请选择1至${mutualSupportSelection?.maxCards || 2}张手牌交给 ${mutualSupportSelection?.otherPlayerName || '队友'}。`}
+
+
+
+
+
+ 已选择 {mutualSupportSelectedCardIds.length} 张;
+ {mutualSupportSelection?.minCards === mutualSupportSelection?.maxCards
+ ? ` 本次必须正好选择${mutualSupportSelection?.minCards || 0}张。`
+ : ` 本次可以选择${mutualSupportSelection?.minCards || 0}至${mutualSupportSelection?.maxCards || 0}张。`}
+
+
- case GamePhases.FINISHED:
- // 游戏结束阶段不需要高亮边框,传递 null
- return (
-
- {/* 游戏桌面 - 游戏结束,保留所有人的出牌和底牌 */}
-
{
+ socket.emit(SOCKET_EVENTS.ACTIVATE_EQUIVALENT_RECIPROCITY, {
+ roomId: currentRoom.id,
+ targetPlayerId: equivalentReciprocityTarget?.id
+ });
+ setEquivalentReciprocityTarget(null);
+ }}
+ onCancel={() => setEquivalentReciprocityTarget(null)}
+ >
+
+ 是否与 {equivalentReciprocityTarget?.name || ''} 拼点?
+
+
+ 确认后双方各自秘密选择一张牌;主牌大于副牌,副牌只比较点数。
+ 输家一方失去5分,点数相同则无人失分,但拼点牌仍会交换。
+
+
+
+ {
+ socket.emit(SOCKET_EVENTS.ACTIVATE_MAGIC_TRICK, {
+ roomId: currentRoom.id,
+ targetPlayerIds: magicTrickTargetIds
+ });
+ }}
+ onCancel={() => setMagicTrickTargetIds([])}
+ >
+
+ 是否暗中交换
+ {' '}
+
+ {magicTrickTargetIds
+ .map(playerId => currentRoom?.players?.find(player => player.id === playerId)?.name)
+ .filter(Boolean)
+ .join(' 与 ')}
+
+ {' '}本轮的结算出牌?
+
+
+ 两人仍按各自真实手牌正常出牌;交换只在四家出完后影响本轮胜负、得分归属和下轮牌权。
+
+
+
+
+ {equivalentReciprocitySelection?.submitted ? '已暗置,等待对方' : '确认拼点牌'}
+
+ )}
+ >
+
+ 请暗选一张牌,与 {equivalentReciprocitySelection?.opponentPlayerName || ''} 拼点。
+
+
+ setEquivalentReciprocityCardId(previous => previous === cardId ? null : cardId)}
+ disabled={Boolean(equivalentReciprocitySelection?.submitted)}
+ small
+ trumpSuit={trumpSuit}
+ trumpRank={trumpRank}
+ minimumVisibleWidth={16}
+ />
+
+
+ 你的选择在双方都提交前不会公开;提交后不能更改。
+
+
+
+
+ handleRespondStrawBoatBorrowingArrows(false)}>
+ 放弃发动
+
+ handleRespondStrawBoatBorrowingArrows(true)}
+ >
+ 公开弃牌并取箭
+
+
+ )}
+ >
+
+ 你首置的 {strawBoatDecision?.leadingPoints || 0} 分未被本方获得,可以弃置一张非分数牌,
+ 换取本轮打出的最大非分数牌:
+
+ {strawBoatDecision?.borrowedCard && (
+
+ setTrumpModal(true)}
- revealedBottomCards={revealedBottomCards}
- selectedRule={selectedRule}
- onSelectRule={() => setRuleSelectorModal(true)}
- renderControls={renderControlButtons()}
- isWaitingForReady={false}
- currentTrumpDeclaration={currentTrumpDeclaration}
- buryingPlayerId={gameState?.buryingPlayerId}
- dealerCountdown={dealerCountdown}
- attackerScore={attackerScore}
- collectedPointCards={collectedPointCards}
- bottomScoreResult={bottomScoreResult}
- upgradeResult={upgradeResult}
- team1Level={gameState?.team1Level}
- team2Level={gameState?.team2Level}
- dealerPlayerIndex={gameState?.dealerPlayerIndex}
- onRename={handleOpenRenameModal}
/>
- );
+ )}
+
+ setStrawBoatDiscardCardId(
+ previous => previous === cardId ? null : cardId
+ )}
+ small
+ trumpSuit={trumpSuit}
+ trumpRank={trumpRank}
+ minimumVisibleWidth={20}
+ />
+
+
+ 请选择恰好一张非分数牌。弃掉的牌和获得的牌都会向全场公开;选择放弃则不交换任何牌。
+
+
- default:
- return null;
- }
- };
+
+ {lureTigerDecision?.stage === 'confirm' && (
+
+
+ 是否在第 {lureTigerDecision.round} 轮发动调虎离山?
+
+
+ 确认后还需选择一名本轮非一号位玩家。选定目标才会抢占本方阵营唯一一次机会;
+ 暂不发动不会占用次数,同轮其他预备者会继续依次确认。
+
+
+ {
+ socket.emit(SOCKET_EVENTS.RESPOND_LURE_TIGER, {
+ roomId: currentRoom.id,
+ accept: false
+ });
+ setLureTigerDecision(null);
+ }}>
+ 暂不发动
+
+ {
+ socket.emit(SOCKET_EVENTS.RESPOND_LURE_TIGER, {
+ roomId: currentRoom.id,
+ accept: true
+ });
+ }}>
+ 发动并选择目标
+
+
+
+ )}
+ {lureTigerDecision?.stage === 'target' && (
+
+
+ 请选择本轮要沉默的非一号位玩家
+
+
+ 目标本轮不参与甩牌询问,且整次出牌不计大小与分数;下一轮自动解除。
+
+
+ {(lureTigerDecision.eligibleTargetIds || []).map(targetPlayerId => {
+ const target = currentRoom.players.find(player => player.id === targetPlayerId);
+ if (!target) return null;
+ return (
+ {
+ socket.emit(SOCKET_EVENTS.SELECT_LURE_TIGER_TARGET, {
+ roomId: currentRoom.id,
+ targetPlayerId: target.id
+ });
+ setLureTigerDecision(null);
+ }}
+ >
+ {target.name}{target.id === currentPlayer?.id ? '(自己)' : ''}
+
+ );
+ })}
+
+
+ )}
+
- return (
-
- {contextHolder}
+
{
+ socket.emit(SOCKET_EVENTS.RESPOND_FORBIDDEN_MAGIC, {
+ roomId: currentRoom.id,
+ accept: true
+ });
+ setForbiddenMagicDecision(null);
+ }}
+ onCancel={() => {
+ socket.emit(SOCKET_EVENTS.RESPOND_FORBIDDEN_MAGIC, {
+ roomId: currentRoom.id,
+ accept: false
+ });
+ setForbiddenMagicDecision(null);
+ }}
+ >
+
+ 是否从第 {forbiddenMagicDecision?.round || ''} 轮起发动禁术秘法?
+
+
+ 确认后技能将立即生效并持续到本局结束,不能撤销;暂不发动不会消耗机会,以后仍可再次预备。
+ 同轮其他预备者会继续依次确认,不受你的选择影响。
+
+
- {/* 毙牌动画 */}
- {trumpAnimation && (
-
-
- {trumpAnimation.type === 'overtrump' ? '盖毙!' : '毙了!'}
- {trumpAnimation.playerName}
-
-
- )}
+
{
+ socket.emit(SOCKET_EVENTS.RESPOND_REMOVE_FIREWOOD, {
+ roomId: currentRoom.id,
+ accept: true
+ });
+ }}
+ onCancel={() => {
+ socket.emit(SOCKET_EVENTS.RESPOND_REMOVE_FIREWOOD, {
+ roomId: currentRoom.id,
+ accept: false
+ });
+ }}
+ >
+
+ {removeFirewoodDecision?.counteringPlayerName || ''} 反了你的主。
+ 是否与其交换当前的全部手牌?
+
+
+ 这是一次可选机会,并非必须发动;选择“不交换”不会改动双方手牌。
+ 若本局发生多次反主,系统会继续按照由后向前的顺序逐项询问。
+
+
- {/* 主游戏区域 */}
-
- {renderPhaseContent()}
-
+
{
+ socket.emit(SOCKET_EVENTS.RESPOND_TIME_REVERSAL, {
+ roomId: currentRoom.id,
+ accept: true
+ });
+ setTimeReversalDecision(null);
+ }}
+ onCancel={() => {
+ socket.emit(SOCKET_EVENTS.RESPOND_TIME_REVERSAL, {
+ roomId: currentRoom.id,
+ accept: false
+ });
+ setTimeReversalDecision(null);
+ }}
+ >
+
+ 是否回到第 {timeReversalDecision?.round || ''} 轮开始前,收回本轮四家的出牌并重新出牌?
+
+
+ 选择保留结果不会消耗技能;多名玩家预备时,首个确认倒流的人发动成功。
+
+
{/* 埋底玩家选择弹窗 */}
{currentRoom.players.map(player => (
- {player.name} (当前: {player.level})
+ {player.name} (当前: {formatLevel(player.level)})
))}
@@ -1479,9 +8359,14 @@ export default function GameBoard() {
/>
- {/* 查看我的底牌弹窗 */}
+ {/* 普通规则由庄家查看;人民公社只展示请求者自己埋下的两张牌。 */}
setViewBottomModal(false)}
onCancel={() => setViewBottomModal(false)}
@@ -1492,45 +8377,30 @@ export default function GameBoard() {
]}
>
-
已埋 {myBottomCards.length} 张底牌
-
-
+
+
+ {isPeopleCommuneRule
+ ? `你埋下的 ${myBottomCards.length} 张牌仅自己可随时查看。`
+ : isOpenlyRevealedRule
+ ? `当前 ${myBottomCards.length} 张底牌始终明置,所有玩家均可随时查看。`
+ : isReformAndOpeningUpRule
+ ? `本局最终 ${myBottomCards.length} 张底牌,庄家与庄家队友均可随时查看。`
+ : `本局已埋 ${myBottomCards.length} 张底牌,仅庄家可随时查看。`}
+
+
+ {bottomCardsScore === null ? (
+ <>底牌分数:? 分(按重载点数结算)>
+ ) : (
+ <>{isPeopleCommuneRule ? '这两张牌分数' : '底牌分数'}:{bottomCardsScore} 分>
+ )}
+
+
{myBottomCards.length > 0 && (
)}
- {/* 设置主牌弹窗 */}
-
setTrumpModal(false)}
- footer={null}
- >
-
- 花色:
-
-
- handleSetTrump('hearts', trumpRank || '2')}>♥ 红桃
- handleSetTrump('diamonds', trumpRank || '2')}>♦ 方块
- handleSetTrump('clubs', trumpRank || '2')}>♣ 梅花
- handleSetTrump('spades', trumpRank || '2')}>♠ 黑桃
- handleSetTrump('no_trump', trumpRank || '2')}>无主
-
-
- 点数:
-
-
- {['2', '3', '4', '5', '6', '7', '8', '9', '10', 'J', 'Q', 'K', 'A'].map(rank => (
- handleSetTrump(trumpSuit || 'hearts', rank)}>
- {rank}
-
- ))}
-
-
-
-
{/* 房间设置弹窗 */}
-
底牌数量:
+
Bot策略:
-
发牌间隔(毫秒):
@@ -1742,9 +8614,520 @@ export default function GameBoard() {
{/* 规则选择器弹窗 */}
setRuleSelectorModal(false)}
+ rules={gameState?.ruleOptions || []}
+ selectionMode={gameState?.ruleSelectionMode || 'single'}
+ canChoose={isRuleChooser}
+ canRefresh={canRefreshDoubleHappiness}
onRuleSelected={handleRuleSelected}
+ onRefreshRule={handleRefreshDoubleHappinessOption}
+ onClose={() => setRuleSelectorModal(false)}
/>
+
+
+
+
+ 目标只对你可见。可以选 5、10、K;王和本局级牌不能选。
+
+
+ {(waitingRabbitSelection?.eligibleSuits || []).map(suit => (
+ setSelectedWaitingRabbitSuit(suit)}
+ >
+ {TRANSFORMATION_SUITS.find(option => option.value === suit)?.label || suit}
+
+ ))}
+
+
+ {(waitingRabbitSelection?.eligibleRanks || []).map(rank => (
+ setSelectedWaitingRabbitRank(rank)}
+ >
+ {rank}
+
+ ))}
+
+
+ {selectedWaitingRabbitSuit && selectedWaitingRabbitRank
+ ? `暗定 ${formatPublicCard({ suit: selectedWaitingRabbitSuit, rank: selectedWaitingRabbitRank })}`
+ : '请选择花色和点数'}
+
+
+
+
+
+
+
+ {waitingRabbitDecision?.sourcePlayerName} 打出了你的目标牌
+ {' '}{formatPublicCard(waitingRabbitDecision?.targetCard)}。选择一张非分牌后可将它换回手中。
+
+
+ {waitingRabbitDecision?.targetCard && (
+
+ )}
+
+
+ 本轮胜负和分数已经结算,交换不会倒改本轮结果;被换走的分牌以后再次打出只比较大小,不再计分。
+
+
+ {myCards
+ .filter(card => waitingRabbitDecision?.eligibleDiscardCardIds?.includes(card.id))
+ .map(card => (
+ setWaitingRabbitDiscardCardId(card.id)}
+ trumpSuit={trumpSuit}
+ trumpRank={trumpRank}
+ />
+ ))}
+
+
+ handleRespondWaitingRabbit(false)}>放弃本次交换
+ handleRespondWaitingRabbit(true)}
+ >
+ 交换这一张
+
+
+
+
+
+
+
+
+ 只有你能看到本次选择;该点数首次出牌后才会向所有玩家揭晓。
+
+
+ {(tenSidedAmbushSelection?.eligibleRanks || []).map(rank => (
+ setSelectedTenSidedAmbushRank(rank)}
+ >
+ {rank}
+
+ ))}
+
+
+ 级牌 {tenSidedAmbushSelection?.trumpRank} 以及分牌 5、10、K 不可选择。
+
+
+ {selectedTenSidedAmbushRank
+ ? `确认伏击 ${selectedTenSidedAmbushRank}`
+ : '请选择伏击点数'}
+
+
+
+
+
+
+
+ 你选择的点数将成为 {threePowersSelection?.pointValue || 0} 分牌;首次出现前只有你能看到。
+
+
+ {(threePowersSelection?.eligibleRanks || []).map(rank => (
+ setSelectedThreePowersRank(rank)}
+ >
+ {rank}
+
+ ))}
+
+
+ 仅级牌 {threePowersSelection?.trumpRank} 与王牌不可选;5、10、K可以选择,也可以与其他玩家重复。
+
+
+ {selectedThreePowersRank
+ ? `确认用 ${selectedThreePowersRank} 重载原${threePowersSelection?.sourceRank}分牌`
+ : '请选择重载点数'}
+
+
+
+
+
+
+
+ 以下有效花色均为最少的 {gentlemanPromiseSelection?.minimumCount ?? 0} 张;请选择其中一个公开声明。
+
+
+ {(gentlemanPromiseSelection?.eligibleSuits || []).map(suit => (
+ setSelectedGentlemanPromiseSuit(suit)}
+ >
+ {EFFECTIVE_SUIT_LABELS[suit] || suit}
+ {gentlemanPromiseSelection?.suitCounts?.[suit] ?? 0} 张
+
+ ))}
+
+
+ 级牌、主花色牌和王统一计入“主”,不再计入牌面原花色。
+
+
+ {selectedGentlemanPromiseSuit
+ ? `声明 ${EFFECTIVE_SUIT_LABELS[selectedGentlemanPromiseSuit]}`
+ : '请选择最短花色'}
+
+
+
+
+
+
+
+ 以下点数在手牌中均为最多的 {hiddenDragonSelection?.maximumCount ?? 0} 张;请选择其中一个公开声明。
+
+
+ {(hiddenDragonSelection?.eligibleRanks || []).map(rank => (
+ setSelectedHiddenDragonRank(rank)}
+ >
+ {rank}
+ {hiddenDragonSelection?.rankCounts?.[rank] ?? 0} 张
+
+ ))}
+
+
+ 本局级牌 {hiddenDragonSelection?.trumpRank} 与王牌不参与统计;5、10、K若不是级牌,可以正常声明。
+
+
+ {selectedHiddenDragonRank
+ ? `声明 ${selectedHiddenDragonRank}`
+ : '请选择最多点数'}
+
+
+
+
+
+
+
+ 请选择一个普通花色牌面。你的选择在所有待选玩家提交前不会公开。
+
+
+ {(antinomySelection?.eligibleSuits || []).map(suit => (
+ setSelectedAntinomySuit(suit)}
+ >
+ {EFFECTIVE_SUIT_LABELS[suit] || suit}
+
+ ))}
+
+
+ {(antinomySelection?.eligibleRanks || []).map(rank => (
+ setSelectedAntinomyRank(rank)}
+ >
+ {rank}
+
+ ))}
+
+
+ 王不能指定;级牌仍可按其实体花色和点数指定。同一牌面被多人指定时,拆对效果取消。
+
+
+ {selectedAntinomySuit && selectedAntinomyRank
+ ? `选择 ${EFFECTIVE_SUIT_LABELS[selectedAntinomySuit]} ${selectedAntinomyRank}`
+ : '请选择花色和点数'}
+
+
+
+
+
+
+
+ 请选择恰好{riceToMulberrySelection?.requiredCount || 0}张分牌。
+ 副牌会变为同花色A,主牌会变为大王;改造后的实体牌永久计0分。
+
+
+ (
+ riceToMulberrySelection?.eligibleCardIds?.includes(card.id)
+ ))}
+ selectedCards={selectedRiceToMulberryCardIds}
+ onCardClick={handleToggleRiceToMulberryCard}
+ small
+ trumpSuit={trumpSuit}
+ trumpRank={trumpRank}
+ minimumVisibleWidth={16}
+ />
+
+
+ 已选择 {selectedRiceToMulberryCardIds.length}/{riceToMulberrySelection?.requiredCount || 0}
+
+
+ 确认改造
+
+
+
+
+ handleRespondSurrender(false)}>
+ 不同意,继续打
+ ,
+ handleRespondSurrender(true)}
+ >
+ 同意投降
+
+ ]}
+ >
+
+ 你的队友 {surrenderDecision?.initiatorPlayerName || ''} 发起了投降,
+ 是否同意?
+
+
+ {surrenderDecision?.surrenderingSide === 'dealer'
+ ? `同意后闲家按当前${surrenderDecision?.attackerScore || 0}分再加80分结算,闲家方直接获胜。`
+ : surrenderDecision?.completedRound <= 2
+ ? '这是前两墩投降:同意后庄家方直接获胜并升1级。'
+ : `同意后庄家方直接获胜,并按闲家当前实得${surrenderDecision?.attackerScore || 0}分结算。`}
+
+
+
+ handleRespondDestroyDyke(false)}>
+ 本轮不发动
+ ,
+ handleRespondDestroyDyke(true)}
+ >
+ 发动并作废{destroyDykeDecision?.roundPoints || 0}分
+
+ ]}
+ >
+
+ 闲家赢得第{destroyDykeDecision?.round || ''}轮,本轮原本获得
+ {destroyDykeDecision?.roundPoints || 0}分。
+
+
+ 发动后这些分数暂时作废,接下来三轮进入灾期。灾期内闲家累计达到20分,
+ 或牌局提前结束,将返还作废分数并额外给予闲家20分。
+
+
+
+
+
+
+ 本次选择会立即明告全桌。庄家与其队友都能共同推进这项审查条件。
+
+
+ {(administrativeReviewSelection?.eligibleOptions || []).map(value => (
+ setSelectedAdministrativeReviewValue(value)}
+ >
+ {administrativeReviewSelection?.type === 'suit'
+ ? (EFFECTIVE_SUIT_LABELS[value] || value)
+ : value}
+
+ ))}
+
+
+ {administrativeReviewSelection?.type === 'suit'
+ ? '只能选择当前主花色以外的普通副花色。'
+ : '可选择2至A中的任意点数,包括本局级牌;王不属于点数。'}
+
+
+ {selectedAdministrativeReviewValue
+ ? `公开指定 ${administrativeReviewSelection?.type === 'suit'
+ ? (EFFECTIVE_SUIT_LABELS[selectedAdministrativeReviewValue] || selectedAdministrativeReviewValue)
+ : selectedAdministrativeReviewValue}`
+ : '请选择审查条件'}
+
+
+
);
}
diff --git a/tractor-game-simulator/client/src/components/Game/GameTable.css b/tractor-game-simulator/client/src/components/Game/GameTable.css
index 0642256..12c22dd 100644
--- a/tractor-game-simulator/client/src/components/Game/GameTable.css
+++ b/tractor-game-simulator/client/src/components/Game/GameTable.css
@@ -1,304 +1,4864 @@
.game-table {
+ --felt-dark: #073f32;
+ --felt: #147052;
+ --felt-light: #29936a;
width: 100%;
height: 100%;
- display: flex;
- flex-direction: column;
+ min-width: 0;
+ min-height: 0;
+ display: grid;
+ grid-template-rows: clamp(250px, 25vh, 300px) minmax(160px, 1fr) clamp(300px, 34vh, 415px);
position: relative;
- background: linear-gradient(135deg, #1e3c72 0%, #2a5298 100%);
- border-radius: 12px;
- padding: 20px;
- box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
+ isolation: isolate;
+ overflow: hidden;
+ padding: 18px 26px 14px;
+ border: 1px solid rgba(172, 238, 187, 0.36);
+ border-radius: 30px;
+ background:
+ radial-gradient(ellipse at 50% 48%, rgba(78, 177, 119, 0.38) 0%, transparent 48%),
+ radial-gradient(circle at 18% 16%, rgba(175, 231, 145, 0.16), transparent 26%),
+ repeating-linear-gradient(115deg, rgba(255, 255, 255, 0.014) 0 1px, transparent 1px 5px),
+ linear-gradient(145deg, var(--felt-light) 0%, var(--felt) 42%, var(--felt-dark) 100%);
+ box-shadow:
+ inset 0 0 0 7px rgba(5, 52, 40, 0.45),
+ inset 0 0 90px rgba(0, 24, 18, 0.56),
+ 0 22px 55px rgba(0, 20, 25, 0.45);
}
-/* 上方玩家区域 */
-.position-top {
- flex: 0 0 auto;
+.game-table::before {
+ content: '';
+ position: absolute;
+ z-index: -1;
+ inset: 16px;
+ border: 1px solid rgba(173, 239, 194, 0.22);
+ border-radius: 22px;
+ box-shadow: inset 0 0 40px rgba(0, 31, 23, 0.28);
+ pointer-events: none;
+}
+
+.game-table::after {
+ content: 'TRACTOR · 双升';
+ position: absolute;
+ z-index: -1;
+ left: 50%;
+ top: 46%;
+ transform: translate(-50%, -50%);
+ color: rgba(215, 244, 218, 0.07);
+ font-size: clamp(38px, 5vw, 78px);
+ font-weight: 900;
+ letter-spacing: 0.16em;
+ white-space: nowrap;
+ text-shadow: 0 2px 0 rgba(0, 31, 24, 0.1);
+ pointer-events: none;
+}
+
+.card-exchange-status {
+ position: absolute;
+ z-index: 80;
+ left: 50%;
+ top: 48%;
display: flex;
flex-direction: column;
+ align-items: center;
+ gap: 3px;
+ min-width: 250px;
+ padding: 10px 18px;
+ border: 1px solid rgba(255, 222, 113, 0.64);
+ border-radius: 999px;
+ color: rgba(255, 255, 255, 0.9);
+ background: linear-gradient(145deg, rgba(61, 45, 7, 0.94), rgba(23, 50, 34, 0.94));
+ box-shadow: 0 10px 30px rgba(0, 25, 17, 0.34), inset 0 1px rgba(255, 255, 255, 0.1);
+ font-size: 13px;
+ transform: translate(-50%, -50%);
+ pointer-events: none;
+}
+
+.card-exchange-status-title {
+ color: #ffdc6d;
+ font-size: 16px;
+ font-weight: 800;
+}
+
+.card-exchange-animation-layer {
+ position: absolute;
+ z-index: 260;
+ inset: 0;
+ overflow: hidden;
+ border-radius: inherit;
+ pointer-events: none;
+}
+
+.card-exchange-animation-layer::before {
+ content: '';
+ position: absolute;
+ inset: 0;
+ background: radial-gradient(circle at center, rgba(255, 222, 102, 0.14), transparent 45%);
+ animation: exchangeLayerPulse 1.2s ease-out both;
+}
+
+.card-exchange-animation-title {
+ position: absolute;
+ z-index: 2;
+ left: 50%;
+ top: 48%;
+ padding: 9px 22px;
+ border: 1px solid rgba(255, 226, 128, 0.7);
+ border-radius: 999px;
+ color: #fff2b5;
+ background: rgba(42, 37, 16, 0.9);
+ box-shadow: 0 8px 28px rgba(0, 22, 15, 0.36);
+ font-size: 18px;
+ font-weight: 900;
+ letter-spacing: 0.08em;
+ transform: translate(-50%, -50%);
+ animation: exchangeTitlePop 1.2s ease-out both;
+}
+
+.card-exchange-animation-detail {
+ position: absolute;
+ z-index: 3;
+ left: 50%;
+ top: calc(48% + 50px);
+ width: min(760px, 78%);
+ display: flex;
+ flex-wrap: wrap;
justify-content: center;
+ gap: 6px;
+ transform: translateX(-50%);
+ animation: exchangeDetailIn 1.9s ease-out both;
+}
+
+.card-exchange-animation-detail span {
+ display: inline-flex;
align-items: center;
- margin-bottom: 10px;
- gap: 8px;
+ gap: 5px;
+ padding: 5px 9px;
+ border: 1px solid rgba(255, 235, 158, 0.38);
+ border-radius: 999px;
+ color: rgba(255, 249, 222, 0.9);
+ background: rgba(9, 46, 35, 0.82);
+ box-shadow: 0 4px 14px rgba(0, 24, 17, 0.24);
+ font-size: 11px;
+ font-weight: 700;
+ line-height: 1;
+ white-space: nowrap;
}
-/* 中间区域(左、中央、右) */
-.position-middle {
- flex: 1;
+.card-exchange-animation-detail b {
+ color: #ffdb63;
+ font-size: 14px;
+}
+
+.card-exchange-animation-detail em {
+ color: #ffe799;
+ font-size: 10px;
+ font-style: normal;
+}
+
+.private-card-transfer-reveal {
+ --private-reveal-delay: 780ms;
+ --private-reveal-duration: 1540ms;
+ position: absolute;
+ z-index: 280;
+ left: 50%;
+ top: 56%;
+ width: min(330px, 72vw);
+ padding: 10px 13px 8px;
+ border: 1px solid rgba(255, 226, 122, 0.82);
+ border-radius: 16px;
+ color: #fff5c8;
+ background:
+ radial-gradient(circle at 50% 0, rgba(255, 220, 89, 0.2), transparent 48%),
+ linear-gradient(145deg, rgba(61, 46, 11, 0.97), rgba(5, 61, 43, 0.97));
+ box-shadow:
+ 0 16px 42px rgba(0, 24, 17, 0.48),
+ inset 0 1px rgba(255, 255, 255, 0.14),
+ 0 0 0 4px rgba(255, 215, 76, 0.08);
+ transform: translate(-50%, -50%);
+ pointer-events: none;
+ animation: privateTransferReveal var(--private-reveal-duration) cubic-bezier(.2,.8,.25,1)
+ var(--private-reveal-delay) both;
+}
+
+.private-card-transfer-heading {
display: flex;
+ align-items: baseline;
justify-content: space-between;
- align-items: center;
- gap: 30px;
- min-height: 200px;
+ gap: 10px;
+ padding: 0 3px 2px;
}
-.position-left {
- flex: 0 0 auto;
+.private-card-transfer-heading strong {
+ color: #ffe17a;
+ font-size: 14px;
+}
+
+.private-card-transfer-heading span,
+.private-card-transfer-hint {
+ color: rgba(255, 248, 216, 0.72);
+ font-size: 11px;
+}
+
+.private-card-transfer-reveal .hand {
+ min-height: 82px;
+ padding: 6px 4px 3px;
+}
+
+.private-card-transfer-hint {
+ padding-top: 3px;
+ text-align: center;
+}
+
+.private-card-transfer-reveal.is-static {
+ animation: privateTransferStatic 1.8s ease-out both;
+}
+
+.planned-economy-draw-layer .card-exchange-animation-title {
+ top: 42%;
+ font-size: 16px;
+ letter-spacing: 0.04em;
+}
+
+.exchange-flying-card {
+ --exchange-duration: 1200ms;
+ position: absolute;
+ left: var(--exchange-start-x);
+ top: var(--exchange-start-y);
+ width: 38px;
+ height: 54px;
+ border: 2px solid #f5df9a;
+ border-radius: 6px;
+ background:
+ linear-gradient(45deg, transparent 43%, rgba(255,255,255,0.24) 44% 56%, transparent 57%),
+ linear-gradient(-45deg, transparent 43%, rgba(255,255,255,0.18) 44% 56%, transparent 57%),
+ linear-gradient(145deg, #174f98, #0a2864);
+ background-size: 12px 12px, 12px 12px, 100% 100%;
+ box-shadow: 0 8px 18px rgba(0, 13, 31, 0.42), inset 0 0 0 3px rgba(238, 223, 158, 0.24);
+ opacity: 0;
+ transform: translate(-50%, -50%) rotate(var(--exchange-tilt));
+ animation: exchangeCardTravel var(--exchange-duration) cubic-bezier(.33,.02,.23,1) var(--exchange-delay) both;
+}
+
+@keyframes exchangeCardTravel {
+ 0% {
+ left: var(--exchange-start-x);
+ top: var(--exchange-start-y);
+ opacity: 0;
+ transform: translate(-50%, -50%) scale(0.72) rotate(var(--exchange-tilt));
+ }
+ 12% { opacity: 1; }
+ 52% {
+ opacity: 1;
+ transform: translate(-50%, -72%) scale(1.16) rotate(0deg);
+ }
+ 88% { opacity: 1; }
+ 100% {
+ left: var(--exchange-end-x);
+ top: var(--exchange-end-y);
+ opacity: 0;
+ transform: translate(-50%, -50%) scale(0.76) rotate(var(--exchange-tilt));
+ }
+}
+
+@keyframes exchangeLayerPulse {
+ 0% { opacity: 0; }
+ 25% { opacity: 1; }
+ 100% { opacity: 0; }
+}
+
+@keyframes exchangeTitlePop {
+ 0% { opacity: 0; transform: translate(-50%, -45%) scale(0.8); }
+ 18%, 72% { opacity: 1; transform: translate(-50%, -50%) scale(1); }
+ 100% { opacity: 0; transform: translate(-50%, -55%) scale(0.92); }
+}
+
+@keyframes exchangeDetailIn {
+ 0%, 10% { opacity: 0; transform: translate(-50%, -5px); }
+ 24%, 78% { opacity: 1; transform: translate(-50%, 0); }
+ 100% { opacity: 0; transform: translate(-50%, 5px); }
+}
+
+@keyframes privateTransferReveal {
+ 0% {
+ opacity: 0;
+ transform: translate(-50%, -68%) scale(0.78);
+ }
+ 14%, 72% {
+ opacity: 1;
+ transform: translate(-50%, -50%) scale(1);
+ }
+ 100% {
+ opacity: 0;
+ transform: translate(-50%, 18%) scale(0.9);
+ }
+}
+
+@keyframes privateTransferStatic {
+ 0%, 100% { opacity: 0; }
+ 10%, 88% { opacity: 1; }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .exchange-flying-card,
+ .card-exchange-animation-layer::before,
+ .card-exchange-animation-title,
+ .card-exchange-animation-detail,
+ .open-hand-panel,
+ .open-hand-mini-card {
+ animation-duration: 1ms !important;
+ animation-delay: 0ms !important;
+ }
+
+ .private-card-transfer-reveal {
+ animation: none !important;
+ opacity: 1;
+ transform: translate(-50%, -50%);
+ }
+
+}
+
+.position-top {
+ position: relative;
+ grid-row: 1;
+ min-width: 0;
+ min-height: 0;
+ height: 100%;
display: flex;
- flex-direction: row;
+ flex-direction: column;
justify-content: flex-start;
align-items: center;
- gap: 8px;
+ gap: 18px;
+ padding-top: 6px;
+ box-sizing: border-box;
+ z-index: 2;
}
-.table-center {
- flex: 1;
- display: flex;
- justify-content: center;
+.position-middle {
+ grid-row: 2;
+ min-width: 0;
+ min-height: 0;
+ height: 100%;
+ display: grid;
+ grid-template-columns: minmax(390px, 1fr) minmax(260px, 1fr) minmax(390px, 1fr);
align-items: center;
- background: rgba(255, 255, 255, 0.05);
- border-radius: 8px;
- min-height: 150px;
- max-width: 400px;
+ gap: 22px;
+ z-index: 2;
}
+.position-left,
.position-right {
- flex: 0 0 auto;
+ position: relative;
+ width: 100%;
+ min-width: 0;
display: flex;
- flex-direction: row-reverse;
- justify-content: flex-start;
align-items: center;
- gap: 8px;
+ gap: 18px;
}
-/* 下方玩家区域(自己) */
-.position-bottom {
- flex: 0 0 auto;
+.position-left { justify-content: flex-start; }
+.position-right { justify-content: flex-start; flex-direction: row-reverse; }
+
+/* 算无遗策:对家使用可操作的横向牌架,侧家使用分花色竖向牌架。 */
+.open-hand-panel {
+ position: relative;
+ z-index: 18;
+ min-width: 0;
+ overflow: hidden;
+ border: 1px solid rgba(255, 222, 112, 0.52);
+ border-radius: 14px;
+ background: linear-gradient(150deg, rgba(32, 61, 40, 0.96), rgba(11, 38, 31, 0.94));
+ box-shadow: 0 10px 28px rgba(0, 24, 17, 0.34), inset 0 1px rgba(255,255,255,0.08);
+ animation: openHandReveal 0.58s cubic-bezier(.18,.78,.24,1) both;
+}
+
+.open-hand-header {
+ height: 27px;
display: flex;
- flex-direction: column;
- justify-content: center;
align-items: center;
- margin-top: 10px;
- gap: 8px;
+ gap: 6px;
+ box-sizing: border-box;
+ padding: 4px 8px;
+ color: rgba(255,255,255,0.82);
+ background: linear-gradient(90deg, rgba(128, 85, 15, 0.54), rgba(26, 65, 44, 0.2));
+ font-size: 11px;
+ line-height: 1;
}
-/* 玩家区域通用样式 */
-.player-area {
- background: rgba(255, 255, 255, 0.1);
- border-radius: 8px;
- padding: 12px;
- backdrop-filter: blur(10px);
- border: 2px solid transparent;
- transition: all 0.3s ease;
+.open-hand-seal,
+.open-hand-avatar-badge {
+ display: inline-grid;
+ place-items: center;
+ color: #6b3d06;
+ background: linear-gradient(145deg, #fff0a0, #e7b935);
+ box-shadow: 0 2px 6px rgba(59, 32, 0, 0.34);
+ font-weight: 900;
}
-.player-area.current-turn {
- border-color: #ffd700;
- box-shadow: 0 0 20px rgba(255, 215, 0, 0.5);
- animation: pulse 2s ease-in-out infinite;
+.open-hand-seal { width: 19px; height: 19px; flex: 0 0 19px; border-radius: 6px; }
+.open-hand-title { overflow: hidden; font-weight: 750; text-overflow: ellipsis; white-space: nowrap; }
+.open-hand-count { margin-left: auto; color: #ffe583; font-variant-numeric: tabular-nums; }
+.open-hand-control-hint {
+ padding: 3px 7px;
+ border: 1px solid rgba(255, 228, 129, 0.38);
+ border-radius: 999px;
+ color: #fff0a9;
+ background: rgba(117, 78, 5, 0.44);
+ font-weight: 800;
}
-@keyframes pulse {
- 0%, 100% {
- box-shadow: 0 0 20px rgba(255, 215, 0, 0.5);
- }
- 50% {
- box-shadow: 0 0 30px rgba(255, 215, 0, 0.8);
- }
+.open-hand-avatar-badge {
+ position: absolute;
+ left: -7px;
+ bottom: -6px;
+ z-index: 5;
+ width: 22px;
+ height: 22px;
+ border: 1px solid #fff0a0;
+ border-radius: 50%;
+ font-size: 11px;
}
-.player-area.current-player {
- background: rgba(255, 255, 255, 0.15);
- border: 2px solid rgba(255, 255, 255, 0.3);
+.open-hand-opposite {
+ width: clamp(430px, 40vw, 620px);
+ max-width: calc(100vw - 520px);
+ flex: 0 0 105px;
}
-/* 玩家信息 */
-.player-info {
- margin-bottom: 8px;
+.open-hand-opposite-cards { height: 78px; padding: 0 8px; }
+.open-hand-opposite-cards .hand { min-height: 75px; height: 75px; padding: 4px 3px; }
+.open-hand-opposite.open-hand-kind-partial,
+.open-hand-opposite.open-hand-kind-jokers { width: 230px; max-width: 36vw; }
+.open-hand-kind-jokers {
+ border-color: rgba(255, 212, 87, 0.72);
+ background: linear-gradient(150deg, rgba(58, 54, 30, 0.97), rgba(13, 41, 32, 0.95));
+}
+.open-hand-opposite .card.selected { transform: translateY(-8px); }
+.open-hand-opposite.is-interactive {
+ border-color: #ffe378;
+ box-shadow: 0 0 0 3px rgba(255, 221, 100, 0.1), 0 0 27px rgba(255, 206, 65, 0.25);
+ animation: openHandReveal 0.58s cubic-bezier(.18,.78,.24,1) both, openHandControlPulse 1.9s ease-in-out 0.65s infinite;
+}
+
+.open-hand-side { width: clamp(150px, 10vw, 168px); flex: 0 0 clamp(150px, 10vw, 168px); }
+.open-hand-groups { display: flex; flex-direction: column; gap: 3px; padding: 5px 5px 7px; }
+.open-hand-group {
+ min-width: 0;
+ min-height: 28px;
+ display: grid;
+ grid-template-columns: 17px minmax(0, 1fr);
+ align-items: start;
+ column-gap: 2px;
+}
+.open-hand-group-label {
+ width: 17px;
+ padding-top: 7px;
+ color: #f8dda0;
+ font-size: 11px;
+ font-weight: 900;
text-align: center;
- color: white;
}
+.open-hand-group-hearts .open-hand-group-label,
+.open-hand-group-diamonds .open-hand-group-label { color: #ff9a98; }
+.open-hand-mini-cards {
+ min-width: 0;
+ display: grid;
+ grid-template-columns: repeat(auto-fill, 18px);
+ gap: 2px 1px;
+}
+.open-hand-mini-card {
+ width: 18px;
+ height: 28px;
+ position: relative;
+ animation: openHandCardFan 0.36s ease-out both;
+}
+.open-hand-mini-card:last-child { width: 18px; }
+.open-hand-side .card.micro {
+ width: 18px;
+ height: 28px;
+ border-radius: 3px;
+ box-shadow: 0 1px 0 #aeb3ad, 0 2px 4px rgba(0, 21, 15, 0.22);
+}
+.open-hand-side .card.micro .card-corner.top-left { top: 3px; left: 3px; }
+.open-hand-side .card.micro .card-rank { font-size: 9px; }
+.open-hand-side .card.micro .card-suit { font-size: 8px; }
-.player-info .ant-typography {
- color: white;
+.open-hand-self-status {
+ padding: 4px 9px;
+ border: 1px solid rgba(255, 222, 112, 0.42);
+ border-radius: 999px;
+ color: #ffe894;
+ background: rgba(106, 72, 10, 0.38);
+ font-size: 12px;
+ font-weight: 800;
}
-/* 玩家牌区域 - 固定高度,防止出牌时布局变化 */
-.player-cards-area {
- min-height: 120px; /* 固定最小高度 */
- display: flex;
- flex-direction: column;
- gap: 8px;
+/*
+ * 对家同时存在长期明牌和本轮出牌时,两者必须各自占用一条真实牌高的轨道。
+ * 不能为了塞进顶部网格而只压缩容器高度:普通牌仍有 110px 高,会从 72px
+ * 的旧容器向上溢出并盖住“冰山一角”等明牌。顶部出牌轨道可以自然延伸到
+ * 牌桌中央,但不能侵入明牌轨道。
+ */
+.position-top.has-open-hand {
+ gap: 10px;
+ overflow: visible;
+}
+
+.position-top.has-open-hand > .open-hand-panel {
+ flex: 0 0 105px;
+}
+
+.position-top.has-open-hand .played-cards-area {
+ flex: 0 0 116px;
+ height: 116px;
+}
+
+.position-top.has-open-hand .played-cards-area .hand {
+ min-height: 112px;
+}
+.position-left.has-open-hand,
+.position-right.has-open-hand { gap: 7px; }
+.position-left.has-open-hand .played-cards-area,
+.position-right.has-open-hand .played-cards-area { width: clamp(138px, 12vw, 210px); }
+
+@keyframes openHandReveal {
+ from { opacity: 0; transform: translateY(-10px) scale(0.96); filter: blur(3px); }
+ to { opacity: 1; transform: translateY(0) scale(1); filter: blur(0); }
+}
+@keyframes openHandCardFan {
+ from { opacity: 0; }
+ to { opacity: 1; }
+}
+@keyframes openHandControlPulse {
+ 50% { box-shadow: 0 0 0 4px rgba(255, 221, 100, 0.06), 0 0 34px rgba(255, 206, 65, 0.38); }
}
-/* 亮主区域 - 其他玩家的亮主在这里居中显示 */
-.declared-trump-zone {
- min-height: 100px;
+.position-bottom {
+ position: relative;
+ grid-row: 3;
+ min-width: 0;
+ min-height: 0;
+ height: 100%;
+ max-height: none;
display: flex;
+ flex-direction: column;
+ justify-content: flex-end;
align-items: center;
- justify-content: center;
- width: 100%;
+ gap: 0;
+ padding: 0 12px 14px;
+ box-sizing: border-box;
+ z-index: 3;
}
-/* 出牌区域 - 在玩家框和桌面中央之间,固定高度防止布局变化 */
-.played-cards-area {
+.table-center {
+ min-width: 0;
+ min-height: 150px;
display: flex;
- align-items: center;
justify-content: center;
- padding: 8px;
- height: 130px; /* 固定高度,即使没有牌也占据空间 */
- flex-shrink: 0; /* 防止收缩 */
+ align-items: center;
+ border-radius: 50%;
+ background: radial-gradient(ellipse, rgba(98, 192, 132, 0.12), transparent 68%);
}
-.shown-cards {
+.round-points-indicator {
+ position: absolute;
+ left: 50%;
+ top: 44%;
+ z-index: 12;
display: flex;
- flex-direction: column;
- gap: 4px;
+ align-items: baseline;
+ gap: 6px;
+ padding: 9px 18px 10px;
+ border: 1px solid rgba(255, 224, 117, 0.62);
+ border-radius: 999px;
+ color: #fff6c8;
+ background: linear-gradient(145deg, rgba(75, 49, 5, 0.9), rgba(37, 31, 13, 0.88));
+ box-shadow: 0 8px 24px rgba(0, 24, 17, 0.3), inset 0 1px rgba(255, 255, 255, 0.12);
+ backdrop-filter: blur(10px);
+ transform: translate(-50%, -50%);
+ pointer-events: none;
+ animation: roundPointsIn 0.2s ease-out;
}
-.shown-cards .ant-typography {
- color: white;
- font-size: 12px;
+.round-points-label,
+.round-points-unit {
+ color: rgba(255, 246, 200, 0.78);
+ font-size: 13px;
+ font-weight: 700;
}
-/* 自己的手牌容器 - 包含亮主区和手牌区 */
-.my-hand-container {
- display: flex;
- align-items: flex-start;
- gap: 16px;
- width: 100%;
- max-width: 100%;
- box-sizing: border-box;
- justify-content: center; /* 默认居中 */
+.round-points-value {
+ color: #ffd85c;
+ font-size: 30px;
+ font-variant-numeric: tabular-nums;
+ line-height: 1;
+ text-shadow: 0 2px 8px rgba(255, 190, 36, 0.28);
}
-/* 当没有亮主时,手牌区域居中 */
-.my-hand-container > .my-hand:only-child {
- margin: 0 auto;
+.mobile-round-points-indicator {
+ display: none;
}
-/* 自己的亮主区域 - 在手牌左侧 */
-.bottom-trump-zone {
- flex: 0 0 auto;
- min-width: 100px;
- max-width: 180px;
- min-height: 120px;
- display: flex;
- flex-direction: column;
- align-items: center;
- justify-content: flex-start;
- padding: 8px;
- box-sizing: border-box;
+.table-center.has-public-bottom .round-points-indicator {
+ top: 37%;
}
-/* 当有亮主时,手牌区域占用剩余空间 */
-.bottom-trump-zone ~ .my-hand {
- flex: 1 1 auto;
- margin: 0;
+.public-bottom-tray {
+ position: absolute;
+ left: 50%;
+ top: 52%;
+ z-index: 11;
+ width: min(430px, 42vw);
+ padding: 9px 13px 8px;
+ border: 1px solid rgba(255, 217, 92, 0.66);
+ border-radius: 16px;
+ color: #fff7ca;
+ background: linear-gradient(145deg, rgba(74, 53, 10, 0.93), rgba(8, 60, 43, 0.94));
+ box-shadow: 0 13px 34px rgba(0, 23, 17, 0.38), inset 0 1px rgba(255, 255, 255, 0.11);
+ transform: translate(-50%, -50%);
+ pointer-events: none;
+ animation: divineWeaponTrayDeal 420ms cubic-bezier(.2,.82,.25,1) both;
}
-/* 底部玩家区域的头部(玩家信息 + 控制按钮) */
-.bottom-player-header {
+.public-bottom-heading {
display: flex;
+ align-items: baseline;
justify-content: space-between;
- align-items: flex-start;
- gap: 16px;
- margin-bottom: 10px;
+ gap: 12px;
+ padding: 0 4px 3px;
}
-.bottom-player-header .player-info {
- flex: 0 0 auto;
+.public-bottom-heading strong {
+ color: #ffe177;
+ font-size: 14px;
+ white-space: nowrap;
+}
+
+.public-bottom-heading span {
+ color: rgba(255, 248, 216, 0.72);
+ font-size: 11px;
+ white-space: nowrap;
+}
+
+.public-bottom-tray .hand {
+ min-height: 84px;
+ padding: 7px 4px;
+ background: rgba(2, 40, 30, 0.36);
+}
+
+.second-battlefield-showdown {
+ position: absolute;
+ left: 50%;
+ top: 42%;
+ z-index: 18;
+ min-width: 250px;
+ display: grid;
+ justify-items: center;
+ gap: 3px;
+ padding: 10px 18px;
+ border: 1px solid rgba(255, 216, 92, 0.86);
+ border-radius: 14px;
+ color: #fff4bd;
+ background: linear-gradient(145deg, rgba(73, 49, 5, 0.97), rgba(5, 55, 41, 0.97));
+ box-shadow: 0 12px 30px rgba(0, 22, 15, 0.42), 0 0 22px rgba(255, 205, 61, 0.22);
+ transform: translate(-50%, -50%);
+ animation: secondBattlefieldShowdownIn 360ms cubic-bezier(.2,.82,.25,1) both;
+ pointer-events: none;
+}
+
+.second-battlefield-showdown strong {
+ color: #ffe06a;
+ font-size: 14px;
+}
+
+.second-battlefield-showdown span {
+ color: #fff;
+ font-size: 16px;
+ font-weight: 900;
+}
+
+.second-battlefield-showdown em {
+ color: rgba(255, 246, 205, 0.78);
+ font-size: 10px;
+ font-style: normal;
+}
+
+@keyframes secondBattlefieldShowdownIn {
+ from { opacity: 0; transform: translate(-50%, -44%) scale(0.9); }
+ to { opacity: 1; transform: translate(-50%, -50%) scale(1); }
+}
+
+.divine-weapon-tray {
+ position: absolute;
+ left: 50%;
+ top: 52%;
+ z-index: 11;
+ min-width: 174px;
+ display: grid;
+ grid-template-columns: 70px auto;
+ align-items: center;
+ gap: 9px;
+ padding: 8px 11px;
+ border: 1px solid rgba(255, 224, 112, 0.7);
+ border-radius: 14px;
+ color: #fff1ae;
+ background: linear-gradient(145deg, rgba(74, 51, 7, 0.93), rgba(7, 61, 43, 0.94));
+ box-shadow: 0 12px 30px rgba(0, 24, 17, 0.35), inset 0 1px rgba(255, 255, 255, 0.12);
+ transform: translate(-50%, -50%);
+ animation: divineWeaponTrayDeal 420ms cubic-bezier(.2,.82,.25,1) both;
+}
+
+.divine-weapon-tray-heading {
+ display: flex;
+ flex-direction: column;
+ gap: 3px;
text-align: left;
- margin-bottom: 0;
}
-/* 控制区域 - 右侧对齐 */
-.inline-controls {
- flex: 0 1 auto;
- max-width: 650px;
- background: rgba(255, 255, 255, 0.15);
- padding: 12px;
- border-radius: 8px;
- backdrop-filter: blur(10px);
- margin-left: auto;
+.divine-weapon-tray-heading strong {
+ color: #ffe178;
+ font-size: 14px;
+ white-space: nowrap;
}
-/* 自己的手牌区域 - 居中显示 */
-.my-hand {
- flex: 1 1 auto;
- padding: 10px 20px;
- background: rgba(0, 0, 0, 0.2);
- border-radius: 8px;
- min-height: 120px;
+.divine-weapon-tray-heading span {
+ color: rgba(255, 248, 216, 0.7);
+ font-size: 9px;
+ line-height: 1.25;
+}
+
+.divine-weapon-card-row {
display: flex;
- justify-content: center;
- min-width: 0; /* 允许flex收缩 */
- box-sizing: border-box;
+ align-items: flex-end;
+ gap: 7px;
}
-/* 不同位置的样式调整 - 固定尺寸防止布局变化 */
-.player-top {
- width: 500px;
- max-width: 600px;
- min-width: 400px;
- /* 不设置固定高度,让内容自然撑开 */
+.divine-weapon-card-option {
+ position: relative;
+ width: 50px;
+ height: 70px;
+ border-radius: 7px;
+ transition: transform 150ms ease, filter 150ms ease;
+ animation: divineWeaponCardDeal 360ms cubic-bezier(.2,.82,.25,1) both;
}
-.player-left,
-.player-right {
- width: 350px;
- min-width: 350px;
- /* 不设置固定高度,让内容自然撑开 */
+.divine-weapon-card-option.is-used .card {
+ box-shadow: 0 3px 0 #8c6720, 0 0 0 3px rgba(255, 196, 70, 0.72), 0 9px 18px rgba(0, 25, 18, 0.38);
}
-.player-bottom {
- width: 100%;
- max-width: 1400px; /* 增加最大宽度 */
- /* bottom不设置固定高度,因为包含手牌区域 */
+.divine-weapon-card-option.is-pending-refresh::after {
+ content: '待换';
+ position: absolute;
+ right: -4px;
+ bottom: -5px;
+ z-index: 20;
+ padding: 2px 5px;
+ border-radius: 999px;
+ color: #fff7cf;
+ background: #9a5e0a;
+ font-size: 8px;
+ font-weight: 900;
+ line-height: 1.2;
}
-/* 响应式调整 */
-@media (max-width: 1200px) {
- .position-left,
- .position-right {
- flex: 0 0 150px;
- }
+.divine-weapon-tray.is-selectable .divine-weapon-card-option {
+ cursor: pointer;
+}
- .player-left,
- .player-right {
- width: 140px;
- }
+.divine-weapon-tray.is-selectable .divine-weapon-card-option:hover {
+ transform: translateY(-4px);
+ filter: brightness(1.08);
}
-@media (max-width: 768px) {
- .game-table {
- height: calc(100vh - 150px);
- padding: 10px;
- }
+.divine-weapon-card-option .card.selected {
+ transform: translateY(-7px);
+ box-shadow: 0 3px 0 #9f7920, 0 9px 18px rgba(0, 25, 18, 0.38), 0 0 0 3px rgba(255, 225, 104, 0.5);
+}
- .position-middle {
- gap: 10px;
- }
+.divine-weapon-tray.is-spent {
+ border-color: rgba(196, 205, 198, 0.32);
+ filter: saturate(0.55);
+}
- .player-top,
- .player-left,
- .player-right {
- width: auto;
- font-size: 12px;
- }
+@keyframes divineWeaponTrayDeal {
+ from { opacity: 0; transform: translate(-50%, -42%) scale(0.9); filter: blur(3px); }
+ to { opacity: 1; transform: translate(-50%, -50%) scale(1); filter: blur(0); }
}
-/* 亮主条wrapper */
-.trump-declaration-wrapper {
- margin: 12px 0;
- padding: 0;
+@keyframes divineWeaponCardDeal {
+ from { opacity: 0; transform: translateY(-12px) rotate(-3deg) scale(0.9); }
+ to { opacity: 1; transform: translateY(0) rotate(0) scale(1); }
+}
+
+@keyframes roundPointsIn {
+ from { opacity: 0; filter: blur(2px); }
+ to { opacity: 1; filter: blur(0); }
}
+.center-content {
+ max-width: 460px;
+ padding: 14px 22px;
+ border: 1px solid rgba(203, 239, 207, 0.16);
+ border-radius: 18px;
+ background: rgba(5, 54, 40, 0.3);
+ box-shadow: 0 10px 35px rgba(0, 24, 17, 0.18);
+ backdrop-filter: blur(8px);
+}
-/* 自定义滚动条样式(用于闲家得分框) */
-.game-table ::-webkit-scrollbar {
- height: 6px;
+.center-content.table-tools {
+ position: absolute;
+ top: 28px;
+ right: 28px;
+ z-index: 40;
+ width: clamp(320px, 23vw, 390px);
+ max-width: calc(100% - 56px);
+ max-height: calc(clamp(250px, 25vh, 300px) - 34px);
+ overflow-x: hidden;
+ overflow-y: auto;
+ box-sizing: border-box;
+ padding: 12px 16px;
+ border-radius: 17px;
+ background: linear-gradient(145deg, rgba(4, 61, 46, 0.9), rgba(4, 47, 38, 0.88));
}
-.game-table ::-webkit-scrollbar-track {
- background: rgba(0, 0, 0, 0.3);
- border-radius: 3px;
+.settlement-table-center {
+ align-self: stretch;
+ min-height: 0;
+ align-items: center;
+ overflow: visible;
+ padding: 6px 0 10px;
}
-.game-table ::-webkit-scrollbar-thumb {
- background: rgba(255, 215, 0, 0.6);
- border-radius: 3px;
+.center-content.settlement-panel {
+ width: min(520px, 100%);
+ max-width: 520px;
+ min-height: 0;
+ max-height: none;
+ overflow: visible;
+ box-sizing: border-box;
+ padding: 10px 14px 12px;
+ border-radius: 22px;
+ background: linear-gradient(180deg, rgba(4, 61, 45, 0.92), rgba(4, 45, 37, 0.96));
+ box-shadow: 0 18px 48px rgba(0, 24, 18, 0.38), inset 0 1px rgba(255, 255, 255, 0.08);
}
-.game-table ::-webkit-scrollbar-thumb:hover {
- background: rgba(255, 215, 0, 0.8);
+.settlement-view.has-surrender-showdown .position-middle {
+ grid-template-columns: minmax(170px, 0.65fr) minmax(520px, 2fr) minmax(170px, 0.65fr);
+ gap: 12px;
+}
+
+/* 终局已经用四宫格公开全手牌,不再保留两侧的末墩牌区占位;玩家框仍完整可见。 */
+.settlement-view.has-surrender-showdown .position-left > .played-cards-area,
+.settlement-view.has-surrender-showdown .position-right > .played-cards-area {
+ display: none;
+}
+
+.center-content.settlement-panel.has-surrender-showdown {
+ width: min(720px, 100%);
+ max-width: 720px;
+ max-height: 100%;
+ overflow-x: hidden;
+ overflow-y: auto;
+ scrollbar-gutter: stable;
+}
+
+@media (max-width: 900px) {
+ .settlement-view.has-surrender-showdown .position-middle {
+ grid-template-columns: 104px minmax(360px, 1fr) 104px;
+ gap: 6px;
+ }
+}
+
+.settlement-content {
+ min-width: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 7px;
+}
+
+.settlement-bottom-title {
+ font-size: 16px !important;
+ margin-bottom: 0 !important;
+ line-height: 1.2 !important;
+}
+
+.settlement-content > .settlement-bottom-result,
+.settlement-content > .settlement-upgrade-result {
+ min-width: 0;
+ box-sizing: border-box;
+ margin-top: 0 !important;
+ padding: 7px 12px !important;
+}
+
+.settlement-content > .settlement-bottom-result > span:first-child,
+.settlement-content > .settlement-upgrade-result > span:first-child {
+ margin-bottom: 4px !important;
+ font-size: 18px !important;
+ line-height: 1.25 !important;
+}
+
+.settlement-bottom-result .ten-sided-ambush-bottom-summary {
+ gap: 1px;
+}
+
+.settlement-bottom-result .ant-typography {
+ font-size: 12px !important;
+ line-height: 1.35 !important;
+}
+
+.settlement-bottom-result > div:last-child {
+ margin-top: 4px !important;
+}
+
+.settlement-bottom-result > div:last-child .ant-typography {
+ font-size: 14px !important;
+}
+
+.focus-figure-settlement {
+ display: flex;
+ flex-direction: column;
+ gap: 5px;
+ margin-top: 6px;
+ padding: 7px 9px;
+ border: 1px solid rgba(255, 215, 96, 0.52);
+ border-radius: 8px;
+ background: rgba(2, 37, 30, 0.46);
+ color: #f8fff9;
+}
+
+.focus-figure-settlement-title {
+ display: flex;
+ align-items: baseline;
+ justify-content: space-between;
+ gap: 8px;
+ font-size: 11px;
+ color: rgba(244, 255, 247, 0.78);
+}
+
+.focus-figure-settlement-title strong {
+ flex: 0 0 auto;
+ color: #ffe27a;
+ font-size: 13px;
+}
+
+.focus-figure-player-grid {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 3px 8px;
+}
+
+.focus-figure-player-grid > div {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ min-width: 0;
+ padding: 3px 5px;
+ border-radius: 5px;
+ background: rgba(255, 255, 255, 0.055);
+ font-size: 11px;
+}
+
+.focus-figure-player-grid > div > span {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.focus-figure-player-grid > div > strong {
+ flex: 0 0 auto;
+ margin-left: 5px;
+ color: rgba(255, 255, 255, 0.52);
+}
+
+.focus-figure-player-grid > .is-focus {
+ background: rgba(255, 205, 63, 0.15);
+ color: #fff0a6;
+}
+
+.focus-figure-player-grid > .is-focus > strong {
+ color: #ffd75d;
+}
+
+.focus-figure-score-formula {
+ display: flex;
+ justify-content: center;
+ gap: 18px;
+ color: #fff4bf;
+ font-size: 11px;
+}
+
+.settlement-team-levels {
+ margin-bottom: 6px !important;
+ padding-bottom: 6px !important;
+}
+
+.settlement-team-levels .ant-typography,
+.settlement-next-dealer .ant-typography {
+ line-height: 1.3 !important;
+}
+
+.settlement-team-levels > div > .ant-typography:first-child,
+.settlement-next-dealer > .ant-typography:first-child {
+ font-size: 11px !important;
+}
+
+.settlement-team-levels > div > .ant-typography:nth-child(2),
+.settlement-next-dealer > .ant-typography:last-child {
+ font-size: 14px !important;
+}
+
+.settlement-team-levels > div > .ant-typography:nth-child(2) strong {
+ font-size: 16px !important;
+}
+
+.settlement-team-levels > div > .ant-typography:last-child {
+ font-size: 12px !important;
+}
+
+.settlement-bottom-cards {
+ width: min(330px, 100%);
+ height: 78px;
+ margin: 0 auto;
+ overflow: visible;
+}
+
+.settlement-bottom-cards .hand {
+ min-height: 96px;
+ padding: 2px;
+ transform: scale(0.78);
+ transform-origin: top center;
+}
+
+.settlement-misty-fog {
+ min-width: 0;
+ height: 76px;
+ display: grid;
+ grid-template-columns: 132px minmax(0, 1fr);
+ align-items: center;
+ box-sizing: border-box;
+ padding: 5px 9px;
+ overflow: hidden;
+ border: 1px solid rgba(255, 214, 102, 0.7);
+ border-radius: 8px;
+ background: linear-gradient(135deg, rgba(77, 63, 20, 0.52), rgba(8, 58, 45, 0.78));
+ animation: misty-fog-reveal 420ms cubic-bezier(0.2, 0.8, 0.2, 1) both;
+}
+
+.settlement-lingering-discard {
+ border-color: rgba(255, 214, 102, 0.7);
+ background: linear-gradient(100deg, rgba(74, 55, 10, 0.82), rgba(8, 70, 49, 0.94));
+}
+
+@keyframes misty-fog-reveal {
+ from {
+ opacity: 0;
+ transform: translateY(-7px) scale(0.985);
+ filter: blur(3px);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0) scale(1);
+ filter: blur(0);
+ }
+}
+
+.settlement-misty-fog-summary {
+ min-width: 0;
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 1px 6px;
+ text-align: left;
+}
+
+.settlement-misty-fog-summary .ant-typography {
+ margin: 0;
+ color: rgba(255, 255, 255, 0.86);
+ font-size: 11px;
+ line-height: 1.25;
+ white-space: nowrap;
+}
+
+.settlement-misty-fog-summary .ant-typography:first-child {
+ grid-column: 1 / -1;
+ color: #ffe58f;
+ font-size: 12px;
+}
+
+.settlement-misty-fog-summary .ant-typography:nth-child(3),
+.settlement-misty-fog-summary .ant-typography:last-child {
+ color: #ffd666;
+}
+
+.settlement-misty-fog-cards {
+ min-width: 0;
+ height: 66px;
+ overflow: visible;
+}
+
+.settlement-misty-fog-cards .hand {
+ width: 222px;
+ max-width: none;
+ min-height: 82px;
+ margin: 0 auto;
+ padding: 2px;
+ left: -24px;
+ transform: scale(0.65);
+ transform-origin: top center;
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .settlement-misty-fog { animation: none; }
+}
+
+.player-area {
+ position: relative;
+ min-height: 88px;
+ box-sizing: border-box;
+ padding: 7px 12px;
+ border: 1px solid rgba(214, 245, 222, 0.18);
+ border-radius: 18px;
+ background: linear-gradient(145deg, rgba(3, 47, 39, 0.82), rgba(9, 71, 53, 0.62));
+ box-shadow: 0 8px 25px rgba(0, 22, 18, 0.28);
+ backdrop-filter: blur(10px);
+ transition: border-color 0.2s ease, box-shadow 0.2s ease, transform 0.2s ease;
+}
+
+.player-area.skill-targetable {
+ cursor: pointer;
+ border-color: rgba(255, 215, 0, 0.82);
+ animation: skillTargetPulse 1.15s ease-in-out infinite;
+}
+
+.player-area.skill-targetable:hover,
+.player-area.skill-targetable:focus-visible,
+.player-area.skill-target-selected {
+ outline: 3px solid rgba(255, 215, 0, 0.72);
+ outline-offset: 4px;
+ filter: brightness(1.14);
+}
+
+@keyframes skillTargetPulse {
+ 50% { box-shadow: 0 0 22px rgba(255, 215, 0, 0.52); }
+}
+
+.player-area.current-player {
+ border-color: rgba(173, 231, 185, 0.32);
+ background: linear-gradient(180deg, rgba(4, 49, 40, 0.9), rgba(5, 58, 43, 0.74));
+}
+
+.player-area.current-turn,
+.player-area.current-player.current-turn {
+ border-color: #ffe173;
+ background:
+ linear-gradient(145deg, rgba(88, 70, 18, 0.88), rgba(8, 72, 50, 0.86)),
+ linear-gradient(145deg, rgba(3, 47, 39, 0.82), rgba(9, 71, 53, 0.62));
+ box-shadow:
+ inset 0 0 24px rgba(255, 222, 92, 0.13),
+ 0 0 0 2px rgba(255, 227, 119, 0.34),
+ 0 0 34px rgba(255, 204, 50, 0.52),
+ 0 10px 28px rgba(0, 22, 18, 0.34);
+}
+
+.player-area.current-turn::before {
+ content: '';
+ position: absolute;
+ inset: -6px;
+ z-index: 8;
+ border: 2px solid rgba(255, 232, 137, 0.78);
+ border-radius: calc(18px + 6px);
+ box-shadow: 0 0 16px rgba(255, 211, 61, 0.48);
+ opacity: 0.72;
+ pointer-events: none;
+ animation: turnFramePulse 1.35s ease-in-out infinite;
+}
+
+.player-area.current-turn .player-avatar {
+ border-color: #fff0a0;
+ box-shadow:
+ inset 0 1px rgba(255, 255, 255, 0.4),
+ 0 0 0 3px rgba(255, 222, 97, 0.17),
+ 0 0 18px rgba(255, 205, 47, 0.52);
+}
+
+.turn-indicator {
+ position: absolute;
+ z-index: 16;
+ min-width: 62px;
+ height: 26px;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: 6px;
+ box-sizing: border-box;
+ padding: 0 10px;
+ border: 1px solid #fff1a0;
+ border-radius: 999px;
+ color: #493100;
+ background: linear-gradient(180deg, #fff08d 0%, #f6c63d 58%, #dca21a 100%);
+ box-shadow:
+ inset 0 1px rgba(255, 255, 255, 0.72),
+ 0 4px 12px rgba(45, 29, 0, 0.4),
+ 0 0 18px rgba(255, 214, 64, 0.55);
+ font-size: 12px;
+ font-weight: 950;
+ line-height: 1;
+ letter-spacing: 0.06em;
+ white-space: nowrap;
+ pointer-events: none;
+ animation: turnBadgePulse 1.35s ease-in-out infinite;
+}
+
+.turn-indicator::after {
+ content: '';
+ position: absolute;
+ width: 0;
+ height: 0;
+}
+
+.turn-indicator-pip {
+ width: 7px;
+ height: 7px;
+ flex: 0 0 7px;
+ border: 1px solid rgba(91, 55, 0, 0.38);
+ border-radius: 50%;
+ background: #fffce0;
+ box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.2), 0 0 8px #fff7b5;
+ animation: turnPipBlink 0.9s ease-in-out infinite;
+}
+
+.player-top > .turn-indicator {
+ left: 50%;
+ bottom: -12px;
+ transform: translate(-50%, 50%);
+}
+
+.player-top > .turn-indicator::after {
+ left: 50%;
+ top: -6px;
+ border-right: 6px solid transparent;
+ border-bottom: 7px solid #fff08d;
+ border-left: 6px solid transparent;
+ transform: translateX(-50%);
+}
+
+.player-bottom > .turn-indicator {
+ left: 50%;
+ top: -12px;
+ transform: translate(-50%, -50%);
+}
+
+.player-bottom > .turn-indicator::after {
+ left: 50%;
+ bottom: -6px;
+ border-top: 7px solid #dca21a;
+ border-right: 6px solid transparent;
+ border-left: 6px solid transparent;
+ transform: translateX(-50%);
+}
+
+.player-left > .turn-indicator,
+.player-right > .turn-indicator {
+ width: 27px;
+ min-width: 27px;
+ height: auto;
+ min-height: 64px;
+ flex-direction: column;
+ gap: 4px;
+ padding: 6px 4px;
+ border-radius: 12px;
+}
+
+.player-left > .turn-indicator {
+ left: calc(100% - 1px);
+ top: 50%;
+ transform: translateY(-50%);
+}
+
+.player-left > .turn-indicator::after {
+ left: -6px;
+ top: 50%;
+ border-top: 6px solid transparent;
+ border-right: 7px solid #f6c63d;
+ border-bottom: 6px solid transparent;
+ transform: translateY(-50%);
+}
+
+.player-right > .turn-indicator {
+ right: calc(100% - 1px);
+ top: 50%;
+ transform: translateY(-50%);
+}
+
+.player-right > .turn-indicator::after {
+ right: -6px;
+ top: 50%;
+ border-top: 6px solid transparent;
+ border-bottom: 6px solid transparent;
+ border-left: 7px solid #f6c63d;
+ transform: translateY(-50%);
+}
+
+.player-left .turn-indicator-label,
+.player-right .turn-indicator-label {
+ writing-mode: vertical-rl;
+ letter-spacing: 0.12em;
+}
+
+@keyframes turnFramePulse {
+ 50% {
+ opacity: 1;
+ box-shadow:
+ 0 0 0 4px rgba(255, 224, 102, 0.12),
+ 0 0 27px rgba(255, 204, 45, 0.72);
+ }
+}
+
+@keyframes turnBadgePulse {
+ 50% {
+ filter: brightness(1.08);
+ box-shadow:
+ inset 0 1px rgba(255, 255, 255, 0.82),
+ 0 5px 14px rgba(45, 29, 0, 0.46),
+ 0 0 25px rgba(255, 218, 75, 0.78);
+ }
+}
+
+@keyframes turnPipBlink {
+ 50% { opacity: 0.45; transform: scale(0.72); }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .player-area.current-turn::before,
+ .turn-indicator,
+ .turn-indicator-pip {
+ animation: none;
+ }
+}
+
+.player-avatar {
+ width: 34px;
+ height: 34px;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ flex: 0 0 34px;
+ border: 2px solid rgba(255, 232, 146, 0.8);
+ border-radius: 11px;
+ color: #fff4c4;
+ background: linear-gradient(145deg, #dc9641, #7b461f);
+ box-shadow: inset 0 1px rgba(255, 255, 255, 0.35), 0 4px 10px rgba(0, 0, 0, 0.28);
+ font-size: 12px;
+ font-weight: 900;
+}
+
+.player-avatar-wrap {
+ position: relative;
+ display: inline-flex;
+ flex: 0 0 auto;
+ line-height: 1;
+}
+
+.dealer-badge {
+ position: absolute;
+ right: -7px;
+ bottom: -6px;
+ z-index: 4;
+ width: 22px;
+ height: 22px;
+ display: grid;
+ place-items: center;
+ border: 1px solid #fff09a;
+ border-radius: 50%;
+ color: #784209;
+ background: linear-gradient(145deg, #ffe45d, #efad17);
+ box-shadow: 0 2px 5px rgba(60, 33, 0, 0.38);
+ font-size: 11px;
+ font-weight: 900;
+}
+
+.secondary-burying-badge {
+ position: absolute;
+ left: -7px;
+ bottom: -6px;
+ z-index: 4;
+ width: 22px;
+ height: 22px;
+ display: grid;
+ place-items: center;
+ border: 1px solid #a9f2bd;
+ border-radius: 50%;
+ color: #073d25;
+ background: linear-gradient(145deg, #b9f6ca, #55c97a);
+ box-shadow: 0 2px 5px rgba(0, 45, 25, 0.4);
+ font-size: 11px;
+ font-weight: 900;
+}
+
+.secondary-burying-self-status {
+ padding: 2px 8px;
+ border: 1px solid rgba(163, 241, 184, 0.72);
+ border-radius: 999px;
+ color: #c9fbd5;
+ background: rgba(31, 117, 67, 0.5);
+ font-size: 12px;
+ font-weight: 800;
+}
+
+.ready-badge {
+ position: absolute;
+ right: -4px;
+ top: -5px;
+ z-index: 5;
+ width: 17px;
+ height: 17px;
+ display: grid;
+ place-items: center;
+ box-sizing: border-box;
+ border: 1px solid rgba(255, 255, 255, 0.82);
+ border-radius: 50%;
+ font-size: 11px;
+ font-weight: 900;
+ line-height: 1;
+ box-shadow: 0 2px 5px rgba(0, 25, 18, 0.35);
+}
+
+.ready-badge.is-ready {
+ color: #effff3;
+ background: #35a853;
+}
+
+.ready-badge.not-ready {
+ color: #f3f3f3;
+ background: #758078;
+}
+
+.player-name {
+ display: block;
+ min-width: 0;
+ max-width: 9em;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.player-left .player-name,
+.player-right .player-name {
+ max-width: 6em;
+}
+
+.player-name-self {
+ max-width: 12em;
+}
+
+.player-avatar-self {
+ border-color: #98e4ae;
+ color: #dfffe8;
+ background: linear-gradient(145deg, #3b9c64, #15513b);
+}
+
+.player-info {
+ margin: 0;
+ text-align: center;
+ color: white;
+ line-height: 1.35;
+}
+
+.player-area > .player-info {
+ position: relative;
+ z-index: 21;
+ width: 100%;
+ min-width: 0;
+ min-height: 72px;
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+}
+
+.player-area.has-declaration > .player-info {
+ justify-content: flex-start;
+}
+
+.player-area.has-declaration > .player-info > div:first-child {
+ transform: translateY(-4px);
+}
+
+.player-info .ant-typography { color: rgba(244, 255, 247, 0.96); }
+.player-info .ant-typography-secondary { color: rgba(219, 238, 224, 0.66) !important; }
+
+.player-hand-count {
+ position: absolute;
+ z-index: 17;
+ right: 8px;
+ left: auto;
+ top: 0;
+ min-width: 36px;
+ height: 18px;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ box-sizing: border-box;
+ padding: 0 7px;
+ border: 1px solid rgba(200, 231, 209, 0.24);
+ border-radius: 999px;
+ color: rgba(224, 241, 229, 0.76);
+ background: rgba(2, 39, 31, 0.76);
+ box-shadow: 0 3px 9px rgba(0, 23, 17, 0.22);
+ font-size: 10px;
+ font-weight: 750;
+ line-height: 1;
+ white-space: nowrap;
+ transform: translateY(-50%);
+ pointer-events: none;
+}
+
+.strive-upstream-order-badge {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ flex: 0 0 auto;
+ width: 26px;
+ height: 26px;
+ padding: 0;
+ border: 1px solid rgba(255, 229, 137, 0.75);
+ border-radius: 999px;
+ background: linear-gradient(145deg, rgba(93, 70, 17, 0.96), rgba(170, 128, 30, 0.96));
+ box-shadow: 0 0 10px rgba(245, 200, 75, 0.25);
+ color: #fff1aa;
+ font-size: 14px;
+ font-weight: 900;
+ line-height: 1;
+ font-variant-numeric: tabular-nums;
+}
+
+.strive-upstream-order-badge.order-2 {
+ border-color: rgba(220, 232, 229, 0.72);
+ background: linear-gradient(145deg, rgba(60, 82, 77, 0.96), rgba(109, 136, 129, 0.96));
+ box-shadow: 0 0 9px rgba(204, 226, 219, 0.2);
+ color: #f0faf7;
+}
+
+.strive-upstream-order-badge.order-3 {
+ border-color: rgba(226, 169, 107, 0.72);
+ background: linear-gradient(145deg, rgba(91, 50, 24, 0.96), rgba(151, 86, 42, 0.96));
+ box-shadow: 0 0 9px rgba(207, 128, 65, 0.2);
+ color: #ffe0bd;
+}
+
+.strive-upstream-order-badge.order-4 {
+ border-color: rgba(127, 190, 156, 0.62);
+ background: linear-gradient(145deg, rgba(23, 70, 53, 0.96), rgba(36, 101, 74, 0.96));
+ box-shadow: none;
+ color: #d9f6e7;
+}
+
+.defense-as-offense-player-badge {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ flex: 0 0 auto;
+ min-width: 30px;
+ height: 22px;
+ padding: 0 7px;
+ border: 1px solid rgba(135, 225, 171, 0.84);
+ border-radius: 999px;
+ background: linear-gradient(145deg, rgba(22, 92, 60, 0.96), rgba(43, 150, 91, 0.96));
+ box-shadow: 0 0 10px rgba(80, 220, 137, 0.25);
+ color: #e3ffec;
+ font-size: 11px;
+ font-weight: 900;
+ line-height: 1;
+ font-variant-numeric: tabular-nums;
+}
+
+.focus-figure-player-badge {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ flex: 0 0 auto;
+ min-width: 34px;
+ height: 21px;
+ padding: 0 7px;
+ border: 1px solid rgba(255, 222, 103, 0.9);
+ border-radius: 999px;
+ background: linear-gradient(145deg, #7a5b0b, #d4a72c);
+ box-shadow: 0 0 10px rgba(255, 210, 64, 0.32);
+ color: #fff4b8;
+ font-size: 11px;
+ font-weight: 800;
+ line-height: 1;
+ letter-spacing: 0.04em;
+}
+
+.dream-killing-player-badge {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ min-width: 34px;
+ height: 21px;
+ padding: 0 7px;
+ border: 1px solid rgba(137, 201, 255, 0.72);
+ border-radius: 999px;
+ color: #dff3ff;
+ background: linear-gradient(145deg, #173b5d, #285d7d);
+ box-shadow: 0 0 11px rgba(85, 183, 255, 0.28);
+ font-size: 11px;
+ font-weight: 800;
+}
+
+.teammate-cheer-player-badge {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ flex: 0 0 auto;
+ min-width: 46px;
+ height: 21px;
+ padding: 0 7px;
+ border: 1px solid rgba(255, 220, 102, 0.86);
+ border-radius: 999px;
+ color: #fff4b6;
+ background: linear-gradient(145deg, #9c6b0a, #d5a82c);
+ box-shadow: 0 0 11px rgba(255, 196, 48, 0.3);
+ font-size: 10px;
+ font-weight: 900;
+ line-height: 1;
+}
+
+.afterglow-player-badge {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ flex: 0 0 auto;
+ min-width: 46px;
+ height: 21px;
+ padding: 0 7px;
+ border: 1px solid rgba(255, 163, 112, 0.88);
+ border-radius: 999px;
+ color: #fff0d8;
+ background: linear-gradient(145deg, #933716, #d9752b);
+ box-shadow: 0 0 11px rgba(255, 108, 52, 0.3);
+ font-size: 10px;
+ font-weight: 900;
+ line-height: 1;
+}
+
+.lure-tiger-player-badge {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ flex: 0 0 auto;
+ min-width: 38px;
+ height: 21px;
+ padding: 0 7px;
+ border: 1px solid rgba(177, 189, 204, 0.82);
+ border-radius: 999px;
+ color: #eef3f7;
+ background: linear-gradient(145deg, #394451, #66717d);
+ box-shadow: 0 0 11px rgba(162, 176, 190, 0.24);
+ font-size: 10px;
+ font-weight: 900;
+ line-height: 1;
+}
+
+.dream-killing-self-status {
+ border-color: rgba(123, 200, 255, 0.62);
+ color: #dff4ff;
+ background: rgba(19, 65, 89, 0.72);
+}
+
+.focus-figure-captured-points {
+ width: max-content;
+ max-width: 100%;
+ margin: 4px auto 0;
+ padding: 2px 8px;
+ border: 1px solid rgba(231, 198, 78, 0.38);
+ border-radius: 999px;
+ background: rgba(40, 30, 4, 0.46);
+ color: #f4df88;
+ font-size: 11px;
+ font-weight: 700;
+ line-height: 1.35;
+ white-space: nowrap;
+}
+
+.focus-figure-captured-points-self {
+ margin: 0 0 0 2px;
+}
+
+.player-cards-area {
+ min-height: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+}
+
+.declared-trump-zone:empty { display: none; }
+
+/*
+ * 参考《欢乐升级》的头像角标:亮主固定在玩家框内左下角,亮劣固定在右下角。
+ * 外层覆盖整个玩家框但不参与布局,因此亮牌前后框体尺寸完全不变。
+ */
+.declared-trump-zone.declaration-sidecar {
+ position: absolute;
+ z-index: 19;
+ inset: 0;
+ display: block;
+ pointer-events: none;
+}
+
+.declaration-card-slot {
+ position: absolute;
+ bottom: 6px;
+ width: max-content;
+ min-width: 30px;
+ min-height: 44px;
+ padding: 0;
+ filter: drop-shadow(0 5px 7px rgba(0, 24, 17, 0.34));
+ animation: declarationCardSlotReveal 0.28s cubic-bezier(.2, .82, .28, 1) both;
+}
+
+@keyframes declarationCardSlotReveal {
+ from { opacity: 0; transform: translateY(5px) scale(0.94); }
+ to { opacity: 1; transform: translateY(0) scale(1); }
+}
+
+.declaration-sidecar .declaration-card-slot.is-trump { left: 8px; }
+.declaration-sidecar .declaration-card-slot.is-inferior { right: 8px; }
+
+.declaration-card-pair {
+ display: flex;
+ align-items: flex-end;
+ gap: 0;
+}
+
+/* 一对亮牌轻叠 6px:保留成组感,同时完整露出前牌的点数与中央花色。 */
+.declaration-card-pair > .card + .card {
+ margin-left: -6px;
+}
+
+.declaration-card-slot .card.small {
+ flex: 0 0 30px;
+ width: 30px;
+ height: 44px;
+ border-radius: 4px;
+ box-shadow: 0 2px 0 #9ea49e, 0 4px 7px rgba(0, 21, 15, 0.28);
+}
+
+.declaration-card-slot .card.small .card-corner.top-left {
+ top: 2px;
+ left: 3px;
+}
+
+/* 亮牌小卡中央已经有完整花色,左上角只保留点数,避免重复。 */
+.declaration-card-slot .card.small .card-corner.top-left .card-suit {
+ display: none;
+}
+
+.declaration-card-slot .card.small .card-corner.bottom-right {
+ display: none;
+}
+
+.declaration-card-slot .card.small .card-rank {
+ font-size: 11px;
+ line-height: 0.9;
+}
+
+.declaration-card-slot .card.small .card-suit {
+ margin-top: 1px;
+ font-family: "Segoe UI Symbol", "Arial Unicode MS", sans-serif;
+ font-size: 11px;
+ font-weight: 900;
+}
+
+.declaration-card-slot .card.small .suit-symbol {
+ font-family: "Segoe UI Symbol", "Arial Unicode MS", sans-serif;
+ font-size: 21px;
+ font-weight: 900;
+ opacity: 1;
+ filter: none;
+}
+
+/* 小牌时用轮廓和色调的双重差异区分黑桃与梅花。 */
+.declaration-card-slot .card.card-suit-spades {
+ color: #101a26 !important;
+}
+
+.declaration-card-slot .card.card-suit-spades .suit-symbol {
+ transform: translateY(1px) scale(0.92, 1.12);
+}
+
+.declaration-card-slot .card.card-suit-clubs {
+ color: #143021 !important;
+}
+
+.declaration-card-slot .card.card-suit-clubs .suit-symbol {
+ transform: scale(1.08, 1.03);
+}
+
+.declaration-card-slot .card.small .trump-badge {
+ width: 11px;
+ height: 11px;
+}
+
+.declaration-card-slot .card.small .trump-star {
+ font-size: 7px;
+}
+
+.inferior-declaration-zone {
+ position: relative;
+ border: 1px solid rgba(231, 166, 139, 0.48);
+ border-radius: 8px;
+ background: rgba(112, 52, 40, 0.2);
+}
+
+.three-six-nine-summary {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 6px;
+ min-height: 22px;
+ padding: 2px 10px;
+ border: 1px solid rgba(231, 166, 139, 0.34);
+ border-radius: 999px;
+ background: rgba(112, 52, 40, 0.2);
+}
+
+.shown-cards {
+ display: flex;
+ flex-direction: column;
+ gap: 3px;
+}
+
+.shown-cards .ant-typography { color: white; font-size: 11px; }
+
+.played-cards-area {
+ position: relative;
+ width: clamp(100px, 12vw, 220px);
+ min-width: 0;
+ height: clamp(84px, 12vh, 116px);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 0;
+ flex-shrink: 0;
+ filter: drop-shadow(0 9px 8px rgba(0, 28, 20, 0.32));
+}
+
+.played-cards-area.is-empty {
+ visibility: hidden;
+ filter: none;
+}
+
+.played-cards-area.throw-failed-preview {
+ visibility: visible;
+ z-index: 20;
+ filter: drop-shadow(0 0 14px rgba(255, 107, 87, 0.45));
+ animation: throwFailedPreviewShake 240ms ease-out both;
+}
+
+.throw-failed-preview-label {
+ position: absolute;
+ left: 50%;
+ bottom: -8px;
+ z-index: 24;
+ min-width: max-content;
+ padding: 3px 9px;
+ border: 1px solid rgba(255, 203, 185, 0.72);
+ border-radius: 999px;
+ color: #fff4ed;
+ background: linear-gradient(180deg, rgba(174, 61, 43, 0.96), rgba(111, 31, 25, 0.96));
+ box-shadow: 0 5px 13px rgba(60, 7, 3, 0.34);
+ font-size: 11px;
+ font-weight: 900;
+ line-height: 1.25;
+ letter-spacing: 0.08em;
+ transform: translateX(-50%);
+}
+
+@keyframes throwFailedPreviewShake {
+ 0% { translate: 0 0; }
+ 28% { translate: -5px 0; }
+ 56% { translate: 4px 0; }
+ 78% { translate: -2px 0; }
+ 100% { translate: 0 0; }
+}
+
+.position-top .played-cards-area,
+.position-bottom .played-cards-area {
+ width: clamp(150px, 24vw, 300px);
+}
+
+/* 左右相邻玩家多张出牌时只保留完整点数所需宽度,避免牌列压向桌面中央。 */
+.position-left .played-cards-area,
+.position-right .played-cards-area {
+ width: clamp(280px, 18vw, 300px);
+}
+
+.position-bottom .played-cards-area {
+ margin-bottom: 0;
+}
+
+.played-cards-area .hand {
+ position: relative;
+ z-index: 3;
+ min-height: 112px;
+ padding: 0;
+ overflow: visible;
+}
+
+/* 毙牌 / 盖毙:参考欢乐升级的局部“双火箭砸牌”反馈。 */
+.trump-strike-impact {
+ position: absolute;
+ z-index: 9;
+ left: 50%;
+ top: 48%;
+ width: 104px;
+ height: 104px;
+ transform: translate(-50%, -50%);
+ pointer-events: none;
+}
+
+.trump-strike-smoke,
+.trump-strike-shockwave,
+.trump-strike-flare {
+ position: absolute;
+ left: 50%;
+ top: 58%;
+ border-radius: 50%;
+ opacity: 0;
+ pointer-events: none;
+}
+
+.trump-strike-smoke {
+ z-index: 1;
+ width: 86px;
+ height: 50px;
+ background:
+ radial-gradient(ellipse at center, rgba(0, 11, 8, 0.88) 0 14%, rgba(2, 17, 12, 0.66) 25%, rgba(9, 30, 22, 0.34) 45%, transparent 72%),
+ repeating-conic-gradient(from 7deg, rgba(4, 17, 12, 0.48) 0 9deg, transparent 9deg 22deg);
+ filter: blur(2.5px);
+ transform: translate(-50%, -50%) scale(0.18);
+ animation: trumpStrikeSmoke 760ms cubic-bezier(.2,.68,.2,1) 300ms both;
+}
+
+.trump-strike-shockwave {
+ z-index: 2;
+ width: 26px;
+ height: 26px;
+ border: 3px solid rgba(255, 199, 68, 0.9);
+ box-shadow: 0 0 12px rgba(255, 111, 36, 0.8), inset 0 0 8px rgba(255, 231, 128, 0.72);
+ transform: translate(-50%, -50%) scale(0.2);
+ animation: trumpStrikeWave 560ms ease-out 340ms both;
+}
+
+.trump-strike-flare {
+ z-index: 7;
+ width: 68px;
+ height: 68px;
+ background:
+ repeating-conic-gradient(from 8deg, rgba(255, 218, 95, 0.98) 0 3deg, transparent 3deg 23deg),
+ radial-gradient(circle, rgba(255, 241, 171, 0.96), rgba(255, 111, 32, 0.5) 22%, transparent 57%);
+ filter: drop-shadow(0 0 7px rgba(255, 91, 30, 0.76));
+ transform: translate(-50%, -50%) scale(0.16) rotate(-8deg);
+ animation: trumpStrikeFlare 430ms ease-out 330ms both;
+}
+
+.trump-strike-dart {
+ position: absolute;
+ z-index: 10;
+ left: 50%;
+ top: 8px;
+ width: 17px;
+ height: 47px;
+ border-radius: 55% 55% 38% 38%;
+ background:
+ linear-gradient(90deg, rgba(255, 255, 255, 0.26), transparent 23% 67%, rgba(92, 4, 13, 0.34)),
+ linear-gradient(180deg, #ff5360 0 12%, #e71931 36%, #a80720 77%, #f5ba40 78% 88%, #7b171d 89%);
+ box-shadow:
+ inset 0 0 0 1px rgba(102, 4, 20, 0.42),
+ 0 4px 5px rgba(0, 14, 10, 0.42),
+ 0 0 8px rgba(255, 40, 56, 0.34);
+ clip-path: polygon(50% 100%, 13% 78%, 18% 19%, 34% 5%, 50% 0, 66% 5%, 82% 19%, 87% 78%);
+ opacity: 0;
+ transform-origin: 50% 100%;
+ animation: trumpStrikeDart 720ms cubic-bezier(.18,.68,.2,1) both;
+}
+
+.trump-strike-dart::before,
+.trump-strike-dart::after {
+ content: '';
+ position: absolute;
+ top: 9px;
+ width: 8px;
+ height: 18px;
+ background: linear-gradient(180deg, #ff3046, #94051c);
+ filter: drop-shadow(0 1px rgba(255, 189, 80, 0.35));
+}
+
+.trump-strike-dart::before {
+ right: 10px;
+ transform: rotate(-25deg);
+}
+
+.trump-strike-dart::after {
+ left: 10px;
+ transform: rotate(25deg);
+}
+
+.trump-strike-dart.dart-left {
+ margin-left: -22px;
+ transform: translate(-50%, -92px) rotate(-8deg) scale(0.74);
+}
+
+.trump-strike-dart.dart-right {
+ margin-left: 20px;
+ animation-delay: 72ms;
+ transform: translate(-50%, -92px) rotate(7deg) scale(0.74);
+}
+
+.trump-strike-impact.overtrump .trump-strike-dart {
+ filter: saturate(1.16) brightness(1.06);
+}
+
+.trump-strike-impact.overtrump .trump-strike-shockwave {
+ border-color: rgba(255, 230, 111, 0.96);
+ box-shadow: 0 0 16px rgba(255, 72, 32, 0.92), inset 0 0 10px rgba(255, 243, 162, 0.88);
+}
+
+.trump-action-callout {
+ position: absolute;
+ z-index: 14;
+ left: 50%;
+ bottom: -13px;
+ min-width: 74px;
+ color: #ffd34e;
+ text-align: center;
+ filter: drop-shadow(0 5px 5px rgba(24, 9, 0, 0.52));
+ transform: translateX(-50%) rotate(-3deg);
+ transform-origin: 50% 80%;
+ pointer-events: none;
+ animation: trumpActionCallout 1.6s cubic-bezier(.17,.75,.22,1) both;
+}
+
+.trump-action-callout strong {
+ position: relative;
+ z-index: 2;
+ display: block;
+ padding: 0 5px 3px;
+ color: #ffd84e;
+ font-family: "Microsoft YaHei UI", "PingFang SC", sans-serif;
+ font-size: clamp(25px, 2.25vw, 38px);
+ font-weight: 1000;
+ font-style: italic;
+ letter-spacing: -0.09em;
+ line-height: 1;
+ white-space: nowrap;
+ -webkit-text-stroke: 1.2px #7a2b00;
+ text-shadow:
+ 0 2px 0 #b25800,
+ 0 4px 0 #6e2600,
+ 2px 5px 2px rgba(35, 11, 0, 0.58),
+ 0 0 8px rgba(255, 212, 68, 0.42);
+}
+
+.trump-action-callout > span {
+ position: absolute;
+ z-index: 1;
+ left: 4px;
+ right: -6px;
+ bottom: -1px;
+ height: 12px;
+ border-radius: 50%;
+ background: linear-gradient(90deg, transparent, #9b3500 15% 72%, transparent);
+ opacity: 0.9;
+ transform: skewX(-24deg) rotate(-4deg);
+}
+
+.trump-action-callout.overtrump {
+ min-width: 88px;
+ transform: translateX(-50%) rotate(2deg);
+}
+
+.trump-action-callout.overtrump strong {
+ color: #ffe060;
+ font-size: clamp(28px, 2.5vw, 42px);
+ text-shadow:
+ 0 2px 0 #d06900,
+ 0 4px 0 #7d2500,
+ 2px 6px 3px rgba(45, 8, 0, 0.66),
+ 0 0 11px rgba(255, 128, 42, 0.62);
+}
+
+@keyframes trumpStrikeDart {
+ 0% {
+ opacity: 0;
+ transform: translate(-50%, -92px) rotate(-9deg) scale(0.7);
+ }
+ 13% { opacity: 1; }
+ 54% {
+ opacity: 1;
+ transform: translate(-50%, -5px) rotate(1deg) scale(1.06);
+ }
+ 64% {
+ opacity: 1;
+ transform: translate(-50%, 8px) rotate(0deg) scale(0.94, 0.82);
+ }
+ 76% {
+ opacity: 1;
+ transform: translate(-50%, -3px) rotate(-2deg) scale(1);
+ }
+ 100% {
+ opacity: 0;
+ transform: translate(-50%, 5px) rotate(1deg) scale(0.92);
+ }
+}
+
+@keyframes trumpStrikeSmoke {
+ 0% { opacity: 0; transform: translate(-50%, -50%) scale(0.16); }
+ 18% { opacity: 0.94; }
+ 58% { opacity: 0.7; transform: translate(-50%, -50%) scale(1); }
+ 100% { opacity: 0; transform: translate(-50%, -60%) scale(1.28); filter: blur(5px); }
+}
+
+@keyframes trumpStrikeWave {
+ 0% { opacity: 0; transform: translate(-50%, -50%) scale(0.16); }
+ 16% { opacity: 1; }
+ 100% { opacity: 0; transform: translate(-50%, -50%) scale(2.45); }
+}
+
+@keyframes trumpStrikeFlare {
+ 0% { opacity: 0; transform: translate(-50%, -50%) scale(0.16) rotate(-8deg); }
+ 22% { opacity: 1; }
+ 100% { opacity: 0; transform: translate(-50%, -50%) scale(1.22) rotate(12deg); }
+}
+
+@keyframes trumpActionCallout {
+ 0% { opacity: 0; transform: translate(-50%, 11px) rotate(-8deg) scale(0.25); }
+ 14% { opacity: 1; transform: translate(-50%, -4px) rotate(3deg) scale(1.28); }
+ 24% { transform: translate(-50%, 0) rotate(-3deg) scale(0.94); }
+ 34%, 76% { opacity: 1; transform: translate(-50%, 0) rotate(-3deg) scale(1); }
+ 100% { opacity: 0; transform: translate(-50%, -8px) rotate(-1deg) scale(0.94); }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .trump-strike-smoke,
+ .trump-strike-shockwave,
+ .trump-strike-flare,
+ .trump-strike-dart,
+ .trump-action-callout {
+ animation: none !important;
+ }
+}
+
+.ambiguous-play-options {
+ position: relative;
+ z-index: 4;
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 6px;
+ width: min(360px, 27vw);
+}
+
+.ambiguous-play-option {
+ position: relative;
+ min-width: 0;
+ padding: 10px 3px 1px;
+ border: 1px solid rgba(255, 220, 91, .55);
+ border-radius: 9px;
+ background: rgba(8, 61, 45, .88);
+ box-shadow: 0 5px 14px rgba(0, 28, 20, .3);
+}
+
+.ambiguous-play-option.is-selected {
+ border-color: #ffe16a;
+ box-shadow: 0 0 0 2px rgba(255, 215, 62, .28), 0 5px 14px rgba(0, 28, 20, .3);
+}
+
+.ambiguous-play-option-label {
+ position: absolute;
+ top: -10px;
+ left: 7px;
+ z-index: 8;
+ display: grid;
+ place-items: center;
+ width: 22px;
+ height: 22px;
+ color: #173d30;
+ background: #ffdb58;
+ border-radius: 999px;
+ font-size: 12px;
+ font-weight: 900;
+}
+
+.played-cards-area .ambiguous-play-option .hand.small {
+ min-height: 76px;
+ padding: 3px;
+}
+
+.position-left .ambiguous-play-options,
+.position-right .ambiguous-play-options {
+ width: min(330px, 25vw);
+}
+
+.second-battlefield-staged-cards {
+ position: absolute;
+ z-index: 16;
+ display: flex;
+ align-items: flex-start;
+ gap: 7px;
+ width: max-content;
+ max-width: min(430px, 38vw);
+ min-height: 80px;
+ padding: 5px 9px 6px 7px;
+ border: 1px solid rgba(116, 210, 255, 0.58);
+ border-radius: 11px;
+ background: linear-gradient(145deg, rgba(8, 42, 67, 0.94), rgba(8, 61, 43, 0.94));
+ box-shadow: 0 8px 20px rgba(0, 23, 31, 0.38), inset 0 1px rgba(255, 255, 255, 0.1);
+ pointer-events: none;
+}
+
+.second-battlefield-staged-cards.is-showdown {
+ border-color: rgba(255, 220, 96, 0.7);
+ background: linear-gradient(145deg, rgba(59, 43, 8, 0.96), rgba(8, 61, 43, 0.96));
+}
+
+.second-battlefield-staged-cards.is-winner {
+ border-color: #ffe06a;
+ box-shadow: 0 8px 22px rgba(0, 23, 31, 0.38), 0 0 20px rgba(255, 211, 66, 0.48);
+}
+
+.second-battlefield-staged-cards.is-showdown .second-battlefield-staged-label {
+ color: #ffe37d;
+}
+
+.second-battlefield-staged-top {
+ /* 对家累计牌位于计分板与中央出牌区之间,不能再伸入左上角计分板。 */
+ left: 27%;
+ bottom: 0;
+ transform: translateX(-50%);
+}
+
+.second-battlefield-staged-bottom {
+ left: 72%;
+ top: -28px;
+ transform: translateX(-50%);
+}
+
+.second-battlefield-side-player {
+ position: relative;
+ flex: 0 0 auto;
+}
+
+.second-battlefield-side-player .second-battlefield-staged-cards {
+ top: calc(100% + 8px);
+}
+
+.position-left .second-battlefield-staged-left {
+ left: 0;
+}
+
+.position-right .second-battlefield-staged-right {
+ right: 0;
+}
+
+.second-battlefield-staged-label {
+ flex: 0 0 auto;
+ color: #9ee5ff;
+ font-size: 11px;
+ font-weight: 900;
+ line-height: 1.15;
+ writing-mode: vertical-rl;
+}
+
+.second-battlefield-staged-card-rows {
+ min-width: 0;
+ display: grid;
+ gap: 4px;
+}
+
+.second-battlefield-staged-card-row {
+ min-width: 0;
+ display: flex;
+ align-items: center;
+ overflow: visible;
+}
+
+.second-battlefield-staged-card {
+ position: relative;
+ z-index: 1;
+ width: 30px;
+ height: 70px;
+ flex: 0 0 30px;
+}
+
+.second-battlefield-staged-card:last-child {
+ width: 50px;
+ flex-basis: 50px;
+}
+
+.played-cards-area.treated-as-small {
+ filter: drop-shadow(0 9px 8px rgba(0, 28, 20, 0.32)) saturate(0.82);
+}
+
+.played-cards-area.enduring-inherited {
+ filter: drop-shadow(0 8px 8px rgba(0, 28, 20, 0.32)) drop-shadow(0 0 8px rgba(255, 210, 74, 0.48));
+}
+
+.played-cards-area.enduring-inherited > .hand .card {
+ border-color: #e8bd3d;
+ box-shadow: 0 3px 0 #98701b, 0 7px 13px rgba(0, 21, 15, 0.28), 0 0 0 2px rgba(255, 220, 94, 0.3);
+ animation: enduringCardAwaken 0.62s ease-out both;
+}
+
+.enduring-inheritance-ribbon {
+ position: absolute;
+ z-index: 44;
+ top: -34px;
+ left: 50%;
+ min-width: 128px;
+ max-width: 218px;
+ height: 34px;
+ box-sizing: border-box;
+ display: flex;
+ align-items: center;
+ gap: 5px;
+ padding: 3px 7px 4px 4px;
+ transform: translateX(-50%);
+ border: 1px solid rgba(245, 204, 70, 0.78);
+ border-radius: 999px;
+ color: #fff4bf;
+ background: linear-gradient(110deg, rgba(55, 43, 12, 0.97), rgba(5, 54, 39, 0.96));
+ box-shadow: 0 5px 13px rgba(0, 24, 16, 0.38), inset 0 0 12px rgba(255, 210, 69, 0.08);
+ white-space: nowrap;
+ pointer-events: none;
+ animation: enduringRibbonIn 0.48s cubic-bezier(0.2, 0.84, 0.24, 1.2) both;
+}
+
+.played-cards-top .enduring-inheritance-ribbon {
+ top: auto;
+ bottom: -30px;
+}
+
+.enduring-inheritance-seal {
+ width: 25px;
+ height: 25px;
+ flex: 0 0 25px;
+ display: grid;
+ place-items: center;
+ border: 1px solid #ffe27a;
+ border-radius: 50%;
+ color: #4b3300;
+ background: linear-gradient(145deg, #fff09b, #d99a18);
+ box-shadow: 0 2px 5px rgba(87, 51, 0, 0.35);
+ font-size: 15px;
+ font-weight: 1000;
+ line-height: 1;
+}
+
+.enduring-inheritance-label {
+ flex: 0 0 auto;
+ font-size: 11px;
+ font-weight: 900;
+ letter-spacing: 0.04em;
+}
+
+.enduring-inheritance-cards {
+ min-width: 0;
+ height: 27px;
+ display: flex;
+ align-items: flex-start;
+ padding-right: 1px;
+}
+
+.enduring-inheritance-cards .card.micro {
+ width: 20px;
+ height: 28px;
+ flex: 0 0 20px;
+ border-radius: 3px;
+ box-shadow: 0 1px 0 #9a9e99, 0 2px 4px rgba(0, 15, 9, 0.3);
+}
+
+.enduring-inheritance-cards .card.micro + .card.micro {
+ margin-left: -8px;
+}
+
+.enduring-inheritance-cards .card.micro .card-corner.top-left {
+ top: 2px;
+ left: 3px;
+}
+
+.enduring-inheritance-cards .card.micro .card-rank { font-size: 8px; }
+.enduring-inheritance-cards .card.micro .card-suit { font-size: 7px; }
+.enduring-inheritance-cards .card.micro .trump-badge { display: none; }
+
+.enduring-inheritance-more {
+ align-self: center;
+ margin-left: 3px;
+ color: #ffe16d;
+ font-size: 10px;
+ font-weight: 900;
+}
+
+@keyframes enduringRibbonIn {
+ from { opacity: 0; transform: translateX(-50%) translateY(7px) scale(0.88); }
+ to { opacity: 1; transform: translateX(-50%) translateY(0) scale(1); }
+}
+
+@keyframes enduringCardAwaken {
+ 0% { filter: brightness(1); }
+ 42% { filter: brightness(1.32) saturate(1.18); transform: translateY(-4px); }
+ 100% { filter: brightness(1); transform: translateY(0); }
+}
+
+.concealed-play-stack {
+ position: relative;
+ width: min(150px, 90%);
+ height: 92px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.concealed-play-card {
+ position: absolute;
+ left: calc(50% - 31px + var(--concealed-offset-x));
+ top: calc(50% - 43px + var(--concealed-offset-y));
+ width: 62px;
+ height: 86px;
+ border: 2px solid #ecd88e;
+ border-radius: 7px;
+ background:
+ linear-gradient(45deg, transparent 43%, rgba(255,255,255,0.22) 44% 56%, transparent 57%),
+ linear-gradient(-45deg, transparent 43%, rgba(255,255,255,0.16) 44% 56%, transparent 57%),
+ linear-gradient(145deg, #174f98, #09275f);
+ background-size: 12px 12px, 12px 12px, 100% 100%;
+ box-shadow: 0 4px 9px rgba(0, 18, 35, 0.34), inset 0 0 0 3px rgba(245, 224, 145, 0.2);
+ transform: translateX(calc(0px - var(--concealed-offset-x)));
+ animation: concealedCardIn 0.24s ease-out both;
+}
+
+.concealed-play-label {
+ position: absolute;
+ z-index: 12;
+ bottom: -8px;
+ left: 50%;
+ padding: 2px 8px;
+ border: 1px solid rgba(255, 222, 108, 0.7);
+ border-radius: 999px;
+ color: #fff1b1;
+ background: rgba(39, 35, 18, 0.92);
+ font-size: 11px;
+ font-weight: 800;
+ white-space: nowrap;
+ transform: translateX(-50%);
+}
+
+.played-cards-area.concealed-just-revealed .hand {
+ animation: concealedReveal 0.38s ease-out both;
+}
+
+@keyframes concealedCardIn {
+ from { opacity: 0; transform: translateY(10px) scale(0.9); }
+ to { opacity: 1; }
+}
+
+@keyframes concealedReveal {
+ from { opacity: 0.2; transform: rotateY(75deg); filter: brightness(1.5); }
+ to { opacity: 1; transform: rotateY(0); filter: brightness(1); }
+}
+
+.active-skill-play-badge {
+ position: absolute;
+ left: 50%;
+ top: -8px;
+ z-index: 36;
+ transform: translateX(-50%);
+ padding: 3px 9px;
+ border: 1px solid rgba(255, 224, 105, 0.74);
+ border-radius: 999px;
+ color: #fff5c4;
+ background: linear-gradient(180deg, rgba(105, 82, 20, 0.96), rgba(51, 42, 18, 0.96));
+ box-shadow: 0 4px 12px rgba(0, 20, 12, 0.34);
+ font-size: 11px;
+ font-weight: 800;
+ line-height: 1.25;
+ white-space: nowrap;
+ animation: winnerPop 0.28s ease-out;
+}
+
+.round-rule-play-badge {
+ position: absolute;
+ left: 50%;
+ top: -9px;
+ z-index: 38;
+ transform: translateX(-50%);
+ padding: 3px 9px;
+ border: 1px solid rgba(255, 222, 104, 0.76);
+ border-radius: 999px;
+ color: #fff5bf;
+ background: linear-gradient(180deg, rgba(101, 78, 17, 0.97), rgba(46, 37, 13, 0.97));
+ box-shadow: 0 4px 12px rgba(0, 20, 12, 0.35);
+ font-size: 10px;
+ font-weight: 900;
+ line-height: 1.3;
+ white-space: nowrap;
+ animation: winnerPop 0.32s ease-out;
+}
+
+.played-cards-area.lure-tiger-silenced-play .hand {
+ filter: saturate(0.45) brightness(0.82);
+ opacity: 0.82;
+}
+
+.lure-tiger-silenced-badge {
+ border-color: rgba(184, 197, 211, 0.82);
+ color: #f0f4f7;
+ background: linear-gradient(180deg, rgba(74, 85, 97, 0.97), rgba(35, 42, 49, 0.97));
+}
+
+.joint-harmony-badge {
+ border-color: rgba(255, 145, 197, 0.82);
+ color: #ffe7f3;
+ background: linear-gradient(180deg, rgba(111, 28, 69, 0.97), rgba(54, 17, 38, 0.97));
+}
+
+.dream-killing-success-badge {
+ border-color: rgba(117, 207, 255, 0.82);
+ color: #e6f7ff;
+ background: linear-gradient(180deg, rgba(23, 83, 116, 0.97), rgba(10, 42, 62, 0.97));
+}
+
+.cluster-analysis-badge {
+ border-color: rgba(102, 227, 255, 0.85);
+ color: #e7fbff;
+ background: linear-gradient(180deg, rgba(19, 113, 134, 0.98), rgba(8, 59, 72, 0.98));
+}
+
+.magic-trick-badge {
+ border-color: rgba(207, 147, 255, 0.88);
+ color: #f8eaff;
+ background: linear-gradient(180deg, rgba(105, 38, 145, 0.98), rgba(47, 16, 72, 0.98));
+}
+
+.played-cards-area.magic-trick-swapped .hand {
+ animation: magicTrickSwapReveal 0.72s cubic-bezier(0.2, 0.84, 0.24, 1.12) both;
+}
+
+.average-pooled .card { box-shadow: 0 3px 0 #96731c, 0 7px 14px rgba(0, 21, 15, 0.3), 0 0 0 2px rgba(255, 220, 85, 0.26); }
+.joint-harmony .card { box-shadow: 0 3px 0 #8a2858, 0 7px 14px rgba(0, 21, 15, 0.3), 0 0 0 2px rgba(255, 126, 189, 0.3); }
+.dream-killing-success .card { animation: dreamKillingWake 0.68s ease-out both; }
+
+@keyframes dreamKillingWake {
+ 0% { filter: brightness(0.55) saturate(0.55); transform: rotateY(70deg); }
+ 60% { filter: brightness(1.35); transform: rotateY(0) translateY(-5px); }
+ 100% { filter: none; transform: none; }
+}
+
+@keyframes magicTrickSwapReveal {
+ 0% { opacity: 0.25; transform: rotateY(78deg) scale(0.88); filter: hue-rotate(65deg) brightness(1.35); }
+ 65% { opacity: 1; transform: rotateY(0) translateY(-7px) scale(1.04); filter: brightness(1.2); }
+ 100% { transform: none; filter: none; }
+}
+
+.joker-substitution-badge {
+ position: absolute;
+ left: 50%;
+ bottom: -10px;
+ z-index: 37;
+ max-width: 95%;
+ overflow: hidden;
+ padding: 3px 9px;
+ border: 1px solid rgba(126, 216, 255, 0.7);
+ border-radius: 999px;
+ color: #d9f5ff;
+ background: rgba(11, 58, 72, 0.94);
+ box-shadow: 0 4px 12px rgba(0, 20, 25, 0.3);
+ font-size: 10px;
+ font-weight: 800;
+ line-height: 1.25;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ transform: translateX(-50%);
+ animation: winnerPop 0.28s ease-out;
+}
+
+.abrupt-stop-settlement {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 5px;
+ margin-top: 9px;
+ padding: 8px 10px;
+ border: 1px solid rgba(255, 153, 102, 0.66);
+ border-radius: 8px;
+ color: #fff3e8;
+ background: rgba(113, 42, 18, 0.42);
+ font-size: 12px;
+}
+
+.abrupt-stop-settlement > strong { color: #ffbb96; font-size: 14px; }
+.abrupt-stop-settlement .hand {
+ width: min(100%, 270px);
+ min-height: 60px;
+ padding: 0;
+}
+
+.winning-play-badge {
+ position: absolute;
+ right: 0;
+ bottom: 0;
+ z-index: 30;
+ width: 38px;
+ height: 38px;
+ display: flex;
+ align-items: flex-end;
+ justify-content: flex-end;
+ box-sizing: border-box;
+ padding: 0 4px 2px 0;
+ clip-path: polygon(100% 0, 100% 100%, 0 100%);
+ color: #fff1a8;
+ background: linear-gradient(135deg, #ef5b4d 0%, #d82035 54%, #9c0d24 100%);
+ filter: drop-shadow(-2px -2px 2px rgba(78, 0, 12, 0.28));
+ font-size: 16px;
+ font-weight: 900;
+ line-height: 1;
+ text-shadow: 0 1px 2px rgba(81, 0, 10, 0.78);
+ animation: winnerPop 0.28s ease-out;
+}
+
+@keyframes winnerPop {
+ from { opacity: 0; transform: scale(0.45); transform-origin: right bottom; }
+ to { opacity: 1; transform: scale(1); transform-origin: right bottom; }
+}
+
+.score-panel {
+ top: 18px !important;
+ left: 18px !important;
+ width: 218px !important;
+ padding: 10px 13px !important;
+ border: 1px solid rgba(255, 224, 117, 0.34) !important;
+ border-radius: 16px !important;
+ background: linear-gradient(145deg, rgba(3, 41, 34, 0.91), rgba(13, 72, 51, 0.83)) !important;
+ box-shadow: 0 10px 28px rgba(0, 23, 17, 0.34) !important;
+ backdrop-filter: blur(12px);
+}
+
+.score-panel > div:first-child { margin-bottom: 7px !important; padding-bottom: 6px !important; }
+.score-panel > div:nth-child(3) { font-size: 21px !important; margin-bottom: 2px !important; }
+.score-panel > .score-cards-section { height: 92px !important; }
+
+.mobile-score-panel-trigger {
+ display: none;
+}
+
+.focus-figure-score-hidden {
+ color: #fff0a6;
+ font-size: 13px;
+ font-weight: 700;
+ letter-spacing: 0.02em;
+}
+
+.ten-sided-ambush-score-counter {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 7px;
+ margin: 3px 0 7px;
+ padding: 5px 7px;
+ border: 1px solid rgba(255, 220, 91, 0.42);
+ border-radius: 8px;
+ color: #fff1ad;
+ background: rgba(63, 48, 8, 0.36);
+ font-size: 11px;
+ white-space: nowrap;
+}
+
+.ten-sided-ambush-score-counter strong {
+ color: #ffd84f;
+ font-size: 13px;
+}
+
+.ten-sided-ambush-score-counter.is-negative-score > span:last-child strong { color: #ff9292; }
+.ten-sided-ambush-score-counter.is-positive-score > span:last-child strong { color: #9fea80; }
+
+.score-card-list {
+ height: 74px;
+ display: flex;
+ align-items: flex-start;
+ flex-wrap: nowrap;
+ overflow-x: auto;
+ overflow-y: hidden;
+ padding: 1px 3px 4px;
+}
+
+.score-card-item {
+ position: relative;
+ flex: 0 0 50px;
+}
+
+.score-card-item:not(:first-child) {
+ margin-left: -20px;
+}
+
+.score-card-item:nth-child(n) {
+ z-index: 1;
+}
+
+.mobile-score-details-modal .ant-modal-content {
+ overflow: hidden;
+ padding: 14px 16px 16px;
+ border: 1px solid rgba(255, 221, 103, 0.58);
+ border-radius: 16px;
+ color: #f6fff8;
+ background:
+ linear-gradient(145deg, rgba(4, 56, 43, 0.98), rgba(2, 32, 27, 0.99)),
+ radial-gradient(circle at top right, rgba(255, 214, 77, 0.12), transparent 46%);
+ box-shadow: 0 20px 64px rgba(0, 18, 13, 0.58), inset 0 1px rgba(255, 255, 255, 0.08);
+}
+
+.mobile-score-details-modal .ant-modal-header {
+ margin-bottom: 10px;
+ background: transparent;
+}
+
+.mobile-score-details-modal .ant-modal-title {
+ color: #ffe67f;
+ font-size: 17px;
+ font-weight: 800;
+ text-align: center;
+}
+
+.mobile-score-details-modal .ant-modal-close {
+ color: rgba(255, 255, 255, 0.76);
+}
+
+.mobile-score-details {
+ max-height: min(66vh, 290px);
+ overflow-y: auto;
+ padding: 1px 3px 2px;
+ scrollbar-width: thin;
+ scrollbar-color: rgba(255, 222, 104, 0.72) rgba(0, 31, 24, 0.3);
+}
+
+.mobile-score-detail-levels,
+.mobile-score-detail-totals {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 8px;
+}
+
+.mobile-score-detail-levels {
+ padding-bottom: 8px;
+ border-bottom: 1px solid rgba(255, 219, 88, 0.24);
+}
+
+.mobile-score-detail-levels > div,
+.mobile-score-detail-totals > div {
+ min-width: 0;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 8px;
+ padding: 6px 8px;
+ border-radius: 9px;
+ background: rgba(255, 255, 255, 0.055);
+}
+
+.mobile-score-detail-levels span,
+.mobile-score-detail-totals span {
+ color: rgba(240, 255, 245, 0.68);
+ font-size: 11px;
+}
+
+.mobile-score-detail-levels strong,
+.mobile-score-detail-totals strong {
+ color: #fff;
+ font-size: 17px;
+ line-height: 1.1;
+ text-align: center;
+}
+
+.mobile-score-detail-levels strong.is-my-team { color: #7ee35f; }
+.mobile-score-detail-levels strong.is-opponent-team { color: #ff797b; }
+
+.mobile-score-detail-totals {
+ margin-top: 8px;
+}
+
+.mobile-score-detail-totals > div:last-child strong {
+ color: #ffdc65;
+}
+
+.mobile-score-detail-cards {
+ margin-top: 9px;
+ padding: 8px 9px 6px;
+ border: 1px solid rgba(218, 241, 221, 0.14);
+ border-radius: 11px;
+ background: rgba(0, 24, 19, 0.42);
+}
+
+.mobile-score-detail-cards-heading {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ margin-bottom: 5px;
+ color: #ffe783;
+ font-size: 12px;
+}
+
+.mobile-score-detail-cards-heading span {
+ color: rgba(255, 255, 255, 0.6);
+ font-size: 10px;
+}
+
+.mobile-score-details .score-card-list {
+ width: 100%;
+ height: 82px;
+ padding-bottom: 7px;
+}
+
+.mobile-score-detail-empty {
+ min-height: 54px;
+ display: grid;
+ place-items: center;
+ color: rgba(255, 255, 255, 0.48);
+ font-size: 12px;
+}
+
+.game-table.settlement-view {
+ grid-template-rows: 160px minmax(0, 1fr) 90px;
+}
+
+.settlement-view .position-top .played-cards-area,
+.settlement-view .position-bottom .played-cards-area,
+.settlement-view .my-hand-container,
+.settlement-view .trump-declaration-wrapper {
+ display: none;
+}
+
+.settlement-view .position-bottom {
+ padding-bottom: 10px;
+}
+
+.settlement-view .player-bottom {
+ padding-bottom: 8px;
+}
+
+.player-top { width: clamp(210px, 24vw, 280px); min-width: 0; }
+.player-left, .player-right {
+ width: clamp(142px, 10vw, 156px);
+ min-width: 0;
+ min-height: 110px;
+ flex: 0 0 auto;
+}
+
+.player-bottom {
+ flex: 0 0 auto;
+ width: min(1440px, 100%);
+ min-width: 0;
+ padding: 8px 14px 5px;
+ border-radius: 22px 22px 8px 8px;
+}
+
+.player-bottom > .played-cards-area {
+ position: absolute;
+ z-index: 12;
+ left: 50%;
+ bottom: calc(100% + 18px);
+ margin: 0;
+ transform: translateX(-50%);
+}
+
+.player-bottom > .played-cards-area.is-empty {
+ visibility: hidden;
+}
+
+.bottom-player-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ gap: 14px;
+ margin-bottom: 2px;
+}
+
+.bottom-player-header .player-info { flex: 0 0 auto; text-align: left; }
+
+.inline-controls {
+ display: flex;
+ align-items: center;
+ justify-content: flex-end;
+ flex: 0 1 auto;
+ gap: 6px;
+ width: max-content;
+ min-width: 0;
+ max-width: 900px;
+ padding: 6px 8px;
+ margin-left: auto;
+ overflow-x: auto;
+ overflow-y: hidden;
+ border: 1px solid rgba(210, 242, 218, 0.16);
+ border-radius: 13px;
+ background: rgba(2, 36, 29, 0.5);
+ backdrop-filter: blur(12px);
+}
+
+.inline-controls > .play-controls {
+ flex: 0 0 auto;
+}
+
+.room-action-stack {
+ flex: 0 0 auto;
+ width: 84px;
+ height: 32px;
+ display: flex;
+ gap: 3px;
+}
+
+.room-action-stack.has-surrender {
+ width: 142px;
+ display: grid;
+ grid-template-columns: minmax(48px, 0.72fr) minmax(80px, 1.28fr);
+}
+
+.inline-controls .room-action-stack .ant-btn {
+ width: 100%;
+ min-width: 0;
+ height: 32px;
+ padding: 0 6px;
+ font-size: 12px;
+}
+
+.inline-controls .surrender-button.ant-btn {
+ border-color: rgba(255, 206, 113, 0.62);
+ color: #fff2c9;
+ background: linear-gradient(180deg, rgba(137, 91, 37, 0.96), rgba(83, 52, 24, 0.96));
+}
+
+.inline-controls .return-room-button.ant-btn {
+ border-color: rgba(142, 210, 255, 0.58);
+ color: #e9f7ff;
+ background: linear-gradient(180deg, rgba(49, 116, 139, 0.96), rgba(27, 73, 91, 0.96));
+}
+
+.inline-controls .surrender-button.ant-btn:not(:disabled):hover,
+.inline-controls .surrender-button.ant-btn:not(:disabled):focus-visible {
+ border-color: #ffe0a3;
+ color: #fff;
+ background: linear-gradient(180deg, rgba(166, 111, 43, 0.98), rgba(104, 63, 27, 0.98));
+}
+
+.inline-controls .return-room-button.ant-btn:hover,
+.inline-controls .return-room-button.ant-btn:focus-visible {
+ border-color: #b9e7ff;
+ color: #fff;
+ background: linear-gradient(180deg, rgba(61, 140, 166, 0.98), rgba(31, 88, 108, 0.98));
+}
+
+.inline-controls .ant-btn,
+.center-content .ant-btn {
+ border-color: rgba(223, 242, 218, 0.38);
+ color: #effff2;
+ background: linear-gradient(180deg, rgba(51, 143, 81, 0.96), rgba(20, 91, 58, 0.96));
+ box-shadow: inset 0 1px rgba(255, 255, 255, 0.16), 0 4px 10px rgba(0, 27, 17, 0.22);
+}
+
+.inline-controls .ant-btn-primary {
+ border-color: #f0cf68;
+ color: #3c2b00;
+ background: linear-gradient(180deg, #ffe680, #dcae35);
+}
+
+/* 主动技能按钮默认收敛为灰色,只有“已武装”状态才使用醒目的金色。 */
+.inline-controls .active-skill-button.ant-btn {
+ border-color: rgba(220, 228, 223, 0.32);
+ color: rgba(247, 250, 248, 0.78);
+ background: linear-gradient(180deg, rgba(111, 120, 114, 0.9), rgba(65, 73, 68, 0.94));
+ box-shadow: inset 0 1px rgba(255, 255, 255, 0.1), 0 4px 10px rgba(0, 20, 13, 0.2);
+}
+
+.inline-controls .active-skill-button.ant-btn:not(:disabled):hover {
+ border-color: rgba(255, 224, 119, 0.62);
+ color: #fff8d4;
+ background: linear-gradient(180deg, rgba(132, 139, 132, 0.96), rgba(74, 82, 76, 0.98));
+}
+
+.inline-controls .active-skill-button.ant-btn.is-armed,
+.inline-controls .active-skill-button.ant-btn.is-armed:hover,
+.inline-controls .active-skill-button.ant-btn.is-ready,
+.inline-controls .active-skill-button.ant-btn.is-ready:hover {
+ border-color: #ffe47c !important;
+ color: #3e2c00 !important;
+ background: linear-gradient(180deg, #fff09a, #e6b633) !important;
+ box-shadow: 0 0 0 2px rgba(255, 224, 96, 0.18), 0 0 20px rgba(255, 202, 51, 0.42) !important;
+ animation: activeSkillButtonPulse 1.15s ease-in-out infinite;
+}
+
+.inline-controls .active-skill-button.ant-btn.is-ready,
+.inline-controls .active-skill-button.ant-btn.is-ready:hover {
+ animation-name: activeSkillReadyGlow;
+}
+
+.inline-controls .active-skill-button.ant-btn.is-used,
+.inline-controls .active-skill-button.ant-btn.is-used:disabled {
+ color: rgba(235, 240, 237, 0.34) !important;
+ background: linear-gradient(180deg, rgba(78, 86, 81, 0.58), rgba(48, 55, 51, 0.66)) !important;
+ box-shadow: none !important;
+}
+
+@keyframes activeSkillButtonPulse {
+ 0%, 100% { transform: translateY(0); filter: brightness(1); }
+ 50% { transform: translateY(-1px); filter: brightness(1.1); }
+}
+
+@keyframes activeSkillReadyGlow {
+ 0%, 100% { filter: brightness(1); }
+ 50% { filter: brightness(1.1); }
+}
+
+/* 主题色不能覆盖真实禁用状态:不合法时按钮保持灰色且不显示禁止光标。 */
+.inline-controls .ant-btn.ant-btn-disabled,
+.inline-controls .ant-btn:disabled,
+.center-content .ant-btn.ant-btn-disabled,
+.center-content .ant-btn:disabled {
+ border-color: rgba(205, 215, 208, 0.24);
+ color: rgba(234, 241, 236, 0.48);
+ background: linear-gradient(180deg, rgba(112, 125, 117, 0.58), rgba(67, 78, 71, 0.64));
+ box-shadow: inset 0 1px rgba(255, 255, 255, 0.06);
+ cursor: default !important;
+ opacity: 1;
+}
+
+.my-hand-container {
+ width: 100%;
+ display: flex;
+ align-items: flex-end;
+ justify-content: center;
+ gap: 10px;
+}
+
+.bottom-declaration-dock {
+ flex: 0 0 68px;
+ width: 68px;
+ min-width: 68px;
+ display: flex;
+ align-items: flex-end;
+ padding: 4px 3px 8px;
+}
+
+.bottom-declaration-dock.is-trump { justify-content: flex-start; padding-left: 8px; }
+.bottom-declaration-dock.is-inferior { justify-content: flex-end; padding-right: 8px; }
+
+.bottom-declaration-dock .declaration-card-slot {
+ position: relative;
+ right: auto;
+ bottom: auto;
+ left: auto;
+}
+
+.my-hand {
+ flex: 1 1 auto;
+ min-width: 0;
+ min-height: 140px;
+ display: flex;
+ justify-content: center;
+ padding: 0 14px;
+ border: 1px solid rgba(215, 243, 220, 0.14);
+ border-radius: 16px 16px 7px 7px;
+ background:
+ linear-gradient(180deg, rgba(3, 38, 31, 0.3), rgba(1, 27, 23, 0.73)),
+ repeating-linear-gradient(90deg, transparent 0 48px, rgba(255,255,255,0.013) 48px 49px);
+ box-shadow: inset 0 8px 22px rgba(0, 18, 14, 0.24);
+}
+
+.trump-declaration-wrapper { margin: 2px 0 0; padding: 0; }
+
+.ten-sided-ambush-status {
+ display: flex;
+ align-items: center;
+ gap: 7px;
+ width: max-content;
+ max-width: 100%;
+ margin: 2px auto 8px;
+ padding: 5px 10px;
+ border: 1px solid rgba(255, 218, 93, 0.42);
+ border-radius: 999px;
+ color: #ffe995;
+ background: rgba(2, 28, 23, 0.58);
+ box-shadow: inset 0 0 14px rgba(255, 207, 55, 0.06);
+ font-size: 12px;
+}
+
+.rule-runtime-status {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 6px;
+ width: max-content;
+ max-width: 100%;
+ min-height: 30px;
+ margin: 0 auto 8px;
+ padding: 4px 11px;
+ border: 1px solid rgba(255, 216, 70, 0.68);
+ border-radius: 999px;
+ color: #fff4bd;
+ background: linear-gradient(180deg, rgba(107, 77, 12, 0.72), rgba(48, 36, 8, 0.72));
+ box-shadow: inset 0 0 12px rgba(255, 211, 66, 0.08);
+ font-size: 13px;
+ font-weight: 700;
+ line-height: 1.2;
+ animation: ruleRuntimeStatusEnter 480ms cubic-bezier(0.2, 0.82, 0.28, 1.18) both;
+}
+
+.rule-runtime-status strong {
+ color: #ffd84a;
+ font-size: 16px;
+}
+
+.rule-runtime-status-icon {
+ color: #ffd84a;
+ font-size: 18px;
+ line-height: 1;
+}
+
+.waiting-rabbit-records {
+ width: min(100%, 360px);
+ margin: 0 auto 8px;
+ overflow: hidden;
+ border: 1px solid rgba(255, 216, 70, 0.5);
+ border-radius: 10px;
+ color: #fff7d1;
+ background: linear-gradient(155deg, rgba(70, 54, 12, 0.72), rgba(11, 42, 34, 0.78));
+ box-shadow: inset 0 0 18px rgba(255, 211, 66, 0.06);
+ text-align: left;
+}
+
+.waiting-rabbit-records-heading {
+ display: flex;
+ align-items: center;
+ gap: 7px;
+ padding: 6px 10px;
+ border-bottom: 1px solid rgba(255, 224, 118, 0.2);
+ color: #ffe57a;
+ font-size: 13px;
+}
+
+.waiting-rabbit-records-heading > span {
+ display: grid;
+ width: 22px;
+ height: 22px;
+ place-items: center;
+ border-radius: 50%;
+ color: #1e3e31;
+ background: #ffe57a;
+ font-size: 12px;
+ font-weight: 900;
+}
+
+.waiting-rabbit-records-list {
+ max-height: 154px;
+ padding: 4px 8px 6px;
+ overflow-y: auto;
+}
+
+.waiting-rabbit-record {
+ display: grid;
+ grid-template-columns: 42px minmax(0, 1fr);
+ gap: 7px;
+ align-items: baseline;
+ padding: 4px 2px;
+ border-bottom: 1px solid rgba(255, 255, 255, 0.07);
+ font-size: 12px;
+ line-height: 1.35;
+}
+
+.waiting-rabbit-record:last-child {
+ border-bottom: 0;
+}
+
+.waiting-rabbit-record small {
+ color: rgba(255, 231, 144, 0.65);
+ font-size: 10px;
+ white-space: nowrap;
+}
+
+.waiting-rabbit-record.is-target-triggered span {
+ color: #ffd666;
+}
+
+.waiting-rabbit-record.is-target-exchanged span {
+ color: #a8f0bd;
+}
+
+.waiting-rabbit-record.is-exchange-declined span,
+.waiting-rabbit-record.is-empty span {
+ color: rgba(255, 255, 255, 0.68);
+}
+
+.waiting-rabbit-record.is-empty {
+ display: block;
+ padding: 7px 2px;
+ text-align: center;
+}
+
+.odd-even-round-status.is-odd {
+ border-color: rgba(181, 193, 209, 0.52);
+ background: linear-gradient(135deg, rgba(65, 74, 91, 0.76), rgba(28, 35, 49, 0.72));
+}
+
+.odd-even-round-status.is-odd strong,
+.odd-even-round-status.is-odd .rule-runtime-status-icon {
+ color: #d7e0eb;
+}
+
+.odd-even-round-status.is-even {
+ border-color: rgba(255, 211, 84, 0.72);
+ background: linear-gradient(135deg, rgba(112, 73, 8, 0.76), rgba(61, 40, 5, 0.72));
+ box-shadow: inset 0 0 15px rgba(255, 210, 67, 0.12), 0 0 14px rgba(255, 204, 48, 0.12);
+}
+
+.cultural-revolution-status {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 4px;
+ width: min(100%, 292px);
+ margin: 1px auto 9px;
+ padding: 8px 12px;
+ border: 1px solid rgba(255, 201, 70, 0.66);
+ border-radius: 12px;
+ color: #fff5c5;
+ background: linear-gradient(135deg, rgba(99, 55, 13, 0.86), rgba(42, 26, 10, 0.84));
+ box-shadow: inset 0 0 18px rgba(255, 201, 70, 0.08);
+ line-height: 1.25;
+}
+
+.cultural-revolution-status.is-idle {
+ border-color: rgba(188, 200, 193, 0.36);
+ background: rgba(23, 51, 43, 0.72);
+}
+
+.cultural-revolution-status-heading,
+.cultural-revolution-status-main {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 8px;
+}
+
+.cultural-revolution-status-heading > span {
+ display: inline-grid;
+ place-items: center;
+ width: 24px;
+ height: 24px;
+ border-radius: 50%;
+ color: #5f3304;
+ background: #ffd666;
+ font-weight: 900;
+}
+
+.cultural-revolution-status-heading strong,
+.cultural-revolution-status-main strong {
+ color: #ffd666;
+ font-size: 15px;
+}
+
+.cultural-revolution-status small {
+ color: rgba(245, 250, 244, 0.72);
+ font-size: 11px;
+}
+
+.three-tigers-status {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 4px;
+ width: min(100%, 292px);
+ margin: 1px auto 9px;
+ padding: 8px 12px;
+ border: 1px solid rgba(226, 154, 67, 0.55);
+ border-radius: 12px;
+ color: #fff2d2;
+ background: linear-gradient(135deg, rgba(76, 48, 25, 0.82), rgba(35, 31, 24, 0.80));
+ box-shadow: inset 0 0 18px rgba(231, 137, 39, 0.07);
+ line-height: 1.25;
+}
+
+.three-tigers-status.is-triggered {
+ border-color: rgba(255, 169, 55, 0.88);
+ background:
+ repeating-linear-gradient(135deg, rgba(195, 82, 18, 0.13) 0 8px, transparent 8px 17px),
+ linear-gradient(135deg, rgba(117, 58, 17, 0.9), rgba(50, 29, 15, 0.88));
+ box-shadow: inset 0 0 20px rgba(255, 150, 41, 0.12), 0 0 14px rgba(236, 113, 28, 0.12);
+ animation: ruleRuntimeStatusEnter 420ms ease-out both;
+}
+
+.three-tigers-status-heading,
+.three-tigers-status-main {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 8px;
+}
+
+.three-tigers-status-heading > span {
+ display: inline-grid;
+ width: 24px;
+ height: 24px;
+ place-items: center;
+ border-radius: 50%;
+ color: #fff3d0;
+ background: linear-gradient(145deg, #df7e26, #88380e);
+ font-weight: 950;
+}
+
+.three-tigers-status-heading strong,
+.three-tigers-status-main strong {
+ color: #ffbd68;
+ font-size: 15px;
+}
+
+.three-tigers-status small {
+ color: rgba(255, 243, 217, 0.77);
+ font-size: 11px;
+}
+
+.candle-to-dawn-status {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ width: min(100%, 292px);
+ margin: 1px auto 9px;
+ padding: 8px 12px;
+ border: 1px solid rgba(255, 207, 91, 0.66);
+ border-radius: 13px;
+ background: linear-gradient(135deg, rgba(88, 58, 12, 0.88), rgba(38, 28, 13, 0.82));
+ box-shadow: inset 0 0 18px rgba(255, 194, 61, 0.08);
+ text-align: left;
+}
+
+.candle-to-dawn-status.is-unlit {
+ border-color: rgba(160, 178, 194, 0.45);
+ background: linear-gradient(135deg, rgba(48, 59, 67, 0.86), rgba(24, 31, 36, 0.84));
+ box-shadow: inset 0 0 18px rgba(173, 197, 212, 0.05);
+}
+
+.candle-to-dawn-visual {
+ position: relative;
+ flex: 0 0 28px;
+ width: 28px;
+ height: 46px;
+}
+
+.candle-body,
+.candle-wick,
+.candle-flame {
+ position: absolute;
+ left: 50%;
+ display: block;
+ transform: translateX(-50%);
+}
+
+.candle-body {
+ bottom: 0;
+ width: 18px;
+ height: 28px;
+ border-radius: 5px 5px 3px 3px;
+ background: linear-gradient(90deg, #eadca8, #fff5c8 55%, #c9b875);
+ box-shadow: 0 2px 7px rgba(0, 0, 0, 0.32);
+}
+
+.candle-wick {
+ bottom: 27px;
+ width: 2px;
+ height: 6px;
+ border-radius: 2px;
+ background: #34271c;
+}
+
+.candle-flame {
+ bottom: 32px;
+ width: 13px;
+ height: 17px;
+ border-radius: 58% 42% 56% 44% / 70% 62% 38% 30%;
+ background: radial-gradient(circle at 52% 70%, #fffbc8 0 22%, #ffd04d 24% 58%, #ff7a21 61% 100%);
+ filter: drop-shadow(0 0 6px rgba(255, 180, 45, 0.86));
+ transform: translateX(-50%) rotate(5deg);
+ transform-origin: center bottom;
+ animation: candleFlameFlicker 1.25s ease-in-out infinite alternate;
+}
+
+.candle-to-dawn-status.is-unlit .candle-flame,
+.candle-to-dawn-status.is-pending .candle-flame {
+ display: none;
+}
+
+.candle-to-dawn-status.is-unlit .candle-body,
+.candle-to-dawn-status.is-pending .candle-body {
+ filter: grayscale(0.72) brightness(0.78);
+}
+
+.candle-to-dawn-copy {
+ display: flex;
+ flex: 1;
+ min-width: 0;
+ flex-direction: column;
+ gap: 2px;
+}
+
+.candle-to-dawn-copy strong {
+ color: #ffe293;
+ font-size: 14px;
+ line-height: 1.25;
+}
+
+.candle-to-dawn-status.is-unlit .candle-to-dawn-copy strong,
+.candle-to-dawn-status.is-pending .candle-to-dawn-copy strong {
+ color: #dce5ea;
+}
+
+.candle-to-dawn-copy small {
+ color: rgba(255, 248, 216, 0.8);
+ font-size: 11px;
+ font-weight: 650;
+ line-height: 1.3;
+}
+
+.candle-to-dawn-status.is-unlit .candle-to-dawn-copy small,
+.candle-to-dawn-status.is-pending .candle-to-dawn-copy small {
+ color: rgba(224, 234, 239, 0.76);
+}
+
+@keyframes candleFlameFlicker {
+ from { transform: translateX(-50%) rotate(-4deg) scale(0.94, 1); }
+ to { transform: translateX(-50%) rotate(5deg) scale(1.04, 0.96); }
+}
+
+.planned-economy-status {
+ flex-wrap: wrap;
+ max-width: 245px;
+}
+
+.planned-economy-status small {
+ flex-basis: 100%;
+ color: rgba(255, 247, 203, 0.78);
+ font-size: 11px;
+ font-weight: 600;
+}
+
+.strength-compensation-status {
+ display: block;
+ width: min(100%, 300px);
+ padding: 7px 8px 8px;
+ border-radius: 12px;
+ white-space: normal;
+}
+
+.strength-compensation-heading {
+ margin-bottom: 6px;
+ color: #fff2b2;
+ font-size: 13px;
+ font-weight: 900;
+ line-height: 1;
+ text-align: center;
+}
+
+.strength-compensation-lanes {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 7px;
+}
+
+.strength-compensation-lane {
+ display: grid;
+ grid-template-columns: auto minmax(0, 1fr);
+ align-items: center;
+ gap: 2px 6px;
+ min-width: 0;
+ padding: 5px 7px;
+ border: 1px solid rgba(255, 255, 255, 0.15);
+ border-radius: 8px;
+ background: rgba(255, 255, 255, 0.055);
+ text-align: left;
+}
+
+.strength-compensation-lane > span:not(.strength-compensation-delta) {
+ color: rgba(255, 255, 255, 0.82);
+ font-size: 11px;
+ font-weight: 700;
+}
+
+.strength-compensation-delta {
+ display: inline-grid;
+ min-width: 27px;
+ height: 20px;
+ place-items: center;
+ border-radius: 999px;
+ color: #082b18;
+ background: #91f0b6;
+ font-size: 11px;
+ font-weight: 950;
+ line-height: 1;
+}
+
+.strength-compensation-lane strong {
+ grid-column: 1 / -1;
+ overflow: hidden;
+ color: #b7f5cf;
+ font-size: 12px;
+ line-height: 1.15;
+ text-align: center;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.strength-compensation-lane.is-minus .strength-compensation-delta {
+ color: #4a1408;
+ background: #ffad95;
+}
+
+.strength-compensation-lane.is-minus strong { color: #ffc2b1; }
+
+@keyframes ruleRuntimeStatusEnter {
+ 0% { opacity: 0; transform: translateY(-5px) scale(0.9); }
+ 70% { opacity: 1; transform: translateY(1px) scale(1.04); }
+ 100% { opacity: 1; transform: translateY(0) scale(1); }
+}
+
+.ten-sided-ambush-status.is-revealed {
+ border-color: rgba(255, 216, 70, 0.88);
+ background: linear-gradient(180deg, rgba(121, 83, 11, 0.72), rgba(55, 38, 7, 0.72));
+ animation: ambushStatusReveal 700ms ease-out both;
+}
+
+.ten-sided-ambush-status strong,
+.ten-sided-ambush-hidden-rank {
+ display: inline-grid;
+ place-items: center;
+ min-width: 26px;
+ height: 26px;
+ border-radius: 50%;
+ color: #553400;
+ background: linear-gradient(180deg, #fff1a6, #e8ae2c);
+ font-size: 16px;
+ line-height: 1;
+}
+
+.ten-sided-ambush-hidden-rank {
+ color: rgba(255, 242, 184, 0.72);
+ background: rgba(255, 255, 255, 0.08);
+}
+
+.ten-sided-ambush-status-label { font-weight: 800; }
+.ten-sided-ambush-status-note { color: rgba(255, 255, 255, 0.7); }
+
+.three-powers-status {
+ width: min(100%, 250px);
+ margin: 2px auto 8px;
+ padding: 7px 8px 8px;
+ border: 1px solid rgba(255, 218, 93, 0.42);
+ border-radius: 10px;
+ color: #fff0ad;
+ background: rgba(2, 28, 23, 0.58);
+ box-shadow: inset 0 0 14px rgba(255, 207, 55, 0.06);
+}
+
+.three-powers-status-title {
+ display: block;
+ margin-bottom: 5px;
+ font-size: 12px;
+ font-weight: 800;
+ line-height: 1;
+ text-align: center;
+}
+
+.gentleman-promise-status {
+ width: min(100%, 260px);
+ margin: 2px auto 8px;
+ padding: 7px 8px 8px;
+ border: 1px solid rgba(255, 218, 93, 0.42);
+ border-radius: 10px;
+ color: #fff0ad;
+ background: rgba(2, 28, 23, 0.58);
+ animation: ruleRuntimeStatusEnter 420ms ease-out both;
+}
+
+.gentleman-promise-status-title {
+ display: block;
+ margin-bottom: 6px;
+ font-size: 12px;
+ font-weight: 800;
+ text-align: center;
+}
+
+.gentleman-promise-status-grid {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 5px;
+}
+
+.gentleman-promise-status-grid > div {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) 25px;
+ align-items: center;
+ min-width: 0;
+ padding: 4px 6px;
+ border: 1px solid rgba(255, 255, 255, 0.13);
+ border-radius: 7px;
+ background: rgba(255, 255, 255, 0.055);
+}
+
+.gentleman-promise-status-grid span {
+ overflow: hidden;
+ color: rgba(255, 255, 255, 0.76);
+ font-size: 10px;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.gentleman-promise-status-grid strong {
+ color: #ffd84a;
+ font-size: 14px;
+ text-align: center;
+}
+
+.gentleman-promise-status-grid .is-pending strong {
+ color: rgba(255, 255, 255, 0.45);
+}
+
+.destroy-dyke-status {
+ box-sizing: border-box;
+ width: min(100%, 248px);
+ margin: 2px auto 8px;
+ padding: 7px 9px 8px;
+ overflow: hidden;
+ border: 1px solid rgba(223, 188, 96, 0.34);
+ border-radius: 11px;
+ color: #f9ecc0;
+ background:
+ linear-gradient(120deg, rgba(30, 69, 55, 0.82), rgba(4, 29, 24, 0.9)),
+ rgba(2, 28, 23, 0.72);
+ box-shadow:
+ inset 0 1px 0 rgba(255, 255, 255, 0.045),
+ 0 4px 12px rgba(0, 0, 0, 0.12);
+ animation: ruleRuntimeStatusEnter 420ms ease-out both;
+}
+
+.destroy-dyke-status-heading {
+ display: grid;
+ grid-template-columns: 24px minmax(0, 1fr) auto;
+ grid-template-rows: auto auto;
+ align-items: center;
+ column-gap: 7px;
+ row-gap: 4px;
+}
+
+.destroy-dyke-status-mark {
+ display: grid;
+ grid-column: 1;
+ grid-row: 1 / span 2;
+ width: 22px;
+ height: 22px;
+ place-items: center;
+ border: 1px solid rgba(239, 208, 122, 0.46);
+ border-radius: 50%;
+ color: #f5d77d;
+ background: rgba(238, 195, 75, 0.08);
+ font-family: Georgia, 'Noto Serif SC', serif;
+ font-size: 11px;
+ font-weight: 900;
+ line-height: 1;
+}
+
+.destroy-dyke-status-title {
+ grid-column: 2;
+ grid-row: 1;
+ min-width: 0;
+ overflow: hidden;
+ font-family: Georgia, 'Noto Serif SC', serif;
+ font-size: 12px;
+ line-height: 1.15;
+ letter-spacing: 0.05em;
+ text-align: left;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.destroy-dyke-status-badge {
+ grid-column: 3;
+ grid-row: 1;
+ align-self: center;
+ padding: 2px 6px;
+ border: 1px solid rgba(255, 255, 255, 0.12);
+ border-radius: 999px;
+ color: rgba(255, 255, 255, 0.62);
+ background: rgba(255, 255, 255, 0.055);
+ font-size: 9px;
+ font-weight: 800;
+ line-height: 1.4;
+ white-space: nowrap;
+}
+
+.destroy-dyke-status-value {
+ grid-column: 2;
+ grid-row: 2;
+ min-width: 0;
+ color: #ffd769;
+ font-size: 17px;
+ line-height: 1;
+ text-align: left;
+}
+
+.destroy-dyke-status-detail {
+ grid-column: 3;
+ grid-row: 2;
+ overflow: hidden;
+ color: rgba(255, 255, 255, 0.56);
+ font-size: 9px;
+ line-height: 1.25;
+ text-align: right;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.destroy-dyke-status.is-idle .destroy-dyke-status-badge,
+.destroy-dyke-status.is-spent .destroy-dyke-status-badge {
+ grid-row: 1 / span 2;
+}
+
+.destroy-dyke-status.is-idle .destroy-dyke-status-detail,
+.destroy-dyke-status.is-spent .destroy-dyke-status-detail {
+ grid-column: 2;
+ text-align: left;
+}
+
+.destroy-dyke-status-progress {
+ height: 3px;
+ margin: 7px 1px 0 31px;
+ overflow: hidden;
+ border-radius: 999px;
+ background: rgba(255, 255, 255, 0.09);
+}
+
+.destroy-dyke-status-progress > i {
+ display: block;
+ height: 100%;
+ border-radius: inherit;
+ background: linear-gradient(90deg, #e3b84f, #ef7351);
+ box-shadow: 0 0 9px rgba(244, 127, 74, 0.42);
+ transition: width 420ms ease;
+}
+
+.destroy-dyke-status.is-idle {
+ padding-block: 6px;
+ border-color: rgba(214, 195, 139, 0.2);
+ background: linear-gradient(120deg, rgba(23, 57, 47, 0.66), rgba(4, 29, 24, 0.74));
+ box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.035);
+}
+
+.destroy-dyke-status.is-idle .destroy-dyke-status-badge {
+ color: rgba(227, 218, 181, 0.58);
+}
+
+.destroy-dyke-status.is-decision {
+ border-color: rgba(255, 209, 91, 0.52);
+ box-shadow: inset 0 0 18px rgba(255, 193, 51, 0.07);
+}
+
+.destroy-dyke-status.is-decision .destroy-dyke-status-badge {
+ color: #ffe195;
+ border-color: rgba(255, 211, 104, 0.28);
+ animation: destroyDykeDecisionPulse 1.3s ease-in-out infinite;
+}
+
+.destroy-dyke-status.is-disaster {
+ border-color: rgba(239, 127, 82, 0.48);
+ background:
+ linear-gradient(120deg, rgba(64, 61, 38, 0.84), rgba(32, 28, 23, 0.93)),
+ rgba(2, 28, 23, 0.8);
+ box-shadow:
+ inset 0 0 22px rgba(233, 112, 71, 0.075),
+ 0 4px 14px rgba(0, 0, 0, 0.16);
+}
+
+.destroy-dyke-status.is-disaster .destroy-dyke-status-mark,
+.destroy-dyke-status.is-incident .destroy-dyke-status-mark {
+ color: #ffba78;
+ border-color: rgba(255, 160, 98, 0.54);
+ background: rgba(239, 107, 61, 0.12);
+}
+
+.destroy-dyke-status.is-disaster .destroy-dyke-status-badge {
+ color: #ffc48e;
+ border-color: rgba(255, 157, 96, 0.28);
+}
+
+.destroy-dyke-status.is-incident {
+ border-color: rgba(255, 113, 78, 0.62);
+ background: linear-gradient(120deg, rgba(88, 52, 34, 0.88), rgba(35, 27, 22, 0.95));
+}
+
+.destroy-dyke-status.is-incident .destroy-dyke-status-value {
+ color: #ffb072;
+}
+
+.destroy-dyke-status.is-expired,
+.destroy-dyke-status.is-spent {
+ filter: saturate(0.7);
+}
+
+@keyframes destroyDykeDecisionPulse {
+ 0%, 100% { opacity: 0.65; }
+ 50% { opacity: 1; }
+}
+
+.hidden-dragon-status .gentleman-promise-status-grid > div {
+ grid-template-columns: minmax(0, 1fr) 24px 38px;
+}
+
+.hidden-dragon-status .gentleman-promise-status-grid small {
+ color: rgba(255, 255, 255, 0.52);
+ font-size: 9px;
+ text-align: right;
+ white-space: nowrap;
+}
+
+.hidden-dragon-status .gentleman-promise-status-grid small.not-played {
+ color: #8ff0aa;
+ font-weight: 800;
+}
+
+.hidden-dragon-status .gentleman-promise-status-grid small.has-played {
+ color: #ffae9f;
+ font-weight: 800;
+}
+
+.hidden-dragon-status .gentleman-promise-status-grid small.score-success {
+ color: #8ff0aa;
+ font-weight: 900;
+}
+
+.hidden-dragon-status .gentleman-promise-status-grid small.score-zero {
+ color: rgba(255, 255, 255, 0.58);
+ font-weight: 800;
+}
+
+.administrative-review-status-grid {
+ display: grid;
+ gap: 5px;
+}
+
+.administrative-review-status-grid > div {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) 28px 48px;
+ align-items: center;
+ gap: 4px;
+ padding: 5px 7px;
+ border: 1px solid rgba(255, 255, 255, 0.13);
+ border-radius: 7px;
+ background: rgba(255, 255, 255, 0.055);
+}
+
+.administrative-review-status-grid span {
+ overflow: hidden;
+ color: rgba(255, 255, 255, 0.76);
+ font-size: 10px;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.administrative-review-status-grid strong {
+ color: #ffd84a;
+ font-size: 14px;
+ text-align: center;
+}
+
+.administrative-review-status-grid small {
+ color: #ffae9f;
+ font-size: 9px;
+ text-align: right;
+ white-space: nowrap;
+}
+
+.administrative-review-status-grid small.is-matched {
+ color: #8ff0aa;
+ font-weight: 800;
+}
+
+.administrative-review-bottom-state {
+ margin-top: 6px;
+ color: rgba(255, 255, 255, 0.7);
+ font-size: 10px;
+ text-align: center;
+}
+
+.political-review-status-grid {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 5px;
+}
+
+.political-review-status-grid > div {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 5px;
+ padding: 5px 7px;
+ border: 1px solid rgba(255, 255, 255, 0.13);
+ border-radius: 7px;
+ background: rgba(255, 255, 255, 0.055);
+}
+
+.political-review-status-grid span {
+ overflow: hidden;
+ color: rgba(255, 255, 255, 0.78);
+ font-size: 10px;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.political-review-status-grid small {
+ font-size: 9px;
+ white-space: nowrap;
+}
+
+.political-review-status-grid small.is-available {
+ color: #8ff0aa;
+ font-weight: 800;
+}
+
+.political-review-status-grid small.is-used {
+ color: rgba(255, 255, 255, 0.5);
+}
+
+.political-review-pending {
+ margin-top: 6px;
+ padding: 6px 7px;
+ border-radius: 7px;
+ background: rgba(255, 216, 74, 0.12);
+ color: #ffe58f;
+ font-size: 10px;
+ line-height: 1.45;
+}
+
+.repeated-exhaustion-status {
+ flex-wrap: wrap;
+ width: min(100%, 280px);
+ border-radius: 12px;
+ line-height: 1.35;
+ white-space: normal;
+}
+
+.repeated-exhaustion-status .rule-runtime-status-icon {
+ display: inline-grid;
+ width: 25px;
+ height: 25px;
+ place-items: center;
+ border-radius: 50%;
+ color: #5b3600;
+ background: #ffd84a;
+ font-size: 13px;
+ font-weight: 900;
+}
+
+.repeated-exhaustion-status strong {
+ font-size: 13px;
+}
+
+.three-powers-grid {
+ display: grid;
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ gap: 5px;
+}
+
+.three-powers-slot {
+ display: grid;
+ grid-template-columns: auto 1fr;
+ grid-template-rows: 24px 13px;
+ align-items: center;
+ min-width: 0;
+ padding: 3px 4px;
+ border: 1px solid rgba(255, 255, 255, 0.14);
+ border-radius: 7px;
+ background: rgba(255, 255, 255, 0.055);
+}
+
+.three-powers-slot > span {
+ color: rgba(255, 246, 204, 0.78);
+ font-size: 11px;
+ font-weight: 800;
+}
+
+.three-powers-slot > strong {
+ color: rgba(255, 247, 208, 0.68);
+ font-size: 17px;
+ line-height: 1;
+ text-align: right;
+}
+
+.three-powers-slot > small {
+ grid-column: 1 / -1;
+ overflow: hidden;
+ color: rgba(255, 255, 255, 0.55);
+ font-size: 9px;
+ line-height: 13px;
+ text-align: center;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.three-powers-slot.is-private {
+ border-color: rgba(255, 216, 70, 0.72);
+ background: rgba(114, 78, 9, 0.46);
+}
+
+.three-powers-slot.is-private > strong,
+.three-powers-slot.is-revealed > strong {
+ color: #ffd84a;
+}
+
+.three-powers-slot.is-revealed {
+ border-color: rgba(255, 216, 70, 0.82);
+ background: linear-gradient(180deg, rgba(121, 83, 11, 0.62), rgba(55, 38, 7, 0.62));
+ animation: ambushStatusReveal 700ms ease-out both;
+}
+
+.ten-sided-ambush-bottom-summary {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+}
+
+@keyframes ambushStatusReveal {
+ 0% { opacity: 0; transform: scale(0.72); }
+ 65% { opacity: 1; transform: scale(1.08); }
+ 100% { transform: scale(1); }
+}
+
+.game-table ::-webkit-scrollbar { height: 6px; }
+.game-table ::-webkit-scrollbar-track { background: rgba(0, 0, 0, 0.26); border-radius: 3px; }
+.game-table ::-webkit-scrollbar-thumb { background: rgba(248, 214, 101, 0.72); border-radius: 3px; }
+
+@media (max-width: 1180px) {
+ .game-table { padding-inline: 14px; }
+ .position-middle { grid-template-columns: minmax(104px, 0.9fr) minmax(220px, 1.8fr) minmax(104px, 0.9fr); gap: 8px; }
+ .position-left, .position-right { gap: 10px; }
+ .player-left, .player-right { width: 142px; }
+ .score-panel { width: 184px !important; transform: scale(0.88); transform-origin: top left; }
+ .position-left .played-cards-area,
+ .position-right .played-cards-area { width: clamp(160px, 18vw, 210px); }
+ .open-hand-side { width: 146px; flex-basis: 146px; }
+ .position-left.has-open-hand .played-cards-area,
+ .position-right.has-open-hand .played-cards-area { width: 138px; }
+ .open-hand-opposite { width: min(430px, 48vw); max-width: none; }
+}
+
+@media (max-width: 780px) {
+ .game-table { padding: 10px 8px; border-radius: 18px; }
+ .game-table::after { font-size: 34px; }
+ .position-top { flex-basis: 105px; }
+ .position-middle { grid-template-columns: 120px minmax(150px, 1fr) 120px; }
+ .player-left, .player-right { width: 120px; padding: 5px; font-size: 11px; }
+ .player-avatar { width: 27px; height: 27px; flex-basis: 27px; font-size: 10px; }
+ .played-cards-area { min-width: 82px; width: 116px; height: 95px; }
+ .score-panel { display: none; }
+ .open-hand-side { position: absolute; width: 142px; }
+ .position-left .open-hand-side { left: 78px; }
+ .position-right .open-hand-side { right: 78px; }
+ .open-hand-opposite { width: min(390px, 72vw); }
+ .open-hand-title { max-width: 120px; }
+ .bottom-player-header { flex-direction: column; align-items: stretch; }
+ .inline-controls { width: 100%; margin-left: 0; max-width: 100%; }
+ .my-hand { padding-inline: 4px; }
+
+ .declaration-card-slot .card.small {
+ flex-basis: 26px;
+ width: 26px;
+ height: 40px;
+ }
+
+ .declaration-card-pair { gap: 0; }
+ .declaration-card-pair > .card + .card { margin-left: -5px; }
+ .declaration-card-slot .card.small .suit-symbol { font-size: 19px; }
+ .declaration-sidecar .declaration-card-slot.is-trump { left: 6px; }
+ .declaration-sidecar .declaration-card-slot.is-inferior { right: 6px; }
+}
+
+@media (max-height: 760px) {
+ .game-table {
+ grid-template-rows: 220px minmax(130px, 1fr) 270px;
+ padding-top: 9px;
+ padding-bottom: 7px;
+ }
+ .position-middle { min-height: 120px; }
+ .player-area { padding-block: 5px; }
+ .score-panel { transform: scale(0.72); transform-origin: top left; }
+ .score-panel > .score-cards-section { display: none; }
+ .my-hand { min-height: 122px; }
+ .my-hand .hand { min-height: 120px; transform: scale(0.9); transform-origin: center bottom; }
+
+ .game-table.settlement-view {
+ grid-template-rows: 0 minmax(0, 1fr) 72px;
+ }
+
+ .settlement-view .position-top {
+ display: none;
+ }
+
+ .settlement-view .position-bottom {
+ padding-bottom: 4px;
+ }
+
+ .center-content.settlement-panel {
+ padding: 7px 11px 9px;
+ }
+
+ .settlement-content {
+ gap: 5px;
+ }
+
+ .settlement-bottom-cards .hand {
+ min-height: 96px;
+ padding: 2px;
+ }
+}
+.wooden-ox-card-tray {
+ position: relative;
+ z-index: 4;
+ flex: 0 0 auto;
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ padding: 8px 12px;
+ border: 1px solid rgba(201, 162, 39, 0.72);
+ border-radius: 10px;
+ background: rgba(32, 54, 39, 0.94);
+ box-shadow: 0 5px 16px rgba(0, 0, 0, 0.3);
+}
+
+.wooden-ox-card-label {
+ width: 76px;
+ color: #f6dc87;
+ font-size: 12px;
+ font-weight: 700;
+ line-height: 1.35;
+ text-align: center;
+}
+
+@media (max-width: 780px) {
+ .wooden-ox-card-tray {
+ flex-direction: column;
+ gap: 4px;
+ padding: 6px 8px;
+ }
+
+ .wooden-ox-card-label {
+ width: 78px;
+ font-size: 10px;
+ }
+}
+
+.wooden-ox-rule-status {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 6px;
+ margin: 8px 0;
+}
+
+.wooden-ox-rule-status > div {
+ display: grid;
+ grid-template-columns: 1fr auto;
+ gap: 2px 8px;
+ padding: 6px 8px;
+ border: 1px solid rgba(246, 220, 135, 0.45);
+ border-radius: 7px;
+ background: rgba(0, 0, 0, 0.2);
+ color: #fff;
+ font-size: 12px;
+}
+
+.wooden-ox-rule-status strong,
+.wooden-ox-rule-status small:last-child {
+ text-align: right;
+}
+.double-happiness-selected-rules {
+ display: flex;
+ justify-content: center;
+ gap: 6px;
+ flex-wrap: wrap;
+ margin: 0 0 8px;
+}
+
+.double-happiness-selected-rules span {
+ padding: 3px 9px;
+ border: 1px solid rgba(255, 215, 0, 0.55);
+ border-radius: 999px;
+ color: #ffe58f;
+ background: rgba(255, 215, 0, 0.09);
+ font-size: 13px;
+ font-weight: 700;
+}
+
+/* 手机横屏:保持手牌为完整牌高,将非操作信息压成顶部仪表条。 */
+@media (orientation: landscape) and (max-height: 620px) {
+ .game-table {
+ grid-template-rows:
+ clamp(60px, 18%, 88px)
+ minmax(72px, 1fr)
+ clamp(184px, 52%, 220px);
+ padding: 4px 6px 3px;
+ border-radius: 14px;
+ }
+
+ .game-table::before {
+ inset: 5px;
+ border-radius: 10px;
+ }
+
+ .game-table::after {
+ top: 44%;
+ font-size: clamp(24px, 5vw, 38px);
+ }
+
+ .score-panel {
+ display: block !important;
+ top: 5px !important;
+ left: 6px !important;
+ width: 126px !important;
+ height: 62px !important;
+ padding: 4px 7px !important;
+ overflow: hidden;
+ border-radius: 10px !important;
+ transform: none;
+ }
+
+ .score-panel > div:first-child {
+ margin-bottom: 2px !important;
+ padding-bottom: 2px !important;
+ }
+
+ .score-panel > div:first-child .ant-typography {
+ font-size: 8px !important;
+ line-height: 1.1;
+ }
+
+ .score-panel > div:first-child .ant-typography-strong {
+ font-size: 12px !important;
+ }
+
+ .score-panel > div:nth-child(2) {
+ display: none !important;
+ }
+
+ .score-panel > div:nth-child(3) {
+ position: absolute;
+ left: 7px;
+ bottom: 4px;
+ width: 42px;
+ margin: 0 !important;
+ padding: 0 !important;
+ font-size: 15px !important;
+ line-height: 1.15;
+ text-align: left !important;
+ }
+
+ .score-panel > .score-cards-section,
+ .ten-sided-ambush-score-counter {
+ display: none !important;
+ }
+
+ .mobile-score-panel-trigger {
+ position: absolute;
+ inset: 0;
+ z-index: 4;
+ display: block;
+ width: 100%;
+ height: 100%;
+ padding: 0;
+ border: 0;
+ border-radius: inherit;
+ color: rgba(255, 245, 193, 0.82);
+ background: transparent;
+ cursor: pointer;
+ -webkit-tap-highlight-color: transparent;
+ }
+
+ .mobile-score-panel-trigger span {
+ position: absolute;
+ right: 4px;
+ bottom: 4px;
+ padding: 2px 4px;
+ border: 1px solid rgba(255, 224, 117, 0.28);
+ border-radius: 999px;
+ background: rgba(0, 31, 24, 0.5);
+ font-size: 7px;
+ font-weight: 800;
+ line-height: 1;
+ white-space: nowrap;
+ }
+
+ .mobile-score-panel-trigger:focus-visible {
+ outline: 2px solid #ffe36d;
+ outline-offset: -2px;
+ }
+
+ .position-top {
+ gap: 0;
+ padding-top: 1px;
+ overflow: visible;
+ }
+
+ .player-top {
+ width: clamp(144px, 23vw, 190px);
+ height: 52px;
+ min-height: 52px;
+ padding: 3px 8px;
+ border-radius: 12px;
+ }
+
+ .player-top > .player-info {
+ min-height: 44px;
+ }
+
+ .position-top > .played-cards-area {
+ position: absolute;
+ left: 50%;
+ top: 47px;
+ z-index: 9;
+ width: 150px;
+ height: 76px;
+ transform: translateX(-50%);
+ }
+
+ .position-middle {
+ grid-template-columns: minmax(170px, 1fr) minmax(64px, 0.32fr) minmax(170px, 1fr);
+ gap: 4px;
+ min-height: 72px;
+ }
+
+ .position-left,
+ .position-right {
+ gap: 5px;
+ }
+
+ .player-left,
+ .player-right {
+ width: 96px;
+ height: 68px;
+ min-height: 68px;
+ padding: 3px 5px;
+ border-radius: 12px;
+ font-size: 10px;
+ }
+
+ .player-left > .player-info,
+ .player-right > .player-info {
+ min-height: 60px;
+ }
+
+ .player-avatar {
+ width: 28px;
+ height: 28px;
+ flex-basis: 28px;
+ border-radius: 9px;
+ font-size: 10px;
+ }
+
+ .player-left .player-name,
+ .player-right .player-name {
+ max-width: 5em;
+ }
+
+ .player-hand-count {
+ right: 2px;
+ min-width: 31px;
+ height: 16px;
+ padding: 0 5px;
+ font-size: 9px;
+ }
+
+ .played-cards-area,
+ .position-left .played-cards-area,
+ .position-right .played-cards-area,
+ .position-bottom .played-cards-area {
+ width: 120px;
+ height: 76px;
+ }
+
+ .played-cards-area .hand {
+ width: 152%;
+ max-width: none;
+ min-height: 112px;
+ height: 112px;
+ flex: 0 0 152%;
+ transform: scale(0.66);
+ transform-origin: center center;
+ }
+
+ .position-bottom {
+ padding: 0 3px 2px;
+ }
+
+ .player-bottom {
+ width: 100%;
+ padding: 3px 5px 2px;
+ border-radius: 14px 14px 6px 6px;
+ }
+
+ .player-bottom > .played-cards-area {
+ bottom: calc(100% + 2px);
+ }
+
+ .bottom-player-header {
+ min-height: 30px;
+ flex-direction: row;
+ align-items: center;
+ gap: 4px;
+ margin-bottom: 0;
+ }
+
+ .bottom-player-header .player-info {
+ min-width: 104px;
+ flex: 0 1 auto;
+ }
+
+ .bottom-player-header .player-info > div {
+ gap: 4px !important;
+ }
+
+ .bottom-player-header .player-avatar-self {
+ width: 27px;
+ height: 27px;
+ flex-basis: 27px;
+ }
+
+ .player-name-self {
+ max-width: 8em;
+ font-size: 11px;
+ }
+
+ .inline-controls {
+ width: auto;
+ max-width: calc(100% - 108px);
+ min-height: 30px;
+ flex-direction: row;
+ gap: 3px;
+ padding: 2px 3px;
+ border-radius: 9px;
+ }
+
+ .inline-controls > .play-controls {
+ gap: 4px !important;
+ }
+
+ .inline-controls > .play-controls .ant-btn {
+ width: auto !important;
+ min-width: 48px;
+ flex: 0 0 auto;
+ }
+
+ .inline-controls > .play-controls .active-skill-button.ant-btn {
+ min-width: 68px;
+ }
+
+ .inline-controls .ant-btn,
+ .inline-controls .room-action-stack .ant-btn {
+ height: 27px;
+ padding: 0 7px;
+ font-size: 10px;
+ }
+
+ .room-action-stack,
+ .room-action-stack.has-surrender {
+ width: 118px;
+ height: 27px;
+ grid-template-columns: 42px 73px;
+ }
+
+ .my-hand-container {
+ min-height: 114px;
+ gap: 4px;
+ }
+
+ .my-hand {
+ height: 114px;
+ min-height: 114px;
+ padding: 0 3px;
+ overflow: visible;
+ border-radius: 10px 10px 5px 5px;
+ }
+
+ .my-hand .hand {
+ height: 112px;
+ min-height: 112px;
+ padding: 0 3px 2px;
+ transform: none;
+ }
+
+ .bottom-declaration-dock {
+ width: 46px;
+ min-width: 46px;
+ flex-basis: 46px;
+ padding: 2px 1px 5px;
+ }
+
+ .bottom-declaration-dock.is-trump { padding-left: 3px; }
+ .bottom-declaration-dock.is-inferior { padding-right: 3px; }
+
+ .center-content.table-tools {
+ top: 5px;
+ right: 6px;
+ width: clamp(188px, 31vw, 248px);
+ max-width: 32vw;
+ max-height: clamp(104px, 27vh, 132px);
+ padding: 4px 7px;
+ border-radius: 10px;
+ overscroll-behavior: contain;
+ scrollbar-width: thin;
+ }
+
+ .table-status-stack {
+ gap: 3px !important;
+ }
+
+ .table-trump-summary {
+ gap: 3px !important;
+ line-height: 1;
+ }
+
+ .table-trump-summary > .ant-typography {
+ font-size: 12px !important;
+ }
+
+ .table-rule-summary {
+ min-width: 0 !important;
+ max-width: 100% !important;
+ gap: 2px !important;
+ padding: 3px 6px !important;
+ }
+
+ .table-rule-summary > div:first-child {
+ display: none !important;
+ }
+
+ .table-rule-title {
+ margin-bottom: 0 !important;
+ overflow: hidden;
+ font-size: 12px !important;
+ line-height: 1.25 !important;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ }
+
+ .table-rule-body {
+ min-height: 0;
+ }
+
+ .table-rule-description {
+ display: block !important;
+ max-height: 43px;
+ margin-top: 2px;
+ padding: 2px 4px 1px;
+ overflow-x: hidden;
+ overflow-y: auto;
+ color: rgba(255, 255, 255, 0.88) !important;
+ font-size: 9px !important;
+ line-height: 1.35 !important;
+ text-align: left;
+ white-space: normal;
+ overscroll-behavior: contain;
+ scrollbar-width: thin;
+ scrollbar-color: rgba(255, 222, 104, 0.72) rgba(0, 31, 24, 0.3);
+ }
+
+ .dealer-countdown-panel {
+ min-width: 0;
+ flex-direction: row !important;
+ gap: 3px !important;
+ padding: 2px 6px !important;
+ border-width: 1px !important;
+ border-radius: 7px !important;
+ }
+
+ .dealer-countdown-main {
+ gap: 4px !important;
+ }
+
+ .dealer-countdown-main > div {
+ flex-direction: row !important;
+ gap: 5px;
+ }
+
+ .dealer-countdown-main .ant-typography {
+ font-size: 9px !important;
+ white-space: nowrap;
+ }
+
+ .dealer-countdown-icon {
+ font-size: 14px !important;
+ }
+
+ .dealer-countdown-value {
+ font-size: 15px !important;
+ line-height: 1 !important;
+ }
+
+ .dealer-countdown-hint {
+ display: none !important;
+ }
+
+ .double-happiness-selected-rules {
+ gap: 2px;
+ margin: 1px 0 2px;
+ }
+
+ .double-happiness-selected-rules span {
+ padding: 1px 4px;
+ font-size: 8px;
+ }
+
+ .rule-runtime-status {
+ min-height: 18px;
+ margin: 1px auto 2px;
+ padding: 1px 5px;
+ font-size: 8px;
+ }
+
+ .rule-runtime-status strong,
+ .rule-runtime-status-icon {
+ font-size: 10px;
+ }
+
+ .round-points-indicator {
+ display: none;
+ }
+
+ .mobile-round-points-indicator {
+ position: absolute;
+ right: 42px;
+ bottom: 4px;
+ display: flex !important;
+ align-items: baseline;
+ gap: 2px;
+ margin: 0 !important;
+ padding: 2px 5px;
+ border: 1px solid rgba(255, 224, 117, 0.48);
+ border-radius: 999px;
+ color: rgba(255, 246, 200, 0.82);
+ background: rgba(69, 47, 8, 0.82);
+ font-size: 8px;
+ font-weight: 800;
+ line-height: 1;
+ }
+
+ .mobile-round-points-indicator strong {
+ color: #ffda61;
+ font-size: 12px;
+ font-variant-numeric: tabular-nums;
+ }
+
+ .score-panel:has(.mobile-round-points-indicator) > div:nth-child(3) {
+ width: 34px;
+ }
+
+ /*
+ * 横屏中央功能坞:公开底牌、第二战场和神兵只使用牌桌中部的短轨道。
+ * 主视角出牌临时让到右侧,避免与公共牌复用同一条中央轴线。
+ */
+ .game-table.has-center-table-feature .player-bottom > .played-cards-area {
+ left: 70%;
+ }
+
+ /* 完整甩牌失败牌面只停留一秒,应回到玩家正前方,不能继承中央功能区的右移轨道。 */
+ .game-table.has-center-table-feature .player-bottom > .played-cards-area.throw-failed-preview {
+ left: 50%;
+ }
+
+ .game-table.has-center-table-feature .position-left > .played-cards-area,
+ .game-table.has-center-table-feature .position-right > .played-cards-area {
+ transform: translateY(-12px);
+ }
+
+ .public-bottom-tray,
+ .divine-weapon-tray {
+ animation: none;
+ transform-origin: center;
+ }
+
+ .public-bottom-tray {
+ top: 35%;
+ width: min(410px, 58vw);
+ transform: translate(-50%, -50%) scale(0.6);
+ }
+
+ .second-battlefield-showdown {
+ top: 40%;
+ min-width: 210px;
+ gap: 2px;
+ padding: 6px 12px;
+ animation: none;
+ transform: translate(-50%, -50%) scale(0.78);
+ }
+
+ .divine-weapon-tray {
+ top: 36%;
+ transform: translate(-50%, -50%) scale(0.68);
+ }
+
+ /* 对家明置牌在横屏中拥有独立轨道,出牌轨道排列在它的下方。 */
+ .game-table.has-top-open-hand {
+ grid-template-rows: 112px minmax(58px, 1fr) clamp(184px, 49%, 212px);
+ }
+
+ .position-top.has-open-hand > .open-hand-panel {
+ position: absolute;
+ left: 50%;
+ top: 56px;
+ width: min(330px, 42vw);
+ height: 58px;
+ max-width: none;
+ flex: none;
+ transform: translateX(-50%);
+ animation: none;
+ }
+
+ .position-top.has-open-hand .open-hand-header {
+ height: 19px;
+ padding: 2px 6px;
+ font-size: 9px;
+ }
+
+ .position-top.has-open-hand .open-hand-seal {
+ width: 14px;
+ height: 14px;
+ flex-basis: 14px;
+ font-size: 8px;
+ }
+
+ .position-top.has-open-hand .open-hand-opposite-cards {
+ height: 39px;
+ padding: 0 4px;
+ overflow: hidden;
+ }
+
+ .position-top.has-open-hand .open-hand-opposite-cards .hand {
+ --card-width: 28px;
+ --card-overlap: 18px;
+ width: 100%;
+ max-width: 100%;
+ height: 39px;
+ min-height: 39px;
+ padding: 0 2px;
+ transform: none;
+ }
+
+ .position-top.has-open-hand .open-hand-opposite-cards .hand > .card-wrapper {
+ width: 28px;
+ height: 39px;
+ margin-left: -18px;
+ }
+
+ .position-top.has-open-hand .open-hand-opposite-cards .hand > .card-wrapper:first-child {
+ margin-left: 0;
+ }
+
+ .position-top.has-open-hand .open-hand-opposite-cards .card {
+ transform: scale(0.56);
+ transform-origin: top left;
+ }
+
+ .position-top.has-open-hand .open-hand-opposite-cards .card:not(.disabled):hover,
+ .position-top.has-open-hand .open-hand-opposite-cards .card.selected {
+ transform: translateY(-5px) scale(0.56);
+ }
+
+ .position-top.has-open-hand > .played-cards-area {
+ top: 115px;
+ height: 66px;
+ }
+
+ .position-top.has-open-hand > .played-cards-area .hand {
+ width: 172%;
+ min-height: 104px;
+ height: 104px;
+ transform: scale(0.58);
+ }
+
+ /* 侧家明置牌脱离横向排版,压缩成玩家框上方的只读卡册。 */
+ .position-left.has-open-hand > .open-hand-side,
+ .position-right.has-open-hand > .open-hand-side {
+ position: absolute;
+ top: -99px;
+ width: 132px;
+ max-height: 112px;
+ overflow: hidden;
+ animation: none;
+ transform: scale(0.56);
+ }
+
+ .position-left.has-open-hand > .open-hand-side {
+ left: 190px;
+ transform-origin: left bottom;
+ }
+
+ .position-right.has-open-hand > .open-hand-side {
+ right: 190px;
+ transform-origin: right bottom;
+ }
+
+ .position-left.has-open-hand,
+ .position-right.has-open-hand {
+ gap: 5px;
+ }
+
+ .position-left.has-open-hand .played-cards-area,
+ .position-right.has-open-hand .played-cards-area {
+ width: 120px;
+ transform: translateY(30px);
+ }
+
+ /* 手机上的全局提示保持为一行小胶囊,不再遮住对家出牌。 */
+ .ant-message {
+ top: 2px !important;
+ display: flex;
+ align-items: flex-start;
+ justify-content: center;
+ gap: 3px;
+ }
+
+ .ant-message > .ant-message-notice-wrapper {
+ display: none;
+ flex: 0 1 min(32vw, 240px);
+ margin: 0 !important;
+ }
+
+ .ant-message > .ant-message-notice-wrapper:nth-last-child(-n + 2) {
+ display: block;
+ }
+
+ .ant-message .ant-message-notice {
+ padding: 1px 0 !important;
+ }
+
+ .ant-message .ant-message-notice-content {
+ max-width: min(32vw, 240px);
+ padding: 3px 7px !important;
+ overflow: hidden;
+ font-size: 10px;
+ line-height: 1.2;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ }
+
+ .turn-indicator {
+ height: 20px;
+ min-width: 52px;
+ padding: 0 7px;
+ font-size: 10px;
+ }
+
+ .player-left > .turn-indicator,
+ .player-right > .turn-indicator {
+ width: 22px;
+ min-width: 22px;
+ min-height: 52px;
+ padding: 4px 3px;
+ }
}
diff --git a/tractor-game-simulator/client/src/components/Game/GameTable.jsx b/tractor-game-simulator/client/src/components/Game/GameTable.jsx
index 318c47b..480f930 100644
--- a/tractor-game-simulator/client/src/components/Game/GameTable.jsx
+++ b/tractor-game-simulator/client/src/components/Game/GameTable.jsx
@@ -1,33 +1,81 @@
-import { Typography, Button } from 'antd';
+import { Typography, Button } from 'antd';
+import { useState } from 'react';
+import { Modal } from 'antd';
import Hand from './Hand';
+import Card from './Card';
+import OpenHandPanel from './OpenHandPanel';
+import RecordOnFileTracker from './RecordOnFileTracker';
+import SurrenderShowdown from './SurrenderShowdown';
import { sortCards } from '../../utils/cardUtils';
+import {
+ calculateCardPoints,
+ getScoringDisplayCard,
+ getOddEvenRoundMultiplier,
+ getCandleToDawnCardPoints,
+ getDisplayedCandleState,
+ getMeticulousAccountingCardPoints,
+ getThreePowersCardPoints
+} from '../../utils/scoringUtils';
+import {
+ formatLevel,
+ getDestroyDykeDisplayState,
+ getDisplayedDefenseAsOffense,
+ getRecordOnFileTrackerView,
+ getStriveUpstreamActionOrder
+} from '../../utils/gameViewUtils';
+import { ruleIncludesId } from '../../utils/ruleCatalog';
+import { getRuleTableContent } from '../../utils/ruleDisplayContent';
import './GameTable.css';
const { Text } = Typography;
+const ORIGINAL_JOKER_LABELS = Object.freeze({
+ small_joker: '小王',
+ big_joker: '大王',
+ county_prince_joker: '郡王',
+ prince_joker: '亲王',
+ white_joker: '白王'
+});
+
+const getJokerSubstitutionSourceLabel = (played, substitution) => {
+ const transformedCard = (played?.cards || []).find(card => card.id === substitution.cardId);
+ const originalRank = substitution.fromRank || transformedCard?.originalRank;
+ return ORIGINAL_JOKER_LABELS[originalRank] || '王';
+};
+
/**
* 游戏桌面组件 - 4人位置布局
* @param {Object} props
* @param {Array} props.players - 所有玩家
* @param {Object} props.currentPlayer - 当前玩家
+ * @param {Function} props.onReturnToRoom - 返回组房界面的回调,不退出房间
+ * @param {Function} props.onRequestSurrender - 发起投降的回调
* @param {Object} props.playedCards - 每个玩家出的牌 { [playerId]: { playerName, cards } }
+ * @param {Object} props.throwFailedPreviews - 甩牌失败时短暂停留的完整尝试牌面
* @param {Object} props.shownCards - 摸牌阶段展示的牌 { [playerId]: { playerName, cards } }
* @param {Array} props.myCards - 我的手牌
* @param {Array} props.selectedCards - 选中的牌
+ * @param {Array} props.disabledCardIds - 因规则不可选择的手牌ID
+ * @param {String} props.disabledCardReason - 规则禁用牌的提示
+ * @param {Array} props.virtualizedCardIds - “虚虚实实”本次视为不存在的手牌ID
* @param {Function} props.onCardClick - 点击牌的回调
* @param {Function} props.onReorder - 重新排序手牌的回调
* @param {String} props.currentTurnPlayerId - 当前轮到谁出牌
+ * @param {Object} props.trumpAnimation - 毙牌/盖毙的局部打击动画
* @param {String} props.trumpSuit - 主牌花色
* @param {String} props.trumpRank - 主牌点数
- * @param {Boolean} props.isHost - 是否是房主
- * @param {Function} props.onSetTrump - 设置主牌的回调
+ * @param {Array} props.publicBottomCards - “昭然若揭”整局明置的当前底牌
* @param {Array} props.revealedBottomCards - 揭示的底牌
* @param {Object} props.selectedRule - 选中的规则 { name, content }
+ * @param {Number} props.displayRoundNumber - 当前牌面对应的轮次;轮末停留时锁定为刚结束的轮次
+ * @param {Object} props.heldRoundCandle - 轮末停留期间冻结的本轮烛态
* @param {Function} props.onSelectRule - 选择规则的回调
* @param {ReactNode} props.renderControls - 渲染控制区域的函数或组件
* @param {Boolean} props.isWaitingForReady - 是否在等待玩家准备阶段
+ * @param {Boolean} props.isWaitingForNextGame - 是否在终局等待下一局准备
* @param {ReactNode} props.trumpDeclarationComponent - 亮主条组件
* @param {Object} props.currentTrumpDeclaration - 当前亮主信息
+ * @param {Object} props.currentInferiorDeclaration - “三六九等”当前亮劣信息
* @param {Number} props.attackerScore - 闲家当前得分
* @param {Array} props.collectedPointCards - 闲家收集的分数牌
* @param {Object} props.bottomScoreResult - 底牌得分结果
@@ -35,29 +83,62 @@ const { Text } = Typography;
* @param {Number} props.team1Level - 队伍1等级
* @param {Number} props.team2Level - 队伍2等级
* @param {Number} props.dealerPlayerIndex - 庄家玩家索引
+ * @param {Object} props.cardExchange - 摸牌后的公开换牌进度
+ * @param {Object} props.mainstay - “中流砥柱”当前依次处理进度
+ * @param {Object} props.cardExchangeAnimation - 换牌路径动画
+ * @param {Object} props.privateCardTransferReveal - 仅接收者可见的换入牌面
+ * @param {Object} props.threePowers - 三权分立三个重载分牌槽的可见状态
*/
export default function GameTable({
players,
currentPlayer,
+ onReturnToRoom,
+ onRequestSurrender,
+ canRequestSurrender = false,
+ hasRequestedSurrender = false,
playedCards = {},
+ throwFailedPreviews = {},
shownCards = {},
myCards = [],
+ woodenOxCard = null,
selectedCards = [],
+ disabledCardIds = [],
+ disabledCardReason = '',
+ virtualizedCardIds = [],
+ transformableCardIds = [],
onCardClick,
+ onRequestCardTransformation,
+ onCancelCardTransformation,
onReorder,
currentTurnPlayerId,
+ currentWinningPlayerId = null,
+ trumpAnimation = null,
trumpSuit = null,
trumpRank = null,
- isHost = false,
- onSetTrump,
+ publicBottomCards = null,
revealedBottomCards = null,
selectedRule = null,
+ ruleRuntimeStatus = null,
+ displayRoundNumber = null,
+ heldRoundCandle = null,
+ ownFocusFigurePlayerId = null,
+ tenSidedAmbush = null,
+ threePowers = null,
+ divineWeapon = null,
+ selectedDivineWeaponCardId = null,
+ onDivineWeaponCardClick,
+ canSelectDivineWeapon = false,
onSelectRule,
+ ruleChooserPlayerId = null,
+ isRuleSelectionPending = false,
renderControls = null,
isWaitingForReady = false,
+ isWaitingForNextGame = false,
trumpDeclarationComponent = null,
currentTrumpDeclaration = null,
+ currentInferiorDeclaration = null,
buryingPlayerId = null,
+ secondaryBuryingPlayerId = null,
dealerCountdown = null,
attackerScore = 0,
collectedPointCards = [],
@@ -65,8 +146,34 @@ export default function GameTable({
upgradeResult = null,
team1Level = 2,
team2Level = 2,
- dealerPlayerIndex = null
+ dealerPlayerIndex = null,
+ cardExchange = null,
+ mainstay = null,
+ cardExchangeAnimation = null,
+ privateCardTransferReveal = null,
+ highlightedCardIds = [],
+ highlightedCardLabel = '',
+ highlightedCardTone = 'arrival',
+ openHand = null,
+ ruleVisibleHands = [],
+ playerTargeting = null,
+ onPlayerTargetClick,
+ openHandSelectedCards = [],
+ onOpenHandCardClick,
+ canControlOpenHand = false,
+ disableMyHand = false
}) {
+ const [isScoreDetailsOpen, setIsScoreDetailsOpen] = useState(false);
+ const ruleChooser = players.find(player => player.id === ruleChooserPlayerId);
+ const mainstayAction = mainstay?.currentAction || null;
+ const mainstayActor = players.find(player => player.id === mainstayAction?.actorPlayerId);
+ const mainstayChooser = players.find(player => player.id === mainstayAction?.chooserPlayerId);
+ const ruleVisibleHandsByPlayerId = new Map(
+ ruleVisibleHands.map(hand => [hand.playerId, hand])
+ );
+ const hasRevealedHand = player => Boolean(
+ player && (openHand?.playerId === player.id || ruleVisibleHandsByPlayerId.has(player.id))
+ );
// 根据玩家数量和当前玩家位置,计算每个位置显示哪个玩家
const getPlayerPositions = () => {
if (!currentPlayer || !players || players.length === 0) {
@@ -96,6 +203,174 @@ export default function GameTable({
};
const positions = getPlayerPositions();
+ const renderTurnIndicator = isSelf => (
+
+
+ {isSelf ? '轮到你' : '出牌中'}
+
+ );
+ const renderStriveUpstreamOrderBadge = player => {
+ const actionOrder = getStriveUpstreamActionOrder(players, ruleRuntimeStatus, player?.id);
+ if (!actionOrder) return null;
+ return (
+
+ {actionOrder}
+
+ );
+ };
+ const isSettlementView = Boolean(
+ revealedBottomCards?.length && (bottomScoreResult || upgradeResult)
+ );
+ const surrenderRevealedHands = bottomScoreResult?.surrender?.revealedHands || [];
+ const hasSurrenderShowdown = surrenderRevealedHands.length > 0;
+ const isPublicBottomVisible = Boolean(
+ ruleIncludesId(selectedRule, 'openly_revealed')
+ && publicBottomCards?.length
+ && !isSettlementView
+ );
+ const isFocusFigureRule = ruleIncludesId(selectedRule, 'focus_figure');
+ const isLostInFogScoringHidden = ruleIncludesId(selectedRule, 'lost_in_fog') && !isSettlementView;
+ const focusFigure = ruleRuntimeStatus?.focusFigure || null;
+ const knownFocusPlayerIds = new Set(
+ focusFigure?.isRevealed
+ ? (focusFigure.teams || []).map(team => team.focusPlayerId).filter(Boolean)
+ : [ownFocusFigurePlayerId].filter(Boolean)
+ );
+ const focusCapturedPointsByPlayerId = focusFigure?.capturedPointsByPlayerId || {};
+ const dreamKillingSleepingPlayerIds = new Set(
+ ruleRuntimeStatus?.dreamKilling?.sleepingPlayerIds || []
+ );
+ const showFocusFigureProgress = isFocusFigureRule && Boolean(focusFigure);
+ const secondBattlefield = ruleRuntimeStatus?.secondBattlefield || null;
+ const oneCountryTwoSystems = ruleIncludesId(selectedRule, 'one_country_two_systems')
+ ? ruleRuntimeStatus?.oneCountryTwoSystems || null
+ : null;
+ const oneCountryDeclarations = oneCountryTwoSystems?.declarationsByTeam || {};
+ const oneCountryResolution = oneCountryTwoSystems?.resolved || null;
+ const encircleThreeMissingOne = ruleIncludesId(selectedRule, 'encircle_three_missing_one')
+ ? ruleRuntimeStatus?.encircleThreeMissingOne || null
+ : null;
+ const isHoldingSecondBattlefieldResult = Boolean(
+ secondBattlefield?.lastResult?.triggerRound === displayRoundNumber
+ && displayRoundNumber !== ruleRuntimeStatus?.currentRound
+ );
+ const secondBattlefieldResultByPlayerId = Object.fromEntries(
+ (secondBattlefield?.lastResult?.players || []).map(player => [player.playerId, player])
+ );
+ const displayedSecondBattlefieldAccumulatedCards = isHoldingSecondBattlefieldResult
+ ? Object.fromEntries(
+ (secondBattlefield?.lastResult?.players || []).map(
+ player => [player.playerId, player.bestFive || player.accumulatedCards || []]
+ )
+ )
+ : secondBattlefield?.accumulatedCardsByPlayerId || {};
+ const ambushAttackerNetCardCount = Number.isFinite(tenSidedAmbush?.attackerNetCardCount)
+ ? tenSidedAmbush.attackerNetCardCount
+ : 0;
+ const currentRoundNumber = Math.max(
+ 1,
+ displayRoundNumber ?? ruleRuntimeStatus?.currentRound ?? 1
+ );
+ const showRecordOnFileWhiteJoker = ruleIncludesId(selectedRule, 'king_over_white');
+ const showRecordOnFileRoyalJokers = ruleIncludesId(selectedRule, 'eight_kings_council');
+ const recordOnFileTrackerView = getRecordOnFileTrackerView(
+ ruleRuntimeStatus?.recordOnFile,
+ currentRoundNumber,
+ {
+ showWhiteJoker: showRecordOnFileWhiteJoker,
+ showRoyalJokers: showRecordOnFileRoyalJokers
+ }
+ );
+ const candleToDawn = ruleRuntimeStatus?.candleToDawn || null;
+ const threeTigers = ruleRuntimeStatus?.threeTigers || null;
+ const inviteIntoUrn = ruleRuntimeStatus?.inviteIntoUrn || null;
+ const oldHorse = ruleRuntimeStatus?.oldHorse || null;
+ const trumpWins = ruleRuntimeStatus?.trumpWins || null;
+ const strawBoatBorrowingArrows = ruleRuntimeStatus?.strawBoatBorrowingArrows || null;
+ const bushGate = ruleRuntimeStatus?.bushGate || null;
+ const teammateCheer = ruleRuntimeStatus?.teammateCheer || null;
+ const teammateCheerBuffedPlayerIds = new Set(teammateCheer?.buffedPlayerIds || []);
+ const afterglowActivePlayerIds = new Set(
+ ruleRuntimeStatus?.afterglow?.activePlayerIds || []
+ );
+ const defenseAsOffense = ruleIncludesId(selectedRule, 'defense_as_offense')
+ ? getDisplayedDefenseAsOffense(ruleRuntimeStatus, currentRoundNumber)
+ : null;
+ const destroyDykeDisplayState = ruleIncludesId(selectedRule, 'destroy_dyke_flood_fields')
+ ? getDestroyDykeDisplayState(ruleRuntimeStatus?.destroyDyke)
+ : null;
+ const lureTigerSilencedPlayerIds = new Set(
+ ruleRuntimeStatus?.lureTiger?.silencedPlayerIds || []
+ );
+ const displayedThreeTigers = threeTigers?.currentRound?.round === currentRoundNumber
+ ? threeTigers.currentRound
+ : threeTigers?.lastRound?.round === currentRoundNumber
+ ? threeTigers.lastRound
+ : threeTigers?.currentRound || null;
+ const threeTigersProgress = Object.entries(displayedThreeTigers?.suitCounts || {})
+ .reduce((best, entry) => entry[1] > (best?.[1] || 0) ? entry : best, null);
+ const displayedInviteDeclarations = (inviteIntoUrn?.declarations || []).length > 0
+ ? inviteIntoUrn.declarations
+ : inviteIntoUrn?.lastResult?.round === currentRoundNumber
+ ? inviteIntoUrn.lastResult.declarations || []
+ : [];
+ // round_updated 与 room_updated 是两个连续事件。即使 React 尚未来得及写入
+ // heldCompletedRoundNumber,只要四家的末手仍完整留在桌上,就仍属于刚结束的那轮。
+ // 这样服务端的新烛态不会在清桌前闪现一帧。
+ const displayedCandleState = getDisplayedCandleState({
+ candleToDawn,
+ currentRound: ruleRuntimeStatus?.currentRound,
+ displayRoundNumber,
+ heldRoundCandle,
+ visiblePlayCount: Object.keys(playedCards).length,
+ playerCount: players.length
+ });
+ const displayedCandleLit = displayedCandleState.isLit;
+ const displayedCandleRoundNumber = displayedCandleState.round;
+ const currentRoundPointResolver = ruleIncludesId(selectedRule, 'three_powers')
+ ? card => getThreePowersCardPoints(card, threePowers)
+ : ruleIncludesId(selectedRule, 'meticulous_accounting')
+ ? getMeticulousAccountingCardPoints
+ : ruleIncludesId(selectedRule, 'candle_to_dawn')
+ ? card => getCandleToDawnCardPoints(card, displayedCandleLit)
+ : undefined;
+ const visibleRoundPlays = Object.values(playedCards);
+ const hasConcealedRoundPlay = visibleRoundPlays.some(play => play?.concealed);
+ const hasCompleteRevealedRound = players.length > 0
+ && visibleRoundPlays.length >= players.length
+ && !hasConcealedRoundPlay;
+ const shouldHideCurrentRoundPoints = ruleIncludesId(selectedRule, 'no_one_survives')
+ ? !hasCompleteRevealedRound
+ : hasConcealedRoundPlay;
+ const baseCurrentRoundPoints = visibleRoundPlays.reduce((total, play) => {
+ return total + calculateCardPoints(play?.cards || [], currentRoundPointResolver);
+ }, 0);
+ const oddEvenRoundMultiplier = getOddEvenRoundMultiplier(selectedRule, currentRoundNumber);
+ const currentRoundPoints = shouldHideCurrentRoundPoints
+ ? 0
+ : baseCurrentRoundPoints * oddEvenRoundMultiplier;
+ const positionByPlayerId = Object.fromEntries(
+ Object.entries(positions)
+ .filter(([, player]) => player?.id)
+ .map(([position, player]) => [player.id, position])
+ );
+ const exchangeCoordinates = {
+ top: { x: '50%', y: '11%' },
+ right: { x: '91%', y: '50%' },
+ bottom: { x: '50%', y: '91%' },
+ left: { x: '9%', y: '50%' }
+ };
+ const myExchangeTarget = cardExchange
+ ? players.find(player => player.id === cardExchange.targetByPlayerId?.[currentPlayer?.id])
+ : null;
// 获取花色符号
const getSuitSymbol = (suit) => {
@@ -110,7 +385,7 @@ export default function GameTable({
// 获取花色颜色
const getSuitColor = (suit) => {
- return (suit === 'hearts' || suit === 'diamonds') ? 'red' : 'black';
+ return (suit === 'hearts' || suit === 'diamonds') ? '#ff4d4f' : '#f5f5f5';
};
// 获取花色符号(扩展版)
@@ -120,12 +395,131 @@ export default function GameTable({
diamonds: '♦',
clubs: '♣',
spades: '♠',
+ trump: '主',
joker: '王',
no_trump: '无主'
};
return symbols[suit] || suit;
};
+ const getPublicCardLabel = card => {
+ if (!card) return '未知牌';
+ if (card.rank === 'small_joker') return '小王';
+ if (card.rank === 'big_joker') return '大王';
+ if (card.rank === 'county_prince_joker') return '郡王';
+ if (card.rank === 'prince_joker') return '亲王';
+ if (card.rank === 'white_joker') return '白王(皇)';
+ return `${getSuitSymbolExtended(card.suit)}${card.rank}`;
+ };
+
+ const waitingRabbit = ruleIncludesId(selectedRule, 'waiting_rabbit')
+ ? ruleRuntimeStatus?.waitingRabbit || null
+ : null;
+ const getWaitingRabbitRecordText = record => {
+ switch (record.type) {
+ case 'target_locked':
+ return record.playerId === currentPlayer?.id && waitingRabbit?.ownPrivateTarget
+ ? `${record.playerName} 已暗选 ${getPublicCardLabel(waitingRabbit.ownPrivateTarget)}`
+ : `${record.playerName} 已完成暗选`;
+ case 'target_triggered':
+ return `${record.sourcePlayerName} 打出 ${record.playerName} 的目标 ${getPublicCardLabel(record.targetCard)}`;
+ case 'target_exchanged':
+ return `${record.playerName} 用 ${getPublicCardLabel(record.discardedCard)} 换回 ${getPublicCardLabel(record.targetCard)}`;
+ case 'exchange_declined':
+ return `${record.playerName} 放弃换回 ${getPublicCardLabel(record.targetCard)}`;
+ default:
+ return `${record.playerName || '玩家'} 完成守株待兔操作`;
+ }
+ };
+
+ const getPlayerTeamIndex = player => {
+ const fixedTeamIndex = ruleRuntimeStatus?.happyTwins
+ ?.teamIndexByPlayerId?.[player?.id];
+ if (Number.isInteger(fixedTeamIndex)) return fixedTeamIndex;
+ const playerIndex = players.findIndex(candidate => candidate.id === player?.id);
+ return playerIndex >= 0 ? playerIndex % 2 : null;
+ };
+
+ const getPlayerTrumpSuit = player => {
+ if (!oneCountryTwoSystems) return trumpSuit;
+ if (oneCountryTwoSystems.hasJokerDeclaration || oneCountryResolution?.isNoTrump) {
+ return 'no_trump';
+ }
+ const teamIndex = getPlayerTeamIndex(player);
+ const resolvedSuit = oneCountryResolution?.teamTrumpSuits?.[teamIndex];
+ if (resolvedSuit) return resolvedSuit;
+
+ const declaredSuits = [...new Set(
+ Object.values(oneCountryDeclarations)
+ .map(declaration => declaration?.suit)
+ .filter(suit => suit && suit !== 'joker')
+ )];
+ if (declaredSuits.length === 1) return declaredSuits[0];
+ return oneCountryDeclarations?.[teamIndex]?.suit || trumpSuit;
+ };
+
+ const getPlayerTrumpDeclaration = player => {
+ if (!oneCountryTwoSystems) return currentTrumpDeclaration;
+ const teamIndex = getPlayerTeamIndex(player);
+ return oneCountryDeclarations?.[teamIndex] || null;
+ };
+ const getPlayerInferiorDeclaration = player => (
+ currentInferiorDeclaration?.playerId === player?.id
+ ? currentInferiorDeclaration
+ : null
+ );
+ const renderDeclarationCardSlot = ({
+ cards,
+ declarationRole,
+ player,
+ playerTrumpSuit
+ }) => {
+ if (!cards?.length) return null;
+ const isInferior = declarationRole === 'inferior';
+ return (
+
+
+ {cards.map(card => (
+
+ ))}
+
+
+ );
+ };
+
+ const oneCountryTrumpRows = (() => {
+ if (!oneCountryTwoSystems) return [];
+ const dealerTeamIndex = oneCountryResolution?.dealerTeamIndex
+ ?? (Number.isInteger(dealerPlayerIndex) ? dealerPlayerIndex % 2 : null);
+ return [0, 1].map(teamIndex => {
+ const declaration = oneCountryDeclarations?.[teamIndex];
+ const resolvedSuit = oneCountryResolution?.teamTrumpSuits?.[teamIndex];
+ const suit = oneCountryTwoSystems.hasJokerDeclaration || oneCountryResolution?.isNoTrump
+ ? 'no_trump'
+ : resolvedSuit || declaration?.suit || null;
+ const label = dealerTeamIndex === null
+ ? `队伍${teamIndex + 1}`
+ : teamIndex === dealerTeamIndex ? '庄家方' : '闲家方';
+ return {
+ teamIndex,
+ label,
+ suit,
+ playerName: declaration?.playerName || null
+ };
+ });
+ })();
+
// 渲染单个玩家区域
const renderPlayerArea = (player, position) => {
if (!player) return null;
@@ -135,7 +529,34 @@ export default function GameTable({
const shown = shownCards[player.id];
// 检查是否是当前亮主的玩家
- const hasDeclaredTrump = currentTrumpDeclaration && currentTrumpDeclaration.playerId === player.id;
+ const playerTrumpDeclaration = getPlayerTrumpDeclaration(player);
+ const playerTrumpSuit = getPlayerTrumpSuit(player);
+ const hasDeclaredTrump = playerTrumpDeclaration?.playerId === player.id;
+ const playerInferiorDeclaration = getPlayerInferiorDeclaration(player);
+ const hasInferiorDeclaration = Boolean(playerInferiorDeclaration?.cards?.length);
+ const hasAnyDeclaration = hasDeclaredTrump || hasInferiorDeclaration;
+ const isOpenHand = hasRevealedHand(player);
+ const isSecondaryBuryingPlayer = player.id === secondaryBuryingPlayerId;
+ const isKnownFocusFigure = knownFocusPlayerIds.has(player.id);
+ const isDreamKillingSleeping = dreamKillingSleepingPlayerIds.has(player.id);
+ const hasTeammateCheerBuff = teammateCheerBuffedPlayerIds.has(player.id);
+ const hasAfterglow = afterglowActivePlayerIds.has(player.id);
+ const defenseAsOffenseDelta = defenseAsOffense?.playerId === player.id
+ ? Number(defenseAsOffense.delta) || 0
+ : 0;
+ const isLureTigerSilenced = lureTigerSilencedPlayerIds.has(player.id);
+ const focusCapturedPoints = Number(focusCapturedPointsByPlayerId[player.id]) || 0;
+ const isSelectableTarget = Boolean(
+ playerTargeting?.active
+ && (playerTargeting?.allowSelf || player.id !== currentPlayer?.id)
+ && (playerTargeting?.requireCards === false || player.cardsCount > 0)
+ );
+ const isSelectedTarget = playerTargeting?.targetPlayerId === player.id
+ || playerTargeting?.selectedPlayerIds?.includes(player.id);
+ const shouldShowReadyState = isWaitingForReady || isWaitingForNextGame;
+ const isPlayerReady = isWaitingForNextGame
+ ? Boolean(player.isReadyForNext)
+ : Boolean(player.isReady);
// 检查是否是庄家
// 优先使用 dealerPlayerIndex(从第二局开始就知道了)
@@ -150,55 +571,113 @@ export default function GameTable({
}
return (
-
+
onPlayerTargetClick?.(player) : undefined}
+ role={isSelectableTarget ? 'button' : undefined}
+ tabIndex={isSelectableTarget ? 0 : undefined}
+ aria-label={isSelectableTarget
+ ? `${playerTargeting?.label || '选择玩家'}:${player.name}`
+ : undefined}
+ onKeyDown={isSelectableTarget ? (event) => {
+ if (event.key === 'Enter' || event.key === ' ') {
+ event.preventDefault();
+ onPlayerTargetClick?.(player);
+ }
+ } : undefined}
+ >
+ {isCurrentTurn && renderTurnIndicator(false)}
- {player.name}
- {isDealer && (
-
- 庄
+
+
+ {player.isBot ? 'AI' : (player.name || '?').slice(0, 1).toUpperCase()}
- )}
- {isWaitingForReady && player.isReady !== undefined && (
- 庄}
+ {isSecondaryBuryingPlayer && 埋}
+ {isOpenHand && 明}
+ {shouldShowReadyState && (
+
+ {isPlayerReady ? '✓' : '○'}
+
+ )}
+
+ {player.name}
+ {renderStriveUpstreamOrderBadge(player)}
+ {defenseAsOffenseDelta > 0 && (
+
- {player.isReady ? '✓' : '○'}
-
+ +{defenseAsOffenseDelta}
+
+ )}
+ {isKnownFocusFigure && (
+ 焦点
+ )}
+ {isDreamKillingSleeping && (
+ 梦中
+ )}
+ {hasTeammateCheerBuff && (
+
+ 加油+1
+
+ )}
+ {hasAfterglow && (
+
+ 返照+1
+
+ )}
+ {isLureTigerSilenced && (
+
+ 沉默
+
)}
- {isCurrentTurn &&
(出牌中)}
-
-
手牌: {player.cardsCount || 0}
-
-
分数: {player.score || 0} | 等级: {player.level || 2}
+ {showFocusFigureProgress && (
+
+ 被闲家收走 {focusCapturedPoints} 分
+
+ )}
+
+ {player.cardsCount || 0}张
+
+
- {/* 亮主区域 - 其他玩家的亮主在这里居中显示 */}
-
- {hasDeclaredTrump && currentTrumpDeclaration.cards && currentTrumpDeclaration.cards.length > 0 && (
-
- )}
+ {/* 亮主占玩家框左下角,亮劣占右下角,都不参与框体尺寸计算。 */}
+
+ {hasDeclaredTrump && renderDeclarationCardSlot({
+ cards: playerTrumpDeclaration.cards,
+ declarationRole: 'trump',
+ player,
+ playerTrumpSuit
+ })}
+ {renderDeclarationCardSlot({
+ cards: playerInferiorDeclaration?.cards,
+ declarationRole: 'inferior',
+ player,
+ playerTrumpSuit
+ })}
{shown && shown.cards && shown.cards.length > 0 && (
展示:
-
+
)}
@@ -206,6 +685,26 @@ export default function GameTable({
);
};
+ const renderOpenHand = (player, position) => {
+ if (!player) return null;
+ const isControlledOpenHand = openHand?.playerId === player.id;
+ const visibleHand = ruleVisibleHandsByPlayerId.get(player.id);
+ const revealedHand = isControlledOpenHand ? openHand : visibleHand;
+ if (!revealedHand) return null;
+ return (
+
+ );
+ };
+
// 判断我方队伍(索引0和2是队伍1,索引1和3是队伍2)
const getTeamLabels = () => {
if (!currentPlayer || !players || players.length === 0) {
@@ -217,8 +716,8 @@ export default function GameTable({
return { myTeamLabel: '我方', opponentTeamLabel: '对方', myTeamLevel: team1Level, opponentTeamLevel: team2Level };
}
- // 索引0和2是队伍1,索引1和3是队伍2
- const myTeam = myIndex % 2 === 0 ? 1 : 2;
+ // 欢乐成双只换座位,队伍仍按换位前的固定玩家组合。
+ const myTeam = (getPlayerTeamIndex(currentPlayer) ?? (myIndex % 2)) + 1;
if (myTeam === 1) {
return {
@@ -242,22 +741,372 @@ export default function GameTable({
// 渲染出牌区域(在玩家框和桌面中央之间)
const renderPlayedCardsArea = (player, position) => {
if (!player) return null;
- const played = playedCards[player.id];
+ const played = isHoldingSecondBattlefieldResult
+ ? null
+ : throwFailedPreviews[player.id] || playedCards[player.id];
// 始终渲染出牌区域,即使没有牌,以保持布局稳定
+ const hasPlayedCards = Boolean(played && (played.cards?.length > 0 || played.concealed));
+ const isWinningPlay = !played?.throwFailedAttempt
+ && player.id === currentWinningPlayerId
+ && played?.cards?.length > 0;
+ const isTrumpActionPlayer = trumpAnimation?.playerId === player.id;
+ const isTrumpActionTarget = trumpAnimation?.targetPlayerId === player.id;
return (
-
- {played && played.cards && played.cards.length > 0 && (
-
+
+ {played?.throwFailedAttempt && (
+
+ 甩牌失败
+
+ )}
+ {isTrumpActionTarget && (
+
+
+
+
+
+
+
+ )}
+ {played?.ambiguousOptions?.length === 2 && (
+
+ {played.ambiguousOptions.map(option => (
+
+
+ {option.index === 0 ? 'A' : 'B'}
+
+
+
+ ))}
+
+ )}
+ {(!played?.ambiguousOptions || played.ambiguousOptions.length !== 2)
+ && played?.cards?.length > 0 && (
+
+ )}
+ {played?.concealed && !played?.ownConcealedCards && (
+
+ {Array.from({ length: played.cardsCount || 0 }, (_, index) => (
+
+ ))}
+ 暗置 · {played.cardsCount || 0}
+
+ )}
+ {played?.treatedAsSmall && (
+
+ {played.activeSkillName || '李代桃僵'} · 小
+
+ )}
+ {played?.lureTigerSilenced && (
+
+ 默 · 不比大小 · 不计分
+
+ )}
+ {played?.jokerSubstitutions?.length > 0 && (
+
+ {played.jokerSubstitutions.map(substitution => {
+ const suit = { hearts: '♥', diamonds: '♦', clubs: '♣', spades: '♠' }[substitution.suit] || '';
+ return `${getJokerSubstitutionSourceLabel(played, substitution)}→${substitution.rank}${suit}`;
+ }).join(' · ')}
+
+ )}
+ {played?.clusterAnalysisSubstitutions?.length > 0 && (
+
+ 聚 · {played.clusterAnalysisSubstitutions
+ .map(substitution => `${substitution.fromRank}→${substitution.toRank}`)
+ .join(' · ')}
+
+ )}
+ {played?.enduringInheritance && (
+
+ 承
+ 上轮牌力
+
+ {(played.enduringInheritance.sourceCards || []).slice(0, 6).map((card, index) => (
+
+ ))}
+ {(played.enduringInheritance.sourceCards?.length || 0) > 6 && (
+
+ +{played.enduringInheritance.sourceCards.length - 6}
+
+ )}
+
+
+ )}
+ {played?.averagePooling && (
+
均 · 队友平均
+ )}
+ {played?.jointHarmony && (
+
合 · 珠联璧合
+ )}
+ {played?.dreamKilling?.success && (
+
醒 · 视为最大
+ )}
+ {played?.oldHorseAbsolute && (
+
骥 · 绝对最大
+ )}
+ {played?.magicTrickSwapped && (
+
术 · 结算换位
+ )}
+ {isTrumpActionPlayer && (
+
+ {trumpAnimation.type === 'overtrump' ? '盖毙' : '毙了'}
+
+
)}
);
};
+ const renderSecondBattlefieldStagedArea = (player, position) => {
+ if (!player || !ruleIncludesId(selectedRule, 'second_battlefield')) return null;
+ const playerResult = secondBattlefieldResultByPlayerId[player.id] || null;
+ const isWinner = Boolean(
+ isHoldingSecondBattlefieldResult
+ && secondBattlefield?.lastResult?.winnerPlayerIds?.includes(player.id)
+ );
+ const currentCardIds = new Set(isHoldingSecondBattlefieldResult
+ ? []
+ : (playedCards[player.id]?.cards || []).map(card => card.id).filter(Boolean));
+ const stagedCards = (
+ displayedSecondBattlefieldAccumulatedCards?.[player.id] || []
+ ).filter(card => !currentCardIds.has(card.id));
+ if (stagedCards.length === 0) return null;
+ const sortedStagedCards = sortCards(stagedCards, trumpSuit, trumpRank);
+ const stagedCardRows = [];
+ for (let index = 0; index < sortedStagedCards.length; index += 5) {
+ stagedCardRows.push(sortedStagedCards.slice(index, index + 5));
+ }
+
+ return (
+
1 ? 'has-multiple-rows' : ''} ${isHoldingSecondBattlefieldResult ? 'is-showdown' : ''} ${isWinner ? 'is-winner' : ''}`}
+ data-testid={`second-battlefield-staged-${player.id}`}
+ data-position={position}
+ aria-label={isHoldingSecondBattlefieldResult
+ ? `${player.name}的第二战场最佳五张:${playerResult?.categoryName || '牌型待定'}`
+ : `${player.name}尚未参与第二战场结算的牌`}
+ >
+
+ {isHoldingSecondBattlefieldResult
+ ? `${isWinner ? '胜·' : ''}${playerResult?.categoryName || '牌型'}`
+ : `待比牌 ${stagedCards.length}`}
+
+
+ {stagedCardRows.map((row, rowIndex) => (
+
+ {row.map((card, cardIndex) => (
+
+
+
+ ))}
+
+ ))}
+
+
+ );
+ };
+
+ const bottomTrumpDeclaration = positions.bottom
+ ? getPlayerTrumpDeclaration(positions.bottom)
+ : null;
+ const bottomInferiorDeclaration = positions.bottom
+ ? getPlayerInferiorDeclaration(positions.bottom)
+ : null;
+ const bottomHasTrumpDeclaration = Boolean(
+ positions.bottom
+ && bottomTrumpDeclaration?.playerId === positions.bottom.id
+ && bottomTrumpDeclaration?.cards?.length
+ );
+ const bottomHasInferiorDeclaration = Boolean(bottomInferiorDeclaration?.cards?.length);
+ const hasCenterTableFeature = Boolean(
+ isPublicBottomVisible
+ || isHoldingSecondBattlefieldResult
+ || (ruleIncludesId(selectedRule, 'divine_weapon') && divineWeapon?.cards?.length > 0)
+ );
+ const hasTopOpenHand = hasRevealedHand(positions.top);
+
return (
-
+
+ {cardExchange && (
+
+ {cardExchange.ruleName}
+
+ {cardExchange.submittedPlayerIds?.includes(currentPlayer?.id)
+ ? `等待其他玩家 · ${cardExchange.submittedPlayerIds.length}/${players.length}`
+ : cardExchange.operation === 'discard'
+ ? `请选择 ${cardExchange.requiredCards} 张牌暗中弃置`
+ : `请选择 ${cardExchange.requiredCards} 张牌交给 ${myExchangeTarget?.name || '目标玩家'}`}
+
+
+ )}
+
+ {mainstayAction && (
+
+ 中流砥柱
+
+ {mainstayAction.stage === 'decision'
+ ? `等待${mainstayActor?.name || '当前玩家'}决定是否发动`
+ : mainstayAction.stage === 'give'
+ ? `等待${mainstayChooser?.name || '当前玩家'}交出包含全部主牌的5张牌`
+ : `等待${mainstayChooser?.name || '队友'}返还5张牌`}
+
+
+ )}
+
+ {cardExchangeAnimation && (
+
+
+ {cardExchangeAnimation.title || cardExchangeAnimation.ruleName}
+
+ {cardExchangeAnimation.kind === 'exchange' && (
+
+ {(cardExchangeAnimation.transfers || []).map(transfer => (
+
+ {transfer.fromPlayerName || '玩家'}
+ →
+ {transfer.toPlayerName || '玩家'}
+ {transfer.cardsCount || 0}张
+
+ ))}
+
+ )}
+ {(cardExchangeAnimation.transfers || []).flatMap((transfer, transferIndex) => {
+ const isPlannedEconomyDraw = cardExchangeAnimation.kind === 'planned_economy_draw';
+ const start = isPlannedEconomyDraw
+ ? { x: '50%', y: '50%' }
+ : exchangeCoordinates[positionByPlayerId[transfer.fromPlayerId]];
+ const discardEnds = [
+ { x: '49%', y: '49%' },
+ { x: '51%', y: '49%' },
+ { x: '49%', y: '51%' },
+ { x: '51%', y: '51%' }
+ ];
+ const end = cardExchangeAnimation.kind === 'discard'
+ ? discardEnds[transferIndex % discardEnds.length]
+ : exchangeCoordinates[positionByPlayerId[transfer.toPlayerId]];
+ if (!start || !end) return [];
+ const visualCardCount = cardExchangeAnimation.kind === 'whole_hand'
+ ? Math.min(7, transfer.cardsCount || 0)
+ : isPlannedEconomyDraw
+ ? 1
+ : (transfer.cardsCount || 2);
+ return Array.from({ length: visualCardCount }, (_, cardIndex) => (
+
+ ));
+ })}
+
+ )}
+
+ {privateCardTransferReveal?.cards?.length > 0 && (
+
+
+ {privateCardTransferReveal.ruleName} · 收到的牌
+ 来自 {privateCardTransferReveal.fromPlayerName || '其他玩家'}
+
+
+
即将落入你的手牌
+
+ )}
+
{/* 左上角得分和等级显示 - 始终显示 */}
-
{teamLabels.myTeamLabel}等级
- {teamLabels.myTeamLevel}
+ {formatLevel(teamLabels.myTeamLevel)}
{teamLabels.opponentTeamLabel}等级
- {teamLabels.opponentTeamLevel}
+ {formatLevel(teamLabels.opponentTeamLevel)}
- 闲家得分
+
+ {isFocusFigureRule ? '闲家收牌' : '闲家得分'}
+
- {attackerScore} 分
+ {isFocusFigureRule || isLostInFogScoringHidden ? (
+
+ {isLostInFogScoringHidden ? '分值终局揭晓' : '实际得分终局揭晓'}
+
+ ) : (
+ `${attackerScore} 分`
+ )}
-
0
+ && !ruleIncludesId(selectedRule, 'second_battlefield') && (
+
+ 本轮
+ {currentRoundPoints}
+ 分
+
+ )}
+ {tenSidedAmbush && (
+
0 ? 'is-negative-score' : ambushAttackerNetCardCount < 0 ? 'is-positive-score' : ''}`}
+ aria-label={`伏击点数 ${tenSidedAmbush.rank || '未揭晓'},闲家净拿 ${ambushAttackerNetCardCount} 张`}
+ >
+ 伏击 {tenSidedAmbush.rank || '?'}
+
+ 闲家净拿 {ambushAttackerNetCardCount > 0 ? '+' : ''}{ambushAttackerNetCardCount} 张
+
+
+ )}
+
- 分数牌 ({collectedPointCards.length}):
+ {isLostInFogScoringHidden ? '得分牌:' : `分数牌 (${collectedPointCards.length}):`}
- {collectedPointCards.length > 0 ? (
-
+ {isLostInFogScoringHidden ? (
+
+ 牌面与分值已隐藏
+
+ ) : collectedPointCards.length > 0 ? (
+
{collectedPointCards.map((card, index) => (
-
@@ -330,13 +1206,103 @@ export default function GameTable({
暂无
)}
+
setIsScoreDetailsOpen(true)}
+ aria-label="查看详细计分和分数牌"
+ >
+ {isLostInFogScoringHidden ? '分牌—' : `分牌${collectedPointCards.length}`} ›
+
+
setIsScoreDetailsOpen(false)}
+ footer={null}
+ centered
+ width={520}
+ className="mobile-score-details-modal"
+ >
+
+
+
+ {teamLabels.myTeamLabel}等级
+ {formatLevel(teamLabels.myTeamLevel)}
+
+
+ {teamLabels.opponentTeamLabel}等级
+ {formatLevel(teamLabels.opponentTeamLevel)}
+
+
+
+
+
+ {isFocusFigureRule ? '闲家收牌' : '闲家得分'}
+
+ {isLostInFogScoringHidden
+ ? '分值终局揭晓'
+ : isFocusFigureRule
+ ? '实际得分终局揭晓'
+ : `${attackerScore} 分`}
+
+
+ {!isSettlementView && !isLostInFogScoringHidden
+ && !ruleIncludesId(selectedRule, 'second_battlefield') && (
+
+ 本轮牌面
+ {currentRoundPoints} 分
+
+ )}
+
+
+ {tenSidedAmbush && (
+
0 ? 'is-negative-score' : ambushAttackerNetCardCount < 0 ? 'is-positive-score' : ''}`}
+ >
+ 伏击 {tenSidedAmbush.rank || '?'}
+
+ 闲家净拿 {ambushAttackerNetCardCount > 0 ? '+' : ''}{ambushAttackerNetCardCount} 张
+
+
+ )}
+
+
+
+ {isLostInFogScoringHidden ? '得分牌' : '闲家分牌'}
+ {!isLostInFogScoringHidden && {collectedPointCards.length} 张}
+
+ {isLostInFogScoringHidden ? (
+
牌面与分值已隐藏
+ ) : collectedPointCards.length > 0 ? (
+
+ {collectedPointCards.map((card, index) => (
+
+
+
+ ))}
+
+ ) : (
+
暂无分牌
+ )}
+
+
+
+
{/* 上方玩家 */}
{positions.top && (
-
+
{renderPlayerArea(positions.top, 'top')}
+ {renderOpenHand(positions.top, 'top')}
{renderPlayedCardsArea(positions.top, 'top')}
+ {renderSecondBattlefieldStagedArea(positions.top, 'top')}
)}
@@ -344,32 +1310,133 @@ export default function GameTable({
{/* 左边玩家 */}
{positions.left && (
-
- {renderPlayerArea(positions.left, 'left')}
+
+
+ {renderPlayerArea(positions.left, 'left')}
+ {renderSecondBattlefieldStagedArea(positions.left, 'left')}
+
+ {renderOpenHand(positions.left, 'left')}
{renderPlayedCardsArea(positions.left, 'left')}
)}
{/* 中央桌面 */}
-
-
+
+ {!isSettlementView && !isLostInFogScoringHidden && currentRoundPoints > 0
+ && (
+
+ 本轮
+ {currentRoundPoints}
+ 分
+
+ )}
+ {isPublicBottomVisible && (
+
+
+ 昭然若揭 · 明置底牌
+
+ {ruleRuntimeStatus?.phase === 'drawing' ? '摸牌开始即公开' : '所有玩家随时可见'}
+
+
+
+
+ )}
+ {isHoldingSecondBattlefieldResult && (
+
+ 第二战场 · 第{secondBattlefield.lastResult.showdownNumber}场
+
+ {secondBattlefield.lastResult.winnerPlayerNames.join('、')}
+ 以{secondBattlefield.lastResult.winningCategoryName}最大
+
+ {secondBattlefield.lastResult.scoreDelta > 0
+ ? '闲家阵营 +5分'
+ : secondBattlefield.lastResult.scoreDelta < 0
+ ? '庄家阵营 +5分'
+ : '跨阵营并列,相互抵消'}
+
+ )}
+ {!isSettlementView && ruleIncludesId(selectedRule, 'divine_weapon') && divineWeapon?.cards?.length > 0 && (
+
+
+ 本轮神兵
+ {divineWeapon.usedThisRound ? '已发动·轮末两张全换' : canSelectDivineWeapon ? '请选择一张' : '可供转化'}
+
+
+ {divineWeapon.cards.map(card => (
+
onDivineWeaponCardClick?.(card.id) : undefined}
+ onKeyDown={canSelectDivineWeapon ? event => {
+ if (event.key === 'Enter' || event.key === ' ') {
+ event.preventDefault();
+ onDivineWeaponCardClick?.(card.id);
+ }
+ } : undefined}
+ >
+
+
+ ))}
+
+
+ )}
+
+ {recordOnFileTrackerView && !revealedBottomCards?.length && (
+
+ )}
{/* 底牌展示(优先显示) */}
{revealedBottomCards && revealedBottomCards.length > 0 ? (
-
-
+
+
底牌:
-
+
+
+
{/* 底牌得分结果 */}
{bottomScoreResult && (
-
{bottomScoreResult.resultText}
- {bottomScoreResult.attackerWonBottom && (
+ {bottomScoreResult.peopleCommune ? (
+
+
+ 庄家方埋分:{bottomScoreResult.peopleCommune.dealerBuriedPoints} 分
+ 闲家方埋分:{bottomScoreResult.peopleCommune.attackerBuriedPoints} 分
+
+ = 0 ? '#95de64' : '#ff8f8f', fontSize: '14px' }}>
+ {bottomScoreResult.attackerWonBottom ? '闲家抄庄家底' : '庄家方抄闲家底'}:
+ {bottomScoreResult.bottomPoints} × {bottomScoreResult.bottomMultiplier}
+
+
+ 闲家分数变化:{bottomScoreResult.bottomScoreGained > 0 ? '+' : ''}{bottomScoreResult.bottomScoreGained} 分
+
+
+ ) : bottomScoreResult.ambushCardCount > 0 ? (
+
+
+ 常规底分:{bottomScoreResult.attackerWonBottom
+ ? `${bottomScoreResult.bottomPoints} × ${bottomScoreResult.bottomMultiplier} = ${bottomScoreResult.bottomPoints * bottomScoreResult.bottomMultiplier}`
+ : '0'} 分
+
+ 0 ? '#ffd666' : '#ff8f8f', fontSize: '14px' }}>
+ 伏击 {bottomScoreResult.ambushRank} × {bottomScoreResult.ambushCardCount}:闲家{bottomScoreResult.ambushScoreDelta > 0 ? '+' : ''}{bottomScoreResult.ambushScoreDelta} 分
+
+
+ 底牌净变化:{bottomScoreResult.bottomScoreGained > 0 ? '+' : ''}{bottomScoreResult.bottomScoreGained} 分
+
+
+ ) : bottomScoreResult.attackerWonBottom && (
底牌 {bottomScoreResult.bottomPoints} 分 × {bottomScoreResult.bottomMultiplier} 倍 = {bottomScoreResult.bottomScoreGained} 分
)}
+ {bottomScoreResult.focusFigure && (
+
+
+ 焦点人物 · 终局揭晓
+
+ {bottomScoreResult.focusFigure.teams.map(team => (
+ `${team.side === 'dealer' ? '庄家方' : '闲家方'}:${team.focusPlayerName}`
+ )).join(' ')}
+
+
+
+ {bottomScoreResult.focusFigure.players.map(player => (
+
+ {player.isFocus ? '★ ' : ''}{player.playerName}
+
+ {player.capturedPoints} × {player.isFocus ? 2 : 0} = {player.countedPoints}
+
+
+ ))}
+
+
+ 焦点逐墩 {bottomScoreResult.focusFigure.focusTrickScore} 分
+ 正常底牌 {bottomScoreResult.focusFigure.normalBottomScore} 分
+
+
+ )}
+ {bottomScoreResult.abruptStop && (
+
+ 戛然而止 · 庄家余牌
+
+ {bottomScoreResult.abruptStop.dealerPlayerName} 剩余牌面分
+ {' '}{bottomScoreResult.abruptStop.dealerRemainingPoints},
+ 闲家获得一半 +{bottomScoreResult.abruptStop.attackerBonus} 分
+
+ {bottomScoreResult.abruptStop.dealerRemainingCards?.length > 0 && (
+
+ )}
+
+ )}
- 闲家总分:{bottomScoreResult.totalScore} 分
+ 闲家总分:{bottomScoreResult.scoreBeforeMistyFog
+ ?? bottomScoreResult.scoreBeforeLingeringDiscard
+ ?? bottomScoreResult.totalScore} 分
)}
+ {hasSurrenderShowdown && (
+
+ )}
+
+ {/* 迷雾牌在逐墩分和底牌分之后才公开并补分。 */}
+ {bottomScoreResult?.mistyFogCards?.length > 0 && (
+
+
+ 迷雾牌 · 终局公开
+ 牌面 {bottomScoreResult.mistyFogPoints} 分
+ 闲家补 +{bottomScoreResult.mistyFogBonus} 分
+ 最终 {bottomScoreResult.totalScore} 分
+
+
+
+
+
+ )}
+
+ {/* 庄家方暗弃的分牌只在终局公开。 */}
+ {bottomScoreResult?.lingeringDiscardCards?.length > 0 && (
+
+
+ 弃掷逦迤 · 分牌公开
+ 牌面 {bottomScoreResult.lingeringDiscardPoints} 分
+ 闲家补 +{bottomScoreResult.lingeringDiscardBonus} 分
+ 最终 {bottomScoreResult.totalScore} 分
+
+
+
+
+
+ )}
+
{/* 升级结果 */}
{upgradeResult && (
-
-
庄家队伍
- {upgradeResult.oldDealerLevel} → {upgradeResult.newDealerLevel}
+ {formatLevel(upgradeResult.oldDealerLevel)} → {formatLevel(upgradeResult.newDealerLevel)}
{upgradeResult.dealerLevelUp > 0 && (
@@ -437,7 +1636,7 @@ export default function GameTable({
闲家队伍
- {upgradeResult.oldAttackerLevel} → {upgradeResult.newAttackerLevel}
+ {formatLevel(upgradeResult.oldAttackerLevel)} → {formatLevel(upgradeResult.newAttackerLevel)}
{upgradeResult.attackerLevelUp > 0 && (
@@ -447,45 +1646,80 @@ export default function GameTable({
-
+
- 下一局庄家
+ {upgradeResult.dealerContinues ? '势如破竹 · 庄家连庄' : '下一局庄家'}
- {upgradeResult.nextDealerName} (等级 {upgradeResult.nextDealerLevel})
+ {upgradeResult.nextDealerName} (等级 {formatLevel(upgradeResult.nextDealerLevel)})
)}
) : (
-
+
{/* 主牌显示 */}
-
-
主牌:
- {trumpSuit && trumpRank ? (
+
+
主牌:
+ {oneCountryTwoSystems ? (
+
+ {oneCountryTrumpRows.map(row => (
+
+ {row.label} {row.suit === 'no_trump' || row.suit === 'joker'
+ ? '无主'
+ : row.suit
+ ? `${getSuitSymbol(row.suit)} ${trumpRank || ''}`
+ : '未亮'}
+
+ ))}
+
+ ) : (
- {getSuitSymbol(trumpSuit)} {trumpRank}
+ {!trumpSuit || trumpSuit === 'no_trump' ? '无主' : getSuitSymbol(trumpSuit)}{trumpRank ? ` ${trumpRank}` : ''}
- ) : (
-
未设置
- )}
- {isHost && (
-
- {trumpSuit && trumpRank ? '修改' : '设置'}
-
)}
+ {ruleIncludesId(selectedRule, 'three_six_nine_grades') && (
+
+ 劣牌:
+
+ {currentInferiorDeclaration?.suit
+ ? `${getSuitSymbol(currentInferiorDeclaration.suit)} ${trumpRank || ''}`
+ : '无劣花色'}
+
+
+ )}
{/* 庄家倒计时显示 */}
{dealerCountdown !== null && dealerCountdown > 0 && (
-
-
-
⏰
+
-
+
{currentTrumpDeclaration ? '有人亮主,倒计时已重置' : '无人亮主将随机指定'}
)}
{/* 规则显示 */}
-
规则:
-
- {selectedRule ? '更换' : '选择'}规则
-
+ {isRuleSelectionPending && onSelectRule && (
+
+ {ruleRuntimeStatus?.ruleSelectionMode === 'double_happiness'
+ ? '查看三选二'
+ : '二选一'}
+
+ )}
{selectedRule ? (
-
-
+
+
{selectedRule.name}
-
- {selectedRule.content}
+ {Array.isArray(selectedRule.rules) && (
+
+ {selectedRule.rules.map(rule => (
+ {rule.name}
+ ))}
+
+ )}
+ {ruleIncludesId(selectedRule, 'route_swing') && ruleRuntimeStatus?.phase === 'playing' && (
+
+
+ {ruleRuntimeStatus?.turnDirection === 'clockwise' ? '↻' : '↺'}
+
+ 当前:{ruleRuntimeStatus?.turnDirection === 'clockwise' ? '顺时针' : '逆时针'}
+
+ )}
+ {ruleIncludesId(selectedRule, 'day_night_rotation') && ruleRuntimeStatus?.phase === 'playing' && (
+
+ ☯
+ 第{Math.max(1, ruleRuntimeStatus?.currentRound || 1)}轮 · 当前最大点数:
+ {ruleRuntimeStatus?.dayNightHighestRank || '—'}
+
+ )}
+ {ruleIncludesId(selectedRule, 'encircle_three_missing_one') && encircleThreeMissingOne && (
+
+ 围
+
+ 记录:{encircleThreeMissingOne.seenSuits?.length
+ ? encircleThreeMissingOne.seenSuits.map(getSuitSymbolExtended).join(' ')
+ : '—'} ({encircleThreeMissingOne.seenSuits?.length || 0}/3)
+
+
+ )}
+ {ruleIncludesId(selectedRule, 'waiting_rabbit') && waitingRabbit && (
+
+
+ 兔
+ 行为记录
+
+
+ {(waitingRabbit.behaviorRecords || []).length > 0 ? (
+ waitingRabbit.behaviorRecords.map(record => (
+
+ {record.type === 'target_locked' ? '暗选' : `第${record.round}轮`}
+ {getWaitingRabbitRecordText(record)}
+
+ ))
+ ) : (
+
+ 等待四家暗选目标牌
+
+ )}
+
+
+ )}
+ {ruleIncludesId(selectedRule, 'strength_compensation') && ruleRuntimeStatus?.strengthCompensation && (
+
+
+ 第{ruleRuntimeStatus.strengthCompensation.round}轮
+
+
+
+ +1
+ {ruleRuntimeStatus.strengthCompensation.plusSeatNumber}号位
+ player.id === ruleRuntimeStatus.strengthCompensation.plusPlayerId)?.name || ''}>
+ {players.find(player => player.id === ruleRuntimeStatus.strengthCompensation.plusPlayerId)?.name || '未知玩家'}
+
+
+
+ −1
+ {ruleRuntimeStatus.strengthCompensation.minusSeatNumber}号位
+ player.id === ruleRuntimeStatus.strengthCompensation.minusPlayerId)?.name || ''}>
+ {players.find(player => player.id === ruleRuntimeStatus.strengthCompensation.minusPlayerId)?.name || '未知玩家'}
+
+
+
+
+ )}
+ {ruleIncludesId(selectedRule, 'odd_even_scoring') && ruleRuntimeStatus?.phase === 'playing' && (
+
+
+ {oddEvenRoundMultiplier === 2 ? '双' : '零'}
+
+ 第{currentRoundNumber}轮 · {oddEvenRoundMultiplier === 2 ? '偶数轮' : '奇数轮'}
+ {oddEvenRoundMultiplier === 2 ? '本轮双倍' : '本轮0分'}
+
+ )}
+ {ruleIncludesId(selectedRule, 'candle_to_dawn') && candleToDawn && (
+
+
+
+
+
+
+
+
+ {candleToDawn.isSelectionPending
+ ? '等待选择初始烛态'
+ : `第${displayedCandleRoundNumber}轮 · 烛已${displayedCandleLit ? '点燃' : '熄灭'}`}
+
+
+ {candleToDawn.isSelectionPending
+ ? '确定后才能开始第1轮'
+ : displayedCandleLit
+ ? '红色分牌 +5 · 黑色分牌 −5'
+ : '黑色分牌 +5 · 红色分牌 −5'}
+
+ 小王黑 · 大王红
+
+
+ )}
+ {ruleIncludesId(selectedRule, 'cultural_revolution') && ruleRuntimeStatus?.culturalRevolution && (
+
+ {ruleRuntimeStatus.culturalRevolution.declaration ? (
+ <>
+
+ 革
+
+ 第{ruleRuntimeStatus.culturalRevolution.declaration.activatedRound}–
+ {ruleRuntimeStatus.culturalRevolution.declaration.expiresAfterRound}轮
+
+
+
+ {ruleRuntimeStatus.culturalRevolution.declaration.declarationType === 'suit'
+ ? '革花色'
+ : '革点数'}
+
+ {ruleRuntimeStatus.culturalRevolution.declaration.declarationType === 'suit'
+ ? getSuitSymbolExtended(ruleRuntimeStatus.culturalRevolution.declaration.value)
+ : ruleRuntimeStatus.culturalRevolution.declaration.value}
+
+
+
+ 原主:
+ {getSuitSymbolExtended(ruleRuntimeStatus.culturalRevolution.baseTrumpSuit)}
+ {' '}{ruleRuntimeStatus.culturalRevolution.baseTrumpRank || '—'};被替换项暂为副牌
+
+ >
+ ) : (
+ <>
+
+ 革
+ 尚未发动
+
+
一号位可选择革花色或革点数
+ >
+ )}
+
+ )}
+ {ruleIncludesId(selectedRule, 'three_tigers') && (
+
+
+ 虎
+ 第{displayedThreeTigers?.round || currentRoundNumber}轮
+
+ {displayedThreeTigers?.triggeredSuit ? (
+ <>
+
+ {getSuitSymbolExtended(displayedThreeTigers.triggeredSuit)}
+ 已成虎
+
+
+ {(displayedThreeTigers.contributingPlayerIds || []).length}人同花色 · 视为主牌,牌面−4
+
+ >
+ ) : (
+ <>
+
+ 待成虎
+ {threeTigersProgress?.[1] > 0 && (
+
+ {getSuitSymbolExtended(threeTigersProgress[0])} {threeTigersProgress[1]}/3
+
+ )}
+
+
+ {threeTigersProgress?.[1] > 0
+ ? '同花色整手再累计至三人即刻转换'
+ : '等待本轮同花色整手出牌'}
+
+ >
+ )}
+
+ )}
+ {ruleIncludesId(selectedRule, 'invite_into_urn') && (
+
+ 瓮
+ {displayedInviteDeclarations.length > 0 ? (
+
+ {displayedInviteDeclarations[0].targetPlayerName} · {' '}
+
+ {displayedInviteDeclarations[0].rank === 'small_joker'
+ ? '小王'
+ : displayedInviteDeclarations[0].rank === 'big_joker'
+ ? '大王'
+ : `${displayedInviteDeclarations[0].rank}${getSuitSymbolExtended(displayedInviteDeclarations[0].suit)}`}
+
+ {displayedInviteDeclarations[0].triggered === true
+ ? ' · 已命中,扣5分'
+ : displayedInviteDeclarations[0].triggered === false
+ ? ' · 未命中'
+ : ' · 本轮监视中'}
+
+ ) : (
+ 一号位可指定玩家与牌面
+ )}
+
+ )}
+ {ruleIncludesId(selectedRule, 'old_horse_still_has_strength') && oldHorse && (
+
+ 骥
+
+ 已获牌权 {oldHorse.rightHolderPlayerIds?.length || 0}/{players.length}
+
+
+ {oldHorse.protectedPlayerId
+ ? `${players.find(player => player.id === oldHorse.protectedPlayerId)?.name || '目标玩家'}下次首发绝大`
+ : oldHorse.lastAbsolutePlay
+ ? '绝大已发动'
+ : '等待最后一人'}
+
+
+ )}
+ {ruleIncludesId(selectedRule, 'trump_wins') && (
+
+ T
+ {trumpWins?.lastResult?.leaderPlayerId ? (
+
+ 上轮:{trumpWins.lastResult.leaderPlayerName} {' '}
+ {trumpWins.lastResult.highestPoints}分获权
+
+ ) : (
+ 本轮按各家出牌分值争夺牌权
+ )}
+
+ )}
+ {ruleIncludesId(selectedRule, 'straw_boat_borrowing_arrows') && (
+
+ 箭
+ {strawBoatBorrowingArrows?.pending ? (
+
+ {strawBoatBorrowingArrows.pending.playerName} 正在决定 · {' '}
+
+ 可取 {getPublicCardLabel(strawBoatBorrowingArrows.pending.borrowedCard)}
+
+
+ ) : strawBoatBorrowingArrows?.lastResult?.accepted ? (
+
+ {strawBoatBorrowingArrows.lastResult.playerName}:
+
+ {getPublicCardLabel(strawBoatBorrowingArrows.lastResult.discardedCard)}
+ {' → '}
+ {getPublicCardLabel(strawBoatBorrowingArrows.lastResult.borrowedCard)}
+
+
+ ) : strawBoatBorrowingArrows?.lastResult ? (
+ {strawBoatBorrowingArrows.lastResult.playerName} 上轮放弃发动
+ ) : (
+ 首置至少10分失守时,可公开弃牌取箭
+ )}
+
+ )}
+ {ruleIncludesId(selectedRule, 'bush_gate') && (
+
+ 门
+ {bushGate?.restriction ? (
+
+ {bushGate.restriction.leaderPlayerName} 重新首发中 · 禁用 {' '}
+
+ {(bushGate.restriction.returnedCards || [])
+ .map(getPublicCardLabel)
+ .join('、')}
+
+
+ ) : bushGate?.lastResult ? (
+
+ {bushGate.lastResult.activatorPlayerName} 令 {' '}
+ {bushGate.lastResult.leaderPlayerName} 收回重出
+ {bushGate.lastResult.replayCompleted ? ' · 已完成' : ''}
+
+ ) : (
+ 二号位可令一号位收回首发并改出其他牌
+ )}
+
+ )}
+ {ruleIncludesId(selectedRule, 'ten_sided_ambush') && tenSidedAmbush && (
+
+
+ {tenSidedAmbush.isSelectionPending
+ ? '布置中'
+ : tenSidedAmbush.rank
+ ? '伏击点数'
+ : '伏兵未现'}
+
+ {tenSidedAmbush.rank ? (
+ {tenSidedAmbush.rank}
+ ) : (
+ ?
+ )}
+
+ {tenSidedAmbush.isSelectionPending
+ ? '等待庄家队友暗选'
+ : tenSidedAmbush.isPrivate
+ ? '仅你可见'
+ : tenSidedAmbush.isRevealed
+ ? '已向全场揭晓'
+ : '首次出现时揭晓'}
+
+
+ )}
+ {ruleIncludesId(selectedRule, 'three_powers') && threePowers && (
+
+
重载分牌
+
+ {(threePowers.slots || []).map(slot => (
+
+ {slot.sourceRank}
+ {slot.rank || '?'}
+
+ {slot.isPrivate
+ ? '仅你可见'
+ : slot.isRevealed
+ ? `${slot.pointValue}分`
+ : '待揭晓'}
+
+
+ ))}
+
+
+ )}
+ {ruleIncludesId(selectedRule, 'gentleman_promise') && ruleRuntimeStatus?.gentlemanPromise && (
+
+
最短有效花色
+
+ {players.map(player => {
+ const declaration = ruleRuntimeStatus.gentlemanPromise
+ ?.declarationsByPlayerId?.[player.id];
+ const isPending = ruleRuntimeStatus.gentlemanPromise
+ ?.pendingPlayerIds?.includes(player.id);
+ return (
+
+ {player.name}
+ {declaration ? getSuitSymbolExtended(declaration) : '…'}
+
+ );
+ })}
+
+
+ )}
+ {ruleIncludesId(selectedRule, 'hidden_dragon_in_abyss') && ruleRuntimeStatus?.hiddenDragon && (
+
+
潜龙点数
+
+ {players.map(player => {
+ const declaration = ruleRuntimeStatus.hiddenDragon
+ ?.declarationsByPlayerId?.[player.id];
+ const isPending = ruleRuntimeStatus.hiddenDragon
+ ?.pendingPlayerIds?.includes(player.id);
+ const hasPlayedDeclaredRank = ruleRuntimeStatus.hiddenDragon
+ ?.playedDeclaredRankByPlayerId?.[player.id];
+ const result = ruleRuntimeStatus.hiddenDragon
+ ?.results?.find(item => item.playerId === player.id);
+ return (
+
+ {player.name}
+ {declaration || '…'}
+
+ {isPending
+ ? '待声明'
+ : result
+ ? result.success ? '+10' : '0'
+ : hasPlayedDeclaredRank ? '已打出' : '未打出'}
+
+
+ );
+ })}
+
+
+ )}
+ {ruleIncludesId(selectedRule, 'antinomy') && ruleRuntimeStatus?.antinomy && (
+
+
二律背反声明
+
+ {players.map(player => {
+ const declaration = ruleRuntimeStatus.antinomy
+ ?.declarationsByPlayerId?.[player.id];
+ const isPending = ruleRuntimeStatus.antinomy
+ ?.pendingPlayerIds?.includes(player.id);
+ return (
+
+ {player.name}
+
+ {declaration
+ ? `${getSuitSymbolExtended(declaration.suit)}${declaration.rank}`
+ : '…'}
+
+
+ {isPending
+ ? declaration ? '重选中' : '选择中'
+ : declaration?.effective
+ ? '拆对生效'
+ : declaration ? '重复·不拆对' : '待声明'}
+
+
+ );
+ })}
+
+
+ )}
+ {destroyDykeDisplayState && (
+
+
+ 堤
+ 毁堤淹田
+
+ {destroyDykeDisplayState.badge}
+
+ {destroyDykeDisplayState.value ? (
+ <>
+
+ {destroyDykeDisplayState.value}
+
+
+ {destroyDykeDisplayState.detail}
+
+ >
+ ) : (
+
+ {destroyDykeDisplayState.detail}
+
+ )}
+
+ {destroyDykeDisplayState.progress !== null && (
+
+
+
+ )}
+
+ )}
+ {ruleIncludesId(selectedRule, 'administrative_review') && ruleRuntimeStatus?.administrativeReview && (
+
+
行政审查
+
+
+
+ {players.find(player => (
+ player.id === ruleRuntimeStatus.administrativeReview.suitSelectorPlayerId
+ ))?.name || '庄家下家'} · 副花色
+
+
+ {ruleRuntimeStatus.administrativeReview.suit
+ ? getSuitSymbolExtended(ruleRuntimeStatus.administrativeReview.suit)
+ : '…'}
+
+
+ {ruleRuntimeStatus.administrativeReview.suitMatched ? '已满足' : '未满足'}
+
+
+
+
+ {players.find(player => (
+ player.id === ruleRuntimeStatus.administrativeReview.rankSelectorPlayerId
+ ))?.name || '庄家上家'} · 点数
+
+ {ruleRuntimeStatus.administrativeReview.rank || '…'}
+
+ {ruleRuntimeStatus.administrativeReview.rankMatched ? '已满足' : '未满足'}
+
+
+
+
+ {ruleRuntimeStatus.administrativeReview.isBuried
+ ? '底牌已埋 · 牌局继续'
+ : ruleRuntimeStatus.administrativeReview.isBottomReleased
+ ? '条件满足 · 庄家正在埋底'
+ : '12张底牌封存 · 庄家不可查看'}
+
+
+ )}
+ {ruleIncludesId(selectedRule, 'political_review') && ruleRuntimeStatus?.politicalReview && (
+
+
政治审查
+
+ {players.map(player => {
+ const used = ruleRuntimeStatus.politicalReview.usedPlayerIds?.includes(player.id);
+ return (
+
+ {player.name}
+
+ {used ? '已使用' : '未使用'}
+
+
+ );
+ })}
+
+ {ruleRuntimeStatus.politicalReview.pending && (
+
+ {ruleRuntimeStatus.politicalReview.pending.reviewerPlayerName}
+ 正在审查
+ {ruleRuntimeStatus.politicalReview.pending.teammatePlayerName}:
+ {ruleRuntimeStatus.politicalReview.pending.cards.map(card => (
+ card.suit === 'joker'
+ ? (card.rank === 'big_joker' ? '大王' : '小王')
+ : `${getSuitSymbolExtended(card.suit)}${card.rank}`
+ )).join('、')}
+
+ )}
+
+ )}
+ {ruleIncludesId(selectedRule, 'repeated_exhaustion') && ruleRuntimeStatus?.repeatedExhaustion && (
+
+ 衰
+
+ {players.find(player => player.id === ruleRuntimeStatus.repeatedExhaustion.playerId)?.name || '当前赢家'}
+ 连续 {ruleRuntimeStatus.repeatedExhaustion.streak} 轮
+
+
+ {ruleRuntimeStatus.repeatedExhaustion.streak >= 2
+ ? `再赢扣${Math.max(1, ruleRuntimeStatus.repeatedExhaustion.streak - 1) * 5}分`
+ : '尚未受罚'}
+
+
+ )}
+ {ruleIncludesId(selectedRule, 'planned_economy') && ruleRuntimeStatus?.plannedEconomy && (
+
+ 计
+ 封存牌
+ {ruleRuntimeStatus.plannedEconomy.remainingCards} / 20
+
+ {ruleRuntimeStatus.plannedEconomy.isDrawingEnabled
+ ? '每轮结束四家各摸1张'
+ : '埋底完成前不摸牌'}
+
+
+ )}
+ {ruleIncludesId(selectedRule, 'wooden_ox_flowing_horse') && ruleRuntimeStatus?.woodenOx && (
+
+ {(ruleRuntimeStatus.woodenOx.mules || []).map(mule => {
+ const holder = players.find(player => player.id === mule.holderPlayerId);
+ return (
+
+
+ {mule.teamIndex === ((dealerPlayerIndex ?? 0) % 2)
+ ? '庄家方'
+ : '闲家方'}
+
+ {holder?.name || '未知玩家'}
+ {mule.hasStoredCard ? '已装牌' : '空'}
+ 往返 {mule.completedRoundTrips} / {mule.maxRoundTrips}
+
+ );
+ })}
+
+ )}
+
+ {getRuleTableContent(selectedRule)}
) : (
-
- 未选择规则
+
+ {isRuleSelectionPending
+ ? `等待 ${ruleChooser?.name || '指定玩家'} 选择规则`
+ : '未选择规则'}
)}
@@ -556,8 +2436,12 @@ export default function GameTable({
{/* 右边玩家 */}
{positions.right && (
-
- {renderPlayerArea(positions.right, 'right')}
+
+
+ {renderPlayerArea(positions.right, 'right')}
+ {renderSecondBattlefieldStagedArea(positions.right, 'right')}
+
+ {renderOpenHand(positions.right, 'right')}
{renderPlayedCardsArea(positions.right, 'right')}
)}
@@ -566,15 +2450,82 @@ export default function GameTable({
{/* 下方玩家(自己) */}
{positions.bottom && (
- {/* 我的出牌区域 - 在玩家框上方居中 */}
- {renderPlayedCardsArea(positions.bottom, 'bottom')}
+ {renderSecondBattlefieldStagedArea(positions.bottom, 'bottom')}
-
+
0) ? 'skill-targetable' : ''} ${playerTargeting?.selectedPlayerIds?.includes(positions.bottom.id) ? 'skill-target-selected' : ''}`}
+ data-player-id={positions.bottom.id}
+ onClick={playerTargeting?.active && playerTargeting?.allowSelf
+ && (playerTargeting?.requireCards === false || positions.bottom.cardsCount > 0)
+ ? () => onPlayerTargetClick?.(positions.bottom)
+ : undefined}
+ role={playerTargeting?.active && playerTargeting?.allowSelf ? 'button' : undefined}
+ tabIndex={playerTargeting?.active && playerTargeting?.allowSelf ? 0 : undefined}
+ aria-label={playerTargeting?.active && playerTargeting?.allowSelf
+ ? `${playerTargeting?.label || '选择玩家'}:${positions.bottom.name}`
+ : undefined}
+ onKeyDown={playerTargeting?.active && playerTargeting?.allowSelf ? (event) => {
+ if (event.key === 'Enter' || event.key === ' ') {
+ event.preventDefault();
+ onPlayerTargetClick?.(positions.bottom);
+ }
+ } : undefined}
+ >
+ {positions.bottom.id === currentTurnPlayerId && renderTurnIndicator(true)}
+ {/* 我的出牌区域锚定在玩家框上方,不参与挤压手牌框高度。 */}
+ {renderPlayedCardsArea(positions.bottom, 'bottom')}
{/* 上半部分:玩家信息和控制按钮 */}
- {positions.bottom.name} (我)
+
+ {(positions.bottom.name || '我').slice(0, 1).toUpperCase()}
+
+
+ {positions.bottom.name} (我)
+
+ {renderStriveUpstreamOrderBadge(positions.bottom)}
+ {defenseAsOffense?.playerId === positions.bottom.id && Number(defenseAsOffense.delta) > 0 && (
+
+ +{defenseAsOffense.delta}
+
+ )}
+ {knownFocusPlayerIds.has(positions.bottom.id) && (
+ 焦点
+ )}
+ {teammateCheerBuffedPlayerIds.has(positions.bottom.id) && (
+
+ 加油+1
+
+ )}
+ {afterglowActivePlayerIds.has(positions.bottom.id) && (
+
+ 返照+1
+
+ )}
+ {lureTigerSilencedPlayerIds.has(positions.bottom.id) && (
+
+ 沉默
+
+ )}
+ {showFocusFigureProgress && (
+
+ 被闲家收走 {Number(focusCapturedPointsByPlayerId[positions.bottom.id]) || 0} 分
+
+ )}
+ {positions.bottom.id === secondaryBuryingPlayerId && (
+ 再埋底
+ )}
{(() => {
// 检查是否是庄家 - 使用和其他位置相同的判断逻辑
let isDealer = false;
@@ -600,25 +2551,71 @@ export default function GameTable({
) : null;
})()}
- {isWaitingForReady && positions.bottom.isReady !== undefined && (
+ {(isWaitingForReady || isWaitingForNextGame) && (
- {positions.bottom.isReady ? '✓ 已准备' : '○ 未准备'}
+ {(isWaitingForNextGame
+ ? positions.bottom.isReadyForNext
+ : positions.bottom.isReady) ? '✓ 已准备' : '○ 未准备'}
)}
+ {openHand?.playerId === positions.bottom.id && (
+ 明牌 · 由 {openHand.controllerPlayerName} 代打
+ )}
+ {ruleVisibleHandsByPlayerId.has(positions.bottom.id) && (
+
+ {ruleVisibleHandsByPlayerId.get(positions.bottom.id).kind === 'partial'
+ ? `已明置 ${ruleVisibleHandsByPlayerId.get(positions.bottom.id).cards.length} 张`
+ : ruleVisibleHandsByPlayerId.get(positions.bottom.id).kind === 'jokers'
+ ? `二鬼拍门 · 明置 ${ruleVisibleHandsByPlayerId.get(positions.bottom.id).cards.length} 张王`
+ : ruleVisibleHandsByPlayerId.get(positions.bottom.id).label}
+
+ )}
+ {dreamKillingSleepingPlayerIds.has(positions.bottom.id) && (
+ 梦中 · 系统随机出牌
+ )}
-
分数: {positions.bottom.score || 0} | 等级: {positions.bottom.level || 2}
{/* 控制按钮区域 - 右侧 */}
- {renderControls && (
+ {(renderControls || onReturnToRoom || onRequestSurrender) && (
{renderControls}
+ {(onReturnToRoom || onRequestSurrender) && (
+
+ {onRequestSurrender && (
+
+ {hasRequestedSurrender ? '已申请' : '投降'}
+
+ )}
+ {onReturnToRoom && (
+
+ 返回房间
+
+ )}
+
+ )}
)}
@@ -632,12 +2629,29 @@ export default function GameTable({
{/* 自己的手牌区域 - 左端分一小块作为亮主区 */}
- {/* 亮主区域 - 只在有亮主时显示 */}
- {currentTrumpDeclaration && currentTrumpDeclaration.playerId === positions.bottom.id && (
-
- {currentTrumpDeclaration.cards && currentTrumpDeclaration.cards.length > 0 && (
-
- )}
+ {woodenOxCard && (
+
+ 木牛流马 · 可保留后出
+ onCardClick?.(woodenOxCard.id)}
+ disabled={disableMyHand || !onCardClick}
+ trumpSuit={getPlayerTrumpSuit(positions.bottom)}
+ trumpRank={trumpRank}
+ />
+
+ )}
+
+ {/* 自己的亮主在手牌左下角,亮劣在右下角。 */}
+ {bottomHasTrumpDeclaration && (
+
+ {renderDeclarationCardSlot({
+ cards: bottomTrumpDeclaration.cards,
+ declarationRole: 'trump',
+ player: positions.bottom,
+ playerTrumpSuit: getPlayerTrumpSuit(positions.bottom)
+ })}
)}
{/* 手牌区域 */}
@@ -645,12 +2659,41 @@ export default function GameTable({
card.id)
+ : []
+ }
+ highlightedCardIds={highlightedCardIds}
+ highlightedCardLabel={highlightedCardLabel}
+ highlightedCardTone={highlightedCardTone}
onCardClick={onCardClick}
+ transformableCardIds={transformableCardIds}
+ onRequestCardTransformation={onRequestCardTransformation}
+ onCancelCardTransformation={onCancelCardTransformation}
onReorder={onReorder}
- trumpSuit={trumpSuit}
+ disabled={disableMyHand}
+ faceDown={dreamKillingSleepingPlayerIds.has(positions.bottom.id)}
+ anticipateNextCard
+ trumpSuit={getPlayerTrumpSuit(positions.bottom)}
trumpRank={trumpRank}
/>
+ {bottomHasInferiorDeclaration && (
+
+ {renderDeclarationCardSlot({
+ cards: bottomInferiorDeclaration.cards,
+ declarationRole: 'inferior',
+ player: positions.bottom,
+ playerTrumpSuit: getPlayerTrumpSuit(positions.bottom)
+ })}
+
+ )}
diff --git a/tractor-game-simulator/client/src/components/Game/Hand.css b/tractor-game-simulator/client/src/components/Game/Hand.css
index d495c47..644f772 100644
--- a/tractor-game-simulator/client/src/components/Game/Hand.css
+++ b/tractor-game-simulator/client/src/components/Game/Hand.css
@@ -1,26 +1,27 @@
.hand {
- --card-width: 80px;
+ --card-width: 78px;
--card-overlap: 25px;
display: flex;
flex-direction: row;
justify-content: center; /* 改为居中对齐 */
align-items: flex-end;
- padding: 10px 5px; /* 减小内边距,增加可用空间 */
- min-height: 150px;
+ padding: 9px 5px 5px;
+ min-height: 138px;
flex-wrap: nowrap;
width: 100%; /* 添加100%宽度 */
max-width: 100%;
box-sizing: border-box; /* 添加box-sizing */
overflow-x: visible; /* 允许溢出可见,而不是滚动 */
+ position: relative;
}
/* 根据牌数自动调整间距 - 使用负margin-left让牌重叠 */
-.hand > * {
+.hand > .card-wrapper {
margin-left: calc(var(--card-overlap, 25px) * -1);
flex-shrink: 0;
}
-.hand > *:first-child {
+.hand > .card-wrapper:first-child {
margin-left: 0;
}
@@ -64,20 +65,165 @@
--card-overlap: 38px; /* 小卡片33张时的重叠度 */
}
+@media (max-width: 1180px) {
+ .hand.compact-1 { --card-overlap: 38px; }
+ .hand.compact-2 { --card-overlap: 46px; }
+ .hand.compact-3 { --card-overlap: 52px; }
+ .hand.compact-4 { --card-overlap: 57px; }
+}
+
+@media (max-width: 780px) {
+ .hand.compact-1 { --card-overlap: 48px; }
+ .hand.compact-2 { --card-overlap: 57px; }
+ .hand.compact-3 { --card-overlap: 61px; }
+ .hand.compact-4 { --card-overlap: 64px; }
+}
+
/* 拖拉机标记样式 */
.card-wrapper {
position: relative;
+ isolation: isolate;
display: inline-block;
width: var(--card-width, 80px);
+ height: 110px;
+ line-height: 0;
+ vertical-align: bottom;
flex-shrink: 0;
}
-.tractor-line {
- pointer-events: none;
- z-index: 10;
+.card-wrapper.is-publicly-revealed .card {
+ border-color: #bd8618 !important;
+ background:
+ linear-gradient(118deg, rgba(255, 255, 255, 0.72), transparent 42%),
+ linear-gradient(180deg, #fff6bd 0%, #f5d76e 62%, #d9aa36 100%) !important;
+ box-shadow: 0 3px 0 #8c6418, 0 8px 16px rgba(0, 25, 18, 0.3), 0 0 0 2px rgba(255, 225, 111, 0.42) !important;
+ animation: revealedCardGlow 0.42s ease-out both;
+}
+
+.card-wrapper.is-arriving-card {
+ z-index: 180 !important;
}
-.tractor-label {
+.card-wrapper.is-arriving-card .card {
+ border-color: #f2bd42 !important;
+ box-shadow:
+ 0 3px 0 #9d6b13,
+ 0 8px 18px rgba(0, 25, 18, 0.34),
+ 0 0 0 3px rgba(255, 214, 92, 0.72),
+ 0 0 22px rgba(255, 202, 54, 0.56) !important;
+ animation: arrivingCardLand 0.52s cubic-bezier(.2,.85,.22,1) both;
+}
+
+.card-wrapper.is-bottom-source .card {
+ border-color: #b8903b !important;
+ background:
+ linear-gradient(118deg, rgba(255, 255, 255, 0.62), transparent 42%),
+ linear-gradient(180deg, #fff9dc 0%, #f3e3aa 68%, #dfc374 100%) !important;
+ box-shadow:
+ 0 3px 0 #aa8a42,
+ 0 7px 15px rgba(0, 25, 18, 0.3),
+ inset 0 0 0 2px rgba(255, 225, 126, 0.28) !important;
+}
+
+.arrival-card-badge {
+ position: absolute;
+ z-index: 12;
+ right: -5px;
+ top: -8px;
+ min-width: 25px;
+ height: 25px;
+ display: grid;
+ place-items: center;
+ padding: 0 5px;
+ border: 1px solid rgba(255, 241, 178, 0.9);
+ border-radius: 999px;
+ color: #523300;
+ background: linear-gradient(180deg, #ffe88a, #efb92e);
+ box-shadow: 0 4px 12px rgba(67, 38, 0, 0.42);
+ font-size: 12px;
+ font-weight: 900;
+ line-height: 1;
+ white-space: nowrap;
+}
+
+@keyframes arrivingCardLand {
+ 0% {
+ filter: brightness(1.22);
+ transform: translateY(-18px) scale(1.08);
+ }
+ 58% { transform: translateY(3px) scale(0.98); }
+ 100% {
+ filter: brightness(1);
+ transform: translateY(0) scale(1);
+ }
+}
+
+@keyframes revealedCardGlow {
+ from { filter: brightness(1.28); }
+ to { filter: brightness(1); }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .card-wrapper.is-publicly-revealed .card,
+ .card-wrapper.is-arriving-card .card { animation: none; }
+}
+
+.hand.small .card-wrapper {
+ height: 70px;
+}
+
+.card-wrapper > .card {
+ position: absolute;
+ left: 0;
+ top: 0;
+}
+
+.my-hand .hand {
+ filter: drop-shadow(0 6px 6px rgba(0, 24, 18, 0.28));
+}
+
+.my-hand .card:not(.selected) {
+ box-shadow: 0 3px 0 #aeb3ad;
+}
+
+.tractor-group-marker {
+ position: absolute;
pointer-events: none;
- z-index: 11;
+ z-index: 200;
+ height: 3px;
+ border-radius: 2px;
+ background: linear-gradient(90deg, #d98512, #f3b62e 18%, #f3b62e 82%, #d98512);
+ box-shadow: 0 1px 3px rgba(83, 43, 0, 0.48);
+}
+
+.tractor-group-marker::before,
+.tractor-group-marker::after {
+ content: '';
+ position: absolute;
+ top: -2px;
+ width: 3px;
+ height: 7px;
+ border-radius: 2px;
+ background: #f3b62e;
+}
+
+.tractor-group-marker::before { left: 0; }
+.tractor-group-marker::after { right: 0; }
+
+.tractor-group-label {
+ position: absolute;
+ left: 50%;
+ top: 50%;
+ padding: 3px 9px 4px;
+ border: 1px solid rgba(247, 195, 72, 0.72);
+ border-radius: 7px;
+ color: #ffe36d;
+ background: rgba(55, 47, 28, 0.88);
+ box-shadow: 0 2px 6px rgba(21, 17, 7, 0.32);
+ backdrop-filter: blur(3px);
+ font-size: 12px;
+ font-weight: 800;
+ line-height: 1;
+ white-space: nowrap;
+ transform: translate(-50%, -50%);
}
diff --git a/tractor-game-simulator/client/src/components/Game/Hand.jsx b/tractor-game-simulator/client/src/components/Game/Hand.jsx
index 3a1c9f3..1642fe5 100644
--- a/tractor-game-simulator/client/src/components/Game/Hand.jsx
+++ b/tractor-game-simulator/client/src/components/Game/Hand.jsx
@@ -1,11 +1,38 @@
-import { useState, useMemo } from 'react';
+import { useState, useMemo, useLayoutEffect, useRef } from 'react';
import Card from './Card';
import { getCardStrength, getEffectiveSuit } from '../../utils/cardPatternUtils';
import { RANK_ORDER } from '../../utils/constants.js';
import './Hand.css';
-export default function Hand({ cards, selectedCards = [], onCardClick, disabled = false, small = false, onReorder, trumpSuit = null, trumpRank = null }) {
+export default function Hand({
+ cards,
+ selectedCards = [],
+ disabledCardIds = [],
+ disabledCardReason = '',
+ virtualizedCardIds = [],
+ revealedCardIds = [],
+ highlightedCardIds = [],
+ highlightedCardLabel = '',
+ highlightedCardTone = 'arrival',
+ transformableCardIds = [],
+ onCardClick,
+ onRequestCardTransformation,
+ onCancelCardTransformation,
+ disabled = false,
+ faceDown = false,
+ small = false,
+ showOriginalFace = false,
+ anticipateNextCard = false,
+ showWinningBadge = false,
+ onReorder,
+ trumpSuit = null,
+ trumpRank = null,
+ minimumVisibleWidth = null
+}) {
const [draggedCard, setDraggedCard] = useState(null);
+ const handRef = useRef(null);
+ const cardWrapperRefs = useRef(new Map());
+ const [layoutMetrics, setLayoutMetrics] = useState(null);
const cardIndexMap = useMemo(() => {
const map = new Map();
cards.forEach((card, index) => {
@@ -13,6 +40,12 @@ export default function Hand({ cards, selectedCards = [], onCardClick, disabled
});
return map;
}, [cards]);
+ // 转化牌会在牌数不变的情况下重新排序。布局测量必须感知实际牌序,
+ // 否则拖拉机标线会继续读取这些牌转换前所在位置的旧坐标。
+ const cardOrderKey = useMemo(
+ () => cards.map(card => card.id).join('|'),
+ [cards]
+ );
const getCardIndex = (cardId) => cardIndexMap.get(cardId) ?? -1;
@@ -24,7 +57,7 @@ export default function Hand({ cards, selectedCards = [], onCardClick, disabled
// 检测手牌中的所有拖拉机
const tractorGroups = useMemo(() => {
- if (!trumpSuit || !trumpRank || cards.length < 4) {
+ if (cards.length < 4) {
return [];
}
@@ -127,40 +160,6 @@ export default function Hand({ cards, selectedCards = [], onCardClick, disabled
return tractors;
}, [cards, trumpSuit, trumpRank]);
- // 获取卡片所属的拖拉机索引
- const getCardTractorIndex = (cardId) => {
- for (let i = 0; i < tractorGroups.length; i++) {
- if (tractorGroups[i].cardIds.includes(cardId)) {
- return i;
- }
- }
- return -1;
- };
-
- // 检查卡片是否是拖拉机的第一张
- const isTractorStart = (cardId) => {
- for (const tractor of tractorGroups) {
- const cardIndex = getCardIndex(cardId);
- const firstTractorCardIndex = getCardIndex(tractor.cardIds[0]);
- if (cardIndex === firstTractorCardIndex) {
- return tractor;
- }
- }
- return null;
- };
-
- // 检查卡片是否是拖拉机的最后一张
- const isTractorEnd = (cardId) => {
- for (const tractor of tractorGroups) {
- const cardIndex = getCardIndex(cardId);
- const lastTractorCardIndex = getCardIndex(tractor.cardIds[tractor.cardIds.length - 1]);
- if (cardIndex === lastTractorCardIndex) {
- return true;
- }
- }
- return false;
- };
-
// 根据牌数计算紧凑程度
const getCompactClass = () => {
const cardCount = cards.length;
@@ -173,6 +172,84 @@ export default function Hand({ cards, selectedCards = [], onCardClick, disabled
const compactClass = getCompactClass();
+ // 按容器的真实宽度连续计算叠牌量,并提前预留一张牌的露出宽度。
+ useLayoutEffect(() => {
+ const handElement = handRef.current;
+ if (!handElement) return undefined;
+
+ const updateOverlap = () => {
+ const cardCount = cards.length;
+ const cardWidth = small ? 50 : 78;
+ const naturalOverlap = small ? 18 : 25;
+ const minimumCardReveal = Number.isFinite(minimumVisibleWidth)
+ ? Math.max(1, Math.min(cardWidth, minimumVisibleWidth))
+ : (small ? 12 : 18);
+ const computedStyle = window.getComputedStyle(handElement);
+ const paddingLeft = Number.parseFloat(computedStyle.paddingLeft) || 0;
+ const paddingRight = Number.parseFloat(computedStyle.paddingRight) || 0;
+ const paddingBottom = Number.parseFloat(computedStyle.paddingBottom) || 0;
+ const horizontalPadding = paddingLeft + paddingRight;
+ const nextCardReserve = anticipateNextCard && cardCount > 1 ? cardWidth - naturalOverlap : 0;
+ const usableWidth = Math.max(
+ cardWidth,
+ handElement.clientWidth - horizontalPadding - nextCardReserve
+ );
+ const requiredOverlap = cardCount > 1
+ ? cardWidth - (usableWidth - cardWidth) / (cardCount - 1)
+ : naturalOverlap;
+ const overlap = Math.min(
+ cardWidth - minimumCardReveal,
+ Math.max(naturalOverlap, Math.ceil(requiredOverlap))
+ );
+
+ handElement.style.setProperty('--card-overlap', `${overlap}px`);
+ const cardPositions = {};
+ cards.forEach((card) => {
+ const wrapper = cardWrapperRefs.current.get(card.id);
+ if (!wrapper) return;
+ // 弹窗入场会用 transform 缩放整只手牌。视觉矩形会暂时变小,
+ // 而 offset 坐标始终对应最终布局,可避免拖拉机标线停在动画中的旧位置。
+ cardPositions[card.id] = {
+ left: wrapper.offsetLeft,
+ right: wrapper.offsetLeft + wrapper.offsetWidth,
+ bottom: wrapper.offsetTop + wrapper.offsetHeight
+ };
+ });
+ const nextMetrics = {
+ width: handElement.clientWidth,
+ height: handElement.clientHeight,
+ paddingLeft,
+ paddingRight,
+ paddingBottom,
+ cardWidth,
+ overlap,
+ cardPositions
+ };
+ setLayoutMetrics((previous) => {
+ const sameScalarMetrics = previous && [
+ 'width', 'height', 'paddingLeft', 'paddingRight', 'paddingBottom', 'cardWidth', 'overlap'
+ ].every((key) => previous[key] === nextMetrics[key]);
+ const sameCardPositions = sameScalarMetrics && cards.every((card) => {
+ const previousPosition = previous.cardPositions?.[card.id];
+ const nextPosition = cardPositions[card.id];
+ return previousPosition && nextPosition &&
+ previousPosition.left === nextPosition.left &&
+ previousPosition.right === nextPosition.right &&
+ previousPosition.bottom === nextPosition.bottom;
+ });
+ if (sameCardPositions) {
+ return previous;
+ }
+ return nextMetrics;
+ });
+ };
+
+ updateOverlap();
+ const resizeObserver = new ResizeObserver(updateOverlap);
+ resizeObserver.observe(handElement);
+ return () => resizeObserver.disconnect();
+ }, [cards.length, cardOrderKey, small, anticipateNextCard, minimumVisibleWidth]);
+
// 拖拽开始
const handleDragStart = (e, card) => {
setDraggedCard(card);
@@ -219,52 +296,45 @@ export default function Hand({ cards, selectedCards = [], onCardClick, disabled
setDraggedCard(null);
};
- // 拖拉机颜色数组
- const tractorColors = ['#ff6b6b', '#4ecdc4', '#45b7d1', '#96ceb4', '#ffeaa7'];
-
return (
-
+
{cards.map((card) => {
- const tractorIndex = getCardTractorIndex(card.id);
- const tractorStart = isTractorStart(card.id);
- const isLastInTractor = isTractorEnd(card.id);
- const isTractor = tractorIndex !== -1;
- const tractorColor = isTractor ? tractorColors[tractorIndex % tractorColors.length] : null;
const cardIndex = getCardIndex(card.id);
const isRightmostCard = cardIndex === cards.length - 1;
-
- // 计算拖拉机线的样式
- // 如果是拖拉机的最后一张牌,限制线的宽度,不延伸到右边
- const tractorLineStyle = {
- position: 'absolute',
- bottom: small ? '-2px' : '-4px',
- left: 0,
- height: small ? '2px' : '3px',
- backgroundColor: tractorColor,
- borderRadius: '2px'
- };
-
- if (isLastInTractor) {
- // 最后一张牌:根据是否到手牌末尾决定宽度
- if (isRightmostCard) {
- tractorLineStyle.width = 'var(--card-width, 80px)';
- } else {
- tractorLineStyle.width = 'calc(var(--card-width, 80px) - var(--card-overlap, 25px))';
- }
- } else {
- // 非最后一张:延伸到右边,会被下一张牌覆盖
- tractorLineStyle.right = 0;
- }
+ const isSelected = selectedCards.includes(card.id);
+ const isRuleDisabled = disabledCardIds.includes(card.id);
+ const isVirtualized = virtualizedCardIds.includes(card.id);
+ const isPubliclyRevealed = revealedCardIds.includes(card.id);
+ const isHighlighted = highlightedCardIds.includes(card.id);
return (
-
+
{
+ if (element) cardWrapperRefs.current.set(card.id, element);
+ else cardWrapperRefs.current.delete(card.id);
+ }}
+ className={`card-wrapper ${isPubliclyRevealed ? 'is-publicly-revealed' : ''} ${isHighlighted ? (highlightedCardTone === 'bottom' ? 'is-bottom-source' : 'is-arriving-card') : ''} ${isVirtualized ? 'is-virtualized' : ''}`}
+ style={{ zIndex: cardIndex + 1 }}
+ >
onCardClick && onCardClick(card.id)}
- disabled={disabled}
+ onRequestTransformation={transformableCardIds.includes(card.id) && onRequestCardTransformation
+ ? () => onRequestCardTransformation(card.id)
+ : undefined}
+ onCancelTransformation={card.explicitTransformationPreview && onCancelCardTransformation
+ ? () => onCancelCardTransformation(card.id)
+ : undefined}
+ disabled={disabled || isRuleDisabled || isVirtualized}
+ ruleDisabled={isRuleDisabled}
+ ruleDisabledReason={disabledCardReason}
+ virtualized={isVirtualized}
+ faceDown={faceDown}
small={small}
- draggable={!disabled && !!onReorder}
+ showOriginalFace={showOriginalFace}
+ draggable={!disabled && !isRuleDisabled && !isVirtualized && !!onReorder}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
onDragOver={handleDragOver}
@@ -272,33 +342,46 @@ export default function Hand({ cards, selectedCards = [], onCardClick, disabled
trumpSuit={trumpSuit}
trumpRank={trumpRank}
/>
- {isTractor && (
-
+ {isHighlighted && highlightedCardLabel && (
+
+ {highlightedCardLabel}
+
)}
- {tractorStart && (
-
- 拖拉机
-
+ {showWinningBadge && isRightmostCard && (
+ 大
)}
);
})}
+ {layoutMetrics && tractorGroups.map((tractor, tractorIndex) => {
+ const indices = tractor.cardIds.map(getCardIndex).filter((index) => index >= 0);
+ if (indices.length === 0) return null;
+ const firstIndex = Math.min(...indices);
+ const lastIndex = Math.max(...indices);
+ const firstPosition = layoutMetrics.cardPositions?.[cards[firstIndex]?.id];
+ const lastPosition = layoutMetrics.cardPositions?.[cards[lastIndex]?.id];
+ if (!firstPosition || !lastPosition) return null;
+ const nextPosition = layoutMetrics.cardPositions?.[cards[lastIndex + 1]?.id];
+ const groupLeft = firstPosition.left;
+ const groupRight = lastIndex === cards.length - 1
+ ? lastPosition.right
+ : nextPosition?.left ?? lastPosition.right;
+ const groupWidth = Math.max(3, groupRight - groupLeft);
+
+ return (
+
+ 拖拉机
+
+ );
+ })}
);
}
diff --git a/tractor-game-simulator/client/src/components/Game/MobileLandscapeGuard.jsx b/tractor-game-simulator/client/src/components/Game/MobileLandscapeGuard.jsx
new file mode 100644
index 0000000..d7d3c7f
--- /dev/null
+++ b/tractor-game-simulator/client/src/components/Game/MobileLandscapeGuard.jsx
@@ -0,0 +1,58 @@
+import { useEffect, useState } from 'react';
+
+export default function MobileLandscapeGuard() {
+ const [status, setStatus] = useState('');
+
+ useEffect(() => {
+ const resetViewportOrigin = () => {
+ window.requestAnimationFrame(() => window.scrollTo({ left: 0, top: 0, behavior: 'instant' }));
+ };
+ resetViewportOrigin();
+ window.addEventListener('resize', resetViewportOrigin);
+ window.screen?.orientation?.addEventListener?.('change', resetViewportOrigin);
+ return () => {
+ window.removeEventListener('resize', resetViewportOrigin);
+ window.screen?.orientation?.removeEventListener?.('change', resetViewportOrigin);
+ };
+ }, []);
+
+ const requestLandscape = async () => {
+ const orientation = window.screen?.orientation;
+
+ if (typeof orientation?.lock !== 'function') {
+ setStatus('当前浏览器不能自动旋转,请关闭竖屏锁定后横置手机');
+ return;
+ }
+
+ try {
+ if (document.fullscreenEnabled && !document.fullscreenElement) {
+ await document.documentElement.requestFullscreen({ navigationUI: 'hide' });
+ }
+ await orientation.lock('landscape');
+ setStatus('已请求横屏显示');
+ } catch {
+ setStatus('请关闭竖屏锁定后横置手机');
+ }
+ };
+
+ return (
+
+ );
+}
diff --git a/tractor-game-simulator/client/src/components/Game/OpenHandPanel.jsx b/tractor-game-simulator/client/src/components/Game/OpenHandPanel.jsx
new file mode 100644
index 0000000..0fa73d1
--- /dev/null
+++ b/tractor-game-simulator/client/src/components/Game/OpenHandPanel.jsx
@@ -0,0 +1,78 @@
+import Hand from './Hand';
+import Card from './Card';
+import { sortCards } from '../../utils/cardUtils';
+import { getEffectiveSuit } from '../../utils/cardPatternUtils';
+
+const GROUPS = [
+ ['trump', '主'],
+ ['spades', '♠'],
+ ['hearts', '♥'],
+ ['clubs', '♣'],
+ ['diamonds', '♦']
+];
+
+export default function OpenHandPanel({
+ openHand,
+ position,
+ selectedCards = [],
+ onCardClick,
+ interactive = false,
+ label = '明牌',
+ trumpSuit = null,
+ trumpRank = null
+}) {
+ if (!openHand?.cards?.length || position === 'bottom') return null;
+
+ const cards = sortCards(openHand.cards, trumpSuit, trumpRank);
+ const sidePosition = position === 'left' || position === 'right';
+
+ return (
+
+
+ {openHand.kind === 'jokers' ? '鬼' : '明'}
+ {openHand.playerName} · {label}
+ {cards.length}
+ {interactive && 由你代打}
+
+
+ {sidePosition ? (
+
+ {GROUPS.map(([suit, label]) => {
+ const groupedCards = cards.filter(card => getEffectiveSuit(card, trumpSuit, trumpRank) === suit);
+ if (groupedCards.length === 0) return null;
+ return (
+
+
{label}
+
+ {groupedCards.map((card, index) => (
+
+
+
+ ))}
+
+
+ );
+ })}
+
+ ) : (
+
+
+
+ )}
+
+ );
+}
diff --git a/tractor-game-simulator/client/src/components/Game/RecordOnFileTracker.css b/tractor-game-simulator/client/src/components/Game/RecordOnFileTracker.css
new file mode 100644
index 0000000..e3d8b8d
--- /dev/null
+++ b/tractor-game-simulator/client/src/components/Game/RecordOnFileTracker.css
@@ -0,0 +1,189 @@
+.record-on-file-tracker {
+ position: sticky;
+ z-index: 3;
+ top: 0;
+ width: 100%;
+ box-sizing: border-box;
+ margin: 0 0 10px;
+ padding: 8px;
+ border: 1px solid rgba(255, 220, 105, 0.42);
+ border-radius: 10px;
+ color: rgba(246, 255, 247, 0.94);
+ background:
+ linear-gradient(145deg, rgba(53, 49, 13, 0.25), transparent 45%),
+ linear-gradient(145deg, rgba(3, 41, 34, 0.98), rgba(7, 65, 47, 0.97));
+ box-shadow:
+ 0 8px 20px rgba(0, 24, 17, 0.28),
+ inset 0 1px rgba(255, 255, 255, 0.05);
+ pointer-events: none;
+ animation: recordOnFileArrive 260ms ease-out both;
+}
+
+.record-on-file-heading {
+ display: flex;
+ align-items: center;
+ min-height: 27px;
+ gap: 7px;
+ margin-bottom: 7px;
+}
+
+.record-on-file-seal {
+ display: grid;
+ flex: 0 0 25px;
+ width: 25px;
+ height: 25px;
+ place-items: center;
+ border: 1px solid rgba(255, 222, 104, 0.72);
+ border-radius: 50%;
+ color: #ffe071;
+ background: rgba(111, 79, 11, 0.38);
+ font-size: 13px;
+ font-weight: 900;
+}
+
+.record-on-file-heading > div {
+ display: flex;
+ min-width: 0;
+ flex-direction: column;
+ line-height: 1.05;
+}
+
+.record-on-file-heading strong {
+ color: #fff0aa;
+ font-size: 12px;
+ letter-spacing: 0.06em;
+}
+
+.record-on-file-heading small {
+ margin-top: 2px;
+ color: rgba(219, 239, 222, 0.64);
+ font-size: 8px;
+ white-space: nowrap;
+}
+
+.record-on-file-legend {
+ margin-left: auto;
+ color: rgba(222, 241, 223, 0.52);
+ font-size: 8px;
+ line-height: 1.1;
+ text-align: right;
+}
+
+.record-on-file-body {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) 55px;
+ gap: 7px;
+ align-items: stretch;
+}
+
+.record-on-file-grid {
+ display: grid;
+ grid-template-columns: 18px repeat(13, minmax(0, 1fr));
+ gap: 2px;
+ width: 100%;
+}
+
+.record-cell {
+ display: grid;
+ min-width: 0;
+ height: 14px;
+ box-sizing: border-box;
+ place-items: center;
+ border-radius: 2px;
+ font-variant-numeric: tabular-nums;
+ line-height: 1;
+}
+
+.record-rank {
+ overflow: visible;
+ color: rgba(220, 239, 223, 0.64);
+ font-size: 8px;
+ font-weight: 700;
+}
+
+.record-suit {
+ color: rgba(246, 248, 240, 0.9);
+ font-size: 12px;
+ font-weight: 800;
+}
+
+.record-suit.suit-hearts,
+.record-suit.suit-diamonds {
+ color: #ff8585;
+}
+
+.record-count {
+ border: 1px solid rgba(213, 238, 217, 0.06);
+ background: rgba(216, 242, 220, 0.025);
+ color: transparent;
+ font-size: 8px;
+ font-weight: 900;
+}
+
+.record-count.has-count {
+ border-color: rgba(157, 224, 168, 0.26);
+ color: #d8f5d8;
+ background: rgba(72, 151, 84, 0.24);
+}
+
+.record-count.is-complete {
+ border-color: rgba(255, 219, 91, 0.58);
+ color: #ffe576;
+ background: rgba(128, 91, 12, 0.42);
+ box-shadow: inset 0 0 5px rgba(255, 224, 93, 0.08);
+}
+
+.record-on-file-jokers {
+ display: grid;
+ grid-template-rows: 14px repeat(2, 1fr);
+ gap: 4px;
+ padding-left: 7px;
+ border-left: 1px solid rgba(255, 220, 105, 0.22);
+}
+
+.record-on-file-jokers.has-white-joker {
+ grid-template-rows: 14px repeat(3, 1fr);
+}
+
+.record-on-file-jokers.has-royal-jokers {
+ grid-template-rows: 14px repeat(4, 1fr);
+}
+
+.record-on-file-jokers.has-royal-jokers.has-white-joker {
+ grid-template-rows: 14px repeat(5, 1fr);
+}
+
+.record-on-file-jokers-title {
+ align-self: center;
+ color: rgba(231, 207, 131, 0.72);
+ font-size: 8px;
+ font-weight: 800;
+ text-align: center;
+ letter-spacing: 0.08em;
+}
+
+.record-joker-item {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) 17px;
+ align-items: center;
+ gap: 3px;
+ color: #f3e3aa;
+ font-size: 9px;
+ white-space: nowrap;
+}
+
+.record-joker-item .record-count {
+ width: 17px;
+ height: 18px;
+}
+
+@keyframes recordOnFileArrive {
+ from { opacity: 0; filter: blur(2px); }
+ to { opacity: 1; filter: blur(0); }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .record-on-file-tracker {
+ animation: none;
+ }
+}
diff --git a/tractor-game-simulator/client/src/components/Game/RecordOnFileTracker.jsx b/tractor-game-simulator/client/src/components/Game/RecordOnFileTracker.jsx
new file mode 100644
index 0000000..281f980
--- /dev/null
+++ b/tractor-game-simulator/client/src/components/Game/RecordOnFileTracker.jsx
@@ -0,0 +1,111 @@
+import { getRecordOnFileTrackerView } from '../../utils/gameViewUtils';
+import './RecordOnFileTracker.css';
+
+export default function RecordOnFileTracker({
+ recordOnFile,
+ displayRoundNumber,
+ showWhiteJoker = false,
+ showRoyalJokers = false
+}) {
+ const view = getRecordOnFileTrackerView(
+ recordOnFile,
+ displayRoundNumber,
+ { showWhiteJoker, showRoyalJokers }
+ );
+ if (!view) return null;
+
+ const renderCount = (count, key, label) => (
+
= 2 ? 'is-complete' : ''} ${count > 0 ? 'has-count' : ''}`}
+ title={`${label} 已出 ${count} 张`}
+ aria-label={`${label}已出${count}张`}
+ >
+ {count || ''}
+
+ );
+
+ return (
+
+ );
+}
diff --git a/tractor-game-simulator/client/src/components/Game/RuleSelector.css b/tractor-game-simulator/client/src/components/Game/RuleSelector.css
index 195883a..1c38f24 100644
--- a/tractor-game-simulator/client/src/components/Game/RuleSelector.css
+++ b/tractor-game-simulator/client/src/components/Game/RuleSelector.css
@@ -1,12 +1,99 @@
-.ant-list-item {
- transition: background-color 0.2s;
+.rule-option.ant-btn {
+ display: block;
+ height: auto;
+ min-height: 104px;
+ padding: 18px 24px;
+ text-align: left;
+ white-space: normal;
+ border-color: #d8ddd9;
+ border-radius: 12px;
+ background: linear-gradient(135deg, #ffffff 0%, #fafcfb 100%);
+ box-shadow: 0 4px 12px rgba(4, 55, 43, 0.06);
+ transition: border-color 0.18s ease, box-shadow 0.18s ease, transform 0.18s ease;
}
-.ant-list-item:hover {
- background-color: #f0f0f0;
- border-radius: 4px;
+.rule-option-shell {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) auto;
+ gap: 10px;
+ align-items: stretch;
}
-.ant-modal-body {
- padding: 24px;
+.rule-option-shell.is-selected .rule-option.ant-btn {
+ border-color: #c89a22;
+ background: linear-gradient(135deg, #fffdf3 0%, #f8edc8 100%);
+ box-shadow: 0 7px 18px rgba(92, 65, 9, 0.17);
+}
+
+.rule-option-refresh.ant-btn {
+ align-self: stretch;
+ height: auto;
+ min-width: 94px;
+ border-radius: 12px;
+}
+
+.rule-option-check {
+ margin-right: 7px;
+ color: #c89a22;
+}
+
+.rule-option.ant-btn:hover,
+.rule-option.ant-btn:focus-visible {
+ border-color: #c89a22;
+ color: inherit;
+ background: linear-gradient(135deg, #fffef9 0%, #fbf7e9 100%);
+ box-shadow: 0 8px 20px rgba(92, 65, 9, 0.14);
+ transform: translateY(-1px);
+}
+
+.rule-option-layout {
+ display: grid;
+ grid-template-columns: 128px minmax(0, 1fr);
+ align-items: center;
+ gap: 22px;
+ width: 100%;
+}
+
+.rule-option .rule-option-name,
+.rule-option .rule-option-description {
+ margin: 0;
+}
+
+.rule-option .rule-option-name {
+ padding-right: 22px;
+ border-right: 1px solid #e4e8e5;
+ color: #173d32;
+ font-size: 19px;
+ line-height: 1.35;
+ text-align: right;
+ white-space: nowrap;
+}
+
+.rule-option .rule-option-description {
+ color: #4f5e59;
+ font-size: 15px;
+ line-height: 1.7;
+ text-align: left;
+}
+
+@media (max-width: 560px) {
+ .rule-option-shell {
+ grid-template-columns: 1fr;
+ }
+
+ .rule-option-refresh.ant-btn {
+ min-height: 40px;
+ }
+
+ .rule-option-layout {
+ grid-template-columns: 1fr;
+ gap: 8px;
+ }
+
+ .rule-option .rule-option-name {
+ padding: 0 0 7px;
+ border-right: 0;
+ border-bottom: 1px solid #e4e8e5;
+ text-align: left;
+ }
}
diff --git a/tractor-game-simulator/client/src/components/Game/RuleSelector.jsx b/tractor-game-simulator/client/src/components/Game/RuleSelector.jsx
index 221114f..2ad6cfa 100644
--- a/tractor-game-simulator/client/src/components/Game/RuleSelector.jsx
+++ b/tractor-game-simulator/client/src/components/Game/RuleSelector.jsx
@@ -1,224 +1,133 @@
-import { useState, useEffect } from 'react';
-import { Modal, Button, Input, List, Space, Typography, Divider, message } from 'antd';
-import { SearchOutlined, ReloadOutlined, EditOutlined } from '@ant-design/icons';
+import { useEffect, useState } from 'react';
+import { Modal, Button, Typography, Space, Empty } from 'antd';
+import { CheckCircleFilled, ReloadOutlined } from '@ant-design/icons';
+import { getRuleTableContent } from '../../utils/ruleDisplayContent';
import './RuleSelector.css';
const { Text, Title } = Typography;
-const { TextArea } = Input;
-/**
- * 规则选择器组件
- * @param {Object} props
- * @param {Boolean} props.visible - 是否显示弹窗
- * @param {Function} props.onClose - 关闭弹窗的回调
- * @param {Function} props.onRuleSelected - 选择规则的回调,参数为 { name, content }
- */
-export default function RuleSelector({ visible, onClose, onRuleSelected }) {
- const [rules, setRules] = useState([]);
- const [filteredRules, setFilteredRules] = useState([]);
- const [searchText, setSearchText] = useState('');
- const [customRuleName, setCustomRuleName] = useState('');
- const [customRuleContent, setCustomRuleContent] = useState('');
- const [showCustomInput, setShowCustomInput] = useState(false);
- const [messageApi, contextHolder] = message.useMessage();
+export default function RuleSelector({
+ visible,
+ rules = [],
+ selectionMode = 'single',
+ canChoose = true,
+ canRefresh = false,
+ onRuleSelected,
+ onRefreshRule,
+ onClose
+}) {
+ const isDoubleHappiness = selectionMode === 'double_happiness';
+ const [selectedRuleIds, setSelectedRuleIds] = useState([]);
- // 加载规则数据
useEffect(() => {
- if (visible) {
- fetch('/DLC.json')
- .then(res => res.json())
- .then(data => {
- setRules(data);
- setFilteredRules(data);
- })
- .catch(err => {
- console.error('加载规则失败:', err);
- messageApi.error('加载规则失败,请检查DLC.json文件');
- });
- }
- }, [visible]);
+ setSelectedRuleIds(previous => (
+ previous.filter(id => rules.some(rule => rule.id === id))
+ ));
+ }, [rules]);
- // 搜索规则
useEffect(() => {
- if (searchText.trim() === '') {
- setFilteredRules(rules);
- } else {
- const filtered = rules.filter(rule =>
- rule.name.includes(searchText) || rule.content.includes(searchText)
- );
- setFilteredRules(filtered);
- }
- }, [searchText, rules]);
-
- // 随机选择规则
- const handleRandomSelect = () => {
- if (rules.length === 0) {
- messageApi.warning('没有可用的规则');
- return;
- }
- const randomIndex = Math.floor(Math.random() * rules.length);
- const selectedRule = rules[randomIndex];
- onRuleSelected(selectedRule);
- messageApi.success(`已随机选择规则: ${selectedRule.name}`);
- handleClose();
- };
-
- // 选择指定规则
- const handleSelectRule = (rule) => {
- onRuleSelected(rule);
- messageApi.success(`已选择规则: ${rule.name}`);
- handleClose();
- };
-
- // 保存自定义规则
- const handleSaveCustomRule = () => {
- const trimmedName = customRuleName.trim();
- const trimmedContent = customRuleContent.trim();
-
- if (!trimmedName) {
- messageApi.warning('规则名称不能为空');
- return;
- }
- if (!trimmedContent) {
- messageApi.warning('规则内容不能为空');
- return;
- }
- if (trimmedName.length > 20) {
- messageApi.warning('规则名称不能超过20个字符');
- return;
- }
- if (trimmedContent.length > 200) {
- messageApi.warning('规则内容不能超过200个字符');
- return;
- }
-
- const customRule = {
- name: trimmedName,
- content: trimmedContent
- };
-
- onRuleSelected(customRule);
- messageApi.success(`已保存自定义规则: ${trimmedName}`);
- handleClose();
+ if (!visible || !isDoubleHappiness) setSelectedRuleIds([]);
+ }, [visible, isDoubleHappiness]);
+
+ const toggleRule = ruleId => {
+ if (!canChoose) return;
+ setSelectedRuleIds(previous => {
+ if (previous.includes(ruleId)) {
+ return previous.filter(id => id !== ruleId);
+ }
+ if (previous.length >= 2) return previous;
+ return [...previous, ruleId];
+ });
};
- // 关闭弹窗
- const handleClose = () => {
- setSearchText('');
- setCustomRuleName('');
- setCustomRuleContent('');
- setShowCustomInput(false);
- onClose();
+ const submitDoubleHappiness = () => {
+ if (selectedRuleIds.length !== 2) return;
+ onRuleSelected?.(selectedRuleIds);
};
return (
- <>
- {contextHolder}
-
- {!showCustomInput ? (
- <>
- {/* 操作按钮区域 */}
-
-
- }
- onClick={handleRandomSelect}
- >
- 随机选择
-
+
+ 确认采用这两条规则
+
+ ) : null}
+ closable={!canChoose && Boolean(onClose)}
+ maskClosable={false}
+ keyboard={!canChoose}
+ onCancel={!canChoose ? onClose : undefined}
+ width={760}
+ destroyOnClose
+ >
+
+ {isDoubleHappiness
+ ? canChoose
+ ? '请选择两条同时生效的规则。若候选组合不合理,可以请房主刷新其中一条。'
+ : canRefresh
+ ? '本局选择者正在挑选两条规则;你可以刷新任意一条不合理的候选。'
+ : '本局选择者正在挑选两条规则;所有玩家都可以查看当前候选。'
+ : canChoose
+ ? '你是本局的规则选择者。请选择一条规则,选择后本局立即采用且不能更换。'
+ : '本局选择者正在挑选规则;所有玩家都可以查看当前候选,但只有选择者可以确认。'}
+
+
+ {rules.length === 0 ? (
+
+ ) : (
+
+ {rules.map((rule, index) => {
+ const selected = selectedRuleIds.includes(rule.id);
+ const selectionLocked = (
+ isDoubleHappiness
+ && !selected
+ && selectedRuleIds.length >= 2
+ );
+ return (
+
}
- onClick={() => setShowCustomInput(true)}
+ className="rule-option"
+ block
+ disabled={!canChoose || selectionLocked}
+ onClick={() => (
+ isDoubleHappiness
+ ? toggleRule(rule.id)
+ : onRuleSelected?.(rule)
+ )}
>
- 自定义规则
+
+
+ {selected && }
+ {rule.name}
+
+
+ {getRuleTableContent(rule)}
+
+
-
-
-
- {/* 搜索框 */}
-
}
- value={searchText}
- onChange={(e) => setSearchText(e.target.value)}
- allowClear
- style={{ marginBottom: 16 }}
- />
-
- {/* 规则列表 */}
-
- (
- handleSelectRule(rule)}
- hoverable
+ {isDoubleHappiness && canRefresh && (
+ }
+ onClick={() => onRefreshRule?.(index)}
+ title={`刷新“${rule.name}”`}
>
- {rule.name}}
- description={rule.content}
- />
-
+ 换一条
+
)}
- locale={{ emptyText: '没有找到匹配的规则' }}
- />
-
-
-
-
-
-
- 共 {filteredRules.length} 条规则
- {searchText && ` (从 ${rules.length} 条中筛选)`}
-
-
- >
- ) : (
- <>
- {/* 自定义规则输入 */}
-
-
自定义规则
-
- 规则名称:
- setCustomRuleName(e.target.value)}
- style={{ marginTop: 8, marginBottom: 16 }}
- />
-
- 规则内容:
-
- >
- )}
-
- >
+
+ );
+ })}
+
+ )}
+
);
}
diff --git a/tractor-game-simulator/client/src/components/Game/SurrenderShowdown.css b/tractor-game-simulator/client/src/components/Game/SurrenderShowdown.css
new file mode 100644
index 0000000..beb6a08
--- /dev/null
+++ b/tractor-game-simulator/client/src/components/Game/SurrenderShowdown.css
@@ -0,0 +1,215 @@
+.surrender-showdown {
+ min-width: 0;
+ padding: 9px;
+ border: 1px solid rgba(255, 221, 112, 0.5);
+ border-radius: 16px;
+ background:
+ radial-gradient(circle at 50% 0, rgba(255, 218, 92, 0.12), transparent 42%),
+ linear-gradient(145deg, rgba(8, 68, 50, 0.96), rgba(3, 42, 34, 0.96));
+ box-shadow: inset 0 1px rgba(255, 255, 255, 0.08), 0 10px 25px rgba(0, 25, 18, 0.28);
+ text-align: left;
+}
+
+.surrender-showdown-heading,
+.surrender-showdown-player {
+ min-width: 0;
+ display: flex;
+ align-items: center;
+}
+
+.surrender-showdown-heading {
+ justify-content: space-between;
+ gap: 12px;
+ padding: 0 3px 8px;
+ color: #fff1ae;
+}
+
+.surrender-showdown-heading > div {
+ min-width: 0;
+ display: flex;
+ align-items: baseline;
+ gap: 9px;
+}
+
+.surrender-showdown-heading strong {
+ font-size: 16px;
+}
+
+.surrender-showdown-heading > div > span {
+ overflow: hidden;
+ color: rgba(255, 255, 255, 0.66);
+ font-size: 11px;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.surrender-showdown-total {
+ flex: 0 0 auto;
+ padding: 3px 8px;
+ border: 1px solid rgba(255, 224, 122, 0.34);
+ border-radius: 999px;
+ color: #ffe98f;
+ background: rgba(105, 72, 12, 0.42);
+ font-size: 11px;
+ font-weight: 800;
+}
+
+.surrender-showdown-grid {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 7px;
+}
+
+.surrender-showdown-hand {
+ min-width: 0;
+ padding: 6px 7px 7px;
+ border: 1px solid rgba(197, 232, 205, 0.2);
+ border-radius: 12px;
+ background: rgba(2, 43, 34, 0.7);
+ box-shadow: inset 0 1px rgba(255, 255, 255, 0.05);
+}
+
+.surrender-showdown-hand.is-dealer {
+ border-color: rgba(255, 207, 94, 0.35);
+ background: linear-gradient(145deg, rgba(74, 56, 13, 0.54), rgba(2, 43, 34, 0.76));
+}
+
+.surrender-showdown-hand.is-self {
+ border-color: rgba(136, 242, 171, 0.65);
+ box-shadow: inset 0 1px rgba(255, 255, 255, 0.06), 0 0 0 2px rgba(105, 230, 147, 0.08);
+}
+
+.surrender-showdown-player {
+ gap: 6px;
+ height: 25px;
+ padding-bottom: 4px;
+}
+
+.surrender-showdown-player strong {
+ min-width: 0;
+ overflow: hidden;
+ color: rgba(255, 255, 255, 0.92);
+ font-size: 12px;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.surrender-showdown-player > span:last-child {
+ margin-left: auto;
+ color: rgba(255, 255, 255, 0.58);
+ font-size: 10px;
+ font-variant-numeric: tabular-nums;
+ white-space: nowrap;
+}
+
+.surrender-showdown-side {
+ width: 21px;
+ height: 21px;
+ flex: 0 0 21px;
+ display: grid;
+ place-items: center;
+ border: 1px solid rgba(255, 232, 151, 0.62);
+ border-radius: 7px;
+ color: #674004;
+ background: linear-gradient(145deg, #ffe88a, #d9a72d);
+ font-size: 10px;
+ font-weight: 950;
+}
+
+.is-attacker .surrender-showdown-side {
+ border-color: rgba(167, 241, 187, 0.62);
+ color: #073d28;
+ background: linear-gradient(145deg, #c1f6ce, #59bc79);
+}
+
+.surrender-showdown-groups {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+}
+
+.surrender-showdown-group {
+ min-width: 0;
+ min-height: 28px;
+ display: grid;
+ grid-template-columns: 18px minmax(0, 1fr);
+ align-items: start;
+ column-gap: 3px;
+}
+
+.surrender-showdown-suit {
+ padding-top: 6px;
+ color: #eee2b5;
+ font-size: 11px;
+ font-weight: 950;
+ line-height: 1;
+ text-align: center;
+}
+
+.surrender-showdown-group.group-hearts .surrender-showdown-suit,
+.surrender-showdown-group.group-diamonds .surrender-showdown-suit {
+ color: #ff9e9e;
+}
+
+.surrender-showdown-cards {
+ min-width: 0;
+ display: grid;
+ grid-template-columns: repeat(auto-fill, 22px);
+ gap: 1px;
+}
+
+.surrender-showdown-card {
+ width: 22px;
+ height: 28px;
+ display: block;
+}
+
+.surrender-showdown .card.micro {
+ width: 22px;
+ height: 28px;
+ border-radius: 3px;
+ cursor: default !important;
+ box-shadow: 0 1px 0 #9fa69f, 0 2px 4px rgba(0, 21, 15, 0.24);
+}
+
+.surrender-showdown .card.micro .card-corner.top-left {
+ top: 2px;
+ left: 3px;
+}
+
+.surrender-showdown .card.micro .card-rank {
+ font-size: 9px;
+}
+
+.surrender-showdown .card.micro .card-suit {
+ font-size: 8px;
+}
+
+.surrender-showdown-empty {
+ height: 34px;
+ display: grid;
+ place-items: center;
+ border: 1px dashed rgba(255, 255, 255, 0.16);
+ border-radius: 8px;
+ color: rgba(255, 255, 255, 0.46);
+ font-size: 11px;
+}
+
+@media (max-width: 560px) {
+ .surrender-showdown-grid {
+ grid-template-columns: minmax(0, 1fr);
+ }
+
+ .surrender-showdown-heading > div > span {
+ display: none;
+ }
+
+ .surrender-showdown-cards {
+ grid-template-columns: repeat(auto-fill, 20px);
+ }
+
+ .surrender-showdown-card,
+ .surrender-showdown .card.micro {
+ width: 20px;
+ }
+}
diff --git a/tractor-game-simulator/client/src/components/Game/SurrenderShowdown.jsx b/tractor-game-simulator/client/src/components/Game/SurrenderShowdown.jsx
new file mode 100644
index 0000000..d877245
--- /dev/null
+++ b/tractor-game-simulator/client/src/components/Game/SurrenderShowdown.jsx
@@ -0,0 +1,102 @@
+import Card from './Card';
+import { sortCards } from '../../utils/cardUtils';
+import { getEffectiveSuit } from '../../utils/cardPatternUtils';
+import './SurrenderShowdown.css';
+
+const HAND_GROUPS = [
+ ['trump', '主'],
+ ['spades', '♠'],
+ ['hearts', '♥'],
+ ['clubs', '♣'],
+ ['diamonds', '♦']
+];
+
+export default function SurrenderShowdown({
+ hands = [],
+ currentPlayerId = null,
+ trumpSuit = null,
+ trumpRank = null
+}) {
+ if (!hands.length) return null;
+
+ const totalCards = hands.reduce((sum, hand) => sum + (hand.cards?.length || 0), 0);
+
+ return (
+
+
+
+ 投降摊牌
+ 四家剩余手牌完整公开
+
+ 共 {totalCards} 张
+
+
+
+ {hands.map(hand => {
+ const cards = sortCards(hand.cards || [], trumpSuit, trumpRank);
+ const isSelf = hand.playerId === currentPlayerId;
+ return (
+
+
+
+ {hand.isDealer ? '庄' : hand.side === 'dealer' ? '守' : '闲'}
+
+
+ {hand.playerName}{isSelf ? '(我)' : ''}
+
+ {cards.length} 张
+
+
+ {cards.length > 0 ? (
+
+ {HAND_GROUPS.map(([suit, label]) => {
+ const groupedCards = cards.filter(
+ card => getEffectiveSuit(card, trumpSuit, trumpRank) === suit
+ );
+ if (groupedCards.length === 0) return null;
+ return (
+
+
{label}
+
+ {groupedCards.map((card, cardIndex) => (
+
+
+
+ ))}
+
+
+ );
+ })}
+
+ ) : (
+ 已出完手牌
+ )}
+
+ );
+ })}
+
+
+ );
+}
diff --git a/tractor-game-simulator/client/src/components/Game/TrumpDeclaration.css b/tractor-game-simulator/client/src/components/Game/TrumpDeclaration.css
index 293c427..83f1196 100644
--- a/tractor-game-simulator/client/src/components/Game/TrumpDeclaration.css
+++ b/tractor-game-simulator/client/src/components/Game/TrumpDeclaration.css
@@ -1,30 +1,40 @@
.trump-declaration {
display: flex;
align-items: center;
- gap: 12px;
- padding: 12px 20px;
- background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
- border-radius: 8px;
- margin-bottom: 16px;
+ gap: 8px;
+ padding: 5px 10px;
+ background: linear-gradient(135deg, rgba(49, 118, 75, 0.94), rgba(19, 78, 56, 0.94));
+ border: 1px solid rgba(214, 242, 218, 0.2);
+ border-radius: 10px;
+ margin-bottom: 3px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
}
.declaration-label {
- font-size: 16px;
+ font-size: 14px;
font-weight: 600;
color: white;
- min-width: 60px;
+ min-width: 48px;
}
.declaration-slots {
display: flex;
- gap: 10px;
+ gap: 7px;
flex: 1;
}
+.declaration-slots.three-six-nine {
+ align-items: stretch;
+}
+
+.declaration-slots.three-six-nine .declaration-slot {
+ box-sizing: border-box;
+ height: 57px;
+}
+
.declaration-slot {
flex: 1;
- height: 60px;
+ height: 36px;
display: flex;
align-items: center;
justify-content: center;
@@ -38,7 +48,7 @@
}
.declaration-slot .slot-label {
- font-size: 24px;
+ font-size: 20px;
font-weight: bold;
color: rgba(255, 255, 255, 0.6);
}
@@ -62,6 +72,58 @@
box-shadow: 0 4px 16px rgba(255, 215, 0, 0.8);
}
+.declaration-slot-split {
+ height: 46px;
+ padding: 3px 4px 4px;
+ flex-direction: column;
+ gap: 2px;
+}
+
+.declaration-slot-split .slot-label {
+ font-size: 17px;
+ line-height: 1;
+}
+
+.declaration-role-actions {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 3px;
+ width: 100%;
+}
+
+.declaration-role-button {
+ min-width: 0;
+ height: 19px;
+ padding: 0 3px;
+ border: 1px solid rgba(255, 255, 255, 0.18);
+ border-radius: 4px;
+ color: rgba(255, 255, 255, 0.38);
+ background: rgba(0, 0, 0, 0.18);
+ font-size: 10px;
+ font-weight: 800;
+ line-height: 17px;
+ cursor: default;
+}
+
+.declaration-role-button.active {
+ color: #263027;
+ background: rgba(255, 255, 255, 0.96);
+ border-color: #ffd75b;
+ box-shadow: 0 0 8px rgba(255, 215, 91, 0.72);
+ cursor: pointer;
+}
+
+.declaration-role-button.inferior.active {
+ color: #fff4dd;
+ background: linear-gradient(180deg, #9b5a48, #6f382e);
+ border-color: #efb197;
+ box-shadow: 0 0 8px rgba(222, 132, 98, 0.65);
+}
+
+.declaration-role-button:disabled {
+ opacity: 1;
+}
+
/* 脉动动画 */
@keyframes pulse {
0%, 100% {
@@ -82,3 +144,56 @@
.declaration-slot.active:nth-child(5) .slot-label {
color: #e74c3c;
}
+
+@media (orientation: landscape) and (max-height: 620px) {
+ .trump-declaration {
+ gap: 4px;
+ margin-bottom: 1px;
+ padding: 2px 5px;
+ border-radius: 7px;
+ }
+
+ .declaration-label {
+ min-width: 34px;
+ font-size: 10px;
+ }
+
+ .declaration-slots {
+ gap: 3px;
+ }
+
+ .declaration-slot {
+ height: 28px;
+ border-width: 1px;
+ border-radius: 4px;
+ }
+
+ .declaration-slot .slot-label {
+ font-size: 15px;
+ }
+
+ .declaration-slots.three-six-nine .declaration-slot {
+ height: 38px;
+ }
+
+ .declaration-slot-split {
+ height: 34px;
+ gap: 1px;
+ padding: 2px;
+ }
+
+ .declaration-slot-split .slot-label {
+ font-size: 12px;
+ }
+
+ .declaration-role-actions {
+ gap: 2px;
+ }
+
+ .declaration-role-button {
+ height: 13px;
+ padding: 0 2px;
+ font-size: 8px;
+ line-height: 11px;
+ }
+}
diff --git a/tractor-game-simulator/client/src/components/Game/TrumpDeclaration.jsx b/tractor-game-simulator/client/src/components/Game/TrumpDeclaration.jsx
index 87bff9d..a58b3dc 100644
--- a/tractor-game-simulator/client/src/components/Game/TrumpDeclaration.jsx
+++ b/tractor-game-simulator/client/src/components/Game/TrumpDeclaration.jsx
@@ -10,7 +10,12 @@ import './TrumpDeclaration.css';
* @param {Function} props.onDeclare - 亮主回调 (type, count) => void
* @param {Object} props.currentTrump - 当前主牌 {suit: string, declarationType: 'single'|'pair'|'pair_joker'}
*/
-export default function TrumpDeclaration({ availableDeclarations = [], onDeclare, currentTrump }) {
+export default function TrumpDeclaration({
+ availableDeclarations = [],
+ onDeclare,
+ currentTrump,
+ isThreeSixNine = false
+}) {
const [hoveredSlot, setHoveredSlot] = useState(null);
// 定义五个格子:王、♠、♥、♣、♦
@@ -23,20 +28,23 @@ export default function TrumpDeclaration({ availableDeclarations = [], onDeclare
];
// 获取某个格子的可用亮主选项
- const getSlotOptions = (slotType) => {
- return availableDeclarations.filter(d => d.type === slotType);
+ const getSlotOptions = (slotType, declarationRole = 'trump') => {
+ return availableDeclarations.filter(d => (
+ d.type === slotType
+ && (d.declarationRole || 'trump') === declarationRole
+ ));
};
// 处理点击格子
- const handleSlotClick = (slotType) => {
- const options = getSlotOptions(slotType);
+ const handleSlotClick = (slotType, declarationRole = 'trump') => {
+ const options = getSlotOptions(slotType, declarationRole);
if (options.length === 0) return;
// 如果只有一个选项,直接亮主
if (options.length === 1) {
const option = options[0];
if (option.canDeclare) {
- onDeclare(slotType, option.count);
+ onDeclare(slotType, option.count, declarationRole);
}
return;
}
@@ -45,7 +53,7 @@ export default function TrumpDeclaration({ availableDeclarations = [], onDeclare
if (slotType === 'joker') {
const pairOption = options.find(o => o.count === 2 && o.canDeclare);
if (pairOption) {
- onDeclare(slotType, 2);
+ onDeclare(slotType, 2, declarationRole);
}
return;
}
@@ -54,32 +62,32 @@ export default function TrumpDeclaration({ availableDeclarations = [], onDeclare
const reinforceOption = options.find(o => o.isReinforce && o.canDeclare);
if (reinforceOption) {
// 加固:亮一对
- onDeclare(slotType, 2);
+ onDeclare(slotType, 2, declarationRole);
return;
}
// 花色牌:默认亮一张,后续可以加固
const singleOption = options.find(o => o.count === 1 && o.canDeclare);
if (singleOption) {
- onDeclare(slotType, 1);
+ onDeclare(slotType, 1, declarationRole);
} else {
// 没有单张选项(可能被别人亮过单张了),尝试一对
const pairOption = options.find(o => o.count === 2 && o.canDeclare);
if (pairOption) {
- onDeclare(slotType, 2);
+ onDeclare(slotType, 2, declarationRole);
}
}
};
// 判断格子是否应该高亮(只在玩家可以亮或反时高亮)
- const isSlotActive = (slotType) => {
- const options = getSlotOptions(slotType);
+ const isSlotActive = (slotType, declarationRole = 'trump') => {
+ const options = getSlotOptions(slotType, declarationRole);
return options.some(o => o.canDeclare);
};
// 获取格子的提示文本
- const getSlotTooltip = (slotType) => {
- const options = getSlotOptions(slotType);
+ const getSlotTooltip = (slotType, declarationRole = 'trump') => {
+ const options = getSlotOptions(slotType, declarationRole);
if (options.length === 0) return '';
const tips = options.map(o => o.description).join('\n');
@@ -89,11 +97,41 @@ export default function TrumpDeclaration({ availableDeclarations = [], onDeclare
return (
亮主:
-
+
{slots.map(slot => {
const isActive = isSlotActive(slot.type);
const tooltip = getSlotTooltip(slot.type);
+ if (isThreeSixNine && slot.type !== 'joker') {
+ return (
+
+
{slot.label}
+
+ {['trump', 'inferior'].map(declarationRole => {
+ const roleActive = isSlotActive(slot.type, declarationRole);
+ const roleTooltip = getSlotTooltip(slot.type, declarationRole);
+ return (
+
+ handleSlotClick(slot.type, declarationRole)}
+ >
+ {declarationRole === 'trump' ? '主' : '劣'}
+
+
+ );
+ })}
+
+
+ );
+ }
+
return (
{
form.validateFields().then(values => {
@@ -25,8 +27,9 @@ export default function CreateRoomModal({ visible, onClose, onCreateRoom }) {
initialValues={{
roomName: '我的房间',
playerName: '玩家1',
- bottomCardsCount: 8,
- dealInterval: 500
+ dealInterval: 500,
+ testMode: false,
+ testRuleId: 'normal_game'
}}
>
-
-
-
-
+
+
+ 规则测试模式
+
+
+ 开启后跳过随机二选一,每局直接使用指定规则。
+
+ {testMode && (
+
+
+
+ )}
);
diff --git a/tractor-game-simulator/client/src/components/Room/RoomList.jsx b/tractor-game-simulator/client/src/components/Room/RoomList.jsx
index 943505a..48250c4 100644
--- a/tractor-game-simulator/client/src/components/Room/RoomList.jsx
+++ b/tractor-game-simulator/client/src/components/Room/RoomList.jsx
@@ -33,8 +33,11 @@ export default function RoomList({ rooms, onJoinRoom, onRefresh, loading = false
title: '状态',
dataIndex: 'gameState',
key: 'status',
- render: (gameState) => {
- const phase = gameState?.phase || 'waiting';
+ render: (gameState, record) => {
+ const phase = gameState?.phase || record.phase || 'waiting';
+ if (phase === 'waiting' && gameState?.isWaitingForReady) {
+ return
准备中;
+ }
const statusMap = {
waiting: { text: '等待中', color: 'blue' },
drawing: { text: '摸牌中', color: 'orange' },
@@ -48,16 +51,17 @@ export default function RoomList({ rooms, onJoinRoom, onRefresh, loading = false
}
},
{
- title: '底牌',
+ title: '默认底牌',
key: 'bottomCards',
- render: (_, record) => `${record.config?.bottomCardsCount || 8} 张`
+ render: () => '8 张'
},
{
title: '操作',
key: 'action',
render: (_, record) => {
const isFull = record.playerCount >= record.maxPlayers;
- const phase = record.gameState?.phase;
+ const phase = record.gameState?.phase || record.phase;
+ // 准备阶段仍属于可补位的 waiting;摸牌开始后才锁定座位。
const isPlaying = phase && phase !== 'waiting' && phase !== 'finished';
const canJoin = !isFull && !isPlaying;
diff --git a/tractor-game-simulator/client/src/services/socket.js b/tractor-game-simulator/client/src/services/socket.js
index e903711..b66bb8a 100644
--- a/tractor-game-simulator/client/src/services/socket.js
+++ b/tractor-game-simulator/client/src/services/socket.js
@@ -9,8 +9,14 @@ class SocketService {
connect() {
if (!this.socket) {
this.socket = io(SERVER_URL, {
- transports: ['websocket'],
- autoConnect: true
+ // Allow HTTP polling as a fallback on mobile/proxied networks, then
+ // upgrade to WebSocket when available.
+ transports: ['polling', 'websocket'],
+ autoConnect: true,
+ reconnection: true,
+ reconnectionAttempts: Infinity,
+ reconnectionDelay: 500,
+ reconnectionDelayMax: 5000
});
this.socket.on('connect', () => {
diff --git a/tractor-game-simulator/client/src/store/gameStore.js b/tractor-game-simulator/client/src/store/gameStore.js
index f8371e4..e6e9be1 100644
--- a/tractor-game-simulator/client/src/store/gameStore.js
+++ b/tractor-game-simulator/client/src/store/gameStore.js
@@ -17,24 +17,31 @@ export const useGameStore = create((set, get) => ({
// 主牌信息
trumpSuit: null,
trumpRank: null,
+ inferiorSuit: null,
// Actions
setCurrentRoom: (room) => set({ currentRoom: room }),
setCurrentPlayer: (player) => set({ currentPlayer: player }),
setMyCards: (cards) => set((state) => ({
- myCards: sortCards(cards, state.trumpSuit, state.trumpRank)
+ myCards: sortCards(cards, state.trumpSuit, state.trumpRank, state.inferiorSuit)
})),
setSelectedCards: (cards) => set({ selectedCards: cards }),
setRoomList: (rooms) => set({ roomList: rooms }),
setIsConnected: (status) => set({ isConnected: status }),
// 设置主牌信息
- setTrumpInfo: (trumpSuit, trumpRank) => set((state) => ({
- trumpSuit,
- trumpRank,
- // 主牌变更时自动重新排序手牌
- myCards: sortCards(state.myCards, trumpSuit, trumpRank)
- })),
+ setTrumpInfo: (trumpSuit, trumpRank, inferiorSuit) => set((state) => {
+ const nextInferiorSuit = inferiorSuit === undefined
+ ? state.inferiorSuit
+ : inferiorSuit;
+ return {
+ trumpSuit,
+ trumpRank,
+ inferiorSuit: nextInferiorSuit,
+ // 主牌或劣花色变更时自动重新排序手牌
+ myCards: sortCards(state.myCards, trumpSuit, trumpRank, nextInferiorSuit)
+ };
+ }),
// 切换选中的牌
toggleCardSelection: (cardId) => set((state) => {
@@ -51,7 +58,12 @@ export const useGameStore = create((set, get) => ({
// 添加手牌
addCard: (card) => set((state) => ({
- myCards: sortCards([...state.myCards, card], state.trumpSuit, state.trumpRank)
+ myCards: sortCards(
+ [...state.myCards, card],
+ state.trumpSuit,
+ state.trumpRank,
+ state.inferiorSuit
+ )
})),
// 移除手牌
@@ -72,6 +84,7 @@ export const useGameStore = create((set, get) => ({
selectedCards: [],
isConnected: false,
trumpSuit: null,
- trumpRank: null
+ trumpRank: null,
+ inferiorSuit: null
})
}));
diff --git a/tractor-game-simulator/client/src/styles/App.css b/tractor-game-simulator/client/src/styles/App.css
index 5dbc881..1252fe9 100644
--- a/tractor-game-simulator/client/src/styles/App.css
+++ b/tractor-game-simulator/client/src/styles/App.css
@@ -2,3 +2,178 @@
.app-container {
min-height: 100vh;
}
+
+.room-overview-layout {
+ /* #root 的牌桌模式固定为一屏高;房间页内容可能超过一屏,必须让根节点
+ 跟随内容增长,否则滚动到底部会露出 body 的深色牌桌底色。 */
+ flex: 0 0 auto;
+ min-height: 100vh;
+ background:
+ radial-gradient(circle at 50% 0%, rgba(37, 118, 92, 0.11), transparent 38%),
+ #f3f6f5;
+}
+
+#root:has(> .room-overview-layout) {
+ height: auto;
+ min-height: 100vh;
+ background: #f3f6f5;
+}
+
+.room-overview-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 16px;
+ padding: 0 24px;
+ background: #001f1a;
+}
+
+.room-overview-header .ant-typography {
+ overflow: hidden;
+ margin: 16px 0;
+ color: white;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.room-overview-content {
+ width: min(1180px, calc(100% - 32px));
+ margin: 0 auto;
+ padding: 28px 0 48px;
+}
+
+.room-overview-card {
+ padding: clamp(24px, 4vw, 46px);
+ border: 1px solid rgba(20, 82, 63, 0.1);
+ border-radius: 18px;
+ background: rgba(255, 255, 255, 0.96);
+ box-shadow: 0 18px 48px rgba(16, 65, 52, 0.09);
+}
+
+.room-overview-title-row {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: 20px;
+}
+
+.room-overview-title-row .ant-typography {
+ margin: 0 0 6px;
+}
+
+.room-id,
+.room-player-count {
+ color: #5d6c67;
+ font-size: 15px;
+}
+
+.room-player-count {
+ margin: 26px 0 18px;
+}
+
+.room-resume-panel {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 24px;
+ margin-top: 24px;
+ padding: 18px 20px;
+ border: 1px solid rgba(40, 167, 114, 0.3);
+ border-radius: 14px;
+ background: linear-gradient(135deg, rgba(231, 250, 240, 0.96), rgba(244, 252, 248, 0.96));
+}
+
+.room-resume-copy {
+ display: flex;
+ min-width: 0;
+ flex-direction: column;
+ gap: 4px;
+}
+
+.room-resume-copy strong {
+ color: #155f42;
+ font-size: 17px;
+}
+
+.room-resume-copy span {
+ color: #557066;
+ line-height: 1.6;
+}
+
+.room-resume-panel .ant-btn {
+ min-width: 128px;
+ flex: 0 0 auto;
+}
+
+.room-section {
+ margin-bottom: 26px;
+}
+
+.room-section > .ant-typography {
+ margin-bottom: 12px;
+}
+
+.room-player-row {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 18px;
+ margin-bottom: 8px;
+ padding: 11px 14px;
+ border: 1px solid transparent;
+ border-radius: 9px;
+ background: #f5f7f6;
+}
+
+.room-player-row.is-self {
+ border-color: rgba(40, 167, 114, 0.25);
+ background: #f0faf5;
+}
+
+.room-player-name {
+ display: flex;
+ min-width: 0;
+ align-items: center;
+ gap: 7px;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.room-player-progress {
+ flex: 0 0 auto;
+ color: #66736f;
+}
+
+.room-overview-actions {
+ padding-top: 4px;
+}
+
+@media (max-width: 680px) {
+ .room-overview-header {
+ padding: 0 14px;
+ }
+
+ .room-overview-header .ant-typography {
+ font-size: 17px;
+ }
+
+ .room-overview-content {
+ width: min(100% - 20px, 1180px);
+ padding-top: 12px;
+ }
+
+ .room-resume-panel,
+ .room-player-row {
+ align-items: stretch;
+ flex-direction: column;
+ }
+
+ .room-resume-panel .ant-btn {
+ width: 100%;
+ }
+
+ .room-player-progress {
+ padding-left: 20px;
+ }
+}
diff --git a/tractor-game-simulator/client/src/styles/global.css b/tractor-game-simulator/client/src/styles/global.css
index db9bda8..bf45692 100644
--- a/tractor-game-simulator/client/src/styles/global.css
+++ b/tractor-game-simulator/client/src/styles/global.css
@@ -8,6 +8,8 @@ body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
+ background: #073b35;
+ color: #eef8f0;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
@@ -15,6 +17,7 @@ body {
#root {
width: 100%;
height: 100vh;
+ height: 100dvh;
display: flex;
flex-direction: column;
}
diff --git a/tractor-game-simulator/client/src/utils/actionAvailability.js b/tractor-game-simulator/client/src/utils/actionAvailability.js
new file mode 100644
index 0000000..f7f3766
--- /dev/null
+++ b/tractor-game-simulator/client/src/utils/actionAvailability.js
@@ -0,0 +1,1130 @@
+import {
+ getEffectiveSuit,
+ PatternTypes,
+ resolveClusterAnalysisPlay,
+ resolveForbiddenMagicPlay,
+ resolveJokerSubstitutionPlay,
+ validateFollowingPlay,
+ validateLeadingPlay
+} from './cardPatternUtils.js';
+import { PlayModes } from './constants.js';
+import { ruleIncludesId } from './ruleCatalog.js';
+
+export function isCurrentPlayersTurn(gameState, players = [], currentPlayerId = null) {
+ if (!gameState || !currentPlayerId) return false;
+ if (currentPlayerId === gameState.openHandPlayerId) return false;
+ if (gameState.playMode === PlayModes.FREE) return true;
+ if (!Number.isInteger(gameState.currentPlayerIndex)) return false;
+ const turnPlayerId = players[gameState.currentPlayerIndex]?.id;
+ if (turnPlayerId === gameState.openHandPlayerId) {
+ return gameState.openHandControllerPlayerId === currentPlayerId;
+ }
+ return turnPlayerId === currentPlayerId;
+}
+
+export function getFinalTrickAutoSelectedCardIds({
+ gameState,
+ players = [],
+ handCards = []
+} = {}) {
+ const playedPlayerIndexes = gameState?.playersPlayedThisRound;
+ const requiredLength = Number(gameState?.leadingPattern?.length);
+ if (
+ !Array.isArray(playedPlayerIndexes)
+ || playedPlayerIndexes.length === 0
+ || !Number.isInteger(requiredLength)
+ || requiredLength < 1
+ || !Array.isArray(handCards)
+ || handCards.length !== requiredLength
+ ) {
+ return null;
+ }
+
+ // “一号位”指本墩首家,不一定是固定座位 0。
+ const leadingPlayer = players[playedPlayerIndexes[0]];
+ if (!leadingPlayer || Number(leadingPlayer.cardsCount) !== 0) return null;
+
+ const cardIds = handCards.map(card => card?.id);
+ return cardIds.every(Boolean) ? cardIds : null;
+}
+
+export function isBurySelectionValid(selectedCardIds = [], requiredCount = 0) {
+ return Number.isInteger(requiredCount) &&
+ requiredCount > 0 &&
+ selectedCardIds.length === requiredCount;
+}
+
+export function getPlayActionLabel({ isActiveSkillArmed = false, activeSkill = null } = {}) {
+ const isSmallDiscardSkill = activeSkill?.effect === 'free_discard_treated_small'
+ || activeSkill?.id === 'substitute_sacrifice';
+ return isActiveSkillArmed && isSmallDiscardSkill ? '垫牌' : '出牌';
+}
+
+export function getActiveBuryingPlayerId(gameState) {
+ return gameState?.peopleCommune?.currentBuryingPlayerId
+ || gameState?.secondaryBuryingPlayerId
+ || gameState?.buryingPlayerId
+ || null;
+}
+
+export function canPlayerViewBottomCards({
+ gameState,
+ currentPlayerId,
+ selectedRule = null,
+ bottomCardsCount = 0
+} = {}) {
+ if (!gameState || !currentPlayerId || bottomCardsCount <= 0) return false;
+ const activeRule = gameState.selectedRule || selectedRule;
+ const isPeopleCommune = ruleIncludesId(activeRule, 'people_commune');
+ if (isPeopleCommune) {
+ return Boolean(
+ gameState.peopleCommune?.submittedPlayerIds?.includes(currentPlayerId)
+ );
+ }
+ if (ruleIncludesId(activeRule, 'administrative_review')) {
+ if (!gameState.administrativeReview?.isBottomReleased) return false;
+ }
+ if (ruleIncludesId(activeRule, 'openly_revealed')) return true;
+
+ const isDealer = gameState.buryingPlayerId === currentPlayerId;
+ const isReformAndOpeningUp = ruleIncludesId(activeRule, 'reform_and_opening_up');
+ if (!isReformAndOpeningUp) return isDealer;
+
+ // 首次埋底时庄家照常查看;底牌交给队友后暂时没有“最终底牌”,因此隐藏按钮。
+ if (gameState.phase === 'burying') {
+ return isDealer && !gameState.secondaryBuryingPlayerId;
+ }
+ return isDealer
+ || gameState.reformAndOpeningUpTeammatePlayerId === currentPlayerId;
+}
+
+function isLeadingPlayState(gameState) {
+ const noRecordedPlays = Array.isArray(gameState?.currentRoundPlays)
+ ? gameState.currentRoundPlays.length === 0
+ : gameState?.currentRoundPlays === 0;
+ const noPlayedPlayers = Array.isArray(gameState?.playersPlayedThisRound)
+ && gameState.playersPlayedThisRound.length === 0;
+ return noRecordedPlays || noPlayedPlayers;
+}
+
+export function mapOneCountryCardsForCurrentPlayer(cards, gameState) {
+ const resolution = gameState?.oneCountryTwoSystems?.resolved;
+ const playerIndex = gameState?.currentPlayerIndex;
+ if (
+ !ruleIncludesId(gameState?.selectedRule, 'one_country_two_systems')
+ || !resolution?.hasDistinctTeamSuits
+ || !Number.isInteger(playerIndex)
+ || playerIndex % 2 === resolution.dealerTeamIndex
+ ) {
+ return cards;
+ }
+
+ return cards.map(card => {
+ if (card.suit === resolution.attackerSuit) {
+ return { ...card, suit: resolution.dealerSuit, oneCountryOriginalSuit: card.suit };
+ }
+ if (card.suit === resolution.dealerSuit) {
+ return { ...card, suit: resolution.attackerSuit, oneCountryOriginalSuit: card.suit };
+ }
+ return card;
+ });
+}
+
+export function getRuleDisabledCardIds({
+ gameState,
+ handCards = [],
+ players = [],
+ currentPlayerId = null
+} = {}) {
+ if (ruleIncludesId(gameState?.selectedRule, 'bush_gate') && isLeadingPlayState(gameState)) {
+ const restriction = gameState?.bushGate?.restriction;
+ if (
+ restriction?.round === gameState.currentRound
+ && restriction?.leaderPlayerId === currentPlayerId
+ ) {
+ const forbiddenIds = new Set(restriction.forbiddenCardIds || []);
+ return handCards.filter(card => forbiddenIds.has(card.id)).map(card => card.id);
+ }
+ }
+
+ if (ruleIncludesId(gameState?.selectedRule, 'rites_collapse') && isLeadingPlayState(gameState)) {
+ const roundLeaderId = Number.isInteger(gameState?.roundStartPlayerIndex)
+ ? players[gameState.roundStartPlayerIndex]?.id
+ : null;
+ if (roundLeaderId === currentPlayerId && handCards.some(card => card.rank !== 'A')) {
+ return handCards.filter(card => card.rank === 'A').map(card => card.id);
+ }
+ }
+
+ if (ruleIncludesId(gameState?.selectedRule, 'birds_gone_bow_hidden') && isLeadingPlayState(gameState)) {
+ const roundLeaderId = Number.isInteger(gameState?.roundStartPlayerIndex)
+ ? players[gameState.roundStartPlayerIndex]?.id
+ : null;
+ if (currentPlayerId && roundLeaderId && roundLeaderId !== currentPlayerId) return [];
+ const exhaustedSuits = new Set(
+ gameState?.birdsGoneBowHidden?.exhaustedSuits || []
+ );
+ const restrictedCards = handCards.filter(card => exhaustedSuits.has(
+ getEffectiveSuit(card, gameState?.trumpSuit, gameState?.trumpRank)
+ ));
+ if (handCards.length - restrictedCards.length >= 1) {
+ return restrictedCards.map(card => card.id);
+ }
+ return [];
+ }
+
+ const cooldownType = gameState?.cardCooldown?.type;
+ const expectedType = ruleIncludesId(gameState?.selectedRule, 'cooldown_time')
+ ? 'rank'
+ : ruleIncludesId(gameState?.selectedRule, 'time_cooling')
+ ? 'suit'
+ : null;
+ if (!expectedType || cooldownType !== expectedType || !currentPlayerId) return [];
+ const restrictedValues = new Set(
+ gameState.cardCooldown?.valuesByPlayerId?.[currentPlayerId] || []
+ );
+ if (restrictedValues.size === 0) return [];
+ let restrictedCards = handCards.filter(card => (
+ restrictedValues.has(
+ expectedType === 'rank'
+ ? card.rank
+ : getEffectiveSuit(card, gameState?.trumpSuit, gameState?.trumpRank)
+ )
+ ));
+ const isLeading = isLeadingPlayState(gameState);
+ const leadingSuit = !isLeading ? gameState?.leadingPattern?.suit : null;
+ if (leadingSuit) {
+ // 跟牌花色优先:冷却不能把仍持有的首花色牌禁掉,否则自动跟牌与禁用状态会死锁。
+ restrictedCards = restrictedCards.filter(card => (
+ getEffectiveSuit(card, gameState?.trumpSuit, gameState?.trumpRank) !== leadingSuit
+ ));
+ }
+ const requiredCount = isLeading
+ ? 1
+ : (gameState?.leadingPattern?.length || 1);
+ return handCards.length - restrictedCards.length >= requiredCount
+ ? restrictedCards.map(card => card.id)
+ : [];
+}
+
+// 兼容已有调用;该函数现在也负责整轮生效的冷却牌。
+export const getRuleDisabledLeadCardIds = getRuleDisabledCardIds;
+
+export function getRuleDisabledCardReason(gameState) {
+ if (ruleIncludesId(gameState?.selectedRule, 'bush_gate')) {
+ return '布什戈门:重新首发不能包含刚刚被收回的任意一张牌';
+ }
+ if (ruleIncludesId(gameState?.selectedRule, 'birds_gone_bow_hidden')) {
+ return '鸟尽弓藏:该花色的分数牌已经全部打出,不能再主动打出该花色';
+ }
+ if (ruleIncludesId(gameState?.selectedRule, 'cooldown_time')) {
+ return '冷却时间:该点数在本轮冷却中';
+ }
+ if (ruleIncludesId(gameState?.selectedRule, 'time_cooling')) {
+ return '时间冷却:该花色在本轮冷却中';
+ }
+ return '礼崩乐坏:一号位不能主动打出A';
+}
+
+function getTurnPlayerIndexAtOffset(currentPlayerIndex, offset, playerCount, roomConfig = {}) {
+ const customTurnOrder = roomConfig?.customTurnOrder;
+ if (roomConfig?.turnOrder === 'custom' && Array.isArray(customTurnOrder)) {
+ const currentPosition = customTurnOrder.indexOf(currentPlayerIndex);
+ if (currentPosition >= 0) {
+ return customTurnOrder[(currentPosition + offset + playerCount) % playerCount];
+ }
+ }
+ return (currentPlayerIndex + offset + playerCount) % playerCount;
+}
+
+export function getActiveSkillAvailability({
+ gameState,
+ players = [],
+ currentPlayerId = null,
+ roomConfig = {},
+ handCards = []
+}) {
+ const skill = gameState?.selectedRule?.activeSkill || null;
+ if (!skill || !currentPlayerId) {
+ return {
+ visible: false,
+ skill,
+ canActivate: false,
+ isUsed: false,
+ reason: ''
+ };
+ }
+
+ const usedSkills = gameState?.activeSkillUsesByPlayerId?.[currentPlayerId] || [];
+ const skillEffect = skill.effect || (
+ skill.id === 'substitute_sacrifice' ? 'free_discard_treated_small' : null
+ );
+ const skillTiming = skill.timing || (
+ skill.id === 'stealing_beams' ? 'any_play' : 'following_play'
+ );
+ const isUsed = skill.usageLimit !== null && usedSkills.includes(skill.id);
+ if (skillEffect === 'two_legal_plays_choose_at_round_end') {
+ const isCurrentTurn = isCurrentPlayersTurn(gameState, players, currentPlayerId);
+ const playedCount = Array.isArray(gameState?.playersPlayedThisRound)
+ ? gameState.playersPlayedThisRound.length
+ : Array.isArray(gameState?.currentRoundPlays)
+ ? gameState.currentRoundPlays.length
+ : Number(gameState?.currentRoundPlays || 0);
+ const priorActivatorIds = gameState?.ambiguous?.activePlayerIds || [];
+ const isFreeActivation = priorActivatorIds.length > 0;
+ const isMiddlePosition = playedCount === 1 || playedCount === 2;
+ const canActivate = isCurrentTurn
+ && isMiddlePosition
+ && (!isUsed || isFreeActivation);
+ let reason = '仅本轮二、三号位可以发动';
+ if (!isCurrentTurn) reason = '轮到你出牌时才能发动';
+ else if (playedCount === 0) reason = '一号位不能发动模棱两可';
+ else if (playedCount >= 3) reason = '四号位发动没有意义,不能发动';
+ else if (isUsed && !isFreeActivation) reason = '本局已经发动过';
+ else if (isFreeActivation) reason = '本轮已有玩家发动,你作为后续发动者无需消耗次数';
+ else reason = '先保存方案A,再选择不同且同样合法的方案B并公开展示';
+ return {
+ visible: true,
+ skill,
+ canActivate,
+ isUsed,
+ isFreeActivation,
+ reason
+ };
+ }
+ if (skillEffect === 'demote_trumps_and_transform') {
+ const isActive = Boolean(
+ gameState?.forbiddenMagic?.activePlayerIds?.includes(currentPlayerId)
+ );
+ if (isActive) {
+ return {
+ visible: true,
+ skill,
+ canActivate: false,
+ isUsed: true,
+ isActive: true,
+ reason: '本局已永久生效:你的原有主牌均按副牌处理,可点击“转”改变牌面'
+ };
+ }
+ if (isUsed) {
+ return {
+ visible: true,
+ skill,
+ canActivate: false,
+ isUsed: true,
+ reason: '本局已经发动过'
+ };
+ }
+ const reservation = gameState?.forbiddenMagic?.reservations?.find(
+ item => item.playerId === currentPlayerId
+ );
+ if (reservation) {
+ return {
+ visible: true,
+ skill,
+ canActivate: false,
+ isUsed: false,
+ isReserved: true,
+ targetRound: reservation.targetRound,
+ reason: `已预备,将在第${reservation.targetRound}轮开始时确认;暂不发动不会消耗机会`
+ };
+ }
+ const hasRoundPlay = Array.isArray(gameState?.currentRoundPlays)
+ ? gameState.currentRoundPlays.length > 0
+ : Number(gameState?.currentRoundPlays || 0) > 0;
+ const hasPlayedPlayer = Array.isArray(gameState?.playersPlayedThisRound)
+ && gameState.playersPlayedThisRound.length > 0;
+ if (
+ gameState?.phase !== 'playing'
+ || !Number.isInteger(gameState?.currentRound)
+ || gameState.currentRound < 1
+ ) {
+ return {
+ visible: true,
+ skill,
+ canActivate: false,
+ isUsed: false,
+ reason: '进入出牌阶段后才能预备'
+ };
+ }
+ const targetRound = hasRoundPlay || hasPlayedPlayer
+ ? gameState.currentRound + 1
+ : gameState.currentRound;
+ return {
+ visible: true,
+ skill,
+ canActivate: true,
+ isUsed: false,
+ targetRound,
+ reason: `随时可以预备;将在第${targetRound}轮开始时逐个确认,确认后本局永久生效`
+ };
+ }
+ if (skillEffect === 'silence_non_leader_for_round') {
+ const lureTiger = gameState?.lureTiger || {};
+ const playerIndex = players.findIndex(player => player.id === currentPlayerId);
+ const teamIndex = playerIndex >= 0 ? playerIndex % 2 : null;
+ const isTeamUsed = teamIndex !== null
+ && lureTiger.usedTeamIndexes?.includes(teamIndex);
+ if (isTeamUsed) {
+ return {
+ visible: true,
+ skill,
+ canActivate: false,
+ isUsed: true,
+ isTeamUsed: true,
+ reason: usedSkills.includes(skill.id)
+ ? '你已抢先使用本方阵营的唯一一次调虎离山'
+ : '队友已抢先使用本方阵营的唯一一次调虎离山'
+ };
+ }
+ const reservation = lureTiger.reservations?.find(
+ item => item.playerId === currentPlayerId
+ );
+ if (reservation) {
+ return {
+ visible: true,
+ skill,
+ canActivate: false,
+ isUsed: false,
+ isReserved: true,
+ targetRound: reservation.targetRound,
+ reason: `已预备,将在第${reservation.targetRound}轮开始时询问;放弃不会占用本方次数`
+ };
+ }
+ if (
+ gameState?.phase !== 'playing'
+ || !Number.isInteger(gameState?.currentRound)
+ || gameState.currentRound < 1
+ ) {
+ return {
+ visible: true,
+ skill,
+ canActivate: false,
+ isUsed: false,
+ reason: '进入出牌阶段后才能预备'
+ };
+ }
+ const hasRoundPlay = Array.isArray(gameState?.currentRoundPlays)
+ ? gameState.currentRoundPlays.length > 0
+ : Number(gameState?.currentRoundPlays || 0) > 0;
+ const hasPlayedPlayer = Array.isArray(gameState?.playersPlayedThisRound)
+ && gameState.playersPlayedThisRound.length > 0;
+ const targetRound = hasRoundPlay || hasPlayedPlayer
+ ? gameState.currentRound + 1
+ : gameState.currentRound;
+ return {
+ visible: true,
+ skill,
+ canActivate: true,
+ isUsed: false,
+ targetRound,
+ reason: `随时可以预备;将在第${targetRound}轮开始、首张牌打出前按座次确认`
+ };
+ }
+ if (isUsed) {
+ return {
+ visible: true,
+ skill,
+ canActivate: false,
+ isUsed: true,
+ reason: '本局已经发动过'
+ };
+ }
+
+ if (skillEffect === 'sleep_random_play') {
+ const isSleeping = gameState?.dreamKilling?.sleepingPlayerIds?.includes(currentPlayerId);
+ if (isSleeping) {
+ return {
+ visible: true,
+ skill,
+ canActivate: false,
+ isUsed: false,
+ reason: '你仍在梦中,轮到你时由系统随机出牌'
+ };
+ }
+ if (gameState?.phase !== 'playing' || handCards.length === 0) {
+ return {
+ visible: true,
+ skill,
+ canActivate: false,
+ isUsed: false,
+ reason: '进入出牌阶段且仍有手牌时才能发动'
+ };
+ }
+ if (handCards.some(card => (
+ getEffectiveSuit(card, gameState?.trumpSuit, gameState?.trumpRank) === 'trump'
+ ))) {
+ return {
+ visible: true,
+ skill,
+ canActivate: false,
+ isUsed: false,
+ reason: '手牌中仍有主牌,不能发动'
+ };
+ }
+ return {
+ visible: true,
+ skill,
+ canActivate: true,
+ isUsed: false,
+ reason: '暗置全部手牌,之后由系统完全随机出牌且不检查合法性,命中首家花色或点数后醒来'
+ };
+ }
+
+ if (skillEffect === 'rewind_completed_round') {
+ const reversal = gameState?.timeReversal || {};
+ const targetRound = ['holding', 'awaiting_response'].includes(reversal.decisionState)
+ ? reversal.windowRound
+ : gameState?.currentRound;
+ if (reversal.lockedRounds?.includes(targetRound)) {
+ return {
+ visible: true,
+ skill,
+ canActivate: false,
+ isUsed: false,
+ reason: '本轮已经发动过时间倒流'
+ };
+ }
+ const ownReservation = reversal.reservations?.find(
+ reservation => reservation.playerId === currentPlayerId
+ );
+ if (ownReservation) {
+ return {
+ visible: true,
+ skill,
+ canActivate: false,
+ isUsed: false,
+ reason: `你已预备第${ownReservation.round}轮时间倒流`
+ };
+ }
+ if (reversal.decisionState === 'awaiting_response') {
+ return {
+ visible: true,
+ skill,
+ canActivate: false,
+ isUsed: false,
+ reason: '本轮预备窗口已经结束'
+ };
+ }
+ if (gameState?.phase !== 'playing' || !Number.isInteger(targetRound) || targetRound < 1) {
+ return {
+ visible: true,
+ skill,
+ canActivate: false,
+ isUsed: false,
+ reason: '进入出牌阶段后才能预备'
+ };
+ }
+ return {
+ visible: true,
+ skill,
+ canActivate: true,
+ isUsed: false,
+ reason: '预备回溯本轮;轮末停留后再确认是否发动'
+ };
+ }
+
+ if (!isCurrentPlayersTurn(gameState, players, currentPlayerId)) {
+ return {
+ visible: true,
+ skill,
+ canActivate: false,
+ isUsed: false,
+ reason: '还没有轮到你出牌'
+ };
+ }
+
+ const isFollowing = Boolean(gameState?.leadingPattern) && !(
+ gameState?.currentRoundPlays === 0 ||
+ (Array.isArray(gameState?.playersPlayedThisRound) && gameState.playersPlayedThisRound.length === 0)
+ );
+ if (skillTiming === 'following_play' && !isFollowing) {
+ return {
+ visible: true,
+ skill,
+ canActivate: false,
+ isUsed: false,
+ reason: '只能在跟牌时发动'
+ };
+ }
+ if (skillTiming === 'leading_play' && isFollowing) {
+ return {
+ visible: true,
+ skill,
+ canActivate: false,
+ isUsed: false,
+ reason: '只能在首发时发动'
+ };
+ }
+
+ if (skillEffect === 'ignore_odd_led_side_suit') {
+ const leadingSuit = gameState?.leadingPattern?.suit;
+ if (!isFollowing || !leadingSuit) {
+ return {
+ visible: true,
+ skill,
+ canActivate: false,
+ isUsed: false,
+ reason: '只能在跟牌时发动'
+ };
+ }
+ if (leadingSuit === 'trump') {
+ return {
+ visible: true,
+ skill,
+ canActivate: false,
+ isUsed: false,
+ reason: '当前要求跟出主牌,虚虚实实只能虚置副花色'
+ };
+ }
+ const virtualizedCards = handCards.filter(card => (
+ getEffectiveSuit(card, gameState?.trumpSuit, gameState?.trumpRank) === leadingSuit
+ ));
+ if (virtualizedCards.length === 0 || virtualizedCards.length % 2 === 0) {
+ return {
+ visible: true,
+ skill,
+ canActivate: false,
+ isUsed: false,
+ ignoredSuit: leadingSuit,
+ virtualizedCardIds: [],
+ reason: virtualizedCards.length === 0
+ ? '手中没有当前要求跟出的副花色'
+ : `当前副花色剩余${virtualizedCards.length}张,不是奇数张`
+ };
+ }
+ const requiredCount = gameState?.leadingPattern?.length || 1;
+ if (handCards.length - virtualizedCards.length < requiredCount) {
+ return {
+ visible: true,
+ skill,
+ canActivate: false,
+ isUsed: false,
+ ignoredSuit: leadingSuit,
+ virtualizedCardIds: [],
+ reason: `虚置后其余手牌不足${requiredCount}张,无法完成本次出牌`
+ };
+ }
+ return {
+ visible: true,
+ skill,
+ canActivate: true,
+ isUsed: false,
+ ignoredSuit: leadingSuit,
+ virtualizedCardIds: virtualizedCards.map(card => card.id),
+ reason: `虚置${virtualizedCards.length}张当前副花色牌,本次视为该花色缺门`
+ };
+ }
+
+ if (skillTiming === 'third_position_before_play') {
+ const playedPlayerIndexes = Array.isArray(gameState?.playersPlayedThisRound)
+ ? gameState.playersPlayedThisRound
+ : [];
+ const secondPlayerIndex = playedPlayerIndexes.at(-1);
+ const expectedThirdPlayerIndex = Number.isInteger(secondPlayerIndex)
+ ? getTurnPlayerIndexAtOffset(secondPlayerIndex, 1, players.length, roomConfig)
+ : null;
+ if (
+ playedPlayerIndexes.length !== 2
+ || gameState.currentPlayerIndex !== expectedThirdPlayerIndex
+ ) {
+ return {
+ visible: true,
+ skill,
+ canActivate: false,
+ isUsed: false,
+ reason: '只能在作为本轮三号位出牌前发动'
+ };
+ }
+ }
+
+ if (skillTiming === 'first_position_before_play') {
+ const playedPlayerIndexes = Array.isArray(gameState?.playersPlayedThisRound)
+ ? gameState.playersPlayedThisRound
+ : [];
+ if (
+ !isLeadingPlayState(gameState)
+ || playedPlayerIndexes.length !== 0
+ || gameState.currentPlayerIndex !== gameState.roundStartPlayerIndex
+ ) {
+ return {
+ visible: true,
+ skill,
+ canActivate: false,
+ isUsed: false,
+ reason: '只能在作为本轮一号位出牌前发动'
+ };
+ }
+ }
+
+ if (skillTiming === 'second_position_after_lead') {
+ const playedPlayerIndexes = Array.isArray(gameState?.playersPlayedThisRound)
+ ? gameState.playersPlayedThisRound
+ : [];
+ const leaderIndex = playedPlayerIndexes[0];
+ const expectedSecondPlayerIndex = Number.isInteger(leaderIndex)
+ ? getTurnPlayerIndexAtOffset(leaderIndex, 1, players.length, roomConfig)
+ : null;
+ const currentRoundPlayCount = Array.isArray(gameState?.currentRoundPlays)
+ ? gameState.currentRoundPlays.length
+ : Number(gameState?.currentRoundPlays || 0);
+ if (
+ playedPlayerIndexes.length !== 1
+ || currentRoundPlayCount !== 1
+ || gameState.currentPlayerIndex !== expectedSecondPlayerIndex
+ ) {
+ return {
+ visible: true,
+ skill,
+ canActivate: false,
+ isUsed: false,
+ reason: '只能由本轮二号位在一号位出牌后、自己出牌前发动'
+ };
+ }
+ const leader = players[leaderIndex];
+ if (!leader || Number(leader.cardsCount) < 1) {
+ return {
+ visible: true,
+ skill,
+ canActivate: false,
+ isUsed: false,
+ reason: '一号位已经没有其他手牌,无法要求其改用别的牌重新首发'
+ };
+ }
+ }
+
+ if (skillEffect === 'transform_matching_card') {
+ if (gameState?.divineWeapon?.usedThisRound) {
+ return {
+ visible: true,
+ skill,
+ canActivate: false,
+ isUsed: false,
+ reason: '本轮已有玩家发动神兵天降'
+ };
+ }
+ if ((gameState?.divineWeapon?.cards || []).length !== 2) {
+ return {
+ visible: true,
+ skill,
+ canActivate: false,
+ isUsed: false,
+ reason: '本轮神兵牌尚未就位'
+ };
+ }
+ }
+
+ return {
+ visible: true,
+ skill,
+ canActivate: true,
+ isUsed: false,
+ reason: skillEffect === 'yield_turn_to_next_player'
+ ? '令下家先出牌,自己改为本轮最后出牌'
+ : skillEffect === 'temporarily_replace_trump'
+ ? '先选择革花色或革点数,再声明目标;替换原主并持续本轮与下一轮'
+ : skillEffect === 'declare_target_card'
+ ? '指定一名玩家和一种实体牌面;其本轮打出一张或多张都只扣5分'
+ : skillEffect === 'temporary_teammate_card_transfer'
+ ? '向队友请求0至2张牌,或交给队友1至2张牌;轮末由收牌者等量返还'
+ : skillEffect === 'compare_and_exchange'
+ ? '点击另一名玩家的玩家框,与其各选一张牌拼点并交换'
+ : skillEffect === 'swap_two_plays_at_round_end'
+ ? '暗选两名其他玩家,仅在本轮结算时交换二者的出牌结果'
+ : skillEffect === 'force_leader_replay'
+ ? '令一号位收回本次首发;被收回的每张牌均不能用于紧接着的重新首发'
+ : skillEffect === 'concealed_until_round_end'
+ ? '本次出牌暗置,到本轮结束时同时公开并正常结算'
+ : skillEffect === 'joker_wildcards'
+ ? '点击王牌明确选择目标花色和点数,再自行选牌出牌'
+ : skillEffect === 'belt_and_road_lead'
+ ? '首发同一有效花色的两张非对子单牌'
+ : skillEffect === 'transform_matching_card'
+ ? '先选择牌桌中央的一张神兵牌,再选择一张同花色或同点数手牌转化'
+ : skillEffect === 'adjacent_rank_transform'
+ ? '可依次点击多张普通牌选择相邻目标点数,再自行选牌出牌'
+ : '发动后可任意垫牌,本次出牌始终视为小'
+ };
+}
+
+export function validatePlaySelection({
+ selectedCardIds = [],
+ handCards = [],
+ gameState,
+ trumpSuit = null,
+ trumpRank = null,
+ activeSkillId = null,
+ currentPlayerId = null,
+ jokerSubstitutions = [],
+ clusterAnalysisSubstitutions = [],
+ forbiddenMagicSubstitutions = [],
+ divineWeaponCardId = null,
+ divineWeaponSourceCardId = null
+}) {
+ if (gameState?.timeReversal?.decisionState) {
+ return {
+ valid: false,
+ message: '本轮正在等待时间倒流决定',
+ pattern: null
+ };
+ }
+
+ if (selectedCardIds.length === 0) {
+ return { valid: false, message: '请选择要出的牌', pattern: null };
+ }
+
+ const selectedIdSet = new Set(selectedCardIds);
+ const selectedCards = handCards.filter(card => selectedIdSet.has(card.id));
+ if (selectedCards.length !== selectedIdSet.size) {
+ return { valid: false, message: '选中的牌已不在手牌中', pattern: null };
+ }
+
+ const isLeading = isLeadingPlayState(gameState);
+ const activeRuleContext = gameState?.selectedRule
+ ? {
+ ...gameState.selectedRule,
+ currentRound: gameState.currentRound,
+ inferiorSuit: gameState?.threeSixNine?.inferiorSuit || null,
+ antinomySplitFaceKeys: Object.values(
+ gameState?.antinomy?.declarationsByPlayerId || {}
+ )
+ .filter(declaration => declaration?.effective)
+ .map(declaration => declaration.faceKey)
+ }
+ : null;
+
+ if (
+ isLeading
+ && ruleIncludesId(gameState?.selectedRule, 'rites_collapse')
+ && selectedCards.some(card => card.rank === 'A')
+ && handCards.some(card => card.rank !== 'A')
+ ) {
+ return {
+ valid: false,
+ message: '礼崩乐坏:一号位不能主动打出A',
+ pattern: null
+ };
+ }
+
+ const ruleDisabledCardIds = new Set(getRuleDisabledCardIds({
+ gameState,
+ handCards,
+ currentPlayerId
+ }));
+ if (selectedCards.some(card => ruleDisabledCardIds.has(card.id))) {
+ return {
+ valid: false,
+ message: getRuleDisabledCardReason(gameState),
+ pattern: null
+ };
+ }
+ const availableHandCards = handCards.filter(card => !ruleDisabledCardIds.has(card.id));
+ const followingObligationCards = ruleIncludesId(gameState?.selectedRule, 'wooden_ox_flowing_horse')
+ ? availableHandCards.filter(card => !card.isWoodenOxCard)
+ : availableHandCards;
+
+ if (ruleIncludesId(gameState?.selectedRule, 'one_country_two_systems')) {
+ const effectiveSelectedCards = mapOneCountryCardsForCurrentPlayer(
+ selectedCards,
+ gameState
+ );
+ const effectiveHandCards = mapOneCountryCardsForCurrentPlayer(
+ availableHandCards,
+ gameState
+ );
+ return isLeading || !gameState?.leadingPattern
+ ? validateLeadingPlay(
+ effectiveSelectedCards,
+ trumpSuit,
+ trumpRank,
+ activeRuleContext
+ )
+ : validateFollowingPlay(
+ effectiveSelectedCards,
+ effectiveHandCards,
+ gameState.leadingPattern,
+ trumpSuit,
+ trumpRank,
+ activeRuleContext
+ );
+ }
+
+ if (
+ ruleIncludesId(gameState?.selectedRule, 'forbidden_magic')
+ && gameState?.forbiddenMagic?.activePlayerIds?.includes(currentPlayerId)
+ ) {
+ return resolveForbiddenMagicPlay({
+ selectedCards,
+ handCards: availableHandCards,
+ substitutions: forbiddenMagicSubstitutions,
+ leadingPattern: isLeading ? null : gameState.leadingPattern,
+ trumpSuit,
+ trumpRank,
+ activeRule: activeRuleContext
+ });
+ }
+
+ const afterglowActive = Boolean(
+ ruleIncludesId(gameState?.selectedRule, 'afterglow')
+ && trumpSuit !== 'no_trump'
+ && gameState?.afterglow?.activePlayerIds?.includes(currentPlayerId)
+ );
+ const afterglowHeldTrumps = afterglowActive
+ ? availableHandCards.filter(card => (
+ getEffectiveSuit(card, trumpSuit, trumpRank) === 'trump'
+ ))
+ : [];
+ if (afterglowHeldTrumps.length > 0) {
+ if (!selectedCards.every(card => (
+ getEffectiveSuit(card, trumpSuit, trumpRank) === 'trump'
+ ))) {
+ return {
+ valid: false,
+ message: '回光返照:只要手中仍有主牌,本次出牌就只能由主牌组成,不能混入副牌',
+ pattern: null
+ };
+ }
+ if (isLeading || !gameState?.leadingPattern) {
+ return validateLeadingPlay(
+ selectedCards,
+ trumpSuit,
+ trumpRank,
+ activeRuleContext
+ );
+ }
+ if (selectedCards.length !== gameState.leadingPattern.length) {
+ return {
+ valid: false,
+ message: `回光返照:本轮必须打出 ${gameState.leadingPattern.length} 张牌`,
+ pattern: null
+ };
+ }
+ return {
+ valid: true,
+ message: '回光返照:本次只出主牌并无视通常的跟牌要求,牌面+1',
+ pattern: null,
+ afterglowActive: true
+ };
+ }
+
+ if (activeSkillId) {
+ const configuredSkill = gameState?.selectedRule?.activeSkill;
+ if (!configuredSkill || configuredSkill.id !== activeSkillId) {
+ return { valid: false, message: '当前规则没有这个主动技能', pattern: null };
+ }
+ const configuredEffect = configuredSkill.effect || (
+ configuredSkill.id === 'substitute_sacrifice' ? 'free_discard_treated_small' : null
+ );
+ const configuredTiming = configuredSkill.timing || (
+ configuredSkill.id === 'stealing_beams' ? 'any_play' : 'following_play'
+ );
+ if (configuredTiming === 'following_play' && (isLeading || !gameState?.leadingPattern)) {
+ return { valid: false, message: `${configuredSkill.name}只能在跟牌时发动`, pattern: null };
+ }
+ if (configuredTiming === 'leading_play' && !isLeading) {
+ return { valid: false, message: `${configuredSkill.name}只能在首发时发动`, pattern: null };
+ }
+ if (!isLeading && selectedCards.length !== gameState.leadingPattern.length) {
+ return {
+ valid: false,
+ message: `本轮需要出 ${gameState.leadingPattern.length} 张牌`,
+ pattern: null
+ };
+ }
+ if (configuredEffect === 'free_discard_treated_small') {
+ return {
+ valid: true,
+ message: `${configuredSkill.name}:本次垫牌始终视为小`,
+ pattern: null,
+ activeSkill: configuredSkill
+ };
+ }
+ if (configuredEffect === 'ignore_odd_led_side_suit') {
+ const leadingSuit = gameState?.leadingPattern?.suit;
+ if (isLeading || !leadingSuit) {
+ return { valid: false, message: '虚虚实实只能在跟牌时发动', pattern: null };
+ }
+ if (leadingSuit === 'trump') {
+ return { valid: false, message: '虚虚实实只能虚置当前要求跟出的副花色', pattern: null };
+ }
+ const virtualizedCards = availableHandCards.filter(card => (
+ getEffectiveSuit(card, trumpSuit, trumpRank) === leadingSuit
+ ));
+ if (virtualizedCards.length === 0 || virtualizedCards.length % 2 === 0) {
+ return {
+ valid: false,
+ message: '虚虚实实要求对应副花色恰好剩余奇数张',
+ pattern: null
+ };
+ }
+ if (selectedCards.some(card => (
+ getEffectiveSuit(card, trumpSuit, trumpRank) === leadingSuit
+ ))) {
+ return {
+ valid: false,
+ message: '发动虚虚实实时不能打出被虚置的副花色牌',
+ pattern: null
+ };
+ }
+ const requiredCount = gameState.leadingPattern.length || 1;
+ const effectiveHandCards = availableHandCards.filter(card => (
+ getEffectiveSuit(card, trumpSuit, trumpRank) !== leadingSuit
+ ));
+ if (effectiveHandCards.length < requiredCount) {
+ return {
+ valid: false,
+ message: `虚置后其余手牌不足${requiredCount}张,不能发动虚虚实实`,
+ pattern: null
+ };
+ }
+ const validation = validateFollowingPlay(
+ selectedCards,
+ effectiveHandCards,
+ gameState.leadingPattern,
+ trumpSuit,
+ trumpRank,
+ activeRuleContext
+ );
+ return validation.valid
+ ? { ...validation, activeSkill: configuredSkill, ignoredSuit: leadingSuit }
+ : validation;
+ }
+ if (configuredEffect === 'concealed_until_round_end') {
+ const validation = validateFollowingPlay(
+ selectedCards,
+ handCards,
+ gameState.leadingPattern,
+ trumpSuit,
+ trumpRank,
+ activeRuleContext
+ );
+ return validation.valid
+ ? { ...validation, activeSkill: configuredSkill }
+ : validation;
+ }
+ if (configuredEffect === 'joker_wildcards') {
+ return resolveJokerSubstitutionPlay({
+ selectedCards,
+ handCards,
+ substitutions: jokerSubstitutions,
+ leadingPattern: isLeading ? null : gameState.leadingPattern,
+ trumpSuit,
+ trumpRank,
+ activeRule: activeRuleContext
+ });
+ }
+ if (configuredEffect === 'adjacent_rank_transform') {
+ return resolveClusterAnalysisPlay({
+ selectedCards,
+ handCards: availableHandCards,
+ substitutions: clusterAnalysisSubstitutions,
+ leadingPattern: isLeading ? null : gameState.leadingPattern,
+ trumpSuit,
+ trumpRank,
+ activeRule: activeRuleContext
+ });
+ }
+ if (configuredEffect === 'belt_and_road_lead') {
+ const beltAndRoadRuleContext = {
+ ...activeRuleContext,
+ beltAndRoadSkillActive: true
+ };
+ const validation = validateLeadingPlay(
+ selectedCards,
+ trumpSuit,
+ trumpRank,
+ beltAndRoadRuleContext
+ );
+ if (!validation.valid) return validation;
+ if (validation.pattern?.type !== PatternTypes.BELT_AND_ROAD) {
+ return {
+ valid: false,
+ message: '一带一路必须首发同一有效花色的两张非对子单牌',
+ pattern: validation.pattern
+ };
+ }
+ return { ...validation, activeSkill: configuredSkill };
+ }
+ if (configuredEffect === 'transform_matching_card') {
+ if (gameState?.divineWeapon?.usedThisRound) {
+ return { valid: false, message: '本轮已有玩家发动神兵天降', pattern: null };
+ }
+ const targetCard = gameState?.divineWeapon?.cards?.find(
+ card => card.id === divineWeaponCardId
+ );
+ if (!targetCard) {
+ return { valid: false, message: '请先选择牌桌中央的一张神兵牌', pattern: null };
+ }
+ const sourceCard = selectedCards.find(card => card.id === divineWeaponSourceCardId);
+ if (!sourceCard) {
+ return { valid: false, message: '请选中一张手牌作为神兵转化牌', pattern: null };
+ }
+ if (sourceCard.suit !== targetCard.suit && sourceCard.rank !== targetCard.rank) {
+ return { valid: false, message: '转化牌必须与神兵牌花色或点数相同', pattern: null };
+ }
+ const transformCard = card => card.id === sourceCard.id
+ ? {
+ ...card,
+ suit: targetCard.suit,
+ rank: targetCard.rank,
+ originalSuit: card.suit,
+ originalRank: card.rank,
+ isDivineWeaponTransformed: true,
+ divineWeaponCardId: targetCard.id
+ }
+ : card;
+ const effectiveSelectedCards = selectedCards.map(transformCard);
+ // 跟牌义务以发动前的实体手牌为准;转化牌只作为本次实际打出的牌面参与校验。
+ const effectiveHandCards = availableHandCards;
+ const validation = isLeading || !gameState?.leadingPattern
+ ? validateLeadingPlay(
+ effectiveSelectedCards,
+ trumpSuit,
+ trumpRank,
+ activeRuleContext
+ )
+ : validateFollowingPlay(
+ effectiveSelectedCards,
+ effectiveHandCards,
+ gameState.leadingPattern,
+ trumpSuit,
+ trumpRank,
+ activeRuleContext
+ );
+ return validation.valid
+ ? { ...validation, activeSkill: configuredSkill }
+ : validation;
+ }
+ if (configuredEffect === 'two_legal_plays_choose_at_round_end') {
+ const validation = validateFollowingPlay(
+ selectedCards,
+ followingObligationCards,
+ gameState.leadingPattern,
+ trumpSuit,
+ trumpRank,
+ activeRuleContext
+ );
+ return validation.valid
+ ? { ...validation, activeSkill: configuredSkill }
+ : validation;
+ }
+ }
+
+ if (isLeading || !gameState?.leadingPattern) {
+ return validateLeadingPlay(
+ selectedCards,
+ trumpSuit,
+ trumpRank,
+ activeRuleContext
+ );
+ }
+
+ return validateFollowingPlay(
+ selectedCards,
+ followingObligationCards,
+ gameState.leadingPattern,
+ trumpSuit,
+ trumpRank,
+ activeRuleContext
+ );
+}
diff --git a/tractor-game-simulator/client/src/utils/cardPatternUtils.js b/tractor-game-simulator/client/src/utils/cardPatternUtils.js
index 541c47e..1213bbc 100644
--- a/tractor-game-simulator/client/src/utils/cardPatternUtils.js
+++ b/tractor-game-simulator/client/src/utils/cardPatternUtils.js
@@ -1,4 +1,56 @@
-import { Suits, Ranks, RANK_ORDER } from './constants.js';
+import {
+ Suits,
+ Ranks,
+ RANK_ORDER,
+ STANDARD_ORDINARY_RANKS,
+ EXTENDED_ORDINARY_RANKS,
+ PROMOTED_ORDINARY_RANKS
+} from './constants.js';
+import { ruleIncludesId } from './ruleCatalog.js';
+
+const STANDARD_SUITS = Object.freeze([
+ Suits.HEARTS,
+ Suits.DIAMONDS,
+ Suits.CLUBS,
+ Suits.SPADES
+]);
+const TAI_CHI_SUIT = 'tai_chi';
+const isSixSixGreatSuccessRule = rule => ruleIncludesId(rule, 'six_six_great_success');
+const isTaiChiFourSymbolsRule = rule => ruleIncludesId(rule, 'tai_chi_four_symbols');
+const isBeltAndRoadRule = rule => ruleIncludesId(rule, 'belt_and_road');
+const isDayNightRotationRule = rule => ruleIncludesId(rule, 'day_night_rotation');
+const isStrengthCompensationRule = rule => ruleIncludesId(rule, 'strength_compensation');
+const isTeammateCheerRule = rule => ruleIncludesId(rule, 'teammate_cheer');
+const isAfterglowRule = rule => ruleIncludesId(rule, 'afterglow');
+const isThreeTigersRule = rule => ruleIncludesId(rule, 'three_tigers');
+const isThreeSixNineGradesRule = rule => ruleIncludesId(rule, 'three_six_nine_grades');
+const isUnarmedRule = rule => ruleIncludesId(rule, 'unarmed');
+const isAntinomyRule = rule => ruleIncludesId(rule, 'antinomy');
+const ORDINARY_RANKS = STANDARD_ORDINARY_RANKS;
+const DAY_NIGHT_RANKS = Object.freeze([
+ Ranks.ACE, Ranks.TWO, Ranks.THREE, Ranks.FOUR, Ranks.FIVE,
+ Ranks.SIX, Ranks.SEVEN, Ranks.EIGHT, Ranks.NINE, Ranks.TEN,
+ Ranks.JACK, Ranks.QUEEN, Ranks.KING
+]);
+
+export function getAntinomyFaceKey(cardOrSuit, rank = null) {
+ const suit = typeof cardOrSuit === 'object' ? cardOrSuit?.suit : cardOrSuit;
+ const faceRank = typeof cardOrSuit === 'object' ? cardOrSuit?.rank : rank;
+ return suit && faceRank ? `${suit}:${faceRank}` : null;
+}
+
+function canCardsFormPair(card1, card2, activeRule = null) {
+ if (!card1 || !card2 || card1.rank !== card2.rank || card1.suit !== card2.suit) {
+ return false;
+ }
+ if (!isAntinomyRule(activeRule)) return true;
+ return !(activeRule?.antinomySplitFaceKeys || []).includes(getAntinomyFaceKey(card1));
+}
+
+function getDayNightRotatingRank(roundNumber) {
+ if (!Number.isInteger(roundNumber) || roundNumber < 1) return null;
+ return DAY_NIGHT_RANKS[roundNumber % DAY_NIGHT_RANKS.length];
+}
/**
* 牌型类型
@@ -7,6 +59,9 @@ export const PatternTypes = {
SINGLE: 'single',
PAIR: 'pair',
TRACTOR: 'tractor',
+ STRAIGHT_FLUSH: 'straight_flush',
+ TAI_CHI_FOUR_SYMBOLS: 'tai_chi_four_symbols',
+ BELT_AND_ROAD: 'belt_and_road',
THROW: 'throw',
INVALID: 'invalid'
};
@@ -15,10 +70,22 @@ export const PatternTypes = {
* 判断一张牌是否是主牌
*/
export function isTrumpCard(card, trumpSuit, trumpRank) {
+ if (card?.isForbiddenMagicDemoted) {
+ return false;
+ }
+ if (card.isLastStandTrump) {
+ return true;
+ }
+ if (card.isThreeTigersTrump) {
+ return true;
+ }
+ if (card.rank === Ranks.NO_TRUMP_MINUS) {
+ return true;
+ }
if (card.suit === Suits.JOKER) {
return true;
}
- if (card.rank === trumpRank) {
+ if (!card.isUnarmed && card.rank === trumpRank) {
return true;
}
if (card.suit === trumpSuit) {
@@ -40,44 +107,236 @@ export function getEffectiveSuit(card, trumpSuit, trumpRank) {
/**
* 获取牌的强度值
*/
-export function getCardStrength(card, trumpSuit, trumpRank) {
+export function getCardStrength(card, trumpSuit, trumpRank, activeRule = null) {
+ if (card?.isForbiddenMagicDemoted) {
+ return RANK_ORDER[card.rank] || 0;
+ }
+ const isUnarmed = isUnarmedRule(activeRule) || Boolean(card?.isUnarmed);
+ if (card.rank === Ranks.WHITE_JOKER) {
+ return 1003;
+ }
+ if (card.rank === Ranks.PRINCE_JOKER) {
+ return 1002;
+ }
+ if (card.rank === Ranks.COUNTY_PRINCE_JOKER) {
+ return 1001;
+ }
if (card.rank === Ranks.BIG_JOKER) {
return 1000;
}
if (card.rank === Ranks.SMALL_JOKER) {
return 999;
}
- if (card.rank === trumpRank && card.suit === trumpSuit) {
+ if (card.rank === Ranks.NO_TRUMP_MINUS) {
+ return 996;
+ }
+ if (card.isThreeTigersTrump && card.rank === trumpRank) {
return 998;
}
- if (card.rank === trumpRank) {
+ if (!isUnarmed && card.rank === trumpRank && card.suit === trumpSuit) {
+ return 998;
+ }
+ if (
+ !isUnarmed
+ && card.rank === trumpRank
+ && isThreeSixNineGradesRule(activeRule)
+ && activeRule?.inferiorSuit
+ && card.suit === activeRule.inferiorSuit
+ ) {
+ return 996;
+ }
+ if (!isUnarmed && card.rank === trumpRank) {
return 997;
}
+ const rotatingRank = isDayNightRotationRule(activeRule)
+ ? getDayNightRotatingRank(activeRule?.currentRound)
+ : null;
+ const boostedRank = rotatingRank && rotatingRank !== trumpRank ? rotatingRank : null;
+ const dayNightOrderedRanks = boostedRank
+ ? ORDINARY_RANKS
+ .filter(rank => rank !== trumpRank && rank !== boostedRank)
+ .concat(boostedRank)
+ : null;
+
let baseValue = RANK_ORDER[card.rank] || 0;
+ const usesExtendedRanks = isStrengthCompensationRule(activeRule)
+ || isAfterglowRule(activeRule)
+ || isTeammateCheerRule(activeRule)
+ || isThreeTigersRule(activeRule)
+ || Boolean(card?.isStrengthCompensated)
+ || Boolean(card?.isTeammateCheered)
+ || Boolean(card?.isAfterglowBoosted);
+
+ if (card.isThreeTigersTrump) {
+ const ordinaryTrumpRanks = EXTENDED_ORDINARY_RANKS
+ .filter(rank => !PROMOTED_ORDINARY_RANKS.includes(rank) && rank !== trumpRank);
+ const lowestTrumpStrength = 997 - ordinaryTrumpRanks.length;
+ return lowestTrumpStrength + ordinaryTrumpRanks.indexOf(card.rank);
+ }
+
+ if (card.isLastStandTrump) {
+ const orderedRanks = [
+ Ranks.TWO, Ranks.THREE, Ranks.FOUR, Ranks.FIVE, Ranks.SIX,
+ Ranks.SEVEN, Ranks.EIGHT, Ranks.NINE, Ranks.TEN,
+ Ranks.JACK, Ranks.QUEEN, Ranks.KING, Ranks.ACE
+ ].filter(rank => rank !== trumpRank);
+ return 985 + orderedRanks.indexOf(card.rank);
+ }
// 主花色的牌 - 需要连续排列在副花色级牌之下
if (card.suit === trumpSuit) {
- const trumpRankValue = RANK_ORDER[trumpRank] || 0;
- let position = 14 - baseValue;
- if (baseValue < trumpRankValue) {
- position--;
+ if (dayNightOrderedRanks) {
+ return 985 + dayNightOrderedRanks.indexOf(card.rank);
}
- return 996 - position;
+ const orderedTrumpRanks = (usesExtendedRanks
+ ? EXTENDED_ORDINARY_RANKS.filter(rank => !PROMOTED_ORDINARY_RANKS.includes(rank))
+ : ORDINARY_RANKS
+ );
+ const ordinaryTrumpRanks = isUnarmed
+ ? orderedTrumpRanks
+ : orderedTrumpRanks.filter(rank => rank !== trumpRank);
+ const lowestTrumpStrength = 997 - ordinaryTrumpRanks.length
+ - (isThreeSixNineGradesRule(activeRule) && activeRule?.inferiorSuit ? 1 : 0);
+ return lowestTrumpStrength + ordinaryTrumpRanks.indexOf(card.rank);
}
+ if (dayNightOrderedRanks) {
+ return 2 + dayNightOrderedRanks.indexOf(card.rank);
+ }
return baseValue;
}
+function areStrengthsConsecutive(currentStrength, nextStrength, trumpSuit, trumpRank) {
+ return arePairsConsecutive(
+ { strength: currentStrength },
+ { strength: nextStrength },
+ trumpSuit,
+ trumpRank
+ );
+}
+
+function detectStraightFlush(cards, trumpSuit, trumpRank, activeRule) {
+ if (!isSixSixGreatSuccessRule(activeRule) || cards.length < 6) {
+ return { valid: false };
+ }
+ const effectiveSuits = new Set(
+ cards.map(card => getEffectiveSuit(card, trumpSuit, trumpRank))
+ );
+ if (effectiveSuits.size !== 1) return { valid: false };
+
+ const strengths = cards
+ .map(card => getCardStrength(card, trumpSuit, trumpRank, activeRule))
+ .sort((a, b) => a - b);
+ if (new Set(strengths).size !== cards.length) return { valid: false };
+ for (let index = 1; index < strengths.length; index++) {
+ if (!areStrengthsConsecutive(
+ strengths[index - 1],
+ strengths[index],
+ trumpSuit,
+ trumpRank
+ )) {
+ return { valid: false };
+ }
+ }
+ return {
+ valid: true,
+ suit: [...effectiveSuits][0],
+ strength: strengths[strengths.length - 1],
+ strengths
+ };
+}
+
+export function findStraightFlushes(
+ cards,
+ requiredLength,
+ trumpSuit,
+ trumpRank,
+ activeRule = null
+) {
+ if (!isSixSixGreatSuccessRule(activeRule) || requiredLength < 6) return [];
+ const cardsByEffectiveSuit = new Map();
+ for (const card of cards) {
+ const suit = getEffectiveSuit(card, trumpSuit, trumpRank);
+ const suitCards = cardsByEffectiveSuit.get(suit) || [];
+ suitCards.push(card);
+ cardsByEffectiveSuit.set(suit, suitCards);
+ }
+
+ const results = [];
+ for (const [suit, suitCards] of cardsByEffectiveSuit) {
+ const cardsByStrength = new Map();
+ for (const card of suitCards) {
+ const strength = getCardStrength(card, trumpSuit, trumpRank, activeRule);
+ if (!cardsByStrength.has(strength)) cardsByStrength.set(strength, card);
+ }
+ const strengths = [...cardsByStrength.keys()].sort((a, b) => a - b);
+ for (let start = 0; start <= strengths.length - requiredLength; start++) {
+ const window = strengths.slice(start, start + requiredLength);
+ const isStraight = window.every((strength, index) =>
+ index === 0 || areStrengthsConsecutive(
+ window[index - 1],
+ strength,
+ trumpSuit,
+ trumpRank
+ )
+ );
+ if (isStraight) {
+ results.push({
+ suit,
+ cards: window.map(strength => cardsByStrength.get(strength)),
+ strength: window[window.length - 1],
+ length: requiredLength
+ });
+ }
+ }
+ }
+ return results;
+}
+
+export function findTaiChiFourSymbols(cards, activeRule = null) {
+ if (!isTaiChiFourSymbolsRule(activeRule)) return [];
+ const cardsByRank = new Map();
+ for (const card of cards) {
+ if (!STANDARD_SUITS.includes(card.suit)) continue;
+ const cardsBySuit = cardsByRank.get(card.rank) || new Map();
+ if (!cardsBySuit.has(card.suit)) cardsBySuit.set(card.suit, card);
+ cardsByRank.set(card.rank, cardsBySuit);
+ }
+
+ const results = [];
+ for (const [rank, cardsBySuit] of cardsByRank) {
+ if (STANDARD_SUITS.every(suit => cardsBySuit.has(suit))) {
+ results.push({
+ rank,
+ cards: STANDARD_SUITS.map(suit => cardsBySuit.get(suit)),
+ strength: RANK_ORDER[rank] || 0,
+ length: 4
+ });
+ }
+ }
+ return results;
+}
+
/**
* 检测牌型
*/
-export function detectPattern(cards, trumpSuit, trumpRank) {
+export function detectPattern(cards, trumpSuit, trumpRank, activeRule = null) {
if (!cards || cards.length === 0) {
return { type: PatternTypes.INVALID, suit: null, strength: 0, length: 0 };
}
const count = cards.length;
+ if (count === 4 && findTaiChiFourSymbols(cards, activeRule).length > 0) {
+ const rank = cards[0].rank;
+ return {
+ type: PatternTypes.TAI_CHI_FOUR_SYMBOLS,
+ suit: TAI_CHI_SUIT,
+ rank,
+ strength: RANK_ORDER[rank] || 0,
+ length: 4
+ };
+ }
const effectiveSuits = cards.map(c => getEffectiveSuit(c, trumpSuit, trumpRank));
const uniqueSuits = [...new Set(effectiveSuits)];
@@ -87,21 +346,32 @@ export function detectPattern(cards, trumpSuit, trumpRank) {
const suit = uniqueSuits[0];
+ const straightFlush = detectStraightFlush(cards, trumpSuit, trumpRank, activeRule);
+ if (straightFlush.valid) {
+ return {
+ type: PatternTypes.STRAIGHT_FLUSH,
+ suit: straightFlush.suit,
+ strength: straightFlush.strength,
+ length: count,
+ strengths: straightFlush.strengths
+ };
+ }
+
if (count === 1) {
return {
type: PatternTypes.SINGLE,
suit,
- strength: getCardStrength(cards[0], trumpSuit, trumpRank),
+ strength: getCardStrength(cards[0], trumpSuit, trumpRank, activeRule),
length: 1
};
}
if (count === 2) {
- if (cards[0].rank === cards[1].rank && cards[0].suit === cards[1].suit) {
+ if (canCardsFormPair(cards[0], cards[1], activeRule)) {
return {
type: PatternTypes.PAIR,
suit,
- strength: getCardStrength(cards[0], trumpSuit, trumpRank),
+ strength: getCardStrength(cards[0], trumpSuit, trumpRank, activeRule),
length: 2
};
}
@@ -110,7 +380,19 @@ export function detectPattern(cards, trumpSuit, trumpRank) {
return {
type: PatternTypes.PAIR,
suit,
- strength: getCardStrength(cards[0], trumpSuit, trumpRank),
+ strength: getCardStrength(cards[0], trumpSuit, trumpRank, activeRule),
+ length: 2
+ };
+ }
+ if (isBeltAndRoadRule(activeRule) && activeRule?.beltAndRoadSkillActive === true) {
+ const strengths = cards
+ .map(card => getCardStrength(card, trumpSuit, trumpRank, activeRule))
+ .sort((a, b) => b - a);
+ return {
+ type: PatternTypes.BELT_AND_ROAD,
+ suit,
+ strength: strengths[0],
+ strengths,
length: 2
};
}
@@ -118,7 +400,7 @@ export function detectPattern(cards, trumpSuit, trumpRank) {
}
if (count >= 4 && count % 2 === 0) {
- const tractorResult = detectTractor(cards, trumpSuit, trumpRank);
+ const tractorResult = detectTractor(cards, trumpSuit, trumpRank, activeRule);
if (tractorResult.valid) {
return {
type: PatternTypes.TRACTOR,
@@ -136,15 +418,13 @@ export function detectPattern(cards, trumpSuit, trumpRank) {
/**
* 检测拖拉机
*/
-function detectTractor(cards, trumpSuit, trumpRank) {
+function detectTractor(cards, trumpSuit, trumpRank, activeRule) {
const pairs = [];
const cardsCopy = [...cards];
while (cardsCopy.length >= 2) {
const card1 = cardsCopy.shift();
- const pairIndex = cardsCopy.findIndex(c =>
- c.rank === card1.rank && c.suit === card1.suit
- );
+ const pairIndex = cardsCopy.findIndex(c => canCardsFormPair(card1, c, activeRule));
if (pairIndex === -1) {
return { valid: false, strength: 0, pairs: [] };
@@ -154,7 +434,7 @@ function detectTractor(cards, trumpSuit, trumpRank) {
pairs.push({
rank: card1.rank,
suit: card1.suit,
- strength: getCardStrength(card1, trumpSuit, trumpRank)
+ strength: getCardStrength(card1, trumpSuit, trumpRank, activeRule)
});
}
@@ -167,40 +447,18 @@ function detectTractor(cards, trumpSuit, trumpRank) {
const effectiveSuit = getEffectiveSuit(cards[0], trumpSuit, trumpRank);
if (effectiveSuit === 'trump') {
- return checkTrumpTractor(pairs, trumpSuit, trumpRank);
+ return checkTrumpTractor(pairs, trumpSuit, trumpRank, activeRule);
} else {
- return checkNonTrumpTractor(pairs, trumpRank);
+ return checkNonTrumpTractor(pairs, trumpRank, activeRule);
}
}
-function checkTrumpTractor(pairs, trumpSuit, trumpRank) {
+function checkTrumpTractor(pairs, trumpSuit, trumpRank, activeRule) {
for (let i = 0; i < pairs.length - 1; i++) {
const current = pairs[i];
const next = pairs[i + 1];
-
- // 检查是否无主局
- const isNoTrump = !trumpSuit || trumpSuit === 'no_trump';
-
- if (next.strength - current.strength !== 1 &&
- !(current.strength === 997 && next.strength === 998) &&
- !(current.strength === 998 && next.strength === 999) &&
- !(current.strength === 999 && next.strength === 1000) &&
- !(current.strength === 997 && next.strength === 999 && isNoTrump)) { // 仅无主局:级牌(997) -> 小王(999)
-
- const currentRankValue = RANK_ORDER[current.rank] || 0;
- const nextRankValue = RANK_ORDER[next.rank] || 0;
-
- if (current.strength < 500 || next.strength < 500) {
- return { valid: false, strength: 0, pairs: [] };
- }
-
- if (nextRankValue - currentRankValue !== 1) {
- if (RANK_ORDER[trumpRank] === currentRankValue + 1 &&
- nextRankValue === currentRankValue + 2) {
- continue;
- }
- return { valid: false, strength: 0, pairs: [] };
- }
+ if (!arePairsConsecutive(current, next, trumpSuit, trumpRank, activeRule)) {
+ return { valid: false, strength: 0, pairs: [] };
}
}
@@ -211,17 +469,9 @@ function checkTrumpTractor(pairs, trumpSuit, trumpRank) {
};
}
-function checkNonTrumpTractor(pairs, trumpRank) {
+function checkNonTrumpTractor(pairs, trumpRank, activeRule) {
for (let i = 0; i < pairs.length - 1; i++) {
- const currentRankValue = RANK_ORDER[pairs[i].rank] || 0;
- const nextRankValue = RANK_ORDER[pairs[i + 1].rank] || 0;
-
- let expectedNext = currentRankValue + 1;
- if (RANK_ORDER[trumpRank] === expectedNext) {
- expectedNext++;
- }
-
- if (nextRankValue !== expectedNext) {
+ if (!arePairsConsecutive(pairs[i], pairs[i + 1], null, trumpRank, activeRule)) {
return { valid: false, strength: 0, pairs: [] };
}
}
@@ -233,14 +483,35 @@ function checkNonTrumpTractor(pairs, trumpRank) {
};
}
+function arePairsConsecutive(current, next, trumpSuit, trumpRank, activeRule) {
+ const diff = next.strength - current.strength;
+ const isTrumpSequence = current.strength >= 900 || next.strength >= 900;
+
+ if (isTrumpSequence) {
+ if (diff === 1) return true;
+ const isNoTrump = !trumpSuit || trumpSuit === Suits.NO_TRUMP;
+ return isNoTrump && current.strength === 997 && next.strength === 999;
+ }
+
+ if (diff === 1) return true;
+ if (isUnarmedRule(activeRule)) return false;
+ const trumpRankValue = RANK_ORDER[trumpRank] || 0;
+ return diff === 2 && current.strength + 1 === trumpRankValue;
+}
+
/**
* 验证首发出牌是否合法
*/
-export function validateLeadingPlay(cards, trumpSuit, trumpRank) {
+export function validateLeadingPlay(cards, trumpSuit, trumpRank, activeRule = null) {
if (!cards || cards.length === 0) {
return { valid: false, message: '请选择要出的牌', pattern: null };
}
+ const pattern = detectPattern(cards, trumpSuit, trumpRank, activeRule);
+ if (pattern.type === PatternTypes.TAI_CHI_FOUR_SYMBOLS) {
+ return { valid: true, message: '出牌合法', pattern };
+ }
+
// 首发出牌时,只需要检查是否同花色
// 允许甩牌(多种牌型的组合),具体验证交给后端
const effectiveSuits = cards.map(c => getEffectiveSuit(c, trumpSuit, trumpRank));
@@ -254,15 +525,28 @@ export function validateLeadingPlay(cards, trumpSuit, trumpRank) {
};
}
- // 同花色的牌就允许出,后端会判断是否是有效的甩牌
- const pattern = detectPattern(cards, trumpSuit, trumpRank);
+ // 同花色的牌通常允许作为甩牌;单步调试只保留完整的单张、对子和拖拉机。
+ if (activeRule?.id === 'single_step_debug' && pattern.type === PatternTypes.INVALID) {
+ return {
+ valid: false,
+ message: '单步调试规则下不能甩牌',
+ pattern: null
+ };
+ }
return { valid: true, message: '出牌合法', pattern };
}
/**
* 验证跟牌是否合法
*/
-export function validateFollowingPlay(cards, handCards, leadingPattern, trumpSuit, trumpRank) {
+export function validateFollowingPlay(
+ cards,
+ handCards,
+ leadingPattern,
+ trumpSuit,
+ trumpRank,
+ activeRule = null
+) {
if (!cards || cards.length === 0) {
return { valid: false, message: '请选择要出的牌', pattern: null };
}
@@ -276,10 +560,23 @@ export function validateFollowingPlay(cards, handCards, leadingPattern, trumpSui
}
const leadingSuit = leadingPattern.suit;
+ const patternRuleContext = leadingPattern.type === PatternTypes.BELT_AND_ROAD
+ ? { ...activeRule, beltAndRoadSkillActive: true }
+ : activeRule;
+
+ if (leadingPattern.type === PatternTypes.TAI_CHI_FOUR_SYMBOLS) {
+ return validateFollowingTaiChi(
+ cards,
+ handCards,
+ trumpSuit,
+ trumpRank,
+ activeRule
+ );
+ }
// 如果首发是甩牌,使用特殊的跟牌验证(前端实现与后端一致)
if (leadingPattern.type === PatternTypes.THROW) {
- return validateFollowingThrow(cards, handCards, leadingPattern, trumpSuit, trumpRank);
+ return validateFollowingThrow(cards, handCards, leadingPattern, trumpSuit, trumpRank, activeRule);
}
const sameSuitCards = handCards.filter(c =>
@@ -301,13 +598,13 @@ export function validateFollowingPlay(cards, handCards, leadingPattern, trumpSui
}
}
- const pattern = detectPattern(cards, trumpSuit, trumpRank);
+ const pattern = detectPattern(cards, trumpSuit, trumpRank, patternRuleContext);
if (playedSameSuit.length >= leadingPattern.length) {
- if (!matchPatternRequirement(cards, handCards, leadingPattern, trumpSuit, trumpRank)) {
+ if (!matchPatternRequirement(cards, handCards, leadingPattern, trumpSuit, trumpRank, activeRule)) {
return {
valid: false,
- message: getPatternMismatchMessage(leadingPattern, handCards, trumpSuit, trumpRank),
+ message: getPatternMismatchMessage(leadingPattern, handCards, trumpSuit, trumpRank, activeRule),
pattern
};
}
@@ -316,58 +613,442 @@ export function validateFollowingPlay(cards, handCards, leadingPattern, trumpSui
return { valid: true, message: '跟牌合法', pattern };
}
-function matchPatternRequirement(cards, handCards, leadingPattern, trumpSuit, trumpRank) {
+const SUBSTITUTION_SUITS = Object.freeze([
+ Suits.HEARTS,
+ Suits.DIAMONDS,
+ Suits.CLUBS,
+ Suits.SPADES
+]);
+const SUBSTITUTION_RANKS = Object.freeze([
+ Ranks.TWO,
+ Ranks.THREE,
+ Ranks.FOUR,
+ Ranks.FIVE,
+ Ranks.SIX,
+ Ranks.SEVEN,
+ Ranks.EIGHT,
+ Ranks.NINE,
+ Ranks.TEN,
+ Ranks.JACK,
+ Ranks.QUEEN,
+ Ranks.KING,
+ Ranks.ACE
+]);
+
+function createForbiddenMagicDemotedCard(card, targetSuit = card.suit, targetRank = card.rank) {
+ return {
+ ...card,
+ suit: targetSuit,
+ rank: targetRank,
+ originalSuit: card.originalSuit || card.suit,
+ originalRank: card.originalRank || card.rank,
+ isForbiddenMagicDemoted: true,
+ isForbiddenMagicTransformed: targetSuit !== card.suit || targetRank !== card.rank
+ };
+}
+
+export function demoteForbiddenMagicHand(cards, trumpSuit, trumpRank) {
+ const list = Array.isArray(cards) ? cards : [];
+ return list.map(card => isTrumpCard(card, trumpSuit, trumpRank)
+ ? createForbiddenMagicDemotedCard(card)
+ : card);
+}
+
+export function resolveForbiddenMagicPlay({
+ selectedCards,
+ handCards,
+ substitutions = [],
+ leadingPattern = null,
+ trumpSuit = null,
+ trumpRank = null,
+ activeRule = null
+}) {
+ const cards = Array.isArray(selectedCards) ? selectedCards : [];
+ const hand = Array.isArray(handCards) ? handCards : [];
+ const submitted = Array.isArray(substitutions) ? substitutions : [];
+ if (new Set(submitted.map(item => item?.cardId)).size !== submitted.length) {
+ return { valid: false, message: '同一张牌不能重复设置禁术转化' };
+ }
+
+ const selectedById = new Map(cards.map(card => [card.id, card]));
+ const originalTrumpIds = new Set(
+ hand.filter(card => isTrumpCard(card, trumpSuit, trumpRank)).map(card => card.id)
+ );
+ const replacementById = new Map();
+ const normalizedSubstitutions = [];
+ for (const substitution of submitted) {
+ const sourceCard = selectedById.get(substitution?.cardId);
+ if (!sourceCard) return { valid: false, message: '所有已转化的牌都必须包含在本次出牌中' };
+ if (!originalTrumpIds.has(sourceCard.id)) {
+ return { valid: false, message: '禁术秘法只能转化发动前属于主牌的牌' };
+ }
+ if (!SUBSTITUTION_SUITS.includes(substitution.suit)) {
+ return { valid: false, message: '禁术秘法的目标花色无效' };
+ }
+ const isJoker = sourceCard.suit === Suits.JOKER;
+ const targetRank = isJoker ? substitution.rank : sourceCard.rank;
+ if (!SUBSTITUTION_RANKS.includes(targetRank)) {
+ return { valid: false, message: '禁术秘法的目标点数无效' };
+ }
+ if (!isJoker && String(substitution.rank || sourceCard.rank) !== String(sourceCard.rank)) {
+ return { valid: false, message: '非王牌只能改变花色,不能改变原点数' };
+ }
+ if (
+ trumpSuit
+ && trumpSuit !== Suits.NO_TRUMP
+ && substitution.suit === trumpSuit
+ ) {
+ return { valid: false, message: '禁术秘法只能转化为副牌花色,不能选择当前主花色' };
+ }
+ replacementById.set(
+ sourceCard.id,
+ createForbiddenMagicDemotedCard(sourceCard, substitution.suit, targetRank)
+ );
+ normalizedSubstitutions.push({
+ cardId: sourceCard.id,
+ fromSuit: sourceCard.suit,
+ fromRank: sourceCard.rank,
+ suit: substitution.suit,
+ rank: targetRank
+ });
+ }
+
+ const selectedTrumpsWithoutTarget = cards.filter(card => (
+ originalTrumpIds.has(card.id)
+ && !replacementById.has(card.id)
+ ));
+ if (selectedTrumpsWithoutTarget.length > 0) {
+ return {
+ valid: false,
+ message: '禁术秘法生效后,主牌不能直接打出;请先为每张要出的主牌选择一种副花色'
+ };
+ }
+
+ const effectiveCards = demoteForbiddenMagicHand(cards, trumpSuit, trumpRank)
+ .map(card => replacementById.get(card.id) || card);
+ const effectiveHandCards = demoteForbiddenMagicHand(hand, trumpSuit, trumpRank)
+ .map(card => replacementById.get(card.id) || card);
+ const validation = leadingPattern
+ ? validateFollowingPlay(
+ effectiveCards,
+ effectiveHandCards,
+ leadingPattern,
+ trumpSuit,
+ trumpRank,
+ activeRule
+ )
+ : validateLeadingPlay(effectiveCards, trumpSuit, trumpRank, activeRule);
+ return {
+ ...validation,
+ usesSkill: true,
+ effectiveCards,
+ effectiveHandCards,
+ substitutions: normalizedSubstitutions
+ };
+}
+
+export function resolveJokerSubstitutionPlay({
+ selectedCards,
+ handCards,
+ substitutions = [],
+ leadingPattern = null,
+ trumpSuit = null,
+ trumpRank = null,
+ activeRule = null
+}) {
+ const cards = Array.isArray(selectedCards) ? selectedCards : [];
+ const hand = Array.isArray(handCards) ? handCards : [];
+ const jokers = cards.filter(card => card.suit === Suits.JOKER);
+ const submitted = Array.isArray(substitutions) ? substitutions : [];
+ const validate = (effectiveCards, effectiveHand) => leadingPattern
+ ? validateFollowingPlay(effectiveCards, effectiveHand, leadingPattern, trumpSuit, trumpRank, activeRule)
+ : validateLeadingPlay(effectiveCards, trumpSuit, trumpRank, activeRule);
+
+ if (submitted.length === 0 && cards.length === 1 && jokers.length === 1) {
+ const validation = validate(cards, hand);
+ return { ...validation, effectiveCards: cards, effectiveHandCards: hand, usesSkill: false, substitutions: [] };
+ }
+ if (submitted.length === 0) return { valid: false, message: '请先明确选择至少一张王牌要转换成的牌面' };
+ if (new Set(submitted.map(item => item?.cardId)).size !== submitted.length) {
+ return { valid: false, message: '同一张王牌不能重复设置转换' };
+ }
+ const selectedById = new Map(cards.map(card => [card.id, card]));
+ const replacementById = new Map();
+ for (const substitution of submitted) {
+ const sourceCard = selectedById.get(substitution?.cardId);
+ if (!sourceCard) return { valid: false, message: '所有已转换的王牌都必须包含在本次出牌中' };
+ if (sourceCard.suit !== Suits.JOKER) return { valid: false, message: '偷梁换柱只能转换王牌' };
+ if (!SUBSTITUTION_SUITS.includes(substitution.suit)) return { valid: false, message: '偷梁换柱的目标花色无效' };
+ if (!SUBSTITUTION_RANKS.includes(substitution.rank)) return { valid: false, message: '偷梁换柱的目标点数无效' };
+ replacementById.set(sourceCard.id, {
+ ...sourceCard,
+ suit: substitution.suit,
+ rank: substitution.rank,
+ originalSuit: Suits.JOKER,
+ originalRank: sourceCard.rank,
+ isJokerSubstitution: true
+ });
+ }
+ const effectiveCards = cards.map(card => replacementById.get(card.id) || card);
+ const effectiveHandCards = hand.map(card => replacementById.get(card.id) || card);
+ const validation = validate(effectiveCards, effectiveHandCards);
+ return { ...validation, effectiveCards, effectiveHandCards, usesSkill: true, substitutions: submitted };
+}
+
+const CLUSTER_ANALYSIS_RANKS = Object.freeze([
+ Ranks.TWO,
+ Ranks.THREE,
+ Ranks.FOUR,
+ Ranks.FIVE,
+ Ranks.SIX,
+ Ranks.SEVEN,
+ Ranks.EIGHT,
+ Ranks.NINE,
+ Ranks.TEN,
+ Ranks.JACK,
+ Ranks.QUEEN,
+ Ranks.KING,
+ Ranks.ACE
+]);
+
+export function getClusterAnalysisTargetRanks(card, trumpRank) {
+ if (!card || card.suit === Suits.JOKER) return [];
+ const sourceIndex = CLUSTER_ANALYSIS_RANKS.findIndex(rank => String(rank) === String(card.rank));
+ if (sourceIndex < 0 || [Ranks.FIVE, Ranks.TEN, Ranks.KING].includes(card.rank)) return [];
+ if (String(card.rank) === String(trumpRank)) return [];
+ return [sourceIndex - 1, sourceIndex + 1]
+ .filter(index => index >= 0 && index < CLUSTER_ANALYSIS_RANKS.length)
+ .map(index => CLUSTER_ANALYSIS_RANKS[index])
+ .filter(rank => ![Ranks.FIVE, Ranks.TEN, Ranks.KING].includes(rank))
+ .filter(rank => String(rank) !== String(trumpRank));
+}
+
+function createClusterCard(card, targetRank) {
+ return {
+ ...card,
+ rank: targetRank,
+ originalRank: card.rank,
+ isClusterAnalysisTransformed: true,
+ clusterAnalysisSourceRank: card.rank
+ };
+}
+
+export function resolveClusterAnalysisPlay({
+ selectedCards,
+ handCards,
+ substitutions = [],
+ leadingPattern = null,
+ trumpSuit = null,
+ trumpRank = null,
+ activeRule = null
+}) {
+ const cards = Array.isArray(selectedCards) ? selectedCards : [];
+ const hand = Array.isArray(handCards) ? handCards : [];
+ const submitted = Array.isArray(substitutions) ? substitutions : [];
+ const validate = (effectiveCards, effectiveHandCards) => leadingPattern
+ ? validateFollowingPlay(
+ effectiveCards,
+ effectiveHandCards,
+ leadingPattern,
+ trumpSuit,
+ trumpRank,
+ activeRule
+ )
+ : validateLeadingPlay(effectiveCards, trumpSuit, trumpRank, activeRule);
+
+ if (submitted.length === 0) {
+ return {
+ valid: false,
+ usesSkill: false,
+ effectiveCards: cards,
+ effectiveHandCards: hand,
+ substitutions: [],
+ message: '聚类分析必须先明确转换至少一张牌'
+ };
+ }
+ if (new Set(submitted.map(item => item?.cardId)).size !== submitted.length) {
+ return { valid: false, message: '同一张牌不能重复设置聚类转换' };
+ }
+
+ const selectedById = new Map(cards.map(card => [card.id, card]));
+ const replacementById = new Map();
+ const normalizedSubstitutions = [];
+ for (const substitution of submitted) {
+ const sourceCard = selectedById.get(substitution?.cardId);
+ if (!sourceCard) {
+ return { valid: false, message: '所有已转换的牌都必须包含在本次出牌中' };
+ }
+ const targetRanks = getClusterAnalysisTargetRanks(sourceCard, trumpRank);
+ if (!targetRanks.includes(substitution.toRank)) {
+ return { valid: false, message: `牌 ${sourceCard.rank} 不能转换成所选点数` };
+ }
+ normalizedSubstitutions.push({
+ cardId: sourceCard.id,
+ suit: sourceCard.suit,
+ fromRank: sourceCard.rank,
+ toRank: substitution.toRank
+ });
+ replacementById.set(sourceCard.id, createClusterCard(sourceCard, substitution.toRank));
+ }
+ const effectiveCards = cards.map(card => replacementById.get(card.id) || card);
+ const effectiveHandCards = hand.map(card => replacementById.get(card.id) || card);
+ const validation = validate(effectiveCards, effectiveHandCards);
+ return { ...validation, usesSkill: true, effectiveCards, effectiveHandCards, substitutions: normalizedSubstitutions };
+}
+
+function validateFollowingTaiChi(cards, handCards, trumpSuit, trumpRank, activeRule) {
+ const pattern = detectPattern(cards, trumpSuit, trumpRank, activeRule);
+ const availableTaiChi = findTaiChiFourSymbols(handCards, activeRule);
+ if (
+ availableTaiChi.length > 0 &&
+ pattern.type !== PatternTypes.TAI_CHI_FOUR_SYMBOLS
+ ) {
+ return {
+ valid: false,
+ message: '手牌中有太极四象,必须优先跟出太极四象',
+ pattern
+ };
+ }
+ if (pattern.type === PatternTypes.TAI_CHI_FOUR_SYMBOLS) {
+ return { valid: true, message: '跟牌合法', pattern };
+ }
+
+ const hasNoSideCards = handCards.length > 0 && handCards.every(card =>
+ isTrumpCard(card, trumpSuit, trumpRank)
+ );
+ const playedOnlyTrump = cards.every(card => isTrumpCard(card, trumpSuit, trumpRank));
+ if (hasNoSideCards && playedOnlyTrump) {
+ return {
+ valid: true,
+ message: '四张主牌毙太极四象',
+ pattern: {
+ ...pattern,
+ type: pattern.type === PatternTypes.INVALID ? PatternTypes.THROW : pattern.type,
+ suit: 'trump',
+ length: 4,
+ strength: Math.max(...cards.map(card =>
+ getCardStrength(card, trumpSuit, trumpRank, activeRule)
+ )),
+ canTrumpTaiChi: true
+ }
+ };
+ }
+ return { valid: true, message: '跟牌合法', pattern };
+}
+
+function matchPatternRequirement(
+ cards,
+ handCards,
+ leadingPattern,
+ trumpSuit,
+ trumpRank,
+ activeRule
+) {
const leadingSuit = leadingPattern.suit;
const sameSuitHand = handCards.filter(c =>
getEffectiveSuit(c, trumpSuit, trumpRank) === leadingSuit
);
+ if (leadingPattern.type === PatternTypes.STRAIGHT_FLUSH) {
+ const availableStraightFlushes = findStraightFlushes(
+ sameSuitHand,
+ leadingPattern.length,
+ trumpSuit,
+ trumpRank,
+ activeRule
+ );
+ if (availableStraightFlushes.length > 0) {
+ const playedPattern = detectPattern(cards, trumpSuit, trumpRank, activeRule);
+ return playedPattern.type === PatternTypes.STRAIGHT_FLUSH &&
+ playedPattern.suit === leadingSuit &&
+ playedPattern.length === leadingPattern.length;
+ }
+ }
+
if (leadingPattern.type === PatternTypes.PAIR) {
- const hasPair = findPairsInCards(sameSuitHand, trumpSuit, trumpRank).length > 0;
+ const hasPair = findPairsInCards(sameSuitHand, trumpSuit, trumpRank, activeRule).length > 0;
if (hasPair) {
- const playedPattern = detectPattern(cards, trumpSuit, trumpRank);
+ const playedPattern = detectPattern(cards, trumpSuit, trumpRank, activeRule);
return playedPattern.type === PatternTypes.PAIR;
}
}
if (leadingPattern.type === PatternTypes.TRACTOR) {
- const pairs = findPairsInCards(sameSuitHand, trumpSuit, trumpRank);
+ const pairs = findPairsInCards(sameSuitHand, trumpSuit, trumpRank, activeRule);
const tractorPairs = leadingPattern.length / 2;
- const hasTractor = findTractorInPairs(pairs, tractorPairs, trumpSuit, trumpRank);
+ const longestTractor = getLongestTractorPairCount(
+ pairs,
+ tractorPairs,
+ trumpSuit,
+ trumpRank,
+ activeRule
+ );
- if (hasTractor) {
- const playedPattern = detectPattern(cards, trumpSuit, trumpRank);
+ if (longestTractor === tractorPairs) {
+ const playedPattern = detectPattern(cards, trumpSuit, trumpRank, activeRule);
return playedPattern.type === PatternTypes.TRACTOR &&
playedPattern.length === leadingPattern.length;
}
if (pairs.length > 0) {
- const playedPairs = findPairsInCards(cards, trumpSuit, trumpRank);
+ const playedPairs = findPairsInCards(cards, trumpSuit, trumpRank, activeRule);
const requiredPairs = Math.min(pairs.length, tractorPairs);
- return playedPairs.length >= requiredPairs;
+ if (playedPairs.length < requiredPairs) return false;
+
+ if (longestTractor >= 2) {
+ const playedLongest = getLongestTractorPairCount(
+ playedPairs,
+ longestTractor,
+ trumpSuit,
+ trumpRank,
+ activeRule
+ );
+ return playedLongest >= longestTractor;
+ }
+
+ return true;
}
}
return true;
}
-function getPatternMismatchMessage(leadingPattern, handCards, trumpSuit, trumpRank) {
+function getPatternMismatchMessage(
+ leadingPattern,
+ handCards,
+ trumpSuit,
+ trumpRank,
+ activeRule
+) {
const leadingSuit = leadingPattern.suit;
const sameSuitHand = handCards.filter(c =>
getEffectiveSuit(c, trumpSuit, trumpRank) === leadingSuit
);
+ if (leadingPattern.type === PatternTypes.STRAIGHT_FLUSH) {
+ const straightFlushes = findStraightFlushes(
+ sameSuitHand,
+ leadingPattern.length,
+ trumpSuit,
+ trumpRank,
+ activeRule
+ );
+ if (straightFlushes.length > 0) {
+ return `手牌中有同花色${leadingPattern.length}张同花顺,必须出同花顺`;
+ }
+ }
+
if (leadingPattern.type === PatternTypes.PAIR) {
- const pairs = findPairsInCards(sameSuitHand, trumpSuit, trumpRank);
+ const pairs = findPairsInCards(sameSuitHand, trumpSuit, trumpRank, activeRule);
if (pairs.length > 0) {
return '手牌中有同花色对子,必须出对子';
}
}
if (leadingPattern.type === PatternTypes.TRACTOR) {
- const pairs = findPairsInCards(sameSuitHand, trumpSuit, trumpRank);
+ const pairs = findPairsInCards(sameSuitHand, trumpSuit, trumpRank, activeRule);
if (pairs.length > 0) {
return '手牌中有同花色对子,必须优先把对子打下来';
}
@@ -376,7 +1057,7 @@ function getPatternMismatchMessage(leadingPattern, handCards, trumpSuit, trumpRa
return '出牌不符合规则';
}
-function findPairsInCards(cards, trumpSuit, trumpRank) {
+export function findPairsInCards(cards, trumpSuit, trumpRank, activeRule = null) {
const pairs = [];
const used = new Set();
@@ -386,10 +1067,10 @@ function findPairsInCards(cards, trumpSuit, trumpRank) {
for (let j = i + 1; j < cards.length; j++) {
if (used.has(j)) continue;
- if (cards[i].rank === cards[j].rank && cards[i].suit === cards[j].suit) {
+ if (canCardsFormPair(cards[i], cards[j], activeRule)) {
pairs.push({
cards: [cards[i], cards[j]],
- strength: getCardStrength(cards[i], trumpSuit, trumpRank)
+ strength: getCardStrength(cards[i], trumpSuit, trumpRank, activeRule)
});
used.add(i);
used.add(j);
@@ -401,23 +1082,336 @@ function findPairsInCards(cards, trumpSuit, trumpRank) {
return pairs;
}
-function findTractorInPairs(pairs, requiredLength, trumpSuit, trumpRank) {
- if (pairs.length < requiredLength) return false;
+export function findTractorInPairs(
+ pairs,
+ requiredLength,
+ trumpSuit,
+ trumpRank,
+ activeRule = null
+) {
+ return getLongestTractorPairCount(
+ pairs,
+ requiredLength,
+ trumpSuit,
+ trumpRank,
+ activeRule
+ ) >= requiredLength;
+}
+
+function getLongestTractorPairCount(pairs, maxLength, trumpSuit, trumpRank, activeRule = null) {
+ if (!pairs || pairs.length === 0) return 0;
+
+ const sortedPairs = [...pairs].sort((a, b) => a.strength - b.strength);
+ let longest = 1;
+ let currentLength = 1;
+
+ for (let i = 1; i < sortedPairs.length; i++) {
+ if (arePairsConsecutive(
+ sortedPairs[i - 1],
+ sortedPairs[i],
+ trumpSuit,
+ trumpRank,
+ activeRule
+ )) {
+ currentLength++;
+ longest = Math.max(longest, currentLength);
+ } else {
+ currentLength = 1;
+ }
+ }
+
+ return Math.min(longest, maxLength);
+}
+
+/**
+ * 找出手牌中所有可能的拖拉机
+ * @returns {Array} 拖拉机数组,每个包含 { cards: [], length: number }
+ */
+export function findAllTractors(
+ cards,
+ trumpSuit,
+ trumpRank,
+ requiredLength,
+ activeRule = null
+) {
+ const pairs = findPairsInCards(cards, trumpSuit, trumpRank, activeRule);
+ if (pairs.length < requiredLength / 2) return [];
const sortedPairs = [...pairs].sort((a, b) => a.strength - b.strength);
+ const tractors = [];
+ const numPairsNeeded = requiredLength / 2;
- for (let i = 0; i <= sortedPairs.length - requiredLength; i++) {
+ // 尝试找出所有可能的拖拉机组合
+ for (let i = 0; i <= sortedPairs.length - numPairsNeeded; i++) {
let isConsecutive = true;
- for (let j = 0; j < requiredLength - 1; j++) {
- if (sortedPairs[i + j + 1].strength - sortedPairs[i + j].strength > 2) {
+ for (let j = 0; j < numPairsNeeded - 1; j++) {
+ if (!arePairsConsecutive(
+ sortedPairs[i + j],
+ sortedPairs[i + j + 1],
+ trumpSuit,
+ trumpRank,
+ activeRule
+ )) {
isConsecutive = false;
break;
}
}
- if (isConsecutive) return true;
+
+ if (isConsecutive) {
+ // 找到一个拖拉机,收集所有卡片
+ const tractorCards = [];
+ for (let j = 0; j < numPairsNeeded; j++) {
+ tractorCards.push(...sortedPairs[i + j].cards);
+ }
+ tractors.push({
+ cards: tractorCards,
+ length: requiredLength
+ });
+ }
}
- return false;
+ return tractors;
+}
+
+/**
+ * 计算必须出的牌(用于自动选中)
+ * @param {Array} handCards - 手牌
+ * @param {Object} leadingPattern - 首发牌型
+ * @param {String} trumpSuit - 主花色
+ * @param {String} trumpRank - 级牌
+ * @returns {Array|null} 必须出的牌数组,如果没有唯一解则返回null
+ */
+export function calculateMustPlayCards(
+ handCards,
+ leadingPattern,
+ trumpSuit,
+ trumpRank,
+ activeRule = null
+) {
+ if (!leadingPattern || !handCards || handCards.length === 0) return null;
+
+ const leadingSuit = leadingPattern.suit;
+ const requiredLength = leadingPattern.length;
+
+ if (leadingPattern.type === PatternTypes.TAI_CHI_FOUR_SYMBOLS) {
+ const taiChiPatterns = findTaiChiFourSymbols(handCards, activeRule);
+ if (taiChiPatterns.length === 1) return taiChiPatterns[0].cards;
+ if (taiChiPatterns.length > 1) return null;
+ if (handCards.length === requiredLength) return handCards;
+ return null;
+ }
+
+ // 找出手牌中同花色的牌
+ const sameSuitCards = handCards.filter(c =>
+ getEffectiveSuit(c, trumpSuit, trumpRank) === leadingSuit
+ );
+
+ // 情况1:同花色的牌数量刚好等于需要出的数量
+ if (sameSuitCards.length > 0 && sameSuitCards.length === requiredLength) {
+ return sameSuitCards;
+ }
+
+ // 情况2:没有同花色的牌,且手牌总数等于需要出的数量
+ if (sameSuitCards.length === 0 && handCards.length === requiredLength) {
+ return handCards;
+ }
+
+ // 情况3:同花色的牌数量小于需要出的数量,且手牌总数等于需要出的数量
+ if (sameSuitCards.length > 0 && sameSuitCards.length < requiredLength && handCards.length === requiredLength) {
+ return handCards;
+ }
+
+ // 情况4:同花色的牌数量小于需要出的数量(不够),但同花色牌大于0,则所有同花色牌都必须出
+ if (sameSuitCards.length > 0 && sameSuitCards.length < requiredLength) {
+ return sameSuitCards;
+ }
+
+ // 情况5:有足够的同花色牌,需要根据牌型判断
+ if (sameSuitCards.length <= requiredLength) return null;
+
+ const leadingType = leadingPattern.type;
+
+ if (leadingType === PatternTypes.STRAIGHT_FLUSH) {
+ const straightFlushes = findStraightFlushes(
+ sameSuitCards,
+ requiredLength,
+ trumpSuit,
+ trumpRank,
+ activeRule
+ );
+ return straightFlushes.length === 1 ? straightFlushes[0].cards : null;
+ }
+
+ // 处理单张
+ if (leadingType === PatternTypes.SINGLE && requiredLength === 1) {
+ return null; // 单张有多种选择,不自动选中
+ }
+
+ // 处理对子
+ if (leadingType === PatternTypes.PAIR && requiredLength === 2) {
+ const pairs = findPairsInCards(sameSuitCards, trumpSuit, trumpRank, activeRule);
+ if (pairs.length === 1) {
+ return pairs[0].cards; // 只有一个对子,必须出
+ }
+ return null; // 有多个对子,不自动选中
+ }
+
+ // 处理拖拉机
+ if (leadingType === PatternTypes.TRACTOR && requiredLength >= 4 && requiredLength % 2 === 0) {
+ const tractors = findAllTractors(
+ sameSuitCards,
+ trumpSuit,
+ trumpRank,
+ requiredLength,
+ activeRule
+ );
+ if (tractors.length === 1) {
+ return tractors[0].cards; // 只有一个拖拉机,必须出
+ }
+ if (tractors.length === 0) {
+ // 没有拖拉机,检查对子
+ const pairs = findPairsInCards(sameSuitCards, trumpSuit, trumpRank, activeRule);
+ const numPairsNeeded = requiredLength / 2;
+
+ if (pairs.length === numPairsNeeded) {
+ // 对子数量刚好,必须出所有对子
+ return pairs.flatMap(p => p.cards);
+ } else if (pairs.length === 1) {
+ // 只有一个对子(需要配单牌),自动选中这个对子
+ return pairs[0].cards;
+ }
+ }
+ return null;
+ }
+
+ // 处理甩牌
+ if (leadingType === PatternTypes.THROW && leadingPattern.components) {
+ return calculateMustPlayCardsForThrow(
+ sameSuitCards,
+ leadingPattern,
+ trumpSuit,
+ trumpRank,
+ activeRule
+ );
+ }
+
+ return null;
+}
+
+/**
+ * 计算甩牌时必须出的牌
+ */
+function calculateMustPlayCardsForThrow(
+ sameSuitCards,
+ leadingPattern,
+ trumpSuit,
+ trumpRank,
+ activeRule
+) {
+ const leadingComponents = leadingPattern.components;
+ const requiredLength = leadingPattern.length;
+
+ // 如果同花色牌数量等于需要出的数量,全部必须出
+ if (sameSuitCards.length === requiredLength) {
+ return sameSuitCards;
+ }
+
+ // 如果同花色牌数量小于需要的数量,不处理(这种情况应该由前面的逻辑处理)
+ if (sameSuitCards.length < requiredLength) {
+ return null;
+ }
+
+ // 解析同花色牌的组合
+ const sameSuitParsed = parseThrowCombination(
+ sameSuitCards,
+ trumpSuit,
+ trumpRank,
+ activeRule
+ );
+ if (!sameSuitParsed.valid) return null;
+
+ // 统计首发要求的各种牌型
+ const leadingRequirements = {
+ [PatternTypes.TRACTOR]: [],
+ [PatternTypes.PAIR]: [],
+ [PatternTypes.SINGLE]: []
+ };
+
+ for (const component of leadingComponents) {
+ leadingRequirements[component.type].push(component);
+ }
+
+ // 统计手牌中可用的各种牌型
+ const availableComponents = {
+ [PatternTypes.TRACTOR]: [],
+ [PatternTypes.PAIR]: [],
+ [PatternTypes.SINGLE]: []
+ };
+
+ for (const component of sameSuitParsed.components) {
+ availableComponents[component.type].push(component);
+ }
+
+ const mustPlayCards = [];
+ let hasChoice = false;
+
+ // 统计首发要求的总对子数(包括拖拉机中的对子)
+ let totalPairsRequired = leadingRequirements[PatternTypes.PAIR].length;
+ for (const tractor of leadingRequirements[PatternTypes.TRACTOR]) {
+ totalPairsRequired += tractor.length / 2; // 拖拉机长度除以2就是对子数
+ }
+
+ // 检查拖拉机
+ for (const leadTractor of leadingRequirements[PatternTypes.TRACTOR]) {
+ const matchingTractors = availableComponents[PatternTypes.TRACTOR].filter(
+ t => t.length === leadTractor.length
+ );
+
+ if (matchingTractors.length === 1) {
+ // 只有一个匹配的拖拉机,必须出
+ mustPlayCards.push(...matchingTractors[0].cards);
+ } else if (matchingTractors.length > 1) {
+ // 有多个拖拉机可选,有选择余地
+ hasChoice = true;
+ }
+ }
+
+ // 检查对子
+ const availablePairsCount = availableComponents[PatternTypes.PAIR].length;
+
+ // 如果有对子要求(包括拖拉机要求)且没有选择余地
+ if (totalPairsRequired > 0 && !hasChoice) {
+ // 如果可用对子数量小于等于需要的对子数量,全部必须出
+ if (availablePairsCount > 0 && availablePairsCount <= totalPairsRequired) {
+ for (const pair of availableComponents[PatternTypes.PAIR]) {
+ mustPlayCards.push(...pair.cards);
+ }
+ } else if (availablePairsCount > totalPairsRequired) {
+ // 有多余的对子,有选择余地
+ hasChoice = true;
+ }
+ }
+
+ // 如果有选择余地,返回null
+ if (hasChoice) {
+ return null;
+ }
+
+ // 如果必须出的牌刚好等于需要的数量,返回这些牌
+ if (mustPlayCards.length === requiredLength) {
+ return mustPlayCards;
+ }
+
+ // 特殊情况:如果有对子必须出且没有选择余地,自动选中这些对子
+ // 例如:对方甩牌包含对子,我方只有一个对子,这个对子必须跟
+ if (mustPlayCards.length > 0 && mustPlayCards.length < requiredLength && !hasChoice) {
+ // 确认 mustPlayCards 中包含的是对子(偶数张且至少2张)
+ if (mustPlayCards.length >= 2 && mustPlayCards.length % 2 === 0) {
+ return mustPlayCards;
+ }
+ }
+
+ return null;
}
/**
@@ -427,11 +1421,29 @@ function findTractorInPairs(pairs, requiredLength, trumpSuit, trumpRank) {
* @param {String} trumpRank - 级牌
* @returns {Object} { valid, suit, components } components是组件数组,每个组件包含 { type, cards, strength }
*/
-export function parseThrowCombination(cards, trumpSuit, trumpRank) {
+export function parseThrowCombination(cards, trumpSuit, trumpRank, activeRule = null) {
if (!cards || cards.length === 0) {
return { valid: false, suit: null, components: [] };
}
+ const specialPattern = detectPattern(cards, trumpSuit, trumpRank, activeRule);
+ if (
+ specialPattern.type === PatternTypes.STRAIGHT_FLUSH ||
+ specialPattern.type === PatternTypes.TAI_CHI_FOUR_SYMBOLS
+ ) {
+ return {
+ valid: true,
+ suit: specialPattern.suit,
+ components: [{
+ type: specialPattern.type,
+ cards: [...cards],
+ strength: specialPattern.strength,
+ length: cards.length
+ }],
+ totalCards: cards.length
+ };
+ }
+
// 检查是否同花色
const effectiveSuits = cards.map(c => getEffectiveSuit(c, trumpSuit, trumpRank));
const uniqueSuits = [...new Set(effectiveSuits)];
@@ -470,7 +1482,7 @@ export function parseThrowCombination(cards, trumpSuit, trumpRank) {
}
if (candidateCards.length === len) {
- const pattern = detectPattern(candidateCards, trumpSuit, trumpRank);
+ const pattern = detectPattern(candidateCards, trumpSuit, trumpRank, activeRule);
if (pattern.type === PatternTypes.TRACTOR && pattern.length === len) {
// 找到一个拖拉机
components.push({
@@ -498,7 +1510,7 @@ export function parseThrowCombination(cards, trumpSuit, trumpRank) {
const card1 = remainingCards[i];
const card2 = remainingCards[j];
- if (card1.rank === card2.rank && card1.suit === card2.suit) {
+ if (canCardsFormPair(card1, card2, activeRule)) {
components.push({
type: PatternTypes.PAIR,
cards: [card1, card2],
@@ -562,7 +1574,14 @@ function groupComponentsByType(components) {
* @param {String} trumpRank - 级牌
* @returns {Object} { valid, message, pattern }
*/
-function validateFollowingThrow(cards, handCards, leadingPattern, trumpSuit, trumpRank) {
+function validateFollowingThrow(
+ cards,
+ handCards,
+ leadingPattern,
+ trumpSuit,
+ trumpRank,
+ activeRule
+) {
const leadingSuit = leadingPattern.suit;
const leadingComponents = leadingPattern.components;
@@ -589,7 +1608,7 @@ function validateFollowingThrow(cards, handCards, leadingPattern, trumpSuit, tru
}
// 解析跟牌的组合
- const followParsed = parseThrowCombination(cards, trumpSuit, trumpRank);
+ const followParsed = parseThrowCombination(cards, trumpSuit, trumpRank, activeRule);
// 如果全部是同花色,需要检查牌型匹配
if (playedSameSuit.length >= leadingPattern.length && sameSuitCards.length >= leadingPattern.length) {
@@ -604,43 +1623,42 @@ function validateFollowingThrow(cards, handCards, leadingPattern, trumpSuit, tru
leadingRequirements[component.type].push(component);
}
- const sameSuitParsed = parseThrowCombination(sameSuitCards, trumpSuit, trumpRank);
- const availableComponents = {
- [PatternTypes.TRACTOR]: [],
- [PatternTypes.PAIR]: [],
- [PatternTypes.SINGLE]: []
- };
-
- for (const component of sameSuitParsed.components) {
- availableComponents[component.type].push(component);
- }
-
const errors = [];
+ const availablePairs = findPairsInCards(sameSuitCards, trumpSuit, trumpRank, activeRule);
+ const followPairs = findPairsInCards(cards, trumpSuit, trumpRank, activeRule);
- // 检查拖拉机
+ // 有完整拖跟完整拖;否则仍须优先跟出可组成的最长短拖。
for (const leadTractor of leadingRequirements[PatternTypes.TRACTOR]) {
- const matchingTractors = availableComponents[PatternTypes.TRACTOR].filter(
- t => t.length === leadTractor.length
+ const requiredPairCount = leadTractor.length / 2;
+ const availableLongest = getLongestTractorPairCount(
+ availablePairs,
+ requiredPairCount,
+ trumpSuit,
+ trumpRank,
+ activeRule
);
- if (matchingTractors.length > 0) {
- const followTractors = followParsed.components.filter(
- c => c.type === PatternTypes.TRACTOR && c.length === leadTractor.length
- );
- if (followTractors.length === 0) {
- errors.push(`手牌中有同花色${leadTractor.length}张的拖拉机,必须出`);
- }
+ const followedLongest = getLongestTractorPairCount(
+ followPairs,
+ requiredPairCount,
+ trumpSuit,
+ trumpRank,
+ activeRule
+ );
+
+ if (availableLongest >= 2 && followedLongest < availableLongest) {
+ errors.push(`手牌中有同花色${availableLongest * 2}张的拖拉机,必须优先出`);
}
}
- // 检查对子
- const leadPairsCount = leadingRequirements[PatternTypes.PAIR].length;
- const availablePairsCount = availableComponents[PatternTypes.PAIR].length;
- if (leadPairsCount > 0 && availablePairsCount > 0) {
- const followPairsCount = followParsed.components.filter(c => c.type === PatternTypes.PAIR).length;
- const requiredPairs = Math.min(leadPairsCount, availablePairsCount);
- if (followPairsCount < requiredPairs) {
- errors.push(`手牌中有${availablePairsCount}个同花色对子,必须至少出${requiredPairs}个`);
- }
+ const totalRequiredPairs =
+ leadingRequirements[PatternTypes.PAIR].length +
+ leadingRequirements[PatternTypes.TRACTOR].reduce(
+ (total, tractor) => total + tractor.length / 2,
+ 0
+ );
+ const requiredPairs = Math.min(availablePairs.length, totalRequiredPairs);
+ if (followPairs.length < requiredPairs) {
+ errors.push(`手牌中有${availablePairs.length}个同花色对子,必须至少出${requiredPairs}个`);
}
if (errors.length > 0) {
diff --git a/tractor-game-simulator/client/src/utils/cardUtils.js b/tractor-game-simulator/client/src/utils/cardUtils.js
index 0dfff22..46a52f2 100644
--- a/tractor-game-simulator/client/src/utils/cardUtils.js
+++ b/tractor-game-simulator/client/src/utils/cardUtils.js
@@ -9,13 +9,25 @@ import { getCardStrength, getEffectiveSuit } from './cardPatternUtils.js';
* @returns {Boolean} 是否为主牌
*/
export function isTrumpCard(card, trumpSuit, trumpRank) {
+ if (card?.isForbiddenMagicDemoted) {
+ return false;
+ }
+ if (card.isLastStandTrump) {
+ return true;
+ }
+ if (card.isThreeTigersTrump) {
+ return true;
+ }
+ if (card.rank === Ranks.NO_TRUMP_MINUS) {
+ return true;
+ }
// 大小王总是主牌
if (card.suit === Suits.JOKER) {
return true;
}
// 主点数的牌是主牌
- if (trumpRank && card.rank === trumpRank) {
+ if (!card.isUnarmed && trumpRank && card.rank === trumpRank) {
return true;
}
@@ -31,7 +43,17 @@ export function isTrumpCard(card, trumpSuit, trumpRank) {
* 获取主牌的优先级(用于排序)
* 顺序:大王、小王、主花色级牌、其他花色级牌(按花色顺序)、主花色其他牌
*/
-function getTrumpPriority(card, trumpSuit, trumpRank) {
+function getTrumpPriority(card, trumpSuit, trumpRank, inferiorSuit = null) {
+ // 扩展王依次排在大王之上:郡王、亲王、白王(皇)。
+ if (card.suit === Suits.JOKER && card.rank === Ranks.WHITE_JOKER) {
+ return -3;
+ }
+ if (card.suit === Suits.JOKER && card.rank === Ranks.PRINCE_JOKER) {
+ return -2;
+ }
+ if (card.suit === Suits.JOKER && card.rank === Ranks.COUNTY_PRINCE_JOKER) {
+ return -1;
+ }
// 大王
if (card.suit === Suits.JOKER && card.rank === Ranks.BIG_JOKER) {
return 0;
@@ -43,15 +65,22 @@ function getTrumpPriority(card, trumpSuit, trumpRank) {
}
// 级牌排序
- if (trumpRank && card.rank === trumpRank) {
+ if (!card.isUnarmed && trumpRank && card.rank === trumpRank) {
// 主花色级牌优先级最高(排在王后面)
if (trumpSuit && trumpSuit !== Suits.NO_TRUMP && card.suit === trumpSuit) {
return 2;
}
+ if (inferiorSuit && card.suit === inferiorSuit) {
+ return 7;
+ }
// 其他花色级牌按 SUIT_ORDER 排序
return 3 + (SUIT_ORDER[card.suit] ?? 0);
}
+ if (card.rank === Ranks.NO_TRUMP_MINUS) {
+ return 7 + (SUIT_ORDER[card.suit] ?? 0);
+ }
+
// 其他主花色牌(在级牌之后)
if (trumpSuit && trumpSuit !== Suits.NO_TRUMP && card.suit === trumpSuit) {
return 100 + (RANK_ORDER[card.rank] ?? 0);
@@ -71,7 +100,10 @@ function isJoker(card) {
* 判断一张牌是否为级牌(非王)
*/
function isTrumpRank(card, trumpRank) {
- return trumpRank && card.rank === trumpRank && card.suit !== Suits.JOKER;
+ return card.suit !== Suits.JOKER && (
+ (!card.isUnarmed && trumpRank && card.rank === trumpRank)
+ || card.rank === Ranks.NO_TRUMP_MINUS
+ );
}
/**
@@ -91,7 +123,7 @@ function isTrumpSuitCard(card, trumpSuit, trumpRank) {
}
// 不是级牌
- if (trumpRank && card.rank === trumpRank) {
+ if (!card.isUnarmed && trumpRank && card.rank === trumpRank) {
return false;
}
@@ -116,7 +148,7 @@ function isTrumpSuitCard(card, trumpSuit, trumpRank) {
* @param {String} trumpRank - 主牌点数 (可选)
* @returns {Array} 排序后的牌数组
*/
-export function sortCards(cards, trumpSuit = null, trumpRank = null) {
+export function sortCards(cards, trumpSuit = null, trumpRank = null, inferiorSuit = null) {
if (!Array.isArray(cards) || cards.length === 0) {
return cards;
}
@@ -138,8 +170,8 @@ export function sortCards(cards, trumpSuit = null, trumpRank = null) {
// 都是王或级牌,按主牌优先级排序
if (aIsJokerOrTrumpRank && bIsJokerOrTrumpRank) {
- const priorityA = getTrumpPriority(a, trumpSuit, trumpRank);
- const priorityB = getTrumpPriority(b, trumpSuit, trumpRank);
+ const priorityA = getTrumpPriority(a, trumpSuit, trumpRank, inferiorSuit);
+ const priorityB = getTrumpPriority(b, trumpSuit, trumpRank, inferiorSuit);
return priorityA - priorityB;
}
@@ -158,6 +190,12 @@ export function sortCards(cards, trumpSuit = null, trumpRank = null) {
return rankB - rankA;
}
+ // 三六九等:劣花色普通牌整体排在所有普通副花色之后。
+ const aIsInferiorSuitCard = Boolean(inferiorSuit && a.suit === inferiorSuit);
+ const bIsInferiorSuitCard = Boolean(inferiorSuit && b.suit === inferiorSuit);
+ if (aIsInferiorSuitCard && !bIsInferiorSuitCard) return 1;
+ if (!aIsInferiorSuitCard && bIsInferiorSuitCard) return -1;
+
// 都是副牌,按花色和点数排序
const suitA = SUIT_ORDER[a.suit] ?? 999;
const suitB = SUIT_ORDER[b.suit] ?? 999;
@@ -174,7 +212,12 @@ export function sortCards(cards, trumpSuit = null, trumpRank = null) {
// 第二步:如果有主牌信息,调整拖拉机使其组件相连
if (trumpSuit && trumpRank && sorted.length >= 4) {
- sorted = adjustTractorsForVisualContinuity(sorted, trumpSuit, trumpRank);
+ sorted = adjustTractorsForVisualContinuity(
+ sorted,
+ trumpSuit,
+ trumpRank,
+ inferiorSuit
+ );
}
return sorted;
@@ -187,7 +230,7 @@ export function sortCards(cards, trumpSuit = null, trumpRank = null) {
* @param {String} trumpRank - 级牌
* @returns {Array} 调整后的牌数组
*/
-function adjustTractorsForVisualContinuity(cards, trumpSuit, trumpRank) {
+function adjustTractorsForVisualContinuity(cards, trumpSuit, trumpRank, inferiorSuit = null) {
// 只处理主牌区域的拖拉机
const trumpCards = cards.filter(c => isTrumpCard(c, trumpSuit, trumpRank));
if (trumpCards.length < 4) {
@@ -210,7 +253,12 @@ function adjustTractorsForVisualContinuity(cards, trumpSuit, trumpRank) {
if (card1.rank === card2.rank && card1.suit === card2.suit) {
pairs.push({
cards: [card1, card2],
- strength: getCardStrength(card1, trumpSuit, trumpRank),
+ strength: getCardStrength(
+ card1,
+ trumpSuit,
+ trumpRank,
+ inferiorSuit ? { id: 'three_six_nine_grades', inferiorSuit } : null
+ ),
indices: [
cards.findIndex(c => c.id === card1.id),
cards.findIndex(c => c.id === card2.id)
@@ -304,8 +352,9 @@ function adjustTractorsForVisualContinuity(cards, trumpSuit, trumpRank) {
// 提取拖拉机的所有牌(按强度排序,保证从小到大)
const tractorCards = tractorCardIds.map(id => result.find(c => c.id === id));
tractorCards.sort((a, b) => {
- const strengthA = getCardStrength(a, trumpSuit, trumpRank);
- const strengthB = getCardStrength(b, trumpSuit, trumpRank);
+ const activeRule = inferiorSuit ? { id: 'three_six_nine_grades', inferiorSuit } : null;
+ const strengthA = getCardStrength(a, trumpSuit, trumpRank, activeRule);
+ const strengthB = getCardStrength(b, trumpSuit, trumpRank, activeRule);
return strengthA - strengthB;
});
diff --git a/tractor-game-simulator/client/src/utils/constants.js b/tractor-game-simulator/client/src/utils/constants.js
index f47ad66..f15111f 100644
--- a/tractor-game-simulator/client/src/utils/constants.js
+++ b/tractor-game-simulator/client/src/utils/constants.js
@@ -19,9 +19,12 @@ export const SOCKET_EVENTS = {
// 房间相关
CREATE_ROOM: 'create_room',
JOIN_ROOM: 'join_room',
+ RESUME_ROOM: 'resume_room',
+ REQUEST_PRIVATE_GAME_STATE_SYNC: 'request_private_game_state_sync',
LEAVE_ROOM: 'leave_room',
GET_ROOM_LIST: 'get_room_list',
UPDATE_CONFIG: 'update_config',
+ SET_BOT_TYPE: 'set_bot_type',
ADD_BOT: 'add_bot',
REMOVE_BOT: 'remove_bot',
@@ -29,7 +32,6 @@ export const SOCKET_EVENTS = {
START_GAME: 'start_game',
SET_BURYING_PLAYER: 'set_burying_player',
SET_FIRST_PLAYER: 'set_first_player',
- SET_TRUMP: 'set_trump',
RESTART_GAME: 'restart_game',
// 玩家操作
@@ -39,6 +41,8 @@ export const SOCKET_EVENTS = {
PLAY_CARDS: 'play_cards',
PASS_TURN: 'pass_turn',
UNDO_PLAY: 'undo_play',
+ REQUEST_SURRENDER: 'request_surrender',
+ RESPOND_SURRENDER: 'respond_surrender',
UPDATE_SCORE: 'update_score',
UPDATE_LEVEL: 'update_level',
UPDATE_PLAYER_NAME: 'update_player_name',
@@ -46,7 +50,48 @@ export const SOCKET_EVENTS = {
VIEW_MY_BOTTOM_CARDS: 'view_my_bottom_cards',
REORDER_CARDS: 'reorder_cards',
SEND_CHAT_MESSAGE: 'send_chat_message',
- SELECT_RULE: 'select_rule'
+ SELECT_RULE: 'select_rule',
+ REFRESH_DOUBLE_HAPPINESS_OPTION: 'refresh_double_happiness_option',
+ SUBMIT_CARD_EXCHANGE: 'submit_card_exchange',
+ SUBMIT_ICEBERG_REVEALS: 'submit_iceberg_reveals',
+ SELECT_TEN_SIDED_AMBUSH_RANK: 'select_ten_sided_ambush_rank',
+ SELECT_WAITING_RABBIT_TARGET: 'select_waiting_rabbit_target',
+ ACTIVATE_LATE_MOVER_ADVANTAGE: 'activate_late_mover_advantage',
+ ACTIVATE_RECOMMEND_TALENT: 'activate_recommend_talent',
+ ACTIVATE_MAGIC_TRICK: 'activate_magic_trick',
+ ACTIVATE_EQUIVALENT_RECIPROCITY: 'activate_equivalent_reciprocity',
+ ACTIVATE_MUTUAL_SUPPORT: 'activate_mutual_support',
+ ACTIVATE_CULTURAL_REVOLUTION: 'activate_cultural_revolution',
+ ACTIVATE_INVITE_INTO_URN: 'activate_invite_into_urn',
+ ACTIVATE_BUSH_GATE: 'activate_bush_gate',
+ RESPOND_TEAMMATE_CHEER: 'respond_teammate_cheer',
+ RESPOND_AFTERGLOW: 'respond_afterglow',
+ RESPOND_AMBIGUOUS_CHOICE: 'respond_ambiguous_choice',
+ ACTIVATE_DREAM_KILLING: 'activate_dream_killing',
+ ACTIVATE_FORBIDDEN_MAGIC: 'activate_forbidden_magic',
+ RESPOND_FORBIDDEN_MAGIC: 'respond_forbidden_magic',
+ RESPOND_REMOVE_FIREWOOD: 'respond_remove_firewood',
+ RESPOND_MAINSTAY: 'respond_mainstay',
+ SUBMIT_MAINSTAY_CARDS: 'submit_mainstay_cards',
+ SUBMIT_EQUIVALENT_RECIPROCITY_CARD: 'submit_equivalent_reciprocity_card',
+ SUBMIT_MUTUAL_SUPPORT_CARDS: 'submit_mutual_support_cards',
+ SELECT_THREE_POWERS_RANK: 'select_three_powers_rank',
+ SELECT_GENTLEMAN_PROMISE_SUIT: 'select_gentleman_promise_suit',
+ SELECT_HIDDEN_DRAGON_RANK: 'select_hidden_dragon_rank',
+ SELECT_ANTINOMY_CARD: 'select_antinomy_card',
+ SELECT_RICE_TO_MULBERRY_CARDS: 'select_rice_to_mulberry_cards',
+ RESPOND_DESTROY_DYKE: 'respond_destroy_dyke',
+ SELECT_ADMINISTRATIVE_REVIEW: 'select_administrative_review',
+ RESPOND_POLITICAL_REVIEW: 'respond_political_review',
+ VOTE_FOCUS_FIGURE: 'vote_focus_figure',
+ ACTIVATE_TIME_REVERSAL: 'activate_time_reversal',
+ RESPOND_TIME_REVERSAL: 'respond_time_reversal',
+ RESPOND_NINE_PRINCES: 'respond_nine_princes',
+ ACTIVATE_LURE_TIGER: 'activate_lure_tiger',
+ RESPOND_LURE_TIGER: 'respond_lure_tiger',
+ SELECT_LURE_TIGER_TARGET: 'select_lure_tiger_target',
+ RESPOND_STRAW_BOAT_BORROWING_ARROWS: 'respond_straw_boat_borrowing_arrows',
+ RESPOND_WAITING_RABBIT: 'respond_waiting_rabbit'
};
// 花色
@@ -61,6 +106,10 @@ export const Suits = {
// 牌面值
export const Ranks = {
+ MINUS_TWO: '-2',
+ MINUS_ONE: '-1',
+ ZERO: '0',
+ ONE: '1',
TWO: '2',
THREE: '3',
FOUR: '4',
@@ -74,10 +123,35 @@ export const Ranks = {
QUEEN: 'Q',
KING: 'K',
ACE: 'A',
+ BONUS_ONE: 'B',
+ BONUS_TWO: 'C',
+ BONUS_THREE: 'D',
+ NO_TRUMP_MINUS: 'M',
SMALL_JOKER: 'small_joker',
- BIG_JOKER: 'big_joker'
+ BIG_JOKER: 'big_joker',
+ COUNTY_PRINCE_JOKER: 'county_prince_joker',
+ PRINCE_JOKER: 'prince_joker',
+ WHITE_JOKER: 'white_joker'
};
+export const STANDARD_ORDINARY_RANKS = Object.freeze([
+ Ranks.TWO, Ranks.THREE, Ranks.FOUR, Ranks.FIVE, Ranks.SIX,
+ Ranks.SEVEN, Ranks.EIGHT, Ranks.NINE, Ranks.TEN,
+ Ranks.JACK, Ranks.QUEEN, Ranks.KING, Ranks.ACE
+]);
+
+export const PROMOTED_ORDINARY_RANKS = Object.freeze([
+ Ranks.BONUS_ONE,
+ Ranks.BONUS_TWO,
+ Ranks.BONUS_THREE
+]);
+
+export const EXTENDED_ORDINARY_RANKS = Object.freeze([
+ Ranks.MINUS_TWO, Ranks.MINUS_ONE, Ranks.ZERO, Ranks.ONE,
+ ...STANDARD_ORDINARY_RANKS,
+ ...PROMOTED_ORDINARY_RANKS
+]);
+
// 花色排序 (黑桃、红桃、梅花、方片)
export const SUIT_ORDER = {
[Suits.SPADES]: 0,
@@ -89,6 +163,10 @@ export const SUIT_ORDER = {
// 牌面值排序
export const RANK_ORDER = {
+ [Ranks.MINUS_TWO]: -2,
+ [Ranks.MINUS_ONE]: -1,
+ [Ranks.ZERO]: 0,
+ [Ranks.ONE]: 1,
[Ranks.TWO]: 2,
[Ranks.THREE]: 3,
[Ranks.FOUR]: 4,
@@ -102,12 +180,26 @@ export const RANK_ORDER = {
[Ranks.QUEEN]: 12,
[Ranks.KING]: 13,
[Ranks.ACE]: 14,
+ [Ranks.BONUS_ONE]: 15,
+ [Ranks.BONUS_TWO]: 16,
+ [Ranks.BONUS_THREE]: 17,
+ [Ranks.NO_TRUMP_MINUS]: 18,
[Ranks.SMALL_JOKER]: 100,
- [Ranks.BIG_JOKER]: 101
+ [Ranks.BIG_JOKER]: 101,
+ [Ranks.COUNTY_PRINCE_JOKER]: 102,
+ [Ranks.PRINCE_JOKER]: 103,
+ [Ranks.WHITE_JOKER]: 104
};
// 服务器URL配置
// 生产环境:使用当前域名(前后端在同一服务器)
// 开发环境:使用 localhost:5001
-export const SERVER_URL = import.meta.env.VITE_SERVER_URL ||
- (import.meta.env.PROD ? window.location.origin : 'http://localhost:5001');
+const viteEnv = import.meta.env || {};
+const injectedServerUrl = typeof __TRACTOR_SERVER_URL__ !== 'undefined'
+ ? __TRACTOR_SERVER_URL__
+ : undefined;
+const configuredServerUrl = typeof import.meta.env !== 'undefined'
+ ? import.meta.env.VITE_SERVER_URL
+ : undefined;
+export const SERVER_URL = injectedServerUrl || configuredServerUrl ||
+ (viteEnv.PROD && typeof window !== 'undefined' ? window.location.origin : 'http://localhost:5001');
diff --git a/tractor-game-simulator/client/src/utils/gameViewUtils.js b/tractor-game-simulator/client/src/utils/gameViewUtils.js
new file mode 100644
index 0000000..a06f663
--- /dev/null
+++ b/tractor-game-simulator/client/src/utils/gameViewUtils.js
@@ -0,0 +1,333 @@
+import { ruleIncludesId } from './ruleCatalog.js';
+
+const LEVEL_RANK_LABELS = Object.freeze({
+ 11: 'J',
+ 12: 'Q',
+ 13: 'K',
+ 14: 'A'
+});
+
+/**
+ * 等级在协议和升级计算中使用 2–14;界面上按对应级牌显示 2–10、J、Q、K、A。
+ */
+export function formatLevel(level) {
+ const numericLevel = Number(level);
+ return LEVEL_RANK_LABELS[numericLevel] || String(level ?? '');
+}
+
+/**
+ * WAITING 也可能是首局准备或两局之间的规则选择阶段;这两种情况都应留在牌桌。
+ */
+export function shouldShowGameBoard(gameState) {
+ const phase = gameState?.phase || 'waiting';
+ return phase !== 'waiting' ||
+ Boolean(gameState?.isWaitingForReady) ||
+ Boolean(gameState?.isRuleSelectionPending);
+}
+
+/**
+ * 规则候选属于全房间公开信息,但确认权只属于服务端指定的选择者。
+ */
+export function getRuleSelectionAccess(gameState, playerId) {
+ const canView = Boolean(gameState?.isRuleSelectionPending);
+ return {
+ canView,
+ canChoose: canView && gameState?.ruleChooserPlayerId === playerId
+ };
+}
+
+/**
+ * “算无遗策”的明手牌已是全房间公开信息;明手本人也应以这份服务端快照为准,
+ * 不能长期依赖发牌时建立的本地副本。
+ */
+export function getCanonicalOpenHandCards(gameState, playerId) {
+ const openHand = gameState?.openHand;
+ if (
+ !playerId
+ || openHand?.playerId !== playerId
+ || !Array.isArray(openHand?.cards)
+ ) {
+ return null;
+ }
+ return openHand.cards;
+}
+
+/**
+ * “政治审查”的首次询问通过私密 Socket 事件送达,但刷新或瞬时断线可能错过该事件。
+ * 待审状态本身会保存在房间快照中;只有被指定的审查者可以据此恢复决策框。
+ */
+export function getPendingPoliticalReviewDecision(gameState, playerId) {
+ const pending = gameState?.politicalReview?.pending;
+ if (!playerId || pending?.reviewerPlayerId !== playerId) return null;
+ return pending;
+}
+
+export const THROW_FAILED_PREVIEW_DURATION_MS = 1000;
+
+/**
+ * 甩牌失败时先把完整尝试牌面留在对应玩家的出牌区,实际被强制出的
+ * 最小组件已经由 cards_played 事件在底层更新;预览结束后再露出它。
+ */
+export function getThrowFailedPreview(playerName, attemptedCardObjects, previewKey) {
+ if (!Array.isArray(attemptedCardObjects) || attemptedCardObjects.length === 0) {
+ return null;
+ }
+
+ return {
+ previewKey,
+ playerName,
+ cards: attemptedCardObjects,
+ cardsCount: attemptedCardObjects.length,
+ throwFailedAttempt: true
+ };
+}
+
+/**
+ * “毁堤淹田”在未发动时只需要一个紧凑提示;进入决策或灾期后再展示分数信息。
+ * 将显示语义集中在这里,避免状态卡因通用双栏布局产生大片空白。
+ */
+export function getDestroyDykeDisplayState(destroyDyke) {
+ if (!destroyDyke) return null;
+
+ if (destroyDyke.pending) {
+ return {
+ tone: 'decision',
+ badge: '决策中',
+ value: `${destroyDyke.pending.roundPoints ?? 0}分`,
+ detail: '本轮计分暂停',
+ progress: null
+ };
+ }
+
+ if (destroyDyke.disaster) {
+ const attackerPoints = Number(destroyDyke.disaster.disasterAttackerPoints) || 0;
+ return {
+ tone: 'disaster',
+ badge: `灾期 ${destroyDyke.disaster.roundsElapsed ?? 0}/3`,
+ value: `${attackerPoints}/20`,
+ detail: `已封存 ${destroyDyke.disaster.voidedPoints ?? 0} 分`,
+ progress: Math.min(100, Math.max(0, attackerPoints * 5))
+ };
+ }
+
+ if (destroyDyke.lastResult?.status === 'incident') {
+ return {
+ tone: 'incident',
+ badge: '已事发',
+ value: `+${destroyDyke.lastResult.scoreDelta ?? 0}`,
+ detail: '封存分返还并追加20分',
+ progress: 100
+ };
+ }
+
+ if (destroyDyke.lastResult?.status === 'expired') {
+ return {
+ tone: 'expired',
+ badge: '灾期结束',
+ value: '封存生效',
+ detail: `${destroyDyke.lastResult.voidedPoints ?? 0} 分永久作废`,
+ progress: 0
+ };
+ }
+
+ if (destroyDyke.used) {
+ return {
+ tone: 'spent',
+ badge: '已发动',
+ value: null,
+ detail: '本局次数已用尽',
+ progress: null
+ };
+ }
+
+ return {
+ tone: 'idle',
+ badge: '待命',
+ value: null,
+ detail: '闲家赢墩后可发动 · 本局一次',
+ progress: null
+ };
+}
+
+/**
+ * 发牌进度是逐张推送的,而完整房间快照不会逐张更新。仅在发牌阶段用实时计数
+ * 覆盖快照,离开发牌阶段后立刻恢复以房间状态为准,避免旧计数影响出牌阶段。
+ */
+export function mergeLivePlayerCardCounts(players, liveCounts, isDrawing) {
+ if (!Array.isArray(players)) return [];
+ if (!isDrawing) return players;
+
+ return players.map(player => ({
+ ...player,
+ cardsCount: liveCounts?.[player.id] ?? player.cardsCount
+ }));
+}
+
+/**
+ * 私密换牌动画结束时一次性合并手牌。先删去自己交出的牌,再加入收到的牌,
+ * 同时按牌 ID 去重,避免逐张 addCard 导致手牌在动画落点处连续跳动、重排。
+ */
+export function mergeTransferredHandCards(currentCards, sentCardIds, receivedCards) {
+ const sentIds = new Set(Array.isArray(sentCardIds) ? sentCardIds : []);
+ const incomingCards = Array.isArray(receivedCards) ? receivedCards : [];
+ const incomingIds = new Set(incomingCards.map(card => card?.id).filter(Boolean));
+ const retainedCards = (Array.isArray(currentCards) ? currentCards : []).filter(card => (
+ card?.id && !sentIds.has(card.id) && !incomingIds.has(card.id)
+ ));
+ return [...retainedCards, ...incomingCards];
+}
+
+/**
+ * 将服务端房间快照中的未结算本墩恢复成 GameTable 使用的映射。
+ * 实时 cards_played 事件负责动画,快照负责刷新、断线重连和“返回房间”后的兜底。
+ */
+export function getCurrentRoundPlayedCards(gameState, players = []) {
+ if (!Array.isArray(gameState?.currentRoundTable)) return {};
+ const playerNames = new Map(
+ (Array.isArray(players) ? players : []).map(player => [player.id, player.name])
+ );
+
+ return Object.fromEntries(
+ gameState.currentRoundTable
+ .filter(play => play?.playerId)
+ .map(play => {
+ const cards = Array.isArray(play.cards) ? play.cards : [];
+ const visualCards = play.ironEvidenceMode
+ ? cards.map(card => ({ ...card, ironEvidenceMode: play.ironEvidenceMode }))
+ : cards;
+ return [play.playerId, {
+ playerName: play.playerName || playerNames.get(play.playerId) || '',
+ cards: visualCards,
+ cardsCount: Number.isInteger(play.cardsCount) ? play.cardsCount : cards.length,
+ concealed: Boolean(play.concealed),
+ ownConcealedCards: false,
+ treatedAsSmall: Boolean(play.treatedAsSmall),
+ activeSkillId: play.activeSkillId || null,
+ activeSkillName: play.activeSkillName || null,
+ jokerSubstitutions: play.jokerSubstitutions || [],
+ clusterAnalysisSubstitutions: play.clusterAnalysisSubstitutions || [],
+ forbiddenMagicSubstitutions: play.forbiddenMagicSubstitutions || [],
+ enduringInheritance: play.enduringInheritance || null,
+ dreamKilling: play.dreamKilling || null,
+ oldHorseAbsolute: Boolean(play.oldHorseAbsolute),
+ lureTigerSilenced: Boolean(play.lureTigerSilenced),
+ ironEvidenceMode: play.ironEvidenceMode || null,
+ ambiguousOptions: play.ambiguousOptions || null
+ }];
+ })
+ );
+}
+
+/** 恢复当前墩的最小出牌历史,使重返牌桌后仍能正确判断撤回资格。 */
+export function getCurrentRoundPlayHistory(gameState) {
+ if (!Array.isArray(gameState?.currentRoundTable)) return [];
+ return gameState.currentRoundTable
+ .filter(play => play?.playerId)
+ .map(play => ({
+ playerId: play.playerId,
+ playerName: play.playerName || '',
+ controllerPlayerId: play.controllerPlayerId || play.playerId,
+ controllerPlayerName: play.controllerPlayerName || play.playerName || '',
+ isProxy: Boolean(play.isProxy),
+ treatedAsSmall: Boolean(play.treatedAsSmall),
+ lureTigerSilenced: Boolean(play.lureTigerSilenced),
+ activeSkillId: play.activeSkillId || null,
+ activeSkillName: play.activeSkillName || null,
+ enduringInheritance: play.enduringInheritance || null,
+ timestamp: play.timestamp || null
+ }));
+}
+
+/**
+ * 显式转化是玩家为后续出牌做的本地预设,不能在每次提交出牌时整批清空。
+ * 只移除服务端确认已经离手的牌;一次性的“偷梁换柱”真正发动后,再清掉
+ * 剩余王的预设,因为本局已经不能继续使用该技能。
+ */
+export function retainUnplayedCardTransformations(
+ transformations,
+ { removedCardIds = [], consumedActiveSkillId = null } = {}
+) {
+ const current = transformations && typeof transformations === 'object'
+ ? transformations
+ : {};
+ const removedIds = new Set(Array.isArray(removedCardIds) ? removedCardIds : []);
+ const stealingBeamsConsumed = consumedActiveSkillId === 'stealing_beams';
+
+ return Object.fromEntries(
+ Object.entries(current).filter(([cardId, transformation]) => (
+ !removedIds.has(cardId)
+ && !(stealingBeamsConsumed && transformation?.kind === 'joker')
+ ))
+ );
+}
+
+/** 把服务端公布的座位索引队列转换为某名玩家的行动次序。 */
+export function getStriveUpstreamActionOrder(players, gameState, playerId) {
+ if (!ruleIncludesId(gameState?.selectedRule, 'strive_upstream')) return null;
+ if (!Array.isArray(players) || !Array.isArray(gameState?.striveUpstreamPlayOrder)) return null;
+
+ const playerIndex = players.findIndex(player => player.id === playerId);
+ if (playerIndex < 0) return null;
+ const actionIndex = gameState.striveUpstreamPlayOrder.indexOf(playerIndex);
+ return actionIndex >= 0 ? actionIndex + 1 : null;
+}
+
+/** 轮末停留时,玩家标签必须与仍在桌面上的那一轮牌面保持一致。 */
+export function getDisplayedDefenseAsOffense(gameState, displayRoundNumber = null) {
+ const displayedRound = Number(displayRoundNumber ?? gameState?.currentRound);
+ if (!Number.isInteger(displayedRound)) return null;
+ return [gameState?.defenseAsOffense, gameState?.defenseAsOffenseLastRound]
+ .find(status => status?.round === displayedRound) || null;
+}
+
+export const RECORD_ON_FILE_RANKS = Object.freeze([
+ '2', '3', '4', '5', '6', '7', '8', '9', '10', 'J', 'Q', 'K', 'A'
+]);
+
+const RECORD_ON_FILE_SUITS = Object.freeze([
+ { id: 'spades', label: '♠' },
+ { id: 'hearts', label: '♥' },
+ { id: 'clubs', label: '♣' },
+ { id: 'diamonds', label: '♦' }
+]);
+
+/**
+ * 只为牌桌当前展示的轮次生成记牌器;轮末停留时仍显示刚结束的生效轮,
+ * 清桌切到下一轮后再严格按 activeRound 决定是否继续。
+ */
+export function getRecordOnFileTrackerView(
+ recordOnFile,
+ displayRoundNumber,
+ { showWhiteJoker = false, showRoyalJokers = false } = {}
+) {
+ const displayedRound = Number(displayRoundNumber);
+ if (!recordOnFile || !Number.isInteger(displayedRound)) return null;
+ if (
+ recordOnFile.activeRound !== displayedRound
+ && recordOnFile.lastActiveRound !== displayedRound
+ ) {
+ return null;
+ }
+
+ const counts = recordOnFile.counts || {};
+ return {
+ round: displayedRound,
+ playedCardCount: Number(recordOnFile.playedCardCount) || 0,
+ ranks: RECORD_ON_FILE_RANKS,
+ suits: RECORD_ON_FILE_SUITS.map(suit => ({
+ ...suit,
+ counts: RECORD_ON_FILE_RANKS.map(rank => Number(counts[suit.id]?.[rank]) || 0)
+ })),
+ jokers: [
+ Number(counts.joker?.small_joker) || 0,
+ Number(counts.joker?.big_joker) || 0,
+ ...(showRoyalJokers ? [
+ Number(counts.joker?.county_prince_joker) || 0,
+ Number(counts.joker?.prince_joker) || 0
+ ] : []),
+ ...(showWhiteJoker ? [Number(counts.joker?.white_joker) || 0] : [])
+ ],
+ showWhiteJoker,
+ showRoyalJokers
+ };
+}
diff --git a/tractor-game-simulator/client/src/utils/ironEvidenceUtils.js b/tractor-game-simulator/client/src/utils/ironEvidenceUtils.js
new file mode 100644
index 0000000..89875ad
--- /dev/null
+++ b/tractor-game-simulator/client/src/utils/ironEvidenceUtils.js
@@ -0,0 +1,8 @@
+export function isIronEvidenceSpecialCard(card) {
+ return Boolean(card) && (
+ (card.suit === 'joker' && card.rank === 'small_joker')
+ || (card.suit === 'hearts' && card.rank === 'Q')
+ || (card.suit === 'spades' && card.rank === 'J')
+ || (card.suit === 'clubs' && card.rank === 'J')
+ );
+}
diff --git a/tractor-game-simulator/client/src/utils/ruleCatalog.js b/tractor-game-simulator/client/src/utils/ruleCatalog.js
new file mode 100644
index 0000000..bbc7a66
--- /dev/null
+++ b/tractor-game-simulator/client/src/utils/ruleCatalog.js
@@ -0,0 +1,122 @@
+export const IMPLEMENTED_RULES = Object.freeze([
+ { id: 'reverse_rank_order', name: '倒反天罡' },
+ { id: 'normal_game', name: '世事无常' },
+ { id: 'abundant_harvest', name: '五谷丰登' },
+ { id: 'extreme_challenge', name: '极限挑战' },
+ { id: 'half_realm', name: '江山半壁' },
+ { id: 'shared_prosperity', name: '与民同乐' },
+ { id: 'perfect_strategy', name: '算无遗策' },
+ { id: 'know_yourself_and_enemy', name: '知己知彼' },
+ { id: 'news_minister_i', name: '新闻部长I' },
+ { id: 'news_minister_ii', name: '新闻部长II' },
+ { id: 'tip_of_iceberg', name: '冰山一角' },
+ { id: 'mutual_visibility', name: '互通有无' },
+ { id: 'double_happiness', name: '双喜临门' },
+ { id: 'open_and_honest', name: '为人坦荡' },
+ { id: 'ten_sided_ambush', name: '十面埋伏' },
+ { id: 'irresistible_force', name: '势如破竹' },
+ { id: 'single_step_debug', name: '单步调试' },
+ { id: 'reform_and_opening_up', name: '改革开放' },
+ { id: 'substitute_sacrifice', name: '李代桃僵' },
+ { id: 'six_six_great_success', name: '六六大顺' },
+ { id: 'tai_chi_four_symbols', name: '太极四象' },
+ { id: 'one_horse_leads', name: '一马当先' },
+ { id: 'heavy_fog', name: '迷雾重重' },
+ { id: 'concealed_passage', name: '暗度陈仓' },
+ { id: 'fatal_beauty', name: '红颜祸水' },
+ { id: 'stealing_beams', name: '偷梁换柱' },
+ { id: 'cosmic_shift', name: '斗转星移' },
+ { id: 'last_stand', name: '绝处逢生' },
+ { id: 'late_mover_advantage', name: '后发制人' },
+ { id: 'go_with_the_flow', name: '随波逐流' },
+ { id: 'frequent_fluctuation', name: '频繁波动' },
+ { id: 'minor_disturbance', name: '微小扰动' },
+ { id: 'lingering_discard', name: '弃掷逦迤' },
+ { id: 'time_reversal', name: '时间倒流' },
+ { id: 'route_swing', name: '路线摇摆' },
+ { id: 'belt_and_road', name: '一带一路' },
+ { id: 'day_night_rotation', name: '昼夜轮转' },
+ { id: 'respect_elders_and_children', name: '尊老爱幼' },
+ { id: 'rites_collapse', name: '礼崩乐坏' },
+ { id: 'recommend_talent', name: '举贤任能' },
+ { id: 'accident_insurance', name: '意外保险' },
+ { id: 'three_powers', name: '三权分立' },
+ { id: 'gentleman_promise', name: '君子一言' },
+ { id: 'repeated_exhaustion', name: '再衰三竭' },
+ { id: 'focus_figure', name: '焦点人物' },
+ { id: 'cooldown_time', name: '冷却时间' },
+ { id: 'time_cooling', name: '时间冷却' },
+ { id: 'planned_economy', name: '计划经济' },
+ { id: 'equivalent_reciprocity', name: '等价互惠' },
+ { id: 'enduring', name: '经久不衰' },
+ { id: 'average_pooling', name: '平均池化' },
+ { id: 'dream_killing', name: '梦中杀人' },
+ { id: 'joint_harmony', name: '珠联璧合' },
+ { id: 'divine_weapon', name: '神兵天降' },
+ { id: 'magic_trick', name: '魔术戏法' },
+ { id: 'abrupt_stop', name: '戛然而止' },
+ { id: 'cluster_analysis', name: '聚类分析' },
+ { id: 'forbidden_magic', name: '禁术秘法' },
+ { id: 'meticulous_accounting', name: '锱铢必较' },
+ { id: 'lost_in_fog', name: '如堕云雾' },
+ { id: 'birds_gone_bow_hidden', name: '鸟尽弓藏' },
+ { id: 'odd_even_scoring', name: '无独有偶' },
+ { id: 'second_battlefield', name: '第二战场' },
+ { id: 'one_country_two_systems', name: '一国两制' },
+ { id: 'wooden_ox_flowing_horse', name: '木牛流马' },
+ { id: 'strength_compensation', name: '取长补短' },
+ { id: 'unarmed', name: '手无寸铁' },
+ { id: 'mutual_support', name: '同舟共济' },
+ { id: 'candle_to_dawn', name: '烛尽天明' },
+ { id: 'cultural_revolution', name: '文化革命' },
+ { id: 'three_tigers', name: '三人成虎' },
+ { id: 'invite_into_urn', name: '请君入瓮' },
+ { id: 'old_horse_still_has_strength', name: '老骥伏枥' },
+ { id: 'trump_wins', name: 'Trump wins' },
+ { id: 'openly_revealed', name: '昭然若揭' },
+ { id: 'straw_boat_borrowing_arrows', name: '草船借箭' },
+ { id: 'bush_gate', name: '布什戈门' },
+ { id: 'teammate_cheer', name: '队友加油' },
+ { id: 'illusion_and_reality', name: '虚虚实实' },
+ { id: 'strive_upstream', name: '力争上游' },
+ { id: 'afterglow', name: '回光返照' },
+ { id: 'outward_harmony_inner_division', name: '貌合神离' },
+ { id: 'ambiguous', name: '模棱两可' },
+ { id: 'two_ghosts_knock_door', name: '二鬼拍门' },
+ { id: 'people_commune', name: '人民公社' },
+ { id: 'remove_firewood_from_under_cauldron', name: '釜底抽薪' },
+ { id: 'mainstay', name: '中流砥柱' },
+ { id: 'happy_twins', name: '欢乐成双' },
+ { id: 'encircle_three_missing_one', name: '围三阙一' },
+ { id: 'three_six_nine_grades', name: '三六九等' },
+ { id: 'iron_evidence', name: '铁证如山' },
+ { id: 'waiting_rabbit', name: '守株待兔' },
+ { id: 'burn_the_boats', name: '破釜沉舟' },
+ { id: 'hidden_dragon_in_abyss', name: '潜龙在渊' },
+ { id: 'administrative_review', name: '行政审查' },
+ { id: 'political_review', name: '政治审查' },
+ { id: 'no_one_survives', name: '无人生还' },
+ { id: 'lure_tiger_from_mountain', name: '调虎离山' },
+ { id: 'defense_as_offense', name: '以守为攻' },
+ { id: 'antinomy', name: '二律背反' },
+ { id: 'change_rice_to_mulberry', name: '改稻为桑' },
+ { id: 'destroy_dyke_flood_fields', name: '毁堤淹田' },
+ { id: 'record_on_file', name: '记录在案' },
+ { id: 'weighing_thousand_jin', name: '上称千斤' },
+ { id: 'king_over_white', name: '王上加白' },
+ { id: 'fear_of_breaking_vase', name: '投鼠忌器' },
+ { id: 'eight_kings_council', name: '八王议政' },
+ { id: 'nine_princes_succession', name: '九子夺嫡' }
+]);
+
+export const RULE_SELECT_OPTIONS = IMPLEMENTED_RULES.map(rule => ({
+ value: rule.id,
+ label: rule.name
+}));
+
+export function ruleIncludesId(rule, ruleId) {
+ if (!rule || !ruleId) return false;
+ if (rule.id === ruleId) return true;
+ return Array.isArray(rule.rules)
+ && rule.rules.some(childRule => ruleIncludesId(childRule, ruleId));
+}
diff --git a/tractor-game-simulator/client/src/utils/ruleDisplayContent.js b/tractor-game-simulator/client/src/utils/ruleDisplayContent.js
new file mode 100644
index 0000000..4456935
--- /dev/null
+++ b/tractor-game-simulator/client/src/utils/ruleDisplayContent.js
@@ -0,0 +1,208 @@
+// 历史原始短文案保留作基线;其中一部分早于当前实现,不能直接全部用于牌桌。
+// 来源:Tractor-dev/Tractor@d2b2233(rule-website/DLC.json)。
+const ORIGINAL_RULE_CONTENT_BY_NAME = Object.freeze({
+ '倒反天罡': '每个花色内的大小顺序颠倒。',
+ '万马齐喑': '说一句话需要扣五分。',
+ '十面埋伏': '庄家队友暗中指定一个点数具有赋分功能(+5,-5)。',
+ '世事无常': '正常对局。',
+ '五谷丰登': '庄家12张底牌。闲家开局为30分。',
+ '江山半壁': '庄家4张底牌。闲家开局为-10分。',
+ '与民同乐': '没有底牌。闲家开局为-20分。',
+ '极限挑战': '庄家16张底牌。闲家开局为60分。',
+ '算无遗策': '庄家队友明手。闲家开局为10分。',
+ '知己知彼': '摸牌后,与对家交换两张牌。',
+ '新闻部长I': '摸牌后,每人给上家两张牌。',
+ '新闻部长II': '摸牌后,每人给下家两张牌。',
+ '势如破竹': '本局庄家获胜可以连庄。',
+ '李代桃僵': '玩家可以任意垫一次牌,需要声明(视为小)。',
+ '六六大顺': '本局具有同花顺(6张起步)。',
+ '单步调试': '本局移除甩牌。',
+ '一马当先': '庄家队友先出牌。',
+ '改革开放': '庄家队友可以再埋一次底牌。闲家获得40分。',
+ '迷雾重重': '开局移除牌堆顶8张牌,闲家获得此8张牌中一半分数。',
+ '暗度陈仓': '每名玩家限1次,若不是第一个出牌,可以背面向上打出。',
+ '冰山一角': '始终保持两张牌明置。',
+ '红颜祸水': '黑桃牌视为红桃牌,闲家开局为20分;若红桃为主花色,闲家开局为40分。',
+ '偷梁换柱': '四张王牌可以当做任意数字牌使用,若当做分数牌则不计分。',
+ '斗转星移': '当手牌数首次变为不大于12张时所有玩家与队友交换手牌。',
+ '绝处逢生': '本局不能是无主局;当玩家没有主牌时,若此时手牌数不小于5且花色相同,可以令每张牌视为主牌。',
+ '后发制人': '每名玩家限1次,作为3号位时可以令原本的4号位先出牌。',
+ '频繁波动': '有分数牌被打出的轮次结束后,每人交给下家一张牌,闲家开局-10分。',
+ '微小扰动': '没有分数牌被打出的轮次结束后,每人交给队友一张牌。',
+ '弃掷逦迤': '有分数牌被打出的轮次结束后,每人弃一张牌(庄家弃分则闲家加分)。',
+ '时间倒流': '每名玩家限1次,在一轮出牌结束后可以选择回溯到上一轮出牌结束时的状态。',
+ '路线摇摆': '有不小于10分的牌被打出的轮次结束后逆转出牌顺序。',
+ '一带一路': '可以使用“小甩牌”牌型(两张单牌)。',
+ '昼夜轮转': '第 x 轮中,点数为 x % 13 + 1 的牌在其相应花色内视为最大,闲家开局为20分。',
+ '尊老爱幼': '每轮打出最小牌的玩家在下一轮先出牌。',
+ '礼崩乐坏': '每轮的首位玩家不能主动打出A。',
+ '举贤任能': '每名玩家限一次,作为1号位时可以将出牌权交给下家。',
+ '意外保险': '单轮得分超过30的部分由对方获得。',
+ '为人坦荡': '手牌数首次变化为不大于5时所有玩家明置手牌。',
+ '三权分立': '游戏开始时,2、3、4号位玩家各暗中指定一个数字(不能是主点数),并令本局中这三者点数的牌分别代替原本10、5、K的分数.',
+ '君子一言': '每名玩家开局时声明自己最少的花色(包括0张)。',
+ '再衰三竭': '每名玩家连续3次最大时,从第3轮开始,每次再作为一号位将失去依次失去5,10,15……分/',
+ '焦点人物': '游戏开始前在两方阵营中随机各选择一名成为“焦点”(仅队友相互知晓),本局焦点打出的分数牌分数翻倍,非焦点打出的分数牌不计分。',
+ '冷却时间': '每轮的出牌不能包含上轮自己出牌含有的点数(除非只有该点数)。',
+ '时间冷却': '每轮的出牌不能包含上轮自己出牌含有的花色(除非只有该花色)。',
+ '计划经济': '起始手牌为20张,每轮结束后再摸一轮牌直到没有牌剩余。',
+ '随波逐流': '手牌数首次不大于16时将手牌交给下家,首次不大于9时再交给下家。',
+ '等价互惠': '每名玩家限一次,作为首位出牌时可以与另一名玩家拼点(比较规则为无主局的大小顺序,不区分花色),输的人失去5分,之后交换两张拼点牌。',
+ '经久不衰': '单牌/对子的大小视为本轮和上轮同牌型的最大值。',
+ '平均池化': '单牌/对子的大小视为自身和队友的平均值(垫牌、散牌不参与计算)。',
+ '梦中杀人': '玩家没有主牌时可以暗置所有牌,之后出牌完全随机,若随机出的牌与首位玩家花色相同,则视为最大并“醒来”。',
+ '珠联璧合': '若一方打出完全相同的牌,则视为最大。',
+ '神兵天降': '游戏开始时另外随机抽取2张牌并亮出,每名玩家可以将一张花色或点数与之相同的牌当作此牌打出并再翻出2张(每名玩家限一次)。',
+ '魔术戏法': '每名玩家限一次,在作为一号位出牌前可以暗中选择两名玩家,交换本轮二者打出的牌。',
+ '戛然而止': '手牌数首次小于5时游戏结束,闲家获得庄家剩余手牌中的分数。',
+ '聚类分析': '可以将一张非分数、非主点数的数字牌当作与其绝对值不超过1的牌打出(转化后的牌也不能是分数牌、主点数)。',
+ '锱铢必较': '本局的分数牌是A,2,3,4,5,6,7,闲家开局-10分。',
+ '如堕云雾': '禁止查看已打出的牌和已获得的分数。',
+ '鸟尽弓藏': '一种花色的分数牌全部打出后,不能再主动打出该花色。',
+ '无独有偶': '奇数轮次的分数为0,偶数轮次的分数为原分数的两倍。',
+ '第二战场': '四家各累计至少5张已出牌后,用各自累计牌进行德州比较;王自动作任意牌,胜方+5分。',
+ '太极四象': '新增牌型:四张花色各不相同且点数相同的牌,此牌型按点数比较大小,若没有任何副花色则可以用四张主花色毙之。',
+ '禁术秘法': '每人限一次;发动后原主牌不能直接打出,必须先转为一种副花色,非王保持原点数,王另选普通点数。',
+ '一国两制': '庄家方、闲家方各自亮出主花色。',
+ '取长补短': '玩家编号为0,1,2,3,第x轮中,编号为x%4的玩家的牌大小级别+1,编号为(x+2)%4的玩家的牌大小级别-1。',
+ '木牛流马': '游戏开始时,2、3号位玩家获得“木牛流马”,持有木牛流马的玩家可以在一轮开始时将一张牌置入其中(已有时改为替换)并交给队友,队友可以如手牌般打出此牌。木牛流马中的牌不计入手牌花色限制,每局每人可以传递两次。当手牌不等对游戏造成影响时,玩家必须传递/收回木牛流马中的牌以使牌数正确。',
+ '再续前缘': '打出一张牌时,若另一张相同的牌在本轮或上轮已经被打出,其所属阵营获得2分。',
+ '手无寸铁': '移除4张王牌并令所有主点数牌大小视为原本的数字大小。',
+ '同舟共济': '每名玩家限一次,任意轮次轮到某玩家出牌时,可以要求队友交给自己0-2张牌或选择交给队友1-2张牌,本轮结束时获得牌的玩家再交给给出牌的玩家等量手牌。',
+ '烛尽天明': '游戏开始时庄家队友选择是否点燃“烛”。每轮出牌结束时根据本轮四号位的出牌调整“烛”的状态:若其打出的牌只含有黑色,则熄灭“烛”,若只含有红色,则点燃“烛”,若均不满足则不改变“烛”的状态。“烛”处于点燃的轮次,红色分数牌具有的的分数+5,黑色分数牌具有的分数-5;“烛”处于熄灭的轮次,红色分数牌具有的的分数-5,黑色分数牌具有的分数+5。',
+ '文化革命': '每名玩家限一次,作为一号位出牌时可以选择声明一种花色/点数(不能为10/K),两轮之内该花色/点数视为主花色/主点数,若发动时已经处于此效果生效区间内则覆盖之。',
+ '三人成虎': '一轮中,若至少3名玩家打出的牌花色均相同,则该花色在本轮结算时大小视为主花色,但大小-4。',
+ '势者制权': '游戏开始时庄家队友获得“权”并选择一名对方玩家,令其获得“势”。在一轮中,若持有“权”的玩家位于持有“势”玩家的前置位,则后者本轮打出的分数在计算时不会超过前者;若持有“权”的玩家位于持有“势”玩家的后置位,则前者本轮打出牌的大小在计算时不会小于后者且视为先手(均为垫牌则不触发此条)。以上两条效果任意之一被触发的轮次结束时,持有“权”的玩家需将“权”交给持有“势”的玩家,后者再选择对方的一名玩家交给其“势”。',
+ '请君入瓮': '每名玩家限一次,作为首置位时可以指定一名玩家和一张牌,若其本轮打出了此牌,则失去5分。',
+ '队友加油': '没有主牌时可以声明之并令队友在本局游戏中的所有牌大小级别+1。',
+ '老骥伏枥': '最后一个获得出牌权的玩家的第一次出牌视为必定最大。',
+ '第三战场': '一号和二号位使用每轮打出的四张牌(王牌则不触发)进行加减乘除24点游戏,率先算出者+3分(四号位须在20s内出牌)。',
+ 'Trump wins': '打出分数最多的玩家下轮先出牌。',
+ '昭然若揭': '底牌始终保持明置。',
+ '草船借箭': '若首置位的玩家打出的牌中包含分数之和不小于10且这些分数牌没有被其所属阵营获得,其可以在此轮结束时弃置一张非分数牌并获得本轮打出的非分数牌中最大的一张。',
+ '布什戈门': '每名玩家限一次,作为二号位时若认为一号位打出的牌过于离谱,可以令其收回并重新打出另一(些)合法的牌。',
+ '千金散尽': '闲家的的分数为 200 - 实际得到的分数,不能低于0分。',
+ '虚虚实实': '每名玩家限一次,若某副花色的牌剩余奇数张,出牌时可以视为没有此花色的牌(若利用此规则,则不能打出该花色的牌)。',
+ '力争上游': '每轮的出牌顺序为上轮打出牌的大小顺序(垫牌按照字典序比较单张大小,考虑主花色)。',
+ '回光返照': '当玩家的主牌不大于 3 张时,其出牌可以不受花色限制且打出的主牌大小+1。若使用此规则进行出牌,则之后的每一轮必须打出主牌直到没有主牌为止。',
+ '貌合神离': '任意一方两名玩家在一轮中若打出的牌型不一致(不考虑花色),则对方阵营获得5分。',
+ '模棱两可': '每名玩家限一次,出牌时可以展示两种不同的合法出牌方式,在本轮结束时选择其中一种(多人发动则后续玩家再发动无须消耗次数,并从后置位向前选择)。',
+ '良禽择木': '游戏开始前,将所有 A K 移出游戏并在摸牌结束后平均分配给玩家。',
+ '二鬼拍门': '摸到不小于2张王牌时须将其全部亮出。',
+ '正大光明': '每名玩家限一次,可以随时问另一名玩家一个用“是”或“否”或“不知道”回答的问题,必须诚实回答。',
+ '互通有无': '队友的手牌对你可见。',
+ '双喜临门': '重新从三个规则中选择两个(不矛盾的)规则,于本局游戏中同时生效。',
+ '人民公社': '每名玩家各埋两张底牌,闲家开局-20分。抄底时,双方均获得且仅获得对方所埋的底牌中的分数。',
+ '市场经济': '初始时市场具有8张牌,每过3轮每人将一张牌投入市场,根据本轮的大小依次从市场中获得一张牌。游戏结束时闲家获得市场中的一半分数。',
+ '釜底抽薪': '被反主的玩家可以在反主结算后与其交换手牌。(反多次则由后向前结算)',
+ '中流砥柱': '摸牌结束后,若本局不是无主局,则从一号位开始,若玩家的主牌数不大于5,则可以将包括所有主牌在内的5张牌交给队友,然后队友再交给其5张牌。',
+ '欢乐成双': '庄家与上家交换位置。',
+ '围三阙一': '将除王牌与级牌之外的所有牌分为四种花色,记录游戏中最先出现的三种花色,若最后剩余的一种花色不为主花色,则该花色从下一轮开始替换原本的主花色,然后无论是否发生替换,删除所有记录并重新开始记录。',
+ '三六九等': '本局除亮主外,玩家可以额外亮出一种主花色以使该花色的牌成为“劣花色”,之后游戏中普通副牌相对于劣牌的关系等同于主牌相对于副牌的关系,同时该花色的级牌小于副花色的级牌(即主花色>副花色>劣花色)。亮出劣花色的过程遵循与主牌相同的反主规则,对王即无劣花色,一张/一对级牌/王牌只能用于亮主与亮劣其中之一。',
+ '铁证如山': '若在一轮中出现了小王、红桃Q、黑桃J、梅花J,则本轮打出的分数翻倍,每多出现一张则倍数额外+1;若两张大王均已被打出,则改为本轮不计分。',
+ '守株待兔': '游戏开始时,每名玩家可以暗中指定一张牌(不能是王牌、主点数),然后当此牌被其他玩家打出时,当轮座次最先指定了此牌的玩家可以用一张非分数牌与之交换(只交换一张,一张牌的分数只在其首次被打出时计算一次)。',
+ '破釜沉舟': '本局不能投降,不能重开。',
+ '惊鸿一面': '摸牌时随机一部分牌正面向上。',
+ '潜龙在渊': '游戏开始时每名玩家指定自己最多的点数(主点数除外),手牌数首次不大于12时,若玩家从未打出过该点数,则所属阵营获得10分。',
+ '行政审查': '庄家12张底牌,游戏开始前,庄家下家指定一种副花色,庄家上家指定一种点数,直到庄家方打出过该花色和该点数的牌才可埋底牌。',
+ '政治审查': '每名玩家限一次,可以令队友收回其打出的牌并重新打出(可以继续选择同样的出牌)。',
+ '无人生还': '除一号位的出牌均暗置,在一轮结束后统一展示。',
+ '调虎离山': '每方阵营限一次,一轮开始前可以指定一名非一号位玩家,令其本轮无法响应甩牌询问,且打出的牌不计大小与分数。',
+ '以守为攻': '每轮若一号位玩家打出的牌每小于一名其他玩家,则其下轮打出的牌大小+1。',
+ '二律背反': '游戏开始前每名玩家各自指定一张牌(花色+点数)并同时亮出,被指定的牌本局中视为两张不同的牌(即无法成对),若有多名玩家指定了同一张牌,则不执行此效果。每轮结束后,本轮中指定牌被打出过的所有玩家重新指定一张牌并同时亮出。',
+
+ // 当前项目后来新增、rule-website 原始列表中尚未收录的规则。
+ '改稻为桑': '庄家埋底后,两名闲家各将手中一半分数牌改造为零分牌。',
+ '毁堤淹田': '闲家赢分后庄家可废除此分;后三轮闲家累计满20分则补回并另得20分,否则累计分作废。',
+ '记录在案': '一轮中出现分数牌、级牌或王牌,下一轮显示记牌器。',
+ '上称千斤': '庄家本轮大于至少一名闲家时,每张分牌-5;否则每张分牌分值翻倍。',
+ '王上加白': '本局随机一张王牌变为全局最大的白王(皇)。',
+ '投鼠忌器': '赢家若误伤队友则本方失去10分:首家压过带两对或两王的队友;或第二家唯一毙牌且其队友本是其余三家最大。',
+ '八王议政': '牌堆额外加入两张郡王和两张亲王;二者均为主牌,且可成对亮成无主。',
+ '九子夺嫡': '赢家收下对方分牌后,可令一张剩余手牌永久升一级;首次升出白王时本方+10分,此后不再触发。'
+});
+
+// 局中说明必须优先准确表达会改变玩家决策的时机、对象、次数和结算。
+// 原短文案没有失真时直接沿用;复杂规则则保留必要细节,不强行压成一句口号。
+const TABLE_RULE_CONTENT_BY_NAME = Object.freeze({
+ ...ORIGINAL_RULE_CONTENT_BY_NAME,
+ '倒反天罡': '除级牌和王外,每个花色内普通牌的大小顺序颠倒。',
+ '十面埋伏': '庄家队友暗选一个非级牌、非分牌点数。闲家赢得该点数每张-5分,庄家方赢得每张给闲家+5分;底牌同样乘倍数,首次出现时公开。',
+ '算无遗策': '庄家埋底后,庄家队友明手且由庄家代为出牌。闲家开局为10分。',
+ '李代桃僵': '每名玩家限一次,跟牌时可声明垫牌:仍须出相同张数,但可无视花色与牌型,且本次出牌始终视为小。',
+ '一马当先': '仅第一轮改由庄家队友先出牌;庄家身份、底牌归属和后续轮次均不改变。',
+ '改革开放': '庄家首次埋底后,庄家队友拿起底牌并重新埋底,随后仍由庄家先出。闲家开局+40分。',
+ '迷雾重重': '开局暗中移除牌堆顶8张牌,终局公开并给闲家补得其中总分的一半。',
+ '红颜祸水': '黑桃牌视为红桃牌,但不与红桃混合组成新牌型。闲家开局+20分,红桃为主时+40分。',
+ '偷梁换柱': '每名玩家限一次,可将选中的王牌临时改成任意花色普通牌后按基本规则出牌;替成分牌仍不计分,单出可保持王牌。',
+ '斗转星移': '一轮结束后,若四家手牌均首次不多于12张,所有玩家与队友交换全部手牌。',
+ '绝处逢生': '本局不能无主或用对王反主;无人亮主时随机定主。手牌变化后,若玩家不少于5张、没有主牌且全为同一花色,可令全部手牌视为主牌。',
+ '弃掷逦迤': '有分牌出现的轮次结束后,四家各暗弃一张;终局仅公开庄家方弃出的分牌并补给闲家。',
+ '时间倒流': '每名玩家限一次,可在本轮及轮末停留时预备;轮末依次询问,首个确认者令牌局回到本轮开始前。',
+ '一带一路': '每名玩家限一次,首发时可主动打出同一有效花色的两张非对子单牌,无需满足甩牌必大;普通甩牌不消耗次数。',
+ '昼夜轮转': '第x轮将点数x%13+1提升为相应花色内最大,其他牌序不变;轮转到级牌时仍按A最大。闲家开局+20分。',
+ '尊老爱幼': '每轮正常结算赢家;另按牌型贴合度和牌力评出最小者(异花色越多越小,完全相同后出更小),由其下轮先出。',
+ '为人坦荡': '一轮结束时,若四家手牌均不多于5张,所有玩家同时明置剩余手牌。',
+ '三权分立': '庄家埋底后,2、3、4号位暗选非级牌点数,依次代替原10、5、K分牌;可选原分牌点数,重复选择会叠加,首次出现时公开。',
+ '君子一言': '庄家埋底后,每人公开声明手中最少的有效花色(包括0张);所有主牌统一算“主”,唯一时系统声明,并列时自行选择。',
+ '再衰三竭': '同一玩家连续赢得第3、4、5……轮时依次使本方失去5、10、15……分;换人赢牌后重新累计。',
+ '焦点人物': '埋底后两队分别秘密表决本队焦点,直至队友一致同意。闲家拿到的焦点分牌双倍、非焦点分牌不计分;底牌正常,终局揭晓。',
+ '冷却时间': '不能打出自己上轮出牌包含的点数;若其余可用牌不足以完成本次合法出牌,则解除冷却。',
+ '时间冷却': '不能打出自己上轮出牌包含的有效花色,所有主牌统一算“主”;若其余可用牌不足以完成合法出牌,则解除冷却。',
+ '随波逐流': '轮末四家手牌均首次不多于16张时,全部手牌交给下家;均首次不多于9张时再交换一次。',
+ '等价互惠': '每名玩家限一次,一号位出牌前可与一人各暗选一张拼点:主牌大于副牌,副牌只比点数;输家本方失5分,随后交换拼点牌。',
+ '经久不衰': '本轮与自己上轮的有效花色、牌型、张数及组合结构完全一致时,该组合牌力取两轮较大值;甩牌须逐项匹配结构。',
+ '梦中杀人': '没有主牌时可进入梦中,由系统完全随机出牌;若随机牌与首家有效花色或点数相同则视为最大并醒来,多人成功时先出者大。',
+ '珠联璧合': '一方两名玩家打出完全相同的牌时,该方视为最大并由后出者取得下轮牌权;两队同时达成则正常比较。',
+ '神兵天降': '每轮亮出2张无王神兵。每人限一次,可把与神兵同花色或点数的一张牌按神兵牌力打出,但仍按原牌计分和履行跟牌;本轮仅一人可发动,发动后轮末换新,未发动则保留。',
+ '魔术戏法': '每名玩家限一次,一号位出牌前暗选两名其他玩家;两人仍正常跟牌,仅在本轮得分、胜负和下轮牌权结算时交换出牌结果。',
+ '戛然而止': '一整轮结束后,若任一玩家手牌少于5张则该轮成为最后一轮;闲家获得庄家本人剩余手牌牌面分的一半,底牌照常结算。',
+ '聚类分析': '可将多张非分牌、非级牌普通牌分别临时改成相邻点数后再出牌;转化前后均不得为分牌或级牌,且会参与甩牌失败判定。',
+ '锱铢必较': '分牌改为A、2、3、4、5、6、7,分别计1至7分;闲家开局-10分。',
+ '第二战场': '各家未参赛的出牌跨轮累计;轮末四家均至少5张时,各自从全部累计牌中选德州最佳五张比较,胜方+5分后清空重积。若此时四家余牌都少于5张,则等全部出完再判;王由系统自动作任意牌,五条高于同花顺,跨阵营并列相抵。',
+ '禁术秘法': '每人限一次,可随时预备并在轮首确认;发动后永久生效。此后原主牌不能直接打出,必须先转为一种副花色;非王保持原点数,王另选普通点数,未打出的转化预设会保留。',
+ '一国两制': '庄家方与闲家方各自亮主、反主。对王或双方均未亮时双方无主;只一方亮时共用该主,亮出不同花色时按阵营互换花色后统一跟牌和比较。',
+ '取长补短': '以庄家为0号位逆时针编号。第x轮,x%4号位全部牌升一级,(x+2)%4号位降一级;副牌不跨入主牌,升降可越过牌序端点,实体分值不变。',
+ '木牛流马': '开局2、3号位各持一具。每轮首张牌前,持有者可放入或替换一张并立即传给队友,接收者可跨轮保留并如手牌打出,且不计入跟牌义务。每队最多往返两次;队友无牌时须传递以补齐牌数。',
+ '同舟共济': '每人限一次,轮到自己且未出牌时,可要求队友给0至2张牌,或给队友1至2张牌;轮末由收牌者返还等量手牌。',
+ '烛尽天明': '庄家队友选择初始烛态。每轮先按本轮烛态计分,再由四号位的纯红或纯黑出牌决定下轮烛态;烛亮时红色分牌每张+5、黑色-5,烛灭时相反。小王黑、大王红,底牌按原分结算。',
+ '文化革命': '每人限一次,一号位出牌前可声明一种花色或2至A的点数(包括10、K),在本轮及下一轮替换原主花色或级牌;新声明覆盖旧效果并重新持续两轮。',
+ '三人成虎': '同一轮有三名玩家各自打出全为同一副花色的牌时立即成虎;主牌、级牌和王不计。该花色本轮视为主牌并沿跳过级牌的牌序降低4级,实体分值不变。',
+ '队友加油': '每人限一次。每次出牌后,若仍有手牌且已无主牌,可选择令队友余下全部牌永久升一级;副牌不跨入主牌,实体分值不变,暂不发动仍可以后再选。',
+ '老骥伏枥': '开局首发者视为已获牌权;四人都至少获得过一次牌权后,最后首次获得牌权者的下一次合法首发绝对最大,且仍须遵守首发牌型和甩牌规则。',
+ '布什戈门': '每人限一次,作为二号位且未出牌时,可迫使一号位收回并重新合法首发;被收回的每张牌本次均禁用,无其他手牌时不能发动。',
+ '力争上游': '每轮按上轮四家出牌由大到小决定顺序。同牌型正常比较;牌型不一致时先比与首家结构的贴合度,异花色越多越小,再按最小牌起字典序比较。',
+ '回光返照': '仅非无主局,每人限一次。开局一号位持1至3张主牌可在首次出牌前发动;其余玩家在一次出牌后仍有手牌且只剩1至3张主牌时决定。发动后主牌升一级;此后只要有主便无视跟牌但只能出主,出尽结束。',
+ '模棱两可': '每人限一次,仅本轮二、三号位可发动:公开两种不同且合法的方案,轮末再选一种结算;两人都发动时三号位不耗次数并先选。',
+ '人民公社': '摸牌时不留底,四家各摸27张再各埋两张。闲家开局-20分;抄底按倍数只结算对方阵营埋下的四张牌:闲家拿底加庄家方埋分,庄家方拿底扣闲家埋分。',
+ '中流砥柱': '庄家完成埋底后,非无主局从一号位起依次处理。轮到某玩家时若当前主牌不多于5张,可把包含全部主牌的5张牌交给队友,再由队友返还5张;按当前手牌各自判断,同队两人均可发动。',
+ '欢乐成双': '庄家锁定后与上家换位,但原队伍不变,牌局结束后恢复座次;庄家方胜则由庄家的固定队友上庄,闲家方胜则由换位后庄家的下家上庄。',
+ '围三阙一': '出牌阶段忽略王和级牌并即时记录普通花色;一次更新集齐四门就立即清空重记。轮末恰有三门时,缺门若不是当前主花色,则从下一轮起替换主花色,随后清空记录。',
+ '三六九等': '亮主与亮劣是两条独立反亮链,王只能亮主,普通花色不能在两链重复使用。对王亮成无主时同时无劣;自然无主仍保留已亮劣花色。出牌时主牌>普通副牌>劣牌,劣花色级牌低于其他副花色级牌。',
+ '铁证如山': '每轮开始锁定效果:此前两张大王未全出时,每出现一张小王、红桃Q、黑桃J或梅花J,分数倍数在1倍上+1;此前大王已全出时,有铁证牌才清零,无铁证牌正常计分。本轮才出的第二张大王只影响下一轮。',
+ '守株待兔': '埋底后每人暗选一种有花色的牌面,可选5、10、K但不能选王或级牌。目标牌被他人打出并正常结算后,座次最先的声明者可用一张非分牌换回其中一张;实体分牌仅首次上桌计分。',
+ '行政审查': '本局12张底牌先封存,四家各持24张开始出牌。庄家下家指定副花色、上家指定点数;庄家方累计打出两项后,庄家才查看并立即埋12张底牌。',
+ '二律背反': '庄家埋底后四人各选一种普通花色牌面并同时公开;仅被一人选择的牌面不能成对或拖拉机,多人重复选择则无效。轮末打出过自己声明牌面的玩家须重新选择,完成前不开下一轮。',
+ '改稻为桑': '庄家埋底后,两名闲家各改造⌊手中分牌数÷2⌋张:副牌变同花色A,主牌变大王;实体牌永久零分并按新牌面参与跟牌和比较。',
+ '毁堤淹田': '庄家限一次,可废除闲家刚赢得的一轮分数。随后三轮为灾期:闲家累计满20分、灾期提前终局,或恰在第三轮拿底时,取回废分并额外+20;否则废分永久作废。',
+ '记录在案': '一轮出现5、10、K、当前级牌或王时,下一轮显示记牌器一轮;显示轮若再次触发,则下一轮重新独立显示一轮。记牌器按开局实体牌面累计已出张数,仅在初始牌堆确实含有郡王、亲王或白王时显示对应栏位。',
+ '上称千斤': '轮末按“力争上游”顺序比较庄家与两名闲家。庄家大于至少一名闲家时,本轮每张分牌-5且最低为0;否则每张分牌翻倍。',
+ '九子夺嫡': '轮末赢家若收下对方阵营打出的分牌,可选择一张剩余手牌沿完整牌力序列永久升一级,也可放弃。首次有人因此得到白王时,其阵营+10分,本局不再触发。'
+});
+
+function getSingleRuleTableContent(rule) {
+ if (!rule) return '';
+ return TABLE_RULE_CONTENT_BY_NAME[rule.name] || rule.content || '';
+}
+
+export function getRuleTableContent(rule) {
+ if (!rule) return '';
+ if (Array.isArray(rule.rules) && rule.rules.length > 0) {
+ return rule.rules
+ .map(childRule => `${childRule.name}:${getSingleRuleTableContent(childRule)}`)
+ .join(';');
+ }
+ return getSingleRuleTableContent(rule);
+}
+
+export { TABLE_RULE_CONTENT_BY_NAME, ORIGINAL_RULE_CONTENT_BY_NAME };
diff --git a/tractor-game-simulator/client/src/utils/scoringUtils.js b/tractor-game-simulator/client/src/utils/scoringUtils.js
new file mode 100644
index 0000000..0ea3455
--- /dev/null
+++ b/tractor-game-simulator/client/src/utils/scoringUtils.js
@@ -0,0 +1,145 @@
+import { ruleIncludesId } from './ruleCatalog.js';
+
+const CARD_POINT_VALUES = Object.freeze({
+ '5': 5,
+ '10': 10,
+ K: 10
+});
+
+const METICULOUS_ACCOUNTING_POINT_VALUES = Object.freeze({
+ A: 1,
+ '2': 2,
+ '3': 3,
+ '4': 4,
+ '5': 5,
+ '6': 6,
+ '7': 7
+});
+
+function getScoringRank(card) {
+ if (card?.isNinePrincesPromoted && card?.ninePrincesScoringRank) {
+ return card.ninePrincesScoringRank;
+ }
+ const usesOriginalRank = card?.isDivineWeaponTransformed
+ || card?.isJokerSubstitution
+ || card?.isClusterAnalysisTransformed
+ || card?.isForbiddenMagicDemoted
+ || card?.isStrengthCompensated
+ || card?.isDefenseAsOffenseBoosted
+ || card?.isTeammateCheered
+ || card?.isAfterglowBoosted
+ || card?.isThreeTigersTransformed;
+ return usesOriginalRank && card?.originalRank
+ ? card.originalRank
+ : card?.rank;
+}
+
+export function getScoringDisplayCard(card) {
+ if (
+ !card?.isStrengthCompensated
+ && !card?.isDefenseAsOffenseBoosted
+ && !card?.isTeammateCheered
+ && !card?.isAfterglowBoosted
+ && !card?.isNinePrincesPromoted
+ ) return card;
+
+ return {
+ ...card,
+ suit: card.ninePrincesScoringSuit || card.originalSuit || card.suit,
+ rank: card.ninePrincesScoringRank || card.originalRank || card.rank,
+ originalSuit: null,
+ originalRank: null,
+ isStrengthCompensated: false,
+ strengthCompensationDelta: 0,
+ isDefenseAsOffenseBoosted: false,
+ defenseAsOffenseDelta: 0,
+ isTeammateCheered: false,
+ isAfterglowBoosted: false
+ };
+}
+
+export function getCardPoints(card) {
+ if (card?.isRiceToMulberryTransformed) return 0;
+ return CARD_POINT_VALUES[String(getScoringRank(card))] || 0;
+}
+
+export function getMeticulousAccountingCardPoints(card) {
+ return METICULOUS_ACCOUNTING_POINT_VALUES[String(getScoringRank(card))] || 0;
+}
+
+export function getThreePowersCardPoints(card, threePowers = null) {
+ return (threePowers?.slots || []).reduce(
+ (total, slot) => total + (slot.rank === card?.rank ? Number(slot.pointValue) || 0 : 0),
+ 0
+ );
+}
+
+export function getCandleCardColor(card) {
+ if (card?.suit === 'hearts' || card?.suit === 'diamonds') return 'red';
+ if (card?.suit === 'clubs' || card?.suit === 'spades') return 'black';
+ if (card?.rank === 'small_joker') return 'black';
+ if (card?.rank === 'big_joker') return 'red';
+ return null;
+}
+
+export function getCandleToDawnCardPoints(card, isLit) {
+ const basePoints = getCardPoints(card);
+ if (basePoints <= 0 || typeof isLit !== 'boolean') return basePoints;
+ const color = getCandleCardColor(card);
+ if (!color) return basePoints;
+ const favoredColor = isLit ? 'red' : 'black';
+ return Math.max(0, basePoints + (color === favoredColor ? 5 : -5));
+}
+
+export function getDisplayedCandleState({
+ candleToDawn,
+ currentRound = 1,
+ displayRoundNumber = null,
+ heldRoundCandle = null,
+ visiblePlayCount = 0,
+ playerCount = 4
+} = {}) {
+ const normalizedCurrentRound = Math.max(1, Number(currentRound) || 1);
+ const requestedRound = Math.max(
+ 1,
+ displayRoundNumber ?? normalizedCurrentRound
+ );
+ const explicitlyHeldRound = Number(heldRoundCandle?.round);
+ if (
+ Number.isInteger(explicitlyHeldRound)
+ && explicitlyHeldRound > 0
+ && typeof heldRoundCandle?.isLit === 'boolean'
+ ) {
+ return {
+ round: explicitlyHeldRound,
+ isLit: heldRoundCandle.isLit,
+ holdsCompletedRound: true
+ };
+ }
+ const transition = candleToDawn?.lastTransition || null;
+ const completedRoundStillOnTable = Boolean(
+ transition
+ && playerCount > 0
+ && visiblePlayCount >= playerCount
+ && transition.round === normalizedCurrentRound - 1
+ );
+ const holdsCompletedRound = Boolean(
+ (transition?.round === requestedRound && requestedRound !== normalizedCurrentRound)
+ || completedRoundStillOnTable
+ );
+ return {
+ round: holdsCompletedRound ? transition.round : requestedRound,
+ isLit: holdsCompletedRound ? transition.previousLit : candleToDawn?.isLit,
+ holdsCompletedRound
+ };
+}
+
+export function calculateCardPoints(cards = [], pointResolver = getCardPoints) {
+ if (!Array.isArray(cards)) return 0;
+ return cards.reduce((total, card) => total + pointResolver(card), 0);
+}
+
+export function getOddEvenRoundMultiplier(rule, roundNumber) {
+ if (!ruleIncludesId(rule, 'odd_even_scoring')) return 1;
+ return Number(roundNumber) % 2 === 0 ? 2 : 0;
+}
diff --git a/tractor-game-simulator/client/src/utils/trumpUtils.js b/tractor-game-simulator/client/src/utils/trumpUtils.js
index 101b13b..4082ea5 100644
--- a/tractor-game-simulator/client/src/utils/trumpUtils.js
+++ b/tractor-game-simulator/client/src/utils/trumpUtils.js
@@ -7,7 +7,9 @@ export const DeclarationTypes = {
SINGLE_RANK: 'single_rank', // 单张级牌
PAIR_RANK: 'pair_rank', // 一对级牌
PAIR_SMALL_JOKER: 'pair_small_joker', // 一对小王
- PAIR_BIG_JOKER: 'pair_big_joker' // 一对大王
+ PAIR_BIG_JOKER: 'pair_big_joker', // 一对大王
+ PAIR_COUNTY_PRINCE_JOKER: 'pair_county_prince_joker', // 一对郡王
+ PAIR_PRINCE_JOKER: 'pair_prince_joker' // 一对亲王
};
/**
@@ -17,7 +19,9 @@ export const DeclarationStrength = {
[DeclarationTypes.SINGLE_RANK]: 1,
[DeclarationTypes.PAIR_RANK]: 2,
[DeclarationTypes.PAIR_SMALL_JOKER]: 3,
- [DeclarationTypes.PAIR_BIG_JOKER]: 4
+ [DeclarationTypes.PAIR_BIG_JOKER]: 4,
+ [DeclarationTypes.PAIR_COUNTY_PRINCE_JOKER]: 5,
+ [DeclarationTypes.PAIR_PRINCE_JOKER]: 6
};
/**
@@ -50,7 +54,9 @@ export function detectAvailableDeclarations(cards, trumpRank, currentTrump = nul
// 统计王的数量
const jokerCounts = {
[Ranks.SMALL_JOKER]: 0,
- [Ranks.BIG_JOKER]: 0
+ [Ranks.BIG_JOKER]: 0,
+ [Ranks.COUNTY_PRINCE_JOKER]: 0,
+ [Ranks.PRINCE_JOKER]: 0
};
// 遍历手牌统计
@@ -159,9 +165,106 @@ export function detectAvailableDeclarations(cards, trumpRank, currentTrump = nul
});
}
+ if (jokerCounts[Ranks.COUNTY_PRINCE_JOKER] >= 2) {
+ const strength = DeclarationStrength[DeclarationTypes.PAIR_COUNTY_PRINCE_JOKER];
+ const canDeclare = (!forbidDifferentSuitForSelf) && (strength > currentStrength);
+
+ declarations.push({
+ type: 'joker',
+ suit: Suits.JOKER,
+ count: 2,
+ jokerType: 'county_prince',
+ canDeclare,
+ description: canDeclare ? '可亮一对郡王(无主)' : '无法反主(需要一对亲王)',
+ strength,
+ declarationType: DeclarationTypes.PAIR_COUNTY_PRINCE_JOKER
+ });
+ }
+
+ if (jokerCounts[Ranks.PRINCE_JOKER] >= 2) {
+ const strength = DeclarationStrength[DeclarationTypes.PAIR_PRINCE_JOKER];
+ const canDeclare = (!forbidDifferentSuitForSelf) && (strength > currentStrength);
+
+ declarations.push({
+ type: 'joker',
+ suit: Suits.JOKER,
+ count: 2,
+ jokerType: 'prince',
+ canDeclare,
+ description: canDeclare ? '可亮一对亲王(无主)' : '已是最强',
+ strength,
+ declarationType: DeclarationTypes.PAIR_PRINCE_JOKER
+ });
+ }
+
return declarations;
}
+/**
+ * 三六九等的亮主/亮劣候选。两条反亮链独立比较强度,但普通花色声明全桌共用。
+ */
+export function detectThreeSixNineDeclarations(
+ cards,
+ trumpRank,
+ {
+ currentTrumpDeclaration = null,
+ currentInferiorDeclaration = null,
+ claimedSuits = {}
+ } = {},
+ currentPlayerId = null
+) {
+ const decorate = (declaration, declarationRole, currentDeclaration) => {
+ const claim = claimedSuits?.[declaration.suit] || null;
+ const isOwnCurrentReinforcement = Boolean(
+ declaration.isReinforce
+ && claim?.playerId === currentPlayerId
+ && claim?.declarationRole === declarationRole
+ && currentDeclaration?.playerId === currentPlayerId
+ && currentDeclaration?.suit === declaration.suit
+ );
+ const blockedByClaim = declaration.suit !== Suits.JOKER
+ && Boolean(claim)
+ && !isOwnCurrentReinforcement;
+ const roleName = declarationRole === 'inferior' ? '劣' : '主';
+ return {
+ ...declaration,
+ declarationRole,
+ canDeclare: declaration.canDeclare && !blockedByClaim,
+ description: blockedByClaim
+ ? `${getSuitSymbol(declaration.suit)} 已用于亮${claim.declarationRole === 'inferior' ? '劣' : '主'}`
+ : declaration.description.replaceAll('主', roleName)
+ };
+ };
+
+ const trumpDeclarations = detectAvailableDeclarations(
+ cards,
+ trumpRank,
+ currentTrumpDeclaration,
+ currentPlayerId
+ ).map(declaration => decorate(
+ declaration,
+ 'trump',
+ currentTrumpDeclaration
+ ));
+
+ const inferiorDeclarations = currentTrumpDeclaration?.suit === 'joker'
+ ? []
+ : detectAvailableDeclarations(
+ cards,
+ trumpRank,
+ currentInferiorDeclaration,
+ currentPlayerId
+ )
+ .filter(declaration => declaration.suit !== Suits.JOKER)
+ .map(declaration => decorate(
+ declaration,
+ 'inferior',
+ currentInferiorDeclaration
+ ));
+
+ return [...trumpDeclarations, ...inferiorDeclarations];
+}
+
/**
* 获取花色对应的类型名称
*/
diff --git a/tractor-game-simulator/client/test/actionAvailability.test.mjs b/tractor-game-simulator/client/test/actionAvailability.test.mjs
new file mode 100644
index 0000000..6fb7140
--- /dev/null
+++ b/tractor-game-simulator/client/test/actionAvailability.test.mjs
@@ -0,0 +1,2658 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import {
+ canPlayerViewBottomCards,
+ getActiveBuryingPlayerId,
+ getActiveSkillAvailability,
+ getFinalTrickAutoSelectedCardIds,
+ getPlayActionLabel,
+ getRuleDisabledLeadCardIds,
+ isBurySelectionValid,
+ isCurrentPlayersTurn,
+ validatePlaySelection
+} from '../src/utils/actionAvailability.js';
+import {
+ calculateMustPlayCards,
+ detectPattern,
+ getCardStrength,
+ isTrumpCard,
+ PatternTypes
+} from '../src/utils/cardPatternUtils.js';
+import {
+ calculateCardPoints,
+ getCardPoints,
+ getScoringDisplayCard,
+ getThreePowersCardPoints
+} from '../src/utils/scoringUtils.js';
+import { sortCards } from '../src/utils/cardUtils.js';
+import {
+ detectAvailableDeclarations,
+ detectThreeSixNineDeclarations
+} from '../src/utils/trumpUtils.js';
+import { isIronEvidenceSpecialCard } from '../src/utils/ironEvidenceUtils.js';
+
+const card = (id, suit, rank) => ({ id, suit, rank });
+
+test('时间倒流待决期间禁止提交出牌,待决结束后恢复正常校验', () => {
+ const handCards = [card('club-K', 'clubs', 'K')];
+ const baseState = {
+ currentRoundPlays: 0,
+ playersPlayedThisRound: [],
+ selectedRule: { id: 'time_reversal' },
+ timeReversal: {
+ reservations: [],
+ decisionState: null,
+ windowRound: null,
+ lockedRounds: []
+ }
+ };
+
+ for (const decisionState of ['holding', 'awaiting_response']) {
+ const result = validatePlaySelection({
+ selectedCardIds: ['club-K'],
+ handCards,
+ gameState: {
+ ...baseState,
+ timeReversal: {
+ ...baseState.timeReversal,
+ decisionState
+ }
+ }
+ });
+ assert.equal(result.valid, false);
+ assert.equal(result.message, '本轮正在等待时间倒流决定');
+ }
+
+ assert.equal(validatePlaySelection({
+ selectedCardIds: ['club-K'],
+ handCards,
+ gameState: baseState
+ }).valid, true);
+});
+
+test('改革开放再埋底完成后庄家与队友都能查看最终底牌', () => {
+ const finalizedState = {
+ phase: 'playing',
+ selectedRule: { id: 'reform_and_opening_up' },
+ buryingPlayerId: 'dealer',
+ secondaryBuryingPlayerId: null,
+ reformAndOpeningUpTeammatePlayerId: 'teammate'
+ };
+ const canView = currentPlayerId => canPlayerViewBottomCards({
+ gameState: finalizedState,
+ currentPlayerId,
+ bottomCardsCount: 8
+ });
+
+ assert.equal(canView('dealer'), true);
+ assert.equal(canView('teammate'), true);
+ assert.equal(canView('attacker-1'), false);
+ assert.equal(canView('attacker-2'), false);
+
+ const secondaryBuryingState = {
+ ...finalizedState,
+ phase: 'burying',
+ secondaryBuryingPlayerId: 'teammate'
+ };
+ assert.equal(canPlayerViewBottomCards({
+ gameState: secondaryBuryingState,
+ currentPlayerId: 'dealer',
+ bottomCardsCount: 8
+ }), false);
+});
+
+test('最后一墩首家出完后,按本墩首家位置自动选中跟牌者的全部剩余手牌', () => {
+ const players = [
+ { id: 'player-0', cardsCount: 3 },
+ { id: 'player-1', cardsCount: 3 },
+ { id: 'player-2', cardsCount: 0 },
+ { id: 'player-3', cardsCount: 3 }
+ ];
+ const handCards = [
+ card('heart-5', 'hearts', '5'),
+ card('club-10', 'clubs', '10'),
+ card('spade-K', 'spades', 'K')
+ ];
+ const gameState = {
+ playersPlayedThisRound: [2],
+ leadingPattern: { length: 3 }
+ };
+
+ assert.deepEqual(
+ getFinalTrickAutoSelectedCardIds({ gameState, players, handCards }),
+ handCards.map(value => value.id)
+ );
+});
+
+test('非最后一墩或剩余张数与首家不一致时不触发整手自动选中', () => {
+ const handCards = [
+ card('heart-5', 'hearts', '5'),
+ card('club-10', 'clubs', '10'),
+ card('spade-K', 'spades', 'K')
+ ];
+ const gameState = {
+ playersPlayedThisRound: [1],
+ leadingPattern: { length: 3 }
+ };
+
+ assert.equal(getFinalTrickAutoSelectedCardIds({
+ gameState,
+ players: [{ cardsCount: 3 }, { cardsCount: 1 }],
+ handCards
+ }), null);
+ assert.equal(getFinalTrickAutoSelectedCardIds({
+ gameState,
+ players: [{ cardsCount: 3 }, { cardsCount: 0 }],
+ handCards: handCards.slice(0, 2)
+ }), null);
+});
+
+test('二律背反前端把唯一声明牌面拆成单牌,重复声明牌面仍可成对', () => {
+ const splitPair = [card('c7-0', 'clubs', '7'), card('c7-1', 'clubs', '7')];
+ const duplicatePair = [card('h5-0', 'hearts', '5'), card('h5-1', 'hearts', '5')];
+ const rule = {
+ id: 'antinomy',
+ antinomySplitFaceKeys: ['clubs:7']
+ };
+
+ assert.equal(
+ detectPattern(splitPair, 'spades', '2', rule).type,
+ PatternTypes.INVALID
+ );
+ assert.equal(
+ detectPattern(duplicatePair, 'spades', '2', rule).type,
+ PatternTypes.PAIR
+ );
+});
+
+test('调虎离山按钮支持随时预备,并在队友抢占阵营次数后锁定', () => {
+ const players = [0, 1, 2, 3].map(index => ({ id: `player-${index}` }));
+ const baseState = {
+ phase: 'playing',
+ currentRound: 3,
+ currentRoundPlays: 2,
+ playersPlayedThisRound: [0, 1],
+ selectedRule: {
+ id: 'lure_tiger_from_mountain',
+ activeSkill: {
+ id: 'lure_tiger_from_mountain',
+ name: '调虎离山',
+ usageLimit: 1,
+ timing: 'anytime_prepare_round_start_confirm',
+ effect: 'silence_non_leader_for_round'
+ }
+ },
+ activeSkillUsesByPlayerId: {},
+ lureTiger: {
+ reservations: [],
+ usedTeamIndexes: [],
+ currentDecision: null
+ }
+ };
+ const available = getActiveSkillAvailability({
+ gameState: baseState,
+ players,
+ currentPlayerId: 'player-2'
+ });
+ assert.equal(available.canActivate, true);
+ assert.equal(available.targetRound, 4);
+
+ const reserved = getActiveSkillAvailability({
+ gameState: {
+ ...baseState,
+ lureTiger: {
+ ...baseState.lureTiger,
+ reservations: [{ playerId: 'player-2', targetRound: 4 }]
+ }
+ },
+ players,
+ currentPlayerId: 'player-2'
+ });
+ assert.equal(reserved.isReserved, true);
+ assert.equal(reserved.canActivate, false);
+
+ const teammateUsed = getActiveSkillAvailability({
+ gameState: {
+ ...baseState,
+ activeSkillUsesByPlayerId: { 'player-0': ['lure_tiger_from_mountain'] },
+ lureTiger: {
+ ...baseState.lureTiger,
+ usedTeamIndexes: [0]
+ }
+ },
+ players,
+ currentPlayerId: 'player-2'
+ });
+ assert.equal(teammateUsed.isUsed, true);
+ assert.equal(teammateUsed.canActivate, false);
+ assert.match(teammateUsed.reason, /队友已抢先/);
+});
+
+test('铁证如山前端只标记小王、红桃Q、黑桃J和梅花J', () => {
+ assert.deepEqual([
+ card('small-joker', 'joker', 'small_joker'),
+ card('heart-q', 'hearts', 'Q'),
+ card('spade-j', 'spades', 'J'),
+ card('club-j', 'clubs', 'J'),
+ card('club-k', 'clubs', 'K'),
+ card('diamond-j', 'diamonds', 'J'),
+ card('big-joker', 'joker', 'big_joker')
+ ].map(isIronEvidenceSpecialCard), [true, true, true, true, false, false, false]);
+});
+
+test('三人成虎的扩展负数牌面仍按主牌连续比较', () => {
+ const rule = { id: 'three_tigers' };
+ const minusTwo = {
+ ...card('heart-minus-two', 'hearts', '-2'),
+ originalRank: '2',
+ isThreeTigersTransformed: true,
+ isThreeTigersTrump: true
+ };
+ const minusOne = {
+ ...card('heart-minus-one', 'hearts', '-1'),
+ originalRank: '3',
+ isThreeTigersTransformed: true,
+ isThreeTigersTrump: true
+ };
+
+ assert.equal(isTrumpCard(minusTwo, 'spades', '9'), true);
+ assert.ok(
+ getCardStrength(minusOne, 'spades', '9', rule)
+ > getCardStrength(minusTwo, 'spades', '9', rule)
+ );
+});
+
+test('三六九等前端按共享花色锁定主劣按钮,并保留原声明者的加固入口', () => {
+ const currentTrumpDeclaration = {
+ playerId: 'player-0',
+ suit: 'hearts',
+ declarationType: 'single_rank',
+ strength: 1
+ };
+ const state = {
+ currentTrumpDeclaration,
+ currentInferiorDeclaration: null,
+ claimedSuits: {
+ hearts: { playerId: 'player-0', declarationRole: 'trump' }
+ }
+ };
+ const otherPlayerOptions = detectThreeSixNineDeclarations([
+ card('heart-2-a', 'hearts', '2'),
+ card('heart-2-b', 'hearts', '2'),
+ card('club-2-a', 'clubs', '2')
+ ], '2', state, 'player-1');
+ assert.equal(
+ otherPlayerOptions.some(option => option.suit === 'hearts' && option.canDeclare),
+ false
+ );
+ assert.equal(
+ otherPlayerOptions.some(option => (
+ option.suit === 'clubs'
+ && option.declarationRole === 'inferior'
+ && option.canDeclare
+ )),
+ true
+ );
+
+ const ownerOptions = detectThreeSixNineDeclarations([
+ card('owner-heart-2-a', 'hearts', '2'),
+ card('owner-heart-2-b', 'hearts', '2')
+ ], '2', state, 'player-0');
+ assert.equal(ownerOptions.some(option => (
+ option.suit === 'hearts'
+ && option.declarationRole === 'trump'
+ && option.count === 2
+ && option.isReinforce
+ && option.canDeclare
+ )), true);
+ assert.equal(ownerOptions.some(option => (
+ option.suit === 'hearts'
+ && option.declarationRole === 'inferior'
+ && option.canDeclare
+ )), false);
+});
+
+test('八王议政前端识别对郡王和对亲王的无主声明强度', () => {
+ const declarations = detectAvailableDeclarations([
+ card('county-0', 'joker', 'county_prince_joker'),
+ card('county-1', 'joker', 'county_prince_joker'),
+ card('prince-0', 'joker', 'prince_joker'),
+ card('prince-1', 'joker', 'prince_joker')
+ ], '2', {
+ playerId: 'other-player',
+ suit: 'joker',
+ declarationType: 'pair_big_joker',
+ strength: 4
+ }, 'current-player');
+
+ const countyDeclaration = declarations.find(
+ value => value.declarationType === 'pair_county_prince_joker'
+ );
+ const princeDeclaration = declarations.find(
+ value => value.declarationType === 'pair_prince_joker'
+ );
+ assert.equal(countyDeclaration.canDeclare, true);
+ assert.equal(countyDeclaration.strength, 5);
+ assert.equal(princeDeclaration.canDeclare, true);
+ assert.equal(princeDeclaration.strength, 6);
+});
+
+test('三六九等前端把劣花色排在普通副牌之后,并显示正确级牌层级', () => {
+ const rule = { id: 'three_six_nine_grades', inferiorSuit: 'hearts' };
+ assert.deepEqual(
+ sortCards([
+ card('inferior-a', 'hearts', 'A'),
+ card('side-club', 'clubs', '3'),
+ card('trump-five', 'spades', '5'),
+ card('side-diamond', 'diamonds', '4')
+ ], 'spades', '2', 'hearts').map(value => value.id),
+ ['trump-five', 'side-club', 'side-diamond', 'inferior-a']
+ );
+ assert.equal(getCardStrength(card('trump-a', 'spades', 'A'), 'spades', '2', rule), 995);
+ assert.equal(getCardStrength(card('inferior-2', 'hearts', '2'), 'spades', '2', rule), 996);
+ assert.equal(getCardStrength(card('side-2', 'clubs', '2'), 'spades', '2', rule), 997);
+});
+
+test('埋底按钮只在选中张数恰好正确时可用', () => {
+ assert.equal(isBurySelectionValid([], 8), false);
+ assert.equal(isBurySelectionValid(['1', '2', '3', '4', '5', '6', '7'], 8), false);
+ assert.equal(isBurySelectionValid(['1', '2', '3', '4', '5', '6', '7', '8'], 8), true);
+ assert.equal(isBurySelectionValid(['1', '2', '3', '4', '5', '6', '7', '8', '9'], 8), false);
+});
+
+test('改革开放再埋底时只把庄家队友视为当前埋底玩家', () => {
+ assert.equal(getActiveBuryingPlayerId({ buryingPlayerId: 'dealer' }), 'dealer');
+ assert.equal(getActiveBuryingPlayerId({
+ buryingPlayerId: 'dealer',
+ secondaryBuryingPlayerId: 'teammate'
+ }), 'teammate');
+ assert.equal(getActiveBuryingPlayerId({
+ buryingPlayerId: 'dealer',
+ peopleCommune: { currentBuryingPlayerId: 'commune-player' }
+ }), 'commune-player');
+});
+
+test('只有当前行动玩家可以启用出牌按钮', () => {
+ const players = [{ id: 'p1' }, { id: 'p2' }];
+ assert.equal(isCurrentPlayersTurn({ playMode: 'ordered', currentPlayerIndex: 1 }, players, 'p1'), false);
+ assert.equal(isCurrentPlayersTurn({ playMode: 'ordered', currentPlayerIndex: 1 }, players, 'p2'), true);
+ assert.equal(isCurrentPlayersTurn({ playMode: 'free', currentPlayerIndex: null }, players, 'p1'), true);
+});
+
+test('只有李代桃僵发动后把按钮称为垫牌,其他主动技能仍称为出牌', () => {
+ assert.equal(getPlayActionLabel({
+ isActiveSkillArmed: true,
+ activeSkill: { id: 'substitute_sacrifice', effect: 'free_discard_treated_small' }
+ }), '垫牌');
+ assert.equal(getPlayActionLabel({
+ isActiveSkillArmed: true,
+ activeSkill: { id: 'concealed_passage', effect: 'concealed_until_round_end' }
+ }), '出牌');
+ assert.equal(getPlayActionLabel({
+ isActiveSkillArmed: true,
+ activeSkill: { id: 'stealing_beams', effect: 'joker_wildcards' }
+ }), '出牌');
+});
+
+test('算无遗策轮到明手时只有庄家获得操作权', () => {
+ const players = [{ id: 'dealer' }, { id: 'attacker-1' }, { id: 'open-hand' }, { id: 'attacker-2' }];
+ const gameState = {
+ playMode: 'ordered',
+ currentPlayerIndex: 2,
+ openHandPlayerId: 'open-hand',
+ openHandControllerPlayerId: 'dealer'
+ };
+
+ assert.equal(isCurrentPlayersTurn(gameState, players, 'dealer'), true);
+ assert.equal(isCurrentPlayersTurn(gameState, players, 'open-hand'), false);
+ assert.equal(isCurrentPlayersTurn(gameState, players, 'attacker-1'), false);
+});
+
+test('首发只有同一有效花色的选牌才会启用出牌', () => {
+ const handCards = [
+ card('s9', 'spades', '9'),
+ card('s10', 'spades', '10'),
+ card('h9', 'hearts', '9')
+ ];
+ const gameState = { currentRoundPlays: 0, playersPlayedThisRound: [] };
+
+ assert.equal(validatePlaySelection({
+ selectedCardIds: ['s9', 's10'], handCards, gameState
+ }).valid, true);
+ assert.equal(validatePlaySelection({
+ selectedCardIds: ['s9', 'h9'], handCards, gameState
+ }).valid, false);
+});
+
+test('一国两制前端按阵营换色后校验跟牌', () => {
+ const handCards = [
+ card('attacker-spade-10', 'spades', '10'),
+ card('attacker-heart-A', 'hearts', 'A')
+ ];
+ const gameState = {
+ currentRoundPlays: 1,
+ playersPlayedThisRound: [0],
+ currentPlayerIndex: 1,
+ currentRound: 1,
+ selectedRule: { id: 'one_country_two_systems', name: '一国两制' },
+ leadingPattern: detectPattern(
+ [card('dealer-heart-9', 'hearts', '9')],
+ 'hearts',
+ '2'
+ ),
+ oneCountryTwoSystems: {
+ resolved: {
+ dealerTeamIndex: 0,
+ attackerTeamIndex: 1,
+ dealerSuit: 'hearts',
+ attackerSuit: 'spades',
+ canonicalTrumpSuit: 'hearts',
+ teamTrumpSuits: { 0: 'hearts', 1: 'spades' },
+ hasDistinctTeamSuits: true,
+ isNoTrump: false
+ }
+ }
+ };
+
+ assert.equal(validatePlaySelection({
+ selectedCardIds: ['attacker-heart-A'],
+ handCards,
+ gameState,
+ trumpSuit: 'hearts',
+ trumpRank: '2',
+ currentPlayerId: 'attacker'
+ }).valid, false);
+ assert.equal(validatePlaySelection({
+ selectedCardIds: ['attacker-spade-10'],
+ handCards,
+ gameState,
+ trumpSuit: 'hearts',
+ trumpRank: '2',
+ currentPlayerId: 'attacker'
+ }).valid, true);
+});
+
+test('单步调试首发禁用甩牌,但允许完整对子和拖拉机', () => {
+ const handCards = [
+ card('s3a', 'spades', '3'),
+ card('s3b', 'spades', '3'),
+ card('s4a', 'spades', '4'),
+ card('s4b', 'spades', '4'),
+ card('s7', 'spades', '7')
+ ];
+ const gameState = {
+ currentRoundPlays: 0,
+ playersPlayedThisRound: [],
+ selectedRule: { id: 'single_step_debug', name: '单步调试' }
+ };
+
+ const throwResult = validatePlaySelection({
+ selectedCardIds: ['s3a', 's7'], handCards, gameState
+ });
+ assert.equal(throwResult.valid, false);
+ assert.equal(throwResult.message, '单步调试规则下不能甩牌');
+ assert.equal(validatePlaySelection({
+ selectedCardIds: ['s3a', 's3b'], handCards, gameState
+ }).valid, true);
+ assert.equal(validatePlaySelection({
+ selectedCardIds: ['s3a', 's3b', 's4a', 's4b'], handCards, gameState
+ }).valid, true);
+});
+
+test('跟牌必须满足首家张数与花色要求才会启用出牌', () => {
+ const handCards = [
+ card('s9', 'spades', '9'),
+ card('s10', 'spades', '10'),
+ card('h9', 'hearts', '9')
+ ];
+ const gameState = {
+ currentRoundPlays: 1,
+ playersPlayedThisRound: [0],
+ leadingPattern: { type: 'pair', suit: 'spades', length: 2 }
+ };
+
+ assert.equal(validatePlaySelection({
+ selectedCardIds: ['s9'], handCards, gameState
+ }).valid, false);
+ assert.equal(validatePlaySelection({
+ selectedCardIds: ['s9', 'h9'], handCards, gameState
+ }).valid, false);
+ assert.equal(validatePlaySelection({
+ selectedCardIds: ['s9', 's10'], handCards, gameState
+ }).valid, true);
+});
+
+test('主动技能按钮只在自己的跟牌回合可发动,使用后保持禁用', () => {
+ const players = [{ id: 'p1' }, { id: 'p2' }];
+ const baseState = {
+ playMode: 'ordered',
+ currentPlayerIndex: 1,
+ currentRoundPlays: 1,
+ leadingPattern: { type: 'pair', suit: 'spades', length: 2 },
+ selectedRule: {
+ id: 'substitute_sacrifice',
+ activeSkill: { id: 'substitute_sacrifice', name: '李代桃僵' }
+ },
+ activeSkillUsesByPlayerId: {}
+ };
+
+ const available = getActiveSkillAvailability({
+ gameState: baseState,
+ players,
+ currentPlayerId: 'p2'
+ });
+ assert.equal(available.visible, true);
+ assert.equal(available.canActivate, true);
+ assert.equal(available.isUsed, false);
+
+ assert.equal(getActiveSkillAvailability({
+ gameState: baseState,
+ players,
+ currentPlayerId: 'p1'
+ }).canActivate, false);
+
+ const used = getActiveSkillAvailability({
+ gameState: {
+ ...baseState,
+ activeSkillUsesByPlayerId: { p2: ['substitute_sacrifice'] }
+ },
+ players,
+ currentPlayerId: 'p2'
+ });
+ assert.equal(used.isUsed, true);
+ assert.equal(used.canActivate, false);
+});
+
+test('模棱两可只在二三号位亮起,且本轮后续发动者不消耗次数', () => {
+ const players = ['p0', 'p1', 'p2', 'p3'].map(id => ({ id }));
+ const activeSkill = {
+ id: 'ambiguous',
+ name: '模棱两可',
+ usageLimit: 1,
+ timing: 'middle_positions_play',
+ effect: 'two_legal_plays_choose_at_round_end'
+ };
+ const stateAt = (currentPlayerIndex, playersPlayedThisRound, extra = {}) => ({
+ phase: 'playing',
+ playMode: 'ordered',
+ currentPlayerIndex,
+ currentRound: 1,
+ currentRoundPlays: playersPlayedThisRound.map(playerIndex => ({ playerIndex })),
+ playersPlayedThisRound,
+ leadingPattern: playersPlayedThisRound.length > 0
+ ? { type: 'single', suit: 'hearts', length: 1 }
+ : null,
+ selectedRule: { id: 'ambiguous', activeSkill },
+ activeSkillUsesByPlayerId: {},
+ ambiguous: { activePlayerIds: [] },
+ ...extra
+ });
+
+ assert.equal(getActiveSkillAvailability({
+ gameState: stateAt(0, []), players, currentPlayerId: 'p0'
+ }).canActivate, false);
+ assert.equal(getActiveSkillAvailability({
+ gameState: stateAt(1, [0]), players, currentPlayerId: 'p1'
+ }).canActivate, true);
+
+ const freeThird = getActiveSkillAvailability({
+ gameState: stateAt(2, [0, 1], {
+ activeSkillUsesByPlayerId: { p2: ['ambiguous'] },
+ ambiguous: { activePlayerIds: ['p1'] }
+ }),
+ players,
+ currentPlayerId: 'p2'
+ });
+ assert.equal(freeThird.canActivate, true);
+ assert.equal(freeThird.isFreeActivation, true);
+ assert.match(freeThird.reason, /无需消耗/);
+
+ assert.equal(getActiveSkillAvailability({
+ gameState: stateAt(3, [0, 1, 2]), players, currentPlayerId: 'p3'
+ }).canActivate, false);
+
+ const handCards = [card('h5', 'hearts', '5'), card('h10', 'hearts', '10')];
+ assert.equal(validatePlaySelection({
+ selectedCardIds: ['h5'],
+ handCards,
+ gameState: stateAt(1, [0]),
+ currentPlayerId: 'p1',
+ activeSkillId: 'ambiguous',
+ trumpSuit: 'spades',
+ trumpRank: '2'
+ }).valid, true);
+});
+
+test('同舟共济只在轮到自己且尚未出牌时可发动,并显示轮末返还说明', () => {
+ const players = [{ id: 'p0' }, { id: 'p1' }, { id: 'p2' }, { id: 'p3' }];
+ const gameState = {
+ phase: 'playing',
+ playMode: 'ordered',
+ currentPlayerIndex: 1,
+ currentRound: 3,
+ currentRoundPlays: 1,
+ playersPlayedThisRound: [0],
+ leadingPattern: { type: 'single', suit: 'hearts', length: 1 },
+ selectedRule: {
+ id: 'mutual_support',
+ activeSkill: {
+ id: 'mutual_support',
+ name: '同舟共济',
+ usageLimit: 1,
+ timing: 'own_turn_before_play',
+ effect: 'temporary_teammate_card_transfer'
+ }
+ },
+ activeSkillUsesByPlayerId: {}
+ };
+
+ const available = getActiveSkillAvailability({
+ gameState,
+ players,
+ currentPlayerId: 'p1',
+ handCards: [card('h3', 'hearts', '3')]
+ });
+ assert.equal(available.canActivate, true);
+ assert.match(available.reason, /轮末.*等量返还/);
+ assert.equal(getActiveSkillAvailability({
+ gameState,
+ players,
+ currentPlayerId: 'p2'
+ }).canActivate, false);
+
+ const usedState = {
+ ...gameState,
+ activeSkillUsesByPlayerId: { p1: ['mutual_support'] }
+ };
+ assert.equal(getActiveSkillAvailability({
+ gameState: usedState,
+ players,
+ currentPlayerId: 'p1'
+ }).isUsed, true);
+});
+
+test('文化革命只在本轮一号位出牌前可发动,并明确提示先二选一', () => {
+ const players = [{ id: 'p0' }, { id: 'p1' }, { id: 'p2' }, { id: 'p3' }];
+ const selectedRule = {
+ id: 'cultural_revolution',
+ activeSkill: {
+ id: 'cultural_revolution',
+ name: '文化革命',
+ usageLimit: 1,
+ timing: 'first_position_before_play',
+ effect: 'temporarily_replace_trump'
+ }
+ };
+ const leadingState = {
+ phase: 'playing',
+ playMode: 'ordered',
+ currentPlayerIndex: 2,
+ roundStartPlayerIndex: 2,
+ currentRound: 4,
+ currentRoundPlays: 0,
+ playersPlayedThisRound: [],
+ leadingPattern: null,
+ selectedRule,
+ activeSkillUsesByPlayerId: {}
+ };
+
+ const available = getActiveSkillAvailability({
+ gameState: leadingState,
+ players,
+ currentPlayerId: 'p2'
+ });
+ assert.equal(available.canActivate, true);
+ assert.match(available.reason, /革花色或革点数/);
+
+ assert.equal(getActiveSkillAvailability({
+ gameState: { ...leadingState, currentPlayerIndex: 3 },
+ players,
+ currentPlayerId: 'p3'
+ }).canActivate, false);
+ assert.equal(getActiveSkillAvailability({
+ gameState: {
+ ...leadingState,
+ activeSkillUsesByPlayerId: { p2: ['cultural_revolution'] }
+ },
+ players,
+ currentPlayerId: 'p2'
+ }).isUsed, true);
+});
+
+test('后发制人只在原三号位出牌前亮起,换到四号位后不再误亮', () => {
+ const players = [{ id: 'p0' }, { id: 'p1' }, { id: 'p2' }, { id: 'p3' }];
+ const skill = {
+ id: 'late_mover_advantage',
+ name: '后发制人',
+ timing: 'third_position_before_play',
+ effect: 'yield_turn_to_next_player'
+ };
+ const thirdPlayerState = {
+ playMode: 'ordered',
+ currentPlayerIndex: 2,
+ currentRoundPlays: 2,
+ playersPlayedThisRound: [0, 1],
+ leadingPattern: { type: 'single', suit: 'hearts', length: 1 },
+ selectedRule: { id: 'late_mover_advantage', activeSkill: skill },
+ activeSkillUsesByPlayerId: {}
+ };
+
+ const ready = getActiveSkillAvailability({
+ gameState: thirdPlayerState,
+ players,
+ currentPlayerId: 'p2'
+ });
+ assert.equal(ready.canActivate, true);
+ assert.match(ready.reason, /下家先出牌/);
+
+ const yieldedFourthPlayer = getActiveSkillAvailability({
+ gameState: { ...thirdPlayerState, currentPlayerIndex: 3 },
+ players,
+ currentPlayerId: 'p3'
+ });
+ assert.equal(yieldedFourthPlayer.canActivate, false);
+ assert.match(yieldedFourthPlayer.reason, /三号位/);
+
+ const used = getActiveSkillAvailability({
+ gameState: {
+ ...thirdPlayerState,
+ activeSkillUsesByPlayerId: { p2: ['late_mover_advantage'] }
+ },
+ players,
+ currentPlayerId: 'p2'
+ });
+ assert.equal(used.isUsed, true);
+ assert.equal(used.canActivate, false);
+});
+
+test('礼崩乐坏只在首发时禁用A,跟牌时恢复可选', () => {
+ const handCards = [
+ card('hA', 'hearts', 'A'),
+ card('hK', 'hearts', 'K')
+ ];
+ const leadingState = {
+ roundStartPlayerIndex: 0,
+ currentRoundPlays: 0,
+ playersPlayedThisRound: [],
+ selectedRule: { id: 'rites_collapse' }
+ };
+ const players = [{ id: 'p0' }, { id: 'p1' }, { id: 'p2' }, { id: 'p3' }];
+
+ assert.deepEqual(
+ getRuleDisabledLeadCardIds({
+ gameState: leadingState,
+ handCards,
+ players,
+ currentPlayerId: 'p0'
+ }),
+ ['hA']
+ );
+ assert.deepEqual(
+ getRuleDisabledLeadCardIds({
+ gameState: leadingState,
+ handCards,
+ players,
+ currentPlayerId: 'p1'
+ }),
+ [],
+ '非一号位玩家在等待首发时不应看到自己的A变灰'
+ );
+ assert.equal(validatePlaySelection({
+ selectedCardIds: ['hA'],
+ handCards,
+ gameState: leadingState,
+ trumpSuit: 'spades',
+ trumpRank: '2'
+ }).valid, false);
+ assert.equal(validatePlaySelection({
+ selectedCardIds: ['hK'],
+ handCards,
+ gameState: leadingState,
+ trumpSuit: 'spades',
+ trumpRank: '2'
+ }).valid, true);
+
+ const followingState = {
+ ...leadingState,
+ currentRoundPlays: 1,
+ playersPlayedThisRound: [0],
+ leadingPattern: { type: 'single', suit: 'hearts', length: 1 }
+ };
+ assert.deepEqual(
+ getRuleDisabledLeadCardIds({
+ gameState: followingState,
+ handCards,
+ players,
+ currentPlayerId: 'p0'
+ }),
+ []
+ );
+ assert.equal(validatePlaySelection({
+ selectedCardIds: ['hA'],
+ handCards,
+ gameState: followingState,
+ trumpSuit: 'spades',
+ trumpRank: '2'
+ }).valid, true);
+
+ const onlyAHand = [
+ card('hA1', 'hearts', 'A'),
+ card('sA1', 'spades', 'A')
+ ];
+ assert.deepEqual(getRuleDisabledLeadCardIds({
+ gameState: leadingState,
+ handCards: onlyAHand,
+ players,
+ currentPlayerId: 'p0'
+ }), []);
+ assert.equal(validatePlaySelection({
+ selectedCardIds: ['hA1'],
+ handCards: onlyAHand,
+ gameState: leadingState,
+ trumpSuit: 'spades',
+ trumpRank: '2',
+ currentPlayerId: 'p0'
+ }).valid, true, '一号位只剩A时必须允许首发任意A');
+});
+
+test('鸟尽弓藏只禁用一号位的弓藏花色,跟牌和仅剩禁花时自动放行', () => {
+ const players = [{ id: 'p0' }, { id: 'p1' }, { id: 'p2' }, { id: 'p3' }];
+ const handCards = [
+ card('h8', 'hearts', '8'),
+ card('h9', 'hearts', '9'),
+ card('c8', 'clubs', '8')
+ ];
+ const leadingState = {
+ roundStartPlayerIndex: 0,
+ currentRoundPlays: 0,
+ playersPlayedThisRound: [],
+ selectedRule: { id: 'birds_gone_bow_hidden' },
+ birdsGoneBowHidden: { exhaustedSuits: ['hearts'] }
+ };
+
+ assert.deepEqual(getRuleDisabledLeadCardIds({
+ gameState: leadingState,
+ handCards,
+ players,
+ currentPlayerId: 'p0'
+ }), ['h8', 'h9']);
+ assert.deepEqual(getRuleDisabledLeadCardIds({
+ gameState: leadingState,
+ handCards,
+ players,
+ currentPlayerId: 'p1'
+ }), [], '非一号位等待时不应把自己的牌置灰');
+ assert.match(validatePlaySelection({
+ selectedCardIds: ['h8'],
+ handCards,
+ gameState: leadingState,
+ trumpSuit: 'spades',
+ trumpRank: '2',
+ currentPlayerId: 'p0'
+ }).message, /鸟尽弓藏/);
+
+ const followingState = {
+ ...leadingState,
+ currentRoundPlays: 1,
+ playersPlayedThisRound: [0],
+ leadingPattern: { type: 'single', suit: 'hearts', length: 1 }
+ };
+ assert.deepEqual(getRuleDisabledLeadCardIds({
+ gameState: followingState,
+ handCards,
+ players,
+ currentPlayerId: 'p0'
+ }), []);
+ assert.equal(validatePlaySelection({
+ selectedCardIds: ['h8'],
+ handCards,
+ gameState: followingState,
+ trumpSuit: 'spades',
+ trumpRank: '2',
+ currentPlayerId: 'p0'
+ }).valid, true);
+
+ assert.deepEqual(getRuleDisabledLeadCardIds({
+ gameState: leadingState,
+ handCards: handCards.slice(0, 2),
+ players,
+ currentPlayerId: 'p0'
+ }), [], '手里只剩弓藏花色时应允许主动打出');
+
+ const pointLevelState = {
+ ...leadingState,
+ trumpSuit: 'spades',
+ trumpRank: '5',
+ birdsGoneBowHidden: { exhaustedSuits: ['trump'] }
+ };
+ assert.deepEqual(getRuleDisabledLeadCardIds({
+ gameState: pointLevelState,
+ handCards: [
+ card('d5', 'diamonds', '5'),
+ card('sQ', 'spades', 'Q'),
+ card('c8', 'clubs', '8')
+ ],
+ players,
+ currentPlayerId: 'p0'
+ }), ['d5', 'sQ'], '带分级牌和主花色牌都应按主花色禁用');
+});
+
+test('冷却时间与时间冷却禁用上轮点数或花色,但不能覆盖本轮跟牌义务', () => {
+ const players = [{ id: 'p0' }, { id: 'p1' }, { id: 'p2' }, { id: 'p3' }];
+ const rankHand = [
+ card('h7', 'hearts', '7'),
+ card('c7', 'clubs', '7'),
+ card('h8', 'hearts', '8')
+ ];
+ const rankState = {
+ currentRound: 2,
+ roundStartPlayerIndex: 0,
+ currentRoundPlays: 0,
+ playersPlayedThisRound: [],
+ selectedRule: { id: 'cooldown_time' },
+ cardCooldown: {
+ type: 'rank',
+ valuesByPlayerId: { p0: ['7'], p1: ['9'] }
+ }
+ };
+ assert.deepEqual(getRuleDisabledLeadCardIds({
+ gameState: rankState,
+ handCards: rankHand,
+ players,
+ currentPlayerId: 'p0'
+ }), ['h7', 'c7']);
+ assert.equal(validatePlaySelection({
+ selectedCardIds: ['h7'],
+ handCards: rankHand,
+ gameState: rankState,
+ trumpSuit: 'spades',
+ trumpRank: '2',
+ currentPlayerId: 'p0'
+ }).valid, false);
+
+ const suitHand = [
+ card('dK', 'diamonds', 'K'),
+ card('c8', 'clubs', '8')
+ ];
+ const suitState = {
+ currentRound: 2,
+ roundStartPlayerIndex: 0,
+ currentRoundPlays: 1,
+ playersPlayedThisRound: [0],
+ leadingPattern: { type: 'single', suit: 'diamonds', length: 1 },
+ selectedRule: { id: 'time_cooling' },
+ cardCooldown: {
+ type: 'suit',
+ valuesByPlayerId: { p1: ['diamonds'] }
+ }
+ };
+ assert.deepEqual(getRuleDisabledLeadCardIds({
+ gameState: suitState,
+ handCards: suitHand,
+ players,
+ currentPlayerId: 'p1'
+ }), [], '首家要求方片时,冷却中的方片必须临时解禁');
+ assert.equal(validatePlaySelection({
+ selectedCardIds: ['c8'],
+ handCards: suitHand,
+ gameState: suitState,
+ trumpSuit: 'spades',
+ trumpRank: '2',
+ currentPlayerId: 'p1'
+ }).valid, false, '冷却不能让玩家绕过仍持有的首花色');
+ assert.equal(validatePlaySelection({
+ selectedCardIds: ['dK'],
+ handCards: suitHand,
+ gameState: suitState,
+ trumpSuit: 'spades',
+ trumpRank: '2',
+ currentPlayerId: 'p1'
+ }).valid, true, '必须允许打出本轮被要求跟出的冷却花色');
+ assert.deepEqual(
+ calculateMustPlayCards(
+ suitHand,
+ suitState.leadingPattern,
+ 'spades',
+ '2',
+ suitState.selectedRule
+ ).map(value => value.id),
+ ['dK'],
+ '自动跟牌与冷却解禁必须得出同一张方片,不能再形成选中后禁用的死锁'
+ );
+
+ const otherLeadingSuitState = {
+ ...suitState,
+ leadingPattern: { type: 'single', suit: 'clubs', length: 1 }
+ };
+ assert.deepEqual(getRuleDisabledLeadCardIds({
+ gameState: otherLeadingSuitState,
+ handCards: suitHand,
+ players,
+ currentPlayerId: 'p1'
+ }), ['dK'], '冷却花色不是本轮首花色时仍应保持禁用');
+
+ const onlyRestricted = [card('d7', 'diamonds', '7')];
+ assert.deepEqual(getRuleDisabledLeadCardIds({
+ gameState: suitState,
+ handCards: onlyRestricted,
+ players,
+ currentPlayerId: 'p1'
+ }), [], '手里只剩冷却花色时解除禁用');
+
+ const trumpHand = [
+ card('c2', 'clubs', '2'),
+ card('sK', 'spades', 'K'),
+ card('sj', 'joker', 'small_joker'),
+ card('h7', 'hearts', '7')
+ ];
+ const trumpSuitState = {
+ ...suitState,
+ trumpSuit: 'spades',
+ trumpRank: '2',
+ currentRoundPlays: 0,
+ playersPlayedThisRound: [],
+ cardCooldown: {
+ type: 'suit',
+ valuesByPlayerId: { p1: ['trump'] }
+ }
+ };
+ assert.deepEqual(getRuleDisabledLeadCardIds({
+ gameState: trumpSuitState,
+ handCards: trumpHand,
+ players,
+ currentPlayerId: 'p1'
+ }), ['c2', 'sK', 'sj'], '级牌、主花色牌和王必须作为同一个主花色一起灰显');
+});
+
+test('举贤任能只在原一号位出牌前可发动', () => {
+ const players = [{ id: 'p0' }, { id: 'p1' }, { id: 'p2' }, { id: 'p3' }];
+ const skill = {
+ id: 'recommend_talent',
+ name: '举贤任能',
+ timing: 'first_position_before_play',
+ effect: 'yield_turn_to_next_player'
+ };
+ const leadingState = {
+ playMode: 'ordered',
+ currentPlayerIndex: 0,
+ roundStartPlayerIndex: 0,
+ currentRoundPlays: 0,
+ playersPlayedThisRound: [],
+ selectedRule: { id: 'recommend_talent', activeSkill: skill },
+ activeSkillUsesByPlayerId: {}
+ };
+
+ assert.equal(getActiveSkillAvailability({
+ gameState: leadingState,
+ players,
+ currentPlayerId: 'p0'
+ }).canActivate, true);
+
+ const yieldedState = { ...leadingState, currentPlayerIndex: 1 };
+ const nextPlayerAvailability = getActiveSkillAvailability({
+ gameState: yieldedState,
+ players,
+ currentPlayerId: 'p1'
+ });
+ assert.equal(nextPlayerAvailability.canActivate, false);
+ assert.match(nextPlayerAvailability.reason, /一号位/);
+});
+
+test('等价互惠只在一号位出牌前亮起并提示点击其他玩家', () => {
+ const players = [{ id: 'p0' }, { id: 'p1' }, { id: 'p2' }, { id: 'p3' }];
+ const skill = {
+ id: 'equivalent_reciprocity',
+ name: '等价互惠',
+ timing: 'first_position_before_play',
+ effect: 'compare_and_exchange'
+ };
+ const leadingState = {
+ playMode: 'ordered',
+ currentPlayerIndex: 0,
+ roundStartPlayerIndex: 0,
+ currentRoundPlays: 0,
+ playersPlayedThisRound: [],
+ selectedRule: { id: 'equivalent_reciprocity', activeSkill: skill },
+ activeSkillUsesByPlayerId: {}
+ };
+
+ const availability = getActiveSkillAvailability({
+ gameState: leadingState,
+ players,
+ currentPlayerId: 'p0'
+ });
+ assert.equal(availability.canActivate, true);
+ assert.match(availability.reason, /点击另一名玩家/);
+
+ const afterLead = {
+ ...leadingState,
+ currentPlayerIndex: 1,
+ currentRoundPlays: 1,
+ playersPlayedThisRound: [0]
+ };
+ assert.equal(getActiveSkillAvailability({
+ gameState: afterLead,
+ players,
+ currentPlayerId: 'p1'
+ }).canActivate, false);
+});
+
+test('时间倒流允许多人预备,并在轮末窗口继续指向刚结束的一轮', () => {
+ const players = [{ id: 'p0' }, { id: 'p1' }, { id: 'p2' }, { id: 'p3' }];
+ const skill = {
+ id: 'time_reversal',
+ name: '时间倒流',
+ timing: 'anytime_during_round',
+ effect: 'rewind_completed_round'
+ };
+ const baseState = {
+ phase: 'playing',
+ playMode: 'ordered',
+ currentRound: 3,
+ currentPlayerIndex: 0,
+ currentRoundPlays: 1,
+ playersPlayedThisRound: [0],
+ selectedRule: { id: 'time_reversal', activeSkill: skill },
+ activeSkillUsesByPlayerId: {},
+ timeReversal: { reservations: [], decisionState: null, windowRound: null, lockedRounds: [] }
+ };
+
+ const offTurn = getActiveSkillAvailability({
+ gameState: baseState,
+ players,
+ currentPlayerId: 'p2'
+ });
+ assert.equal(offTurn.canActivate, true);
+ assert.match(offTurn.reason, /轮末/);
+
+ const anotherPlayerPrepared = getActiveSkillAvailability({
+ gameState: {
+ ...baseState,
+ timeReversal: {
+ ...baseState.timeReversal,
+ reservations: [{ playerId: 'p1', playerName: '玩家1', round: 3 }]
+ }
+ },
+ players,
+ currentPlayerId: 'p2'
+ });
+ assert.equal(anotherPlayerPrepared.canActivate, true);
+
+ const ownPrepared = getActiveSkillAvailability({
+ gameState: {
+ ...baseState,
+ timeReversal: {
+ ...baseState.timeReversal,
+ reservations: [{ playerId: 'p2', playerName: '玩家2', round: 3 }]
+ }
+ },
+ players,
+ currentPlayerId: 'p2'
+ });
+ assert.equal(ownPrepared.canActivate, false);
+ assert.match(ownPrepared.reason, /你已预备第3轮/);
+
+ const roundEndWindow = getActiveSkillAvailability({
+ gameState: {
+ ...baseState,
+ currentRound: 4,
+ timeReversal: { reservations: [], decisionState: 'holding', windowRound: 3, lockedRounds: [] }
+ },
+ players,
+ currentPlayerId: 'p3'
+ });
+ assert.equal(roundEndWindow.canActivate, true);
+
+ const locked = getActiveSkillAvailability({
+ gameState: {
+ ...baseState,
+ timeReversal: { reservations: [], decisionState: null, windowRound: null, lockedRounds: [3] }
+ },
+ players,
+ currentPlayerId: 'p3'
+ });
+ assert.equal(locked.canActivate, false);
+ assert.match(locked.reason, /本轮已经发动过/);
+});
+
+test('李代桃僵可无视跟牌要求,但仍须出与首家相同张数且不能首发', () => {
+ const skill = { id: 'substitute_sacrifice', name: '李代桃僵' };
+ const handCards = [
+ card('sK1', 'spades', 'K'),
+ card('sK2', 'spades', 'K'),
+ card('j1', 'joker', 'BJ'),
+ card('j2', 'joker', 'BJ')
+ ];
+ const followingState = {
+ currentRoundPlays: 1,
+ playersPlayedThisRound: [0],
+ leadingPattern: { type: 'pair', suit: 'spades', length: 2 },
+ selectedRule: { id: 'substitute_sacrifice', activeSkill: skill }
+ };
+
+ assert.equal(validatePlaySelection({
+ selectedCardIds: ['j1', 'j2'], handCards, gameState: followingState
+ }).valid, false);
+ assert.equal(validatePlaySelection({
+ selectedCardIds: ['j1', 'j2'],
+ handCards,
+ gameState: followingState,
+ activeSkillId: skill.id
+ }).valid, true);
+ assert.equal(validatePlaySelection({
+ selectedCardIds: ['j1'],
+ handCards,
+ gameState: followingState,
+ activeSkillId: skill.id
+ }).valid, false);
+ assert.equal(validatePlaySelection({
+ selectedCardIds: ['j1', 'j2'],
+ handCards,
+ gameState: {
+ ...followingState,
+ currentRoundPlays: 0,
+ playersPlayedThisRound: []
+ },
+ activeSkillId: skill.id
+ }).valid, false);
+});
+
+test('六六大顺选中六张连续同花牌时识别为同花顺并允许出牌', () => {
+ const handCards = ['2', '3', '4', '5', '6', '7', '9']
+ .map(rank => card(`h${rank}`, 'hearts', rank));
+ const gameState = {
+ currentRoundPlays: 0,
+ playersPlayedThisRound: [],
+ selectedRule: { id: 'six_six_great_success', name: '六六大顺' }
+ };
+
+ const result = validatePlaySelection({
+ selectedCardIds: handCards.slice(0, 6).map(value => value.id),
+ handCards,
+ gameState,
+ trumpSuit: 'spades',
+ trumpRank: 'A'
+ });
+ assert.equal(result.valid, true);
+ assert.equal(result.pattern.type, PatternTypes.STRAIGHT_FLUSH);
+ assert.equal(
+ detectPattern(handCards.slice(0, 6), 'spades', 'A').type,
+ PatternTypes.INVALID
+ );
+});
+
+test('跟六六大顺时若手中有同花顺,破顺选牌不会启用出牌', () => {
+ const leadingCards = ['2', '3', '4', '5', '6', '7']
+ .map(rank => card(`lead-${rank}`, 'hearts', rank));
+ const selectedRule = { id: 'six_six_great_success', name: '六六大顺' };
+ const leadingPattern = detectPattern(
+ leadingCards,
+ 'spades',
+ 'A',
+ selectedRule
+ );
+ const handCards = ['3', '4', '5', '6', '7', '8', '10']
+ .map(rank => card(`follow-${rank}`, 'hearts', rank));
+ const gameState = {
+ currentRoundPlays: 1,
+ playersPlayedThisRound: [0],
+ leadingPattern,
+ selectedRule
+ };
+
+ assert.equal(validatePlaySelection({
+ selectedCardIds: handCards.slice(0, 6).map(value => value.id),
+ handCards,
+ gameState,
+ trumpSuit: 'spades',
+ trumpRank: 'A'
+ }).valid, true);
+ assert.equal(validatePlaySelection({
+ selectedCardIds: [...handCards.slice(0, 5), handCards[6]].map(value => value.id),
+ handCards,
+ gameState,
+ trumpSuit: 'spades',
+ trumpRank: 'A'
+ }).valid, false);
+ assert.deepEqual(
+ calculateMustPlayCards(
+ handCards,
+ leadingPattern,
+ 'spades',
+ 'A',
+ selectedRule
+ ).map(value => value.id),
+ handCards.slice(0, 6).map(value => value.id)
+ );
+});
+
+test('太极四象只在对应规则下允许四种花色同点数混合首发', () => {
+ const handCards = ['hearts', 'diamonds', 'clubs', 'spades']
+ .map(suit => card(`${suit}-9`, suit, '9'));
+ const selectedRule = { id: 'tai_chi_four_symbols', name: '太极四象' };
+
+ const result = validatePlaySelection({
+ selectedCardIds: handCards.map(value => value.id),
+ handCards,
+ gameState: {
+ currentRoundPlays: 0,
+ playersPlayedThisRound: [],
+ selectedRule
+ },
+ trumpSuit: 'spades',
+ trumpRank: '2'
+ });
+ assert.equal(result.valid, true);
+ assert.equal(result.pattern.type, PatternTypes.TAI_CHI_FOUR_SYMBOLS);
+ assert.equal(result.pattern.strength, 9);
+
+ assert.equal(validatePlaySelection({
+ selectedCardIds: handCards.map(value => value.id),
+ handCards,
+ gameState: { currentRoundPlays: 0, playersPlayedThisRound: [] },
+ trumpSuit: 'spades',
+ trumpRank: '2'
+ }).valid, false);
+});
+
+test('跟太极四象时前端强制跟出手中的四象,并可唯一自动选中', () => {
+ const selectedRule = { id: 'tai_chi_four_symbols', name: '太极四象' };
+ const leadingCards = ['hearts', 'diamonds', 'clubs', 'spades']
+ .map(suit => card(`lead-${suit}`, suit, '9'));
+ const leadingPattern = detectPattern(
+ leadingCards,
+ 'spades',
+ '2',
+ selectedRule
+ );
+ const taiChiQueens = ['hearts', 'diamonds', 'clubs', 'spades']
+ .map(suit => card(`queen-${suit}`, suit, 'Q'));
+ const handCards = [...taiChiQueens, card('heart-3', 'hearts', '3')];
+ const gameState = {
+ currentRoundPlays: 1,
+ playersPlayedThisRound: [0],
+ leadingPattern,
+ selectedRule
+ };
+
+ assert.equal(validatePlaySelection({
+ selectedCardIds: taiChiQueens.map(value => value.id),
+ handCards,
+ gameState,
+ trumpSuit: 'spades',
+ trumpRank: '2'
+ }).valid, true);
+ assert.equal(validatePlaySelection({
+ selectedCardIds: [...taiChiQueens.slice(1), handCards[4]].map(value => value.id),
+ handCards,
+ gameState,
+ trumpSuit: 'spades',
+ trumpRank: '2'
+ }).valid, false);
+ assert.deepEqual(
+ calculateMustPlayCards(
+ handCards,
+ leadingPattern,
+ 'spades',
+ '2',
+ selectedRule
+ ).map(value => value.id),
+ taiChiQueens.map(value => value.id)
+ );
+});
+
+test('底牌分数按 5、10、K 正确累计', () => {
+ assert.equal(calculateCardPoints([
+ card('1', 'spades', '5'),
+ card('2', 'hearts', '10'),
+ card('3', 'clubs', 'K'),
+ card('4', 'diamonds', 'A')
+ ]), 25);
+});
+
+test('三权分立的实时牌面分只累计已揭晓的重载点数并允许重复叠加', () => {
+ const threePowers = {
+ slots: [
+ { sourceRank: '5', pointValue: 5, rank: '7', isRevealed: true },
+ { sourceRank: '10', pointValue: 10, rank: '7', isRevealed: true },
+ { sourceRank: 'K', pointValue: 10, rank: null, isRevealed: false }
+ ]
+ };
+ const cards = [
+ card('standard-five', 'spades', '5'),
+ card('standard-ten', 'hearts', '10'),
+ card('reloaded-seven-a', 'clubs', '7'),
+ card('reloaded-seven-b', 'diamonds', '7')
+ ];
+
+ assert.equal(
+ calculateCardPoints(cards, value => getThreePowersCardPoints(value, threePowers)),
+ 30
+ );
+ assert.equal(getThreePowersCardPoints(cards[0], threePowers), 0);
+ assert.equal(getThreePowersCardPoints(cards[2], threePowers), 15);
+});
+
+test('暗度陈仓仍需合法跟牌,偷梁换柱可在前端识别王补对子', () => {
+ const leadPattern = { type: PatternTypes.PAIR, suit: 'hearts', length: 2 };
+ const handCards = [
+ card('h5', 'hearts', '5'),
+ card('h6', 'hearts', '6'),
+ card('joker', 'joker', 'small_joker')
+ ];
+ const commonState = {
+ currentRoundPlays: 1,
+ playersPlayedThisRound: [0],
+ leadingPattern: leadPattern
+ };
+
+ const concealedState = {
+ ...commonState,
+ selectedRule: {
+ id: 'concealed_passage',
+ activeSkill: {
+ id: 'concealed_passage',
+ name: '暗度陈仓',
+ timing: 'following_play',
+ effect: 'concealed_until_round_end'
+ }
+ }
+ };
+ assert.equal(validatePlaySelection({
+ selectedCardIds: ['h5', 'joker'],
+ handCards,
+ gameState: concealedState,
+ trumpSuit: 'spades',
+ trumpRank: '2',
+ activeSkillId: 'concealed_passage'
+ }).valid, false);
+
+ const stealingState = {
+ ...commonState,
+ selectedRule: {
+ id: 'stealing_beams',
+ activeSkill: {
+ id: 'stealing_beams',
+ name: '偷梁换柱',
+ timing: 'any_play',
+ effect: 'joker_wildcards'
+ }
+ }
+ };
+ const substitution = validatePlaySelection({
+ selectedCardIds: ['h5', 'joker'],
+ handCards,
+ gameState: stealingState,
+ trumpSuit: 'spades',
+ trumpRank: '2',
+ activeSkillId: 'stealing_beams',
+ jokerSubstitutions: [{ cardId: 'joker', suit: 'hearts', rank: '5' }]
+ });
+ assert.equal(substitution.valid, true);
+ assert.equal(substitution.pattern.type, PatternTypes.PAIR);
+ assert.equal(substitution.substitutions[0].rank, '5');
+});
+
+test('一带一路未点亮时允许普通甩牌,点亮后识别为限一次的小甩牌', () => {
+ const handCards = [
+ card('hA', 'hearts', 'A'),
+ card('h6', 'hearts', '6'),
+ card('h5a', 'hearts', '5'),
+ card('h5b', 'hearts', '5')
+ ];
+ const gameState = {
+ phase: 'playing',
+ currentPlayerIndex: 0,
+ currentRound: 1,
+ currentRoundPlays: 0,
+ playersPlayedThisRound: [],
+ selectedRule: {
+ id: 'belt_and_road',
+ name: '一带一路',
+ activeSkill: {
+ id: 'belt_and_road',
+ name: '一带一路',
+ timing: 'leading_play',
+ effect: 'belt_and_road_lead'
+ }
+ },
+ activeSkillUsesByPlayerId: {}
+ };
+ const players = [{ id: 'player-0' }, { id: 'player-1' }, { id: 'player-2' }, { id: 'player-3' }];
+ const unarmed = validatePlaySelection({
+ selectedCardIds: ['hA', 'h6'],
+ handCards,
+ gameState,
+ trumpSuit: 'spades',
+ trumpRank: '2',
+ currentPlayerId: 'player-0'
+ });
+ assert.equal(unarmed.valid, true);
+ assert.equal(unarmed.pattern.type, PatternTypes.INVALID);
+
+ const availability = getActiveSkillAvailability({
+ gameState,
+ players,
+ currentPlayerId: 'player-0'
+ });
+ assert.equal(availability.visible, true);
+ assert.equal(availability.canActivate, true);
+ assert.match(availability.reason, /两张非对子单牌/);
+
+ const first = validatePlaySelection({
+ selectedCardIds: ['hA', 'h6'],
+ handCards,
+ gameState,
+ trumpSuit: 'spades',
+ trumpRank: '2',
+ activeSkillId: 'belt_and_road',
+ currentPlayerId: 'player-0'
+ });
+ assert.equal(first.valid, true);
+ assert.equal(first.pattern.type, PatternTypes.BELT_AND_ROAD);
+ assert.deepEqual(first.pattern.strengths, [14, 6]);
+
+ const armedPair = validatePlaySelection({
+ selectedCardIds: ['h5a', 'h5b'],
+ handCards,
+ gameState,
+ trumpSuit: 'spades',
+ trumpRank: '2',
+ activeSkillId: 'belt_and_road',
+ currentPlayerId: 'player-0'
+ });
+ assert.equal(armedPair.valid, false);
+ assert.match(armedPair.message, /两张非对子单牌/);
+
+ const used = getActiveSkillAvailability({
+ gameState: {
+ ...gameState,
+ activeSkillUsesByPlayerId: { 'player-0': ['belt_and_road'] }
+ },
+ players,
+ currentPlayerId: 'player-0'
+ });
+ assert.equal(used.canActivate, false);
+ assert.equal(used.isUsed, true);
+
+ const following = getActiveSkillAvailability({
+ gameState: {
+ ...gameState,
+ currentPlayerIndex: 1,
+ currentRoundPlays: 1,
+ playersPlayedThisRound: [0],
+ leadingPattern: first.pattern
+ },
+ players,
+ currentPlayerId: 'player-1'
+ });
+ assert.equal(following.canActivate, false);
+ assert.match(following.reason, /只能在首发时发动/);
+});
+
+test('昼夜轮转的客户端牌力从第1轮的2开始,轮转到级牌时仍由A最大', () => {
+ const two = card('h2', 'hearts', '2');
+ const ace = card('hA', 'hearts', 'A');
+ const roundOneRule = { id: 'day_night_rotation', currentRound: 1 };
+ const roundTwoRule = { id: 'day_night_rotation', currentRound: 2 };
+ assert.ok(getCardStrength(two, 'spades', '3', roundOneRule) >
+ getCardStrength(ace, 'spades', '3', roundOneRule));
+ assert.ok(getCardStrength(ace, 'spades', '3', roundTwoRule) >
+ getCardStrength(two, 'spades', '3', roundTwoRule));
+});
+
+test('取长补短的B、1和郡王按新牌面比较,分值仍读取实体原牌', () => {
+ const rule = { id: 'strength_compensation' };
+ const ace = card('hA', 'hearts', 'A');
+ const bonusOne = {
+ ...card('hA-shifted', 'hearts', 'B'),
+ originalRank: 'A',
+ isStrengthCompensated: true,
+ strengthCompensationDelta: 1
+ };
+ const one = {
+ ...card('h2-shifted', 'hearts', '1'),
+ originalRank: '2',
+ isStrengthCompensated: true,
+ strengthCompensationDelta: -1
+ };
+ const two = card('h2', 'hearts', '2');
+ const bigJoker = card('bj', 'joker', 'big_joker');
+ const countyPrinceJoker = {
+ ...card('bj-shifted', 'joker', 'county_prince_joker'),
+ originalRank: 'big_joker',
+ isStrengthCompensated: true,
+ strengthCompensationDelta: 1
+ };
+ const physicalFiveShownAsSix = {
+ ...card('h5-shifted', 'hearts', '6'),
+ originalRank: '5',
+ isStrengthCompensated: true,
+ strengthCompensationDelta: 1
+ };
+
+ assert.ok(getCardStrength(bonusOne, 'spades', '9', rule) >
+ getCardStrength(ace, 'spades', '9', rule));
+ assert.ok(getCardStrength(two, 'spades', '9', rule) >
+ getCardStrength(one, 'spades', '9', rule));
+ assert.ok(getCardStrength(countyPrinceJoker, 'spades', '9', rule) >
+ getCardStrength(bigJoker, 'spades', '9', rule));
+ assert.deepEqual(
+ ['A', 'B', 'C', 'D'].map(rank => (
+ getCardStrength(card(`side-${rank}`, 'hearts', rank), 'spades', '9', rule)
+ )),
+ [14, 15, 16, 17]
+ );
+ assert.deepEqual(
+ ['big_joker', 'county_prince_joker', 'prince_joker', 'white_joker'].map(rank => (
+ getCardStrength(card(`joker-${rank}`, 'joker', rank), 'spades', '9', rule)
+ )),
+ [1000, 1001, 1002, 1003]
+ );
+ assert.equal(getCardPoints(physicalFiveShownAsSix), 5);
+
+ const shiftedMainChain = [
+ { ...card('main-a-plus', 'hearts', '2'), isStrengthCompensated: true },
+ { ...card('vice-two-plus', 'spades', '2'), isStrengthCompensated: true },
+ { ...card('main-two-plus', 'joker', 'small_joker'), isStrengthCompensated: true },
+ { ...card('small-plus', 'joker', 'big_joker'), isStrengthCompensated: true },
+ countyPrinceJoker
+ ];
+ assert.deepEqual(
+ shiftedMainChain.map(value => getCardStrength(value, 'spades', '2', rule)),
+ [997, 998, 999, 1000, 1001]
+ );
+});
+
+test('取长补短在无主局把M留在主牌类别内', () => {
+ const rule = { id: 'strength_compensation' };
+ const maximumSideCard = card('side-d', 'hearts', 'D');
+ const underLevel = {
+ ...card('under-level', 'clubs', 'M'),
+ originalRank: '2',
+ isStrengthCompensated: true,
+ strengthCompensationDelta: -1
+ };
+ const levelCard = card('level', 'spades', '2');
+
+ assert.equal(isTrumpCard(underLevel, 'no_trump', '2'), true);
+ assert.ok(getCardStrength(underLevel, 'no_trump', '2', rule) >
+ getCardStrength(maximumSideCard, 'no_trump', '2', rule));
+ assert.ok(getCardStrength(levelCard, 'no_trump', '2', rule) >
+ getCardStrength(underLevel, 'no_trump', '2', rule));
+});
+
+test('手无寸铁在客户端把级牌按原花色和点数处理', () => {
+ const rule = { id: 'unarmed' };
+ const unarmed = (id, suit, rank) => ({ ...card(id, suit, rank), isUnarmed: true });
+ const heartLevel = unarmed('heart-2', 'hearts', '2');
+ const clubLevel = unarmed('club-2', 'clubs', '2');
+ const clubThree = unarmed('club-3', 'clubs', '3');
+ const sideAce = unarmed('spade-A', 'spades', 'A');
+
+ assert.equal(isTrumpCard(heartLevel, 'hearts', '2'), true);
+ assert.equal(isTrumpCard(clubLevel, 'hearts', '2'), false);
+ assert.ok(getCardStrength(clubThree, 'hearts', '2', rule) >
+ getCardStrength(clubLevel, 'hearts', '2', rule));
+ assert.ok(getCardStrength(heartLevel, 'hearts', '2', rule) >
+ getCardStrength(sideAce, 'hearts', '2', rule));
+
+ const tractor = detectPattern([
+ unarmed('club-2-a', 'clubs', '2'),
+ unarmed('club-2-b', 'clubs', '2'),
+ unarmed('club-3-a', 'clubs', '3'),
+ unarmed('club-3-b', 'clubs', '3')
+ ], 'hearts', '2', rule);
+ assert.equal(tractor.type, PatternTypes.TRACTOR);
+ assert.equal(tractor.suit, 'clubs');
+
+ const cannotSkipLevelRank = detectPattern([
+ unarmed('club-Q-a', 'clubs', 'Q'),
+ unarmed('club-Q-b', 'clubs', 'Q'),
+ unarmed('club-A-a', 'clubs', 'A'),
+ unarmed('club-A-b', 'clubs', 'A')
+ ], 'hearts', 'K', rule);
+ assert.equal(cannotSkipLevelRank.type, PatternTypes.INVALID);
+});
+
+test('梦中杀人仅在没有主牌时可随时发动,入梦后按钮显示为等待系统', () => {
+ const players = [{ id: 'player-0' }, { id: 'player-1' }, { id: 'player-2' }, { id: 'player-3' }];
+ const gameState = {
+ phase: 'playing',
+ currentPlayerIndex: 2,
+ trumpSuit: 'spades',
+ trumpRank: '2',
+ selectedRule: {
+ id: 'dream_killing',
+ activeSkill: {
+ id: 'dream_killing',
+ name: '梦中杀人',
+ timing: 'anytime_no_trump',
+ effect: 'sleep_random_play'
+ }
+ },
+ activeSkillUsesByPlayerId: {},
+ dreamKilling: { sleepingPlayerIds: [] }
+ };
+ const sideCards = [card('h4', 'hearts', '4'), card('d5', 'diamonds', '5')];
+ const available = getActiveSkillAvailability({
+ gameState,
+ players,
+ currentPlayerId: 'player-0',
+ handCards: sideCards
+ });
+ assert.equal(available.canActivate, true, '不必等到自己的回合才可入梦');
+
+ const hasTrump = getActiveSkillAvailability({
+ gameState,
+ players,
+ currentPlayerId: 'player-0',
+ handCards: [...sideCards, card('s6', 'spades', '6')]
+ });
+ assert.equal(hasTrump.canActivate, false);
+ assert.match(hasTrump.reason, /仍有主牌/);
+
+ const sleeping = getActiveSkillAvailability({
+ gameState: { ...gameState, dreamKilling: { sleepingPlayerIds: ['player-0'] } },
+ players,
+ currentPlayerId: 'player-0',
+ handCards: sideCards
+ });
+ assert.equal(sleeping.canActivate, false);
+ assert.match(sleeping.reason, /系统随机出牌/);
+});
+
+test('神兵天降必须先选神兵与匹配手牌,客户端按转化后牌面验证出牌', () => {
+ const players = [{ id: 'player-0' }, { id: 'player-1' }, { id: 'player-2' }, { id: 'player-3' }];
+ const source = card('source', 'clubs', 'A');
+ const unrelated = card('unrelated', 'diamonds', '7');
+ const target = card('divine-hearts-A', 'hearts', 'A');
+ const gameState = {
+ phase: 'playing',
+ playMode: 'ordered',
+ currentPlayerIndex: 0,
+ currentRoundPlays: 0,
+ playersPlayedThisRound: [],
+ trumpSuit: 'spades',
+ trumpRank: '2',
+ selectedRule: {
+ id: 'divine_weapon',
+ activeSkill: {
+ id: 'divine_weapon',
+ name: '神兵天降',
+ timing: 'any_play',
+ effect: 'transform_matching_card'
+ }
+ },
+ activeSkillUsesByPlayerId: {},
+ divineWeapon: {
+ cards: [target, card('divine-spades-9', 'spades', '9')],
+ generation: 1,
+ usedThisRound: false
+ }
+ };
+
+ const availability = getActiveSkillAvailability({
+ gameState,
+ players,
+ currentPlayerId: 'player-0',
+ handCards: [source, unrelated]
+ });
+ assert.equal(availability.canActivate, true);
+
+ const missingTarget = validatePlaySelection({
+ selectedCardIds: [source.id],
+ handCards: [source, unrelated],
+ gameState,
+ trumpSuit: 'spades',
+ trumpRank: '2',
+ activeSkillId: 'divine_weapon',
+ currentPlayerId: 'player-0'
+ });
+ assert.equal(missingTarget.valid, false);
+ assert.match(missingTarget.message, /先选择.*神兵牌/);
+
+ const wrongSource = validatePlaySelection({
+ selectedCardIds: [unrelated.id],
+ handCards: [source, unrelated],
+ gameState,
+ trumpSuit: 'spades',
+ trumpRank: '2',
+ activeSkillId: 'divine_weapon',
+ currentPlayerId: 'player-0',
+ divineWeaponCardId: target.id,
+ divineWeaponSourceCardId: unrelated.id
+ });
+ assert.equal(wrongSource.valid, false);
+ assert.match(wrongSource.message, /花色或点数相同/);
+
+ const valid = validatePlaySelection({
+ selectedCardIds: [source.id],
+ handCards: [source, unrelated],
+ gameState,
+ trumpSuit: 'spades',
+ trumpRank: '2',
+ activeSkillId: 'divine_weapon',
+ currentPlayerId: 'player-0',
+ divineWeaponCardId: target.id,
+ divineWeaponSourceCardId: source.id
+ });
+ assert.equal(valid.valid, true);
+ assert.equal(valid.pattern.suit, 'hearts');
+
+ const roundSpent = getActiveSkillAvailability({
+ gameState: {
+ ...gameState,
+ divineWeapon: { ...gameState.divineWeapon, usedThisRound: true }
+ },
+ players,
+ currentPlayerId: 'player-0',
+ handCards: [source, unrelated]
+ });
+ assert.equal(roundSpent.canActivate, false);
+ assert.match(roundSpent.reason, /本轮已有玩家发动/);
+});
+
+test('神兵天降按原牌计分,且不能转化最后一张首花色副牌来毙牌', () => {
+ assert.equal(getCardPoints({
+ rank: '10',
+ originalRank: '5',
+ isDivineWeaponTransformed: true
+ }), 5);
+ assert.equal(getCardPoints({
+ rank: '10',
+ originalRank: '7',
+ isDivineWeaponTransformed: true
+ }), 0);
+
+ const players = [{ id: 'lead' }, { id: 'follower' }, { id: 'partner' }, { id: 'last' }];
+ const lastClub = card('last-club', 'clubs', '7');
+ const divineClubLevel = card('divine-clubs-2', 'clubs', '2');
+ const divineDiamondLevel = card('divine-diamonds-2', 'diamonds', '2');
+ const gameState = {
+ phase: 'playing',
+ playMode: 'ordered',
+ currentPlayerIndex: 1,
+ currentRoundPlays: 1,
+ playersPlayedThisRound: [0],
+ leadingPattern: { type: PatternTypes.SINGLE, suit: 'clubs', length: 1 },
+ trumpSuit: 'spades',
+ trumpRank: '2',
+ selectedRule: {
+ id: 'divine_weapon',
+ activeSkill: {
+ id: 'divine_weapon',
+ name: '神兵天降',
+ timing: 'any_play',
+ effect: 'transform_matching_card'
+ }
+ },
+ activeSkillUsesByPlayerId: {},
+ divineWeapon: {
+ cards: [divineClubLevel, divineDiamondLevel],
+ generation: 1,
+ usedThisRound: false
+ }
+ };
+
+ const illegalRuff = validatePlaySelection({
+ selectedCardIds: [lastClub.id],
+ handCards: [lastClub, card('heart-8', 'hearts', '8')],
+ gameState,
+ trumpSuit: 'spades',
+ trumpRank: '2',
+ activeSkillId: 'divine_weapon',
+ currentPlayerId: 'follower',
+ divineWeaponCardId: divineClubLevel.id,
+ divineWeaponSourceCardId: lastClub.id
+ });
+ assert.equal(illegalRuff.valid, false);
+ assert.match(illegalRuff.message, /同花色.*必须优先出/);
+
+ const diamondSource = card('diamond-7', 'diamonds', '7');
+ const legalRuffAfterVoid = validatePlaySelection({
+ selectedCardIds: [diamondSource.id],
+ handCards: [diamondSource, card('heart-8', 'hearts', '8')],
+ gameState,
+ trumpSuit: 'spades',
+ trumpRank: '2',
+ activeSkillId: 'divine_weapon',
+ currentPlayerId: 'follower',
+ divineWeaponCardId: divineDiamondLevel.id,
+ divineWeaponSourceCardId: diamondSource.id
+ });
+ assert.equal(legalRuffAfterVoid.valid, true);
+ assert.equal(legalRuffAfterVoid.pattern.suit, 'trump');
+});
+
+test('请君入瓮只在本轮一号位出牌前可发动,使用后本局不再开放', () => {
+ const players = [0, 1, 2, 3].map(index => ({ id: `player-${index}` }));
+ const skill = {
+ id: 'invite_into_urn',
+ name: '请君入瓮',
+ usageLimit: 1,
+ timing: 'first_position_before_play',
+ effect: 'declare_target_card'
+ };
+ const leadingState = {
+ phase: 'playing',
+ playMode: 'ordered',
+ currentPlayerIndex: 0,
+ roundStartPlayerIndex: 0,
+ currentRoundPlays: 0,
+ playersPlayedThisRound: [],
+ selectedRule: { id: 'invite_into_urn', activeSkill: skill },
+ activeSkillUsesByPlayerId: {}
+ };
+
+ const ready = getActiveSkillAvailability({
+ gameState: leadingState,
+ players,
+ currentPlayerId: 'player-0'
+ });
+ assert.equal(ready.canActivate, true);
+ assert.match(ready.reason, /一张或多张都只扣5分/);
+
+ const afterLead = getActiveSkillAvailability({
+ gameState: {
+ ...leadingState,
+ currentPlayerIndex: 1,
+ currentRoundPlays: 1,
+ playersPlayedThisRound: [0]
+ },
+ players,
+ currentPlayerId: 'player-1'
+ });
+ assert.equal(afterLead.canActivate, false);
+ assert.match(afterLead.reason, /一号位/);
+
+ const used = getActiveSkillAvailability({
+ gameState: {
+ ...leadingState,
+ activeSkillUsesByPlayerId: { 'player-0': ['invite_into_urn'] }
+ },
+ players,
+ currentPlayerId: 'player-0'
+ });
+ assert.equal(used.isUsed, true);
+ assert.equal(used.canActivate, false);
+});
+
+test('魔术戏法只允许本轮一号位在出牌前预备选择两名玩家', () => {
+ const players = [{ id: 'p0' }, { id: 'p1' }, { id: 'p2' }, { id: 'p3' }];
+ const selectedRule = {
+ id: 'magic_trick',
+ activeSkill: {
+ id: 'magic_trick',
+ name: '魔术戏法',
+ usageLimit: 1,
+ timing: 'first_position_before_play',
+ effect: 'swap_two_plays_at_round_end'
+ }
+ };
+ const baseState = {
+ phase: 'playing',
+ playMode: 'ordered',
+ currentPlayerIndex: 0,
+ roundStartPlayerIndex: 0,
+ currentRoundPlays: 0,
+ playersPlayedThisRound: [],
+ selectedRule,
+ activeSkillUsesByPlayerId: {}
+ };
+
+ const available = getActiveSkillAvailability({
+ gameState: baseState,
+ players,
+ currentPlayerId: 'p0',
+ handCards: [card('c7', 'clubs', '7')]
+ });
+ assert.equal(available.canActivate, true);
+ assert.match(available.reason, /两名其他玩家/);
+
+ const afterLead = getActiveSkillAvailability({
+ gameState: { ...baseState, currentRoundPlays: 1, playersPlayedThisRound: [0] },
+ players,
+ currentPlayerId: 'p0',
+ handCards: [card('c7', 'clubs', '7')]
+ });
+ assert.equal(afterLead.canActivate, false);
+});
+
+test('聚类分析可反复发动,并能把相邻点数转换成合法对子', () => {
+ const handCards = [card('c7', 'clubs', '7'), card('c8', 'clubs', '8')];
+ const selectedRule = {
+ id: 'cluster_analysis',
+ activeSkill: {
+ id: 'cluster_analysis',
+ name: '聚类分析',
+ usageLimit: null,
+ timing: 'any_play',
+ effect: 'adjacent_rank_transform'
+ }
+ };
+ const gameState = {
+ phase: 'playing',
+ playMode: 'ordered',
+ currentPlayerIndex: 0,
+ roundStartPlayerIndex: 0,
+ currentRoundPlays: 0,
+ playersPlayedThisRound: [],
+ trumpSuit: 'spades',
+ trumpRank: '2',
+ selectedRule,
+ activeSkillUsesByPlayerId: { p0: ['cluster_analysis'] }
+ };
+ const players = [{ id: 'p0' }, { id: 'p1' }, { id: 'p2' }, { id: 'p3' }];
+
+ const availability = getActiveSkillAvailability({
+ gameState,
+ players,
+ currentPlayerId: 'p0',
+ handCards
+ });
+ assert.equal(availability.canActivate, true);
+ assert.equal(availability.isUsed, false);
+
+ const validation = validatePlaySelection({
+ selectedCardIds: handCards.map(item => item.id),
+ handCards,
+ gameState,
+ trumpSuit: 'spades',
+ trumpRank: '2',
+ activeSkillId: 'cluster_analysis',
+ currentPlayerId: 'p0',
+ clusterAnalysisSubstitutions: [{
+ cardId: 'c7',
+ suit: 'clubs',
+ fromRank: '7',
+ toRank: '8'
+ }]
+ });
+ assert.equal(validation.valid, true);
+ assert.equal(validation.pattern.type, PatternTypes.PAIR);
+ assert.deepEqual(validation.effectiveCards.map(item => item.rank), ['8', '8']);
+ assert.equal(validation.effectiveCards.some(item => item.isClusterAnalysisTransformed), true);
+
+ const firstJack = card('sJ0', 'spades', 'J');
+ const secondJack = card('sJ1', 'spades', 'J');
+ const multiValidation = validatePlaySelection({
+ selectedCardIds: [firstJack.id, secondJack.id],
+ handCards: [firstJack, secondJack],
+ gameState,
+ trumpSuit: 'hearts',
+ trumpRank: '2',
+ activeSkillId: 'cluster_analysis',
+ currentPlayerId: 'p0',
+ clusterAnalysisSubstitutions: [
+ { cardId: firstJack.id, suit: 'spades', fromRank: 'J', toRank: 'Q' },
+ { cardId: secondJack.id, suit: 'spades', fromRank: 'J', toRank: 'Q' }
+ ]
+ });
+ assert.equal(multiValidation.valid, true);
+ assert.equal(multiValidation.pattern.type, PatternTypes.PAIR);
+ assert.deepEqual(multiValidation.effectiveCards.map(item => item.rank), ['Q', 'Q']);
+});
+
+test('禁术秘法可随时预备、轮首确认并在发动后永久生效', () => {
+ const players = [{ id: 'p1' }, { id: 'p2' }, { id: 'p3' }, { id: 'p4' }];
+ const activeSkill = {
+ id: 'forbidden_magic',
+ name: '禁术秘法',
+ usageLimit: 1,
+ timing: 'anytime_prepare_round_start_confirm',
+ effect: 'demote_trumps_and_transform'
+ };
+ const baseState = {
+ phase: 'playing',
+ playMode: 'ordered',
+ currentRound: 1,
+ currentPlayerIndex: 0,
+ currentRoundPlays: 0,
+ playersPlayedThisRound: [],
+ selectedRule: { id: 'forbidden_magic', activeSkill },
+ activeSkillUsesByPlayerId: {},
+ forbiddenMagic: { reservations: [], activePlayerIds: [] }
+ };
+
+ const nonLeader = getActiveSkillAvailability({
+ gameState: baseState,
+ players,
+ currentPlayerId: 'p3',
+ handCards: [card('c3', 'clubs', '3')]
+ });
+ assert.equal(nonLeader.canActivate, true);
+ assert.match(nonLeader.reason, /随时可以预备/);
+
+ const reserved = getActiveSkillAvailability({
+ gameState: {
+ ...baseState,
+ forbiddenMagic: {
+ reservations: [{ playerId: 'p3', targetRound: 1 }],
+ activePlayerIds: []
+ }
+ },
+ players,
+ currentPlayerId: 'p3',
+ handCards: [card('c3', 'clubs', '3')]
+ });
+ assert.equal(reserved.isReserved, true);
+ assert.equal(reserved.canActivate, false);
+
+ const active = getActiveSkillAvailability({
+ gameState: {
+ ...baseState,
+ activeSkillUsesByPlayerId: { p3: ['forbidden_magic'] },
+ forbiddenMagic: { reservations: [], activePlayerIds: ['p3'] }
+ },
+ players,
+ currentPlayerId: 'p3',
+ handCards: [card('c3', 'clubs', '3')]
+ });
+ assert.equal(active.isActive, true);
+ assert.equal(active.isUsed, true);
+
+ const duringRound = getActiveSkillAvailability({
+ gameState: {
+ ...baseState,
+ currentRoundPlays: 1,
+ playersPlayedThisRound: [0]
+ },
+ players,
+ currentPlayerId: 'p3',
+ handCards: [card('c3', 'clubs', '3')]
+ });
+ assert.equal(duringRound.canActivate, true);
+ assert.equal(duringRound.targetRound, 2);
+ assert.match(duringRound.reason, /第2轮开始时/);
+});
+
+test('禁术秘法前端要求每张原主牌显式转为副花色后再验证', () => {
+ const mainAce = card('main-a', 'hearts', 'A');
+ const levelSix = card('level-6', 'clubs', '6');
+ const joker = card('joker', 'joker', 'big_joker');
+ const handCards = [mainAce, levelSix, joker];
+ const gameState = {
+ currentRound: 1,
+ currentRoundPlays: 0,
+ playersPlayedThisRound: [],
+ selectedRule: {
+ id: 'forbidden_magic',
+ activeSkill: {
+ id: 'forbidden_magic',
+ effect: 'demote_trumps_and_transform'
+ }
+ },
+ forbiddenMagic: { reservations: [], activePlayerIds: ['p1'] }
+ };
+
+ const aceResult = validatePlaySelection({
+ selectedCardIds: [mainAce.id],
+ handCards,
+ gameState,
+ currentPlayerId: 'p1',
+ trumpSuit: 'hearts',
+ trumpRank: '6'
+ });
+ assert.equal(aceResult.valid, false);
+ assert.match(aceResult.message, /主牌不能直接打出/);
+
+ const transformedAceResult = validatePlaySelection({
+ selectedCardIds: [mainAce.id],
+ handCards,
+ gameState,
+ currentPlayerId: 'p1',
+ trumpSuit: 'hearts',
+ trumpRank: '6',
+ forbiddenMagicSubstitutions: [{ cardId: mainAce.id, suit: 'clubs', rank: 'A' }]
+ });
+ assert.equal(transformedAceResult.valid, true);
+ assert.equal(transformedAceResult.pattern.suit, 'clubs');
+ assert.equal(transformedAceResult.pattern.strength, 14);
+
+ const levelResult = validatePlaySelection({
+ selectedCardIds: [levelSix.id],
+ handCards,
+ gameState,
+ currentPlayerId: 'p1',
+ trumpSuit: 'hearts',
+ trumpRank: '6'
+ });
+ assert.equal(levelResult.valid, false);
+ assert.match(levelResult.message, /主牌不能直接打出/);
+
+ const transformedLevelResult = validatePlaySelection({
+ selectedCardIds: [levelSix.id],
+ handCards,
+ gameState,
+ currentPlayerId: 'p1',
+ trumpSuit: 'hearts',
+ trumpRank: '6',
+ forbiddenMagicSubstitutions: [{ cardId: levelSix.id, suit: 'clubs', rank: '6' }]
+ });
+ assert.equal(transformedLevelResult.valid, true);
+ assert.equal(transformedLevelResult.pattern.suit, 'clubs');
+ assert.equal(transformedLevelResult.pattern.strength, 6);
+
+ const invalidMainSuit = validatePlaySelection({
+ selectedCardIds: [mainAce.id],
+ handCards,
+ gameState,
+ currentPlayerId: 'p1',
+ trumpSuit: 'hearts',
+ trumpRank: '6',
+ forbiddenMagicSubstitutions: [{ cardId: mainAce.id, suit: 'hearts', rank: 'A' }]
+ });
+ assert.equal(invalidMainSuit.valid, false);
+ assert.match(invalidMainSuit.message, /不能选择当前主花色/);
+
+ const invalidJoker = validatePlaySelection({
+ selectedCardIds: [joker.id],
+ handCards,
+ gameState,
+ currentPlayerId: 'p1',
+ trumpSuit: 'hearts',
+ trumpRank: '6',
+ forbiddenMagicSubstitutions: [{ cardId: joker.id, suit: 'hearts', rank: 'Q' }]
+ });
+ assert.equal(invalidJoker.valid, false);
+ assert.match(invalidJoker.message, /不能选择当前主花色/);
+
+ const legalJoker = validatePlaySelection({
+ selectedCardIds: [joker.id],
+ handCards,
+ gameState,
+ currentPlayerId: 'p1',
+ trumpSuit: 'hearts',
+ trumpRank: '6',
+ forbiddenMagicSubstitutions: [{ cardId: joker.id, suit: 'spades', rank: 'Q' }]
+ });
+ assert.equal(legalJoker.valid, true);
+ assert.equal(legalJoker.effectiveCards[0].rank, 'Q');
+ assert.equal(getCardPoints(legalJoker.effectiveCards[0]), 0);
+});
+
+test('木牛流马中的牌可选择打出,但不计入实体手牌的跟牌义务', () => {
+ const physicalSpade = card('physical-spade', 'spades', 'A');
+ const woodenOxHeart = {
+ ...card('wooden-ox-heart', 'hearts', '2'),
+ isWoodenOxCard: true
+ };
+ const gameState = {
+ selectedRule: { id: 'wooden_ox_flowing_horse', name: '木牛流马' },
+ currentRound: 3,
+ leadingPattern: detectPattern(
+ [card('lead-heart', 'hearts', '9')],
+ 'clubs',
+ '6'
+ ),
+ currentRoundPlays: [{}]
+ };
+
+ const physicalOffSuitResult = validatePlaySelection({
+ selectedCardIds: [physicalSpade.id],
+ handCards: [physicalSpade, woodenOxHeart],
+ gameState,
+ currentPlayerId: 'p1',
+ trumpSuit: 'clubs',
+ trumpRank: '6'
+ });
+ assert.equal(physicalOffSuitResult.valid, true);
+
+ const storedCardResult = validatePlaySelection({
+ selectedCardIds: [woodenOxHeart.id],
+ handCards: [physicalSpade, woodenOxHeart],
+ gameState,
+ currentPlayerId: 'p1',
+ trumpSuit: 'clubs',
+ trumpRank: '6'
+ });
+ assert.equal(storedCardResult.valid, true);
+});
+
+test('布什戈门只允许二号位发动,并把退回的每张实体牌禁用于紧接着的重新首发', () => {
+ const activeSkill = {
+ id: 'bush_gate',
+ name: '布什戈门',
+ usageLimit: 1,
+ timing: 'second_position_after_lead',
+ effect: 'force_leader_replay'
+ };
+ const players = [
+ { id: 'p0', cardsCount: 1 },
+ { id: 'p1', cardsCount: 2 },
+ { id: 'p2', cardsCount: 2 },
+ { id: 'p3', cardsCount: 2 }
+ ];
+ const activationState = {
+ phase: 'playing',
+ playMode: 'ordered',
+ currentRound: 2,
+ currentPlayerIndex: 1,
+ roundStartPlayerIndex: 0,
+ currentRoundPlays: 1,
+ playersPlayedThisRound: [0],
+ selectedRule: { id: 'bush_gate', activeSkill },
+ activeSkillUsesByPlayerId: {}
+ };
+
+ const secondPosition = getActiveSkillAvailability({
+ gameState: activationState,
+ players,
+ currentPlayerId: 'p1'
+ });
+ assert.equal(secondPosition.canActivate, true);
+ assert.match(secondPosition.reason, /收回/);
+
+ const notCurrentSecondPosition = getActiveSkillAvailability({
+ gameState: activationState,
+ players,
+ currentPlayerId: 'p2'
+ });
+ assert.equal(notCurrentSecondPosition.canActivate, false);
+
+ const noAlternativeCard = getActiveSkillAvailability({
+ gameState: activationState,
+ players: [{ ...players[0], cardsCount: 0 }, ...players.slice(1)],
+ currentPlayerId: 'p1'
+ });
+ assert.equal(noAlternativeCard.canActivate, false);
+ assert.match(noAlternativeCard.reason, /没有其他手牌/);
+
+ const returnedA = card('returned-a', 'hearts', 'A');
+ const returnedB = card('returned-b', 'hearts', 'A');
+ const alternative = card('alternative', 'hearts', '3');
+ const replayState = {
+ ...activationState,
+ currentPlayerIndex: 0,
+ currentRoundPlays: 0,
+ playersPlayedThisRound: [],
+ bushGate: {
+ restriction: {
+ round: 2,
+ leaderPlayerId: 'p0',
+ forbiddenCardIds: [returnedA.id, returnedB.id]
+ }
+ }
+ };
+ const handCards = [returnedA, returnedB, alternative];
+
+ assert.deepEqual(
+ new Set(getRuleDisabledLeadCardIds({
+ gameState: replayState,
+ handCards,
+ players,
+ currentPlayerId: 'p0'
+ })),
+ new Set([returnedA.id, returnedB.id])
+ );
+ const returnedCardResult = validatePlaySelection({
+ selectedCardIds: [returnedA.id],
+ handCards,
+ gameState: replayState,
+ currentPlayerId: 'p0',
+ trumpSuit: 'spades',
+ trumpRank: '2'
+ });
+ assert.equal(returnedCardResult.valid, false);
+ assert.match(returnedCardResult.message, /布什戈门/);
+
+ const mixedResult = validatePlaySelection({
+ selectedCardIds: [alternative.id, returnedB.id],
+ handCards,
+ gameState: replayState,
+ currentPlayerId: 'p0',
+ trumpSuit: 'spades',
+ trumpRank: '2'
+ });
+ assert.equal(mixedResult.valid, false);
+ assert.match(mixedResult.message, /布什戈门/);
+
+ const alternativeResult = validatePlaySelection({
+ selectedCardIds: [alternative.id],
+ handCards,
+ gameState: replayState,
+ currentPlayerId: 'p0',
+ trumpSuit: 'spades',
+ trumpRank: '2'
+ });
+ assert.equal(alternativeResult.valid, true);
+
+ assert.deepEqual(getRuleDisabledLeadCardIds({
+ gameState: { ...replayState, currentRound: 3 },
+ handCards,
+ players,
+ currentPlayerId: 'p0'
+ }), []);
+});
+
+test('队友加油的B和郡王进入扩展牌力,升面分牌仍按实体原牌计分', () => {
+ const rule = { id: 'teammate_cheer' };
+ const boostedSideAce = {
+ ...card('boosted-side-ace', 'clubs', 'B'),
+ originalSuit: 'clubs',
+ originalRank: 'A',
+ isTeammateCheered: true
+ };
+ const ordinarySideAce = card('ordinary-side-ace', 'clubs', 'A');
+ const boostedBigJoker = {
+ ...card('boosted-big-joker', 'joker', 'county_prince_joker'),
+ originalSuit: 'joker',
+ originalRank: 'big_joker',
+ isTeammateCheered: true
+ };
+ const ordinaryBigJoker = card('ordinary-big-joker', 'joker', 'big_joker');
+ const boostedFive = {
+ ...card('boosted-five', 'clubs', '6'),
+ originalSuit: 'clubs',
+ originalRank: '5',
+ isTeammateCheered: true
+ };
+
+ assert.ok(
+ getCardStrength(boostedSideAce, 'hearts', '2', rule)
+ > getCardStrength(ordinarySideAce, 'hearts', '2', rule)
+ );
+ assert.ok(
+ getCardStrength(boostedBigJoker, 'hearts', '2', rule)
+ > getCardStrength(ordinaryBigJoker, 'hearts', '2', rule)
+ );
+ assert.equal(getCardPoints(boostedFive), 5);
+});
+
+test('虚虚实实仅在奇数张首家副花色时亮起,并把这些牌从本次跟牌义务中虚置', () => {
+ const players = [
+ { id: 'player-0' },
+ { id: 'player-1' },
+ { id: 'player-2' },
+ { id: 'player-3' }
+ ];
+ const ledSuitCards = [
+ card('club-7', 'clubs', '7'),
+ card('club-8-a', 'clubs', '8'),
+ card('club-8-b', 'clubs', '8')
+ ];
+ const trumpPair = [
+ card('spade-3-a', 'spades', '3'),
+ card('spade-3-b', 'spades', '3')
+ ];
+ const handCards = [...ledSuitCards, ...trumpPair];
+ const gameState = {
+ phase: 'playing',
+ playMode: 'ordered',
+ currentPlayerIndex: 1,
+ currentRoundPlays: 1,
+ playersPlayedThisRound: [0],
+ leadingPattern: { type: 'pair', suit: 'clubs', length: 2 },
+ trumpSuit: 'spades',
+ trumpRank: '2',
+ selectedRule: {
+ id: 'illusion_and_reality',
+ activeSkill: {
+ id: 'illusion_and_reality',
+ name: '虚虚实实',
+ usageLimit: 1,
+ timing: 'following_play',
+ effect: 'ignore_odd_led_side_suit'
+ }
+ },
+ activeSkillUsesByPlayerId: {}
+ };
+
+ const availability = getActiveSkillAvailability({
+ gameState,
+ players,
+ currentPlayerId: 'player-1',
+ handCards
+ });
+ assert.equal(availability.canActivate, true);
+ assert.equal(availability.ignoredSuit, 'clubs');
+ assert.deepEqual(availability.virtualizedCardIds, ledSuitCards.map(value => value.id));
+
+ const normalOffSuit = validatePlaySelection({
+ selectedCardIds: trumpPair.map(value => value.id),
+ handCards,
+ gameState,
+ trumpSuit: 'spades',
+ trumpRank: '2',
+ currentPlayerId: 'player-1'
+ });
+ assert.equal(normalOffSuit.valid, false);
+
+ const cannotPlayVirtualizedCards = validatePlaySelection({
+ selectedCardIds: ledSuitCards.slice(0, 2).map(value => value.id),
+ handCards,
+ gameState,
+ trumpSuit: 'spades',
+ trumpRank: '2',
+ activeSkillId: 'illusion_and_reality',
+ currentPlayerId: 'player-1'
+ });
+ assert.equal(cannotPlayVirtualizedCards.valid, false);
+ assert.match(cannotPlayVirtualizedCards.message, /不能打出被虚置/);
+
+ const virtualizedPlay = validatePlaySelection({
+ selectedCardIds: trumpPair.map(value => value.id),
+ handCards,
+ gameState,
+ trumpSuit: 'spades',
+ trumpRank: '2',
+ activeSkillId: 'illusion_and_reality',
+ currentPlayerId: 'player-1'
+ });
+ assert.equal(virtualizedPlay.valid, true);
+ assert.equal(virtualizedPlay.ignoredSuit, 'clubs');
+
+ const evenAvailability = getActiveSkillAvailability({
+ gameState,
+ players,
+ currentPlayerId: 'player-1',
+ handCards: handCards.filter(value => value.id !== ledSuitCards[0].id)
+ });
+ assert.equal(evenAvailability.canActivate, false);
+ assert.match(evenAvailability.reason, /不是奇数张/);
+});
+
+test('回光返照生效时可无视花色跟牌,但整次出牌只能由主牌组成', () => {
+ const heldLedSuit = card('club-7', 'clubs', '7');
+ const trumpKing = card('spade-K', 'spades', 'K');
+ const trumpQueen = card('spade-Q', 'spades', 'Q');
+ const offSuitFive = card('diamond-5', 'diamonds', '5');
+ const handCards = [heldLedSuit, trumpKing, trumpQueen, offSuitFive];
+ const activeState = {
+ phase: 'playing',
+ playMode: 'ordered',
+ currentPlayerIndex: 1,
+ currentRound: 4,
+ currentRoundPlays: 1,
+ playersPlayedThisRound: [0],
+ leadingPattern: { type: 'pair', suit: 'clubs', length: 2 },
+ trumpSuit: 'spades',
+ trumpRank: '2',
+ selectedRule: { id: 'afterglow', name: '回光返照' },
+ afterglow: { activePlayerIds: ['player-1'] }
+ };
+
+ const mixedWithSideCard = validatePlaySelection({
+ selectedCardIds: [trumpKing.id, offSuitFive.id],
+ handCards,
+ gameState: activeState,
+ trumpSuit: 'spades',
+ trumpRank: '2',
+ currentPlayerId: 'player-1'
+ });
+ assert.equal(mixedWithSideCard.valid, false);
+ assert.match(mixedWithSideCard.message, /只能由主牌组成/);
+
+ const wrongCount = validatePlaySelection({
+ selectedCardIds: [trumpKing.id],
+ handCards,
+ gameState: activeState,
+ trumpSuit: 'spades',
+ trumpRank: '2',
+ currentPlayerId: 'player-1'
+ });
+ assert.equal(wrongCount.valid, false);
+ assert.match(wrongCount.message, /必须打出 2 张牌/);
+
+ const freeFollow = validatePlaySelection({
+ selectedCardIds: [trumpKing.id, trumpQueen.id],
+ handCards,
+ gameState: activeState,
+ trumpSuit: 'spades',
+ trumpRank: '2',
+ currentPlayerId: 'player-1'
+ });
+ assert.equal(freeFollow.valid, true);
+ assert.equal(freeFollow.afterglowActive, true);
+
+ const inactiveFollow = validatePlaySelection({
+ selectedCardIds: [trumpKing.id, trumpQueen.id],
+ handCards,
+ gameState: { ...activeState, afterglow: { activePlayerIds: [] } },
+ trumpSuit: 'spades',
+ trumpRank: '2',
+ currentPlayerId: 'player-1'
+ });
+ assert.equal(inactiveFollow.valid, false);
+
+ const noTrumpFollow = validatePlaySelection({
+ selectedCardIds: [trumpKing.id, trumpQueen.id],
+ handCards,
+ gameState: {
+ ...activeState,
+ trumpSuit: 'no_trump',
+ afterglow: { activePlayerIds: ['player-1'] }
+ },
+ trumpSuit: 'no_trump',
+ trumpRank: '2',
+ currentPlayerId: 'player-1'
+ });
+ assert.equal(noTrumpFollow.valid, false);
+ assert.equal(noTrumpFollow.afterglowActive, undefined);
+});
+
+test('回光返照的升面主牌使用扩展牌力但仍按实体牌面计分', () => {
+ const boostedBigJoker = {
+ ...card('afterglow-big-joker', 'joker', 'county_prince_joker'),
+ originalSuit: 'joker',
+ originalRank: 'big_joker',
+ isAfterglowBoosted: true
+ };
+ const boostedTrumpKing = {
+ ...card('afterglow-trump-king', 'spades', 'A'),
+ originalSuit: 'spades',
+ originalRank: 'K',
+ isAfterglowBoosted: true
+ };
+
+ assert.ok(
+ getCardStrength(boostedBigJoker, 'spades', '2', { id: 'afterglow' })
+ > getCardStrength(card('big-joker', 'joker', 'big_joker'), 'spades', '2', { id: 'afterglow' })
+ );
+ assert.equal(getCardPoints(boostedTrumpKing), 10);
+ assert.deepEqual(
+ [getScoringDisplayCard(boostedTrumpKing).suit, getScoringDisplayCard(boostedTrumpKing).rank],
+ ['spades', 'K']
+ );
+});
diff --git a/tractor-game-simulator/client/test/e2e/game-ui.spec.js b/tractor-game-simulator/client/test/e2e/game-ui.spec.js
new file mode 100644
index 0000000..3606593
--- /dev/null
+++ b/tractor-game-simulator/client/test/e2e/game-ui.spec.js
@@ -0,0 +1,4908 @@
+import { expect, test } from '@playwright/test';
+
+async function createRoom(page, roomName = '我的房间', { testRuleName = null } = {}) {
+ await page.addInitScript(() => localStorage.removeItem('tractorRoomSession'));
+ await page.goto('/');
+ await expect(page.getByRole('button', { name: '创建房间' })).toBeEnabled();
+ await page.getByRole('button', { name: '创建房间' }).click();
+
+ const dialog = page.getByRole('dialog', { name: '创建房间' });
+ await dialog.getByLabel('房间名称').fill(roomName);
+ await dialog.getByLabel('发牌间隔(毫秒)').fill('10');
+ if (testRuleName) {
+ await dialog.getByRole('checkbox', { name: '规则测试模式' }).check();
+ await dialog.locator('.ant-select-selector').click();
+ await dialog.getByLabel('测试规则').fill(testRuleName);
+ await page.locator('.ant-select-dropdown:visible').getByText(testRuleName, { exact: true }).click();
+ }
+ await dialog.getByRole('button', { name: /创\s*建/ }).click();
+
+ const roomIdText = await page.getByText(/房间ID:/).textContent();
+ return roomIdText.match(/\d+/)[0];
+}
+
+async function joinRoom(page, roomId, playerName) {
+ await page.addInitScript(() => localStorage.removeItem('tractorRoomSession'));
+ await page.goto('/');
+ await expect(page.getByRole('button', { name: '加入房间' })).toBeEnabled();
+ await page.getByRole('button', { name: '加入房间' }).click();
+
+ const dialog = page.getByRole('dialog', { name: '加入房间' });
+ await dialog.getByLabel('房间ID').fill(roomId);
+ await dialog.getByLabel('你的昵称').fill(playerName);
+ await dialog.getByRole('button', { name: /加\s*入/ }).click();
+ await expect(page.getByText(`房间ID: ${roomId}`)).toBeVisible();
+}
+
+const openingExchangeRules = {
+ 知己知彼: { id: 'know_yourself_and_enemy', targetOffset: 2, incomingOffset: 2, endX: '50%', endY: '11%' },
+ 新闻部长I: { id: 'news_minister_i', targetOffset: -1, incomingOffset: 1, endX: '9%', endY: '50%' },
+ 新闻部长II: { id: 'news_minister_ii', targetOffset: 1, incomingOffset: -1, endX: '91%', endY: '50%' }
+};
+
+async function finishOpeningExchange(pages) {
+ await Promise.all(pages.map((playerPage) =>
+ expect(playerPage.getByRole('button', { name: /确认换牌\(0\/2\)/ })).toBeVisible({ timeout: 20_000 })
+ ));
+ await Promise.all(pages.map(async (playerPage) => {
+ const cards = playerPage.locator('.my-hand .card');
+ await cards.nth(0).dispatchEvent('click');
+ await cards.nth(1).dispatchEvent('click');
+ }));
+ await Promise.all(pages.map((playerPage) =>
+ playerPage.getByRole('button', { name: /确认换牌\(2\/2\)/ }).click()
+ ));
+ await Promise.all(pages.map((playerPage) =>
+ expect(playerPage.locator('.card-exchange-animation-layer')).toBeHidden({ timeout: 5_000 })
+ ));
+}
+
+async function selectLegalCard(cards, playButton) {
+ const cardCount = await cards.count();
+ for (let index = 0; index < cardCount; index += 1) {
+ await cards.nth(index).dispatchEvent('click');
+ if (await playButton.isEnabled()) return;
+ await cards.nth(index).dispatchEvent('click');
+ }
+ throw new Error('没有找到可出的单张牌');
+}
+
+async function playLegalSingle(page) {
+ const playButton = page.getByRole('button', { name: /^出牌\(\d+\)$/ });
+ await selectLegalCard(page.locator('.my-hand .card'), playButton);
+ await expect(playButton).toBeEnabled();
+ await playButton.click();
+}
+
+async function openGameOfferingRule(browser, targetRuleName, targetRuleId) {
+ const context = await browser.newContext();
+ const hostPage = await context.newPage();
+ const testRoomName = `__e2e_rule__:${targetRuleId}`;
+ const roomId = await createRoom(hostPage, testRoomName);
+ await expect(hostPage.getByRole('heading', { name: `房间: ${testRoomName}` })).toBeVisible();
+ const pages = [hostPage];
+
+ for (let playerIndex = 2; playerIndex <= 4; playerIndex += 1) {
+ const playerPage = await context.newPage();
+ pages.push(playerPage);
+ await joinRoom(playerPage, roomId, `玩家${playerIndex}`);
+ }
+
+ await hostPage.getByRole('button', { name: '开始游戏' }).click();
+ const getRuleDialog = (playerPage) =>
+ playerPage.getByRole('dialog', { name: '选择本局特殊规则' });
+ await expect.poll(async () => {
+ const visible = await Promise.all(pages.map((playerPage) =>
+ getRuleDialog(playerPage).isVisible().catch(() => false)
+ ));
+ return visible.filter(Boolean).length;
+ }).toBe(pages.length);
+
+ let chooserPage = null;
+ for (const playerPage of pages) {
+ const targetOption = getRuleDialog(playerPage).locator('.rule-option', { hasText: targetRuleName });
+ if (await targetOption.isEnabled().catch(() => false)) {
+ chooserPage = playerPage;
+ break;
+ }
+ }
+ expect(chooserPage).not.toBeNull();
+ const targetOption = getRuleDialog(chooserPage).locator('.rule-option', { hasText: targetRuleName });
+ await expect(targetOption).toHaveCount(1);
+ await targetOption.click();
+ return { context, pages };
+}
+
+async function openTestModeGame(browser, ruleName, { initialHandCount = 25 } = {}) {
+ const context = await browser.newContext();
+ const hostPage = await context.newPage();
+ const roomId = await createRoom(hostPage, `${ruleName}测试房`, { testRuleName: ruleName });
+ const pages = [hostPage];
+
+ for (let playerIndex = 2; playerIndex <= 4; playerIndex += 1) {
+ const playerPage = await context.newPage();
+ pages.push(playerPage);
+ await joinRoom(playerPage, roomId, `玩家${playerIndex}`);
+ }
+
+ await hostPage.getByRole('button', { name: '开始游戏' }).click();
+ await Promise.all(pages.map(async (playerPage) => {
+ await expect(playerPage.locator('.game-table').getByText(ruleName, { exact: true })).toBeVisible();
+ await expect(playerPage.getByRole('dialog', { name: '选择本局特殊规则' })).toHaveCount(0);
+ await playerPage.getByRole('button', { name: /准\s*备/ }).click();
+ await expect(playerPage.locator('.my-hand .card')).toHaveCount(initialHandCount, { timeout: 15_000 });
+ }));
+ return { context, pages };
+}
+
+async function finishDealerBury(pages, { initialHandCount = 25, bottomCardsCount = 8 } = {}) {
+ let dealerPageIndex = -1;
+ await expect.poll(async () => {
+ for (let index = 0; index < pages.length; index += 1) {
+ if (await pages[index].locator('.player-bottom').getByText('庄', { exact: true }).isVisible().catch(() => false)) {
+ return index;
+ }
+ }
+ return -1;
+ }, { timeout: 25_000 }).toBeGreaterThanOrEqual(0);
+
+ for (let index = 0; index < pages.length; index += 1) {
+ if (await pages[index].locator('.player-bottom').getByText('庄', { exact: true }).isVisible().catch(() => false)) {
+ dealerPageIndex = index;
+ break;
+ }
+ }
+
+ const dealerPage = pages[dealerPageIndex];
+ const dealerCards = dealerPage.locator('.my-hand .card');
+ await expect(dealerCards).toHaveCount(initialHandCount + bottomCardsCount);
+ for (let cardIndex = 0; cardIndex < bottomCardsCount; cardIndex += 1) {
+ await dealerCards.nth(cardIndex).dispatchEvent('click');
+ }
+ await dealerPage.getByRole('button', {
+ name: `埋底(${bottomCardsCount}/${bottomCardsCount})`
+ }).click();
+ await expect(dealerPage.locator('.player-bottom.current-turn')).toBeVisible();
+ await Promise.all(pages.map(async (playerPage, pageIndex) => {
+ const indicator = playerPage.locator('.player-area.current-turn .turn-indicator');
+ await expect(indicator).toHaveCount(1);
+ await expect(indicator).toHaveText(pageIndex === dealerPageIndex ? '轮到你' : '出牌中');
+ }));
+ return dealerPageIndex;
+}
+
+test('返回房间会保留座位,并可选择重返牌局或确认退出', async ({ browser }, testInfo) => {
+ test.setTimeout(120_000);
+ const roomName = '世事无常测试房';
+ const { context, pages } = await openTestModeGame(browser, '世事无常');
+ const hostPage = pages[0];
+
+ try {
+ await hostPage.getByRole('button', { name: '返回房间', exact: true }).click();
+
+ await expect(hostPage.getByRole('heading', { name: `房间: ${roomName}` })).toBeVisible();
+ await expect(hostPage.getByRole('region', { name: '牌局进行中' })).toBeVisible();
+ await expect(hostPage.getByText('玩家数: 4 / 4')).toBeVisible();
+ await expect(hostPage.getByRole('button', { name: '重返牌局' })).toBeVisible();
+ await expect(hostPage.getByRole('button', { name: '退出房间' })).toBeVisible();
+ await expect(hostPage.getByRole('button', { name: '添加Bot' })).toHaveCount(0);
+ const roomPageMetrics = await hostPage.evaluate(() => {
+ const root = document.querySelector('#root');
+ const layout = document.querySelector('.room-overview-layout');
+ const card = document.querySelector('.room-overview-card');
+ return {
+ documentHeight: document.documentElement.scrollHeight,
+ rootHeight: root?.getBoundingClientRect().height || 0,
+ layoutBottom: layout?.getBoundingClientRect().bottom || 0,
+ cardBottom: card?.getBoundingClientRect().bottom || 0,
+ rootBackground: getComputedStyle(root).backgroundColor
+ };
+ });
+ expect(roomPageMetrics.rootHeight).toBeGreaterThanOrEqual(roomPageMetrics.documentHeight - 1);
+ expect(roomPageMetrics.layoutBottom).toBeGreaterThanOrEqual(roomPageMetrics.cardBottom);
+ expect(roomPageMetrics.rootBackground).toBe('rgb(243, 246, 245)');
+ await hostPage.screenshot({
+ path: testInfo.outputPath('active-game-room-overview.png'),
+ fullPage: true
+ });
+
+ await hostPage.getByRole('button', { name: '重返牌局' }).click();
+ await expect(hostPage.locator('.game-table')).toBeVisible();
+ await expect(hostPage.getByRole('button', { name: '返回房间', exact: true })).toBeVisible();
+
+ await hostPage.getByRole('button', { name: '返回房间', exact: true }).click();
+ await hostPage.getByRole('button', { name: '退出房间' }).click();
+ const leaveDialog = hostPage.getByRole('dialog', { name: '确定退出房间?' });
+ await expect(leaveDialog).toContainText('这会立即释放你的座位,并终止当前牌局');
+ await leaveDialog.getByRole('button', { name: '保留座位' }).click();
+ await expect(hostPage.getByRole('button', { name: '重返牌局' })).toBeVisible();
+
+ await hostPage.getByRole('button', { name: '退出房间' }).click();
+ await hostPage
+ .getByRole('dialog', { name: '确定退出房间?' })
+ .getByRole('button', { name: '确认退出' })
+ .click();
+ await expect(hostPage.getByRole('heading', { name: '欢迎来到拖拉机纸牌游戏' })).toBeVisible();
+ } finally {
+ await context.close();
+ }
+});
+
+test('轮到自己时返回房间再重返会恢复本墩牌面并可继续跟牌', async ({ browser }) => {
+ test.setTimeout(180_000);
+ const { context, pages } = await openTestModeGame(browser, '世事无常');
+
+ try {
+ const leaderIndex = await finishDealerBury(pages);
+ const followerIndex = (leaderIndex + 1) % pages.length;
+ const leaderPage = pages[leaderIndex];
+ const followerPage = pages[followerIndex];
+
+ await playLegalSingle(leaderPage);
+ await expect(followerPage.locator('.player-bottom.current-turn')).toBeVisible();
+ await expect(followerPage.locator('.played-cards-area.has-cards')).toHaveCount(1);
+
+ await followerPage.getByRole('button', { name: '返回房间', exact: true }).click();
+ await expect(followerPage.getByRole('button', { name: '重返牌局' })).toBeVisible();
+ await followerPage.getByRole('button', { name: '重返牌局' }).click();
+
+ await expect(followerPage.locator('.player-bottom.current-turn')).toBeVisible();
+ await expect(followerPage.locator('.played-cards-area.has-cards')).toHaveCount(1);
+ await expect(followerPage.locator('.my-hand .card:not(.disabled)').first()).toHaveCSS(
+ 'cursor',
+ 'pointer'
+ );
+
+ const playButton = followerPage.getByRole('button', { name: /^出牌\(\d+\)$/ });
+ if (!(await playButton.isEnabled())) {
+ await selectLegalCard(followerPage.locator('.my-hand .card'), playButton);
+ }
+ await expect(playButton).toBeEnabled();
+ await playButton.click();
+ await expect(followerPage.locator('.played-cards-area.has-cards')).toHaveCount(2);
+ } finally {
+ await context.close();
+ }
+});
+
+test('nine princes uses inline hand selection with confirm and skip actions', async ({ browser }) => {
+ test.setTimeout(180_000);
+ const { context, pages } = await openTestModeGame(browser, '九子夺嫡');
+
+ try {
+ await finishDealerBury(pages);
+ const chooserPage = pages[0];
+ await chooserPage.setViewportSize({ width: 667, height: 375 });
+ const candidateIds = await chooserPage.evaluate(async () => {
+ const { useGameStore } = await import('/src/store/gameStore.js');
+ const socketService = (await import('/src/services/socket.js')).default;
+ const state = useGameStore.getState();
+ const rankOrder = ['2', '3', '4', '5', '6', '7', '8', '9', '10', 'J', 'Q', 'K', 'A'];
+ const cards = state.myCards
+ .filter(card => card.suit !== 'joker' && card.rank !== 'A')
+ .slice(0, 2);
+ const decision = {
+ decisionId: 'nine-princes-inline-e2e',
+ round: state.currentRoom.gameState.currentRound,
+ playerId: state.currentPlayer.id,
+ playerName: state.currentPlayer.name,
+ candidates: cards.map(card => ({
+ card,
+ promotedFace: {
+ suit: card.suit,
+ rank: rankOrder[rankOrder.indexOf(card.rank) + 1]
+ }
+ }))
+ };
+
+ window.__ninePrincesResponses = [];
+ const originalEmit = socketService.socket.emit.bind(socketService.socket);
+ socketService.socket.emit = (event, payload, ...args) => {
+ if (event === 'respond_nine_princes') {
+ window.__ninePrincesResponses.push(payload);
+ return socketService.socket;
+ }
+ return originalEmit(event, payload, ...args);
+ };
+ useGameStore.setState({
+ selectedCards: [],
+ currentRoom: {
+ ...state.currentRoom,
+ gameState: {
+ ...state.currentRoom.gameState,
+ ninePrinces: {
+ resolved: false,
+ pending: {
+ decisionId: decision.decisionId,
+ round: decision.round,
+ playerId: decision.playerId,
+ playerName: decision.playerName
+ },
+ lastResult: null
+ }
+ }
+ }
+ });
+ socketService.socket.listeners('nine_princes_selection_required')
+ .forEach(listener => listener(decision));
+ return cards.map(card => card.id);
+ });
+
+ expect(candidateIds).toHaveLength(2);
+ await expect(chooserPage.getByRole('dialog', { name: '九子夺嫡' })).toHaveCount(0);
+ const controls = chooserPage.getByTestId('nine-princes-inline-controls');
+ const confirmButton = chooserPage.getByTestId('nine-princes-confirm');
+ const skipButton = chooserPage.getByTestId('nine-princes-skip');
+ await expect(controls).toBeVisible();
+ await expect(confirmButton).toBeDisabled();
+ const controlsBox = await controls.boundingBox();
+ expect(controlsBox).not.toBeNull();
+ expect(controlsBox.x).toBeGreaterThanOrEqual(0);
+ expect(controlsBox.x + controlsBox.width).toBeLessThanOrEqual(667);
+
+ const firstCandidate = chooserPage.locator(
+ `.my-hand .card[data-card-id="${candidateIds[0]}"]`
+ );
+ const secondCandidate = chooserPage.locator(
+ `.my-hand .card[data-card-id="${candidateIds[1]}"]`
+ );
+ await firstCandidate.dispatchEvent('click');
+ await expect(firstCandidate).toHaveClass(/selected/);
+ await expect(confirmButton).toBeEnabled();
+
+ await secondCandidate.dispatchEvent('click');
+ await expect(firstCandidate).not.toHaveClass(/selected/);
+ await expect(secondCandidate).toHaveClass(/selected/);
+ await confirmButton.click();
+ await expect.poll(() => chooserPage.evaluate(
+ () => window.__ninePrincesResponses?.length || 0
+ )).toBe(1);
+ expect(await chooserPage.evaluate(
+ () => window.__ninePrincesResponses[0].cardId
+ )).toBe(candidateIds[1]);
+
+ await chooserPage.evaluate(async () => {
+ const socketService = (await import('/src/services/socket.js')).default;
+ const { useGameStore } = await import('/src/store/gameStore.js');
+ const state = useGameStore.getState();
+ const cards = state.myCards
+ .filter(card => card.suit !== 'joker' && card.rank !== 'A')
+ .slice(0, 2);
+ socketService.socket.listeners('nine_princes_selection_required').forEach(listener => listener({
+ decisionId: 'nine-princes-inline-e2e-skip',
+ round: state.currentRoom.gameState.currentRound,
+ playerId: state.currentPlayer.id,
+ playerName: state.currentPlayer.name,
+ candidates: cards.map(card => ({
+ card,
+ promotedFace: { suit: card.suit, rank: card.rank }
+ }))
+ }));
+ });
+ await expect(confirmButton).toBeDisabled();
+ await skipButton.click();
+ await expect.poll(() => chooserPage.evaluate(
+ () => window.__ninePrincesResponses?.length || 0
+ )).toBe(2);
+ expect(await chooserPage.evaluate(
+ () => window.__ninePrincesResponses[1].cardId
+ )).toBeNull();
+ } finally {
+ await context.close();
+ }
+});
+
+test('mobile portrait prompts rotation and landscape keeps the full hand inside the table', async ({ browser }, testInfo) => {
+ test.setTimeout(120_000);
+ const { context, pages } = await openTestModeGame(browser, '世事无常');
+ const page = pages[0];
+
+ try {
+ await page.setViewportSize({ width: 390, height: 844 });
+ const orientationGuard = page.getByTestId('portrait-orientation-guard');
+ await expect(orientationGuard).toBeVisible();
+ await expect(orientationGuard.getByText('请横屏游戏', { exact: true })).toBeVisible();
+ await expect(orientationGuard.getByRole('button', { name: '尝试进入横屏' })).toBeVisible();
+ await page.screenshot({ path: testInfo.outputPath('mobile-portrait-rotation-guard.png') });
+
+ await page.setViewportSize({ width: 667, height: 375 });
+ await expect(orientationGuard).toBeHidden();
+ await expect(page.locator('.my-hand .card')).toHaveCount(25);
+ await expect(page.getByRole('button', { name: '全选', exact: true })).toHaveCount(0);
+
+ await page.evaluate(async () => {
+ const { useGameStore } = await import('/src/store/gameStore.js');
+ const state = useGameStore.getState();
+ useGameStore.setState({
+ currentRoom: {
+ ...state.currentRoom,
+ gameState: {
+ ...state.currentRoom.gameState,
+ attackerScore: 15,
+ collectedPointCards: [
+ { id: 'mobile-score-five', suit: 'hearts', rank: '5' },
+ { id: 'mobile-score-ten', suit: 'clubs', rank: '10' },
+ { id: 'mobile-score-king', suit: 'diamonds', rank: 'K' }
+ ]
+ }
+ }
+ });
+ });
+
+ const mobileScoreTrigger = page.locator('.mobile-score-panel-trigger');
+ await expect(mobileScoreTrigger).toBeVisible();
+ await expect(mobileScoreTrigger).toContainText('分牌3');
+ await mobileScoreTrigger.click();
+ const mobileScoreDialog = page.getByRole('dialog', { name: '计分详情' });
+ await expect(mobileScoreDialog).toBeVisible();
+ await expect.poll(async () => (await mobileScoreDialog.boundingBox())?.width || 0).toBeGreaterThan(400);
+ await expect(mobileScoreDialog.getByText('15 分', { exact: true })).toBeVisible();
+ await expect(mobileScoreDialog.locator('.score-card-list .card')).toHaveCount(3);
+ const scoreDialogGeometry = await mobileScoreDialog.evaluate((element) => {
+ const box = element.getBoundingClientRect();
+ const cardList = element.querySelector('.score-card-list');
+ return {
+ top: box.top,
+ right: box.right,
+ bottom: box.bottom,
+ left: box.left,
+ viewportWidth: window.innerWidth,
+ viewportHeight: window.innerHeight,
+ cardListOverflowX: cardList ? getComputedStyle(cardList).overflowX : null
+ };
+ });
+ expect(scoreDialogGeometry.left).toBeGreaterThanOrEqual(0);
+ expect(scoreDialogGeometry.top).toBeGreaterThanOrEqual(0);
+ expect(scoreDialogGeometry.right).toBeLessThanOrEqual(scoreDialogGeometry.viewportWidth + 1);
+ expect(scoreDialogGeometry.bottom).toBeLessThanOrEqual(scoreDialogGeometry.viewportHeight + 1);
+ expect(scoreDialogGeometry.cardListOverflowX).toBe('auto');
+ await mobileScoreDialog.screenshot({ path: testInfo.outputPath('mobile-score-details.png') });
+ await mobileScoreDialog.locator('.ant-modal-close').click();
+ await expect(mobileScoreDialog).toBeHidden();
+
+ const mobileRuleDescription = page.locator('.table-rule-description');
+ await expect(mobileRuleDescription).toBeVisible();
+ await expect(mobileRuleDescription).not.toHaveText('');
+ const mobileRuleDescriptionStyle = await mobileRuleDescription.evaluate((element) => {
+ const style = getComputedStyle(element);
+ return {
+ display: style.display,
+ overflowY: style.overflowY,
+ maxHeight: style.maxHeight
+ };
+ });
+ expect(mobileRuleDescriptionStyle.display).toBe('block');
+ expect(mobileRuleDescriptionStyle.overflowY).toBe('auto');
+ expect(mobileRuleDescriptionStyle.maxHeight).toBe('43px');
+
+ const geometry = await page.evaluate(() => {
+ const rect = (selector, last = false) => {
+ const elements = document.querySelectorAll(selector);
+ const element = last ? elements[elements.length - 1] : elements[0];
+ if (!element) return null;
+ const box = element.getBoundingClientRect();
+ return {
+ top: box.top,
+ right: box.right,
+ bottom: box.bottom,
+ left: box.left,
+ width: box.width,
+ height: box.height
+ };
+ };
+ return {
+ viewport: { width: window.innerWidth, height: window.innerHeight },
+ documentWidth: document.documentElement.scrollWidth,
+ documentHeight: document.documentElement.scrollHeight,
+ table: rect('.game-table'),
+ bottomPlayer: rect('.player-bottom'),
+ hand: rect('.my-hand'),
+ firstCard: rect('.my-hand .card'),
+ lastCard: rect('.my-hand .card', true),
+ controls: rect('.inline-controls')
+ };
+ });
+
+ expect(geometry.documentWidth).toBeLessThanOrEqual(geometry.viewport.width + 1);
+ expect(geometry.documentHeight).toBeLessThanOrEqual(geometry.viewport.height + 1);
+ expect(geometry.table.top).toBeGreaterThanOrEqual(0);
+ expect(geometry.table.bottom).toBeLessThanOrEqual(geometry.viewport.height + 1);
+ expect(geometry.bottomPlayer.bottom).toBeLessThanOrEqual(geometry.viewport.height + 1);
+ expect(geometry.hand.bottom).toBeLessThanOrEqual(geometry.viewport.height + 1);
+ expect(geometry.firstCard.left).toBeGreaterThanOrEqual(0);
+ expect(geometry.lastCard.right).toBeLessThanOrEqual(geometry.viewport.width + 1);
+ expect(geometry.firstCard.height).toBeGreaterThanOrEqual(108);
+ expect(geometry.controls.bottom).toBeLessThanOrEqual(geometry.viewport.height + 1);
+
+ await page.screenshot({ path: testInfo.outputPath('mobile-landscape-table.png') });
+
+ await finishDealerBury(pages);
+ await expect(page.getByRole('button', { name: '全选', exact: true })).toHaveCount(0);
+ await page.evaluate(async () => {
+ const { useGameStore } = await import('/src/store/gameStore.js');
+ const socketService = (await import('/src/services/socket.js')).default;
+ const state = useGameStore.getState();
+ socketService.socket.listeners('cards_played').forEach(listener => listener({
+ playerId: state.currentPlayer.id,
+ playerName: state.currentPlayer.name,
+ cards: [{ id: 'mobile-round-score-card', suit: 'hearts', rank: '10' }],
+ cardsCount: 1,
+ remainingCount: Math.max(0, (state.currentPlayer.cardsCount || 25) - 1)
+ }));
+ });
+
+ const roundPoints = page.locator('.mobile-round-points-indicator');
+ await expect(roundPoints).toBeVisible();
+ const playingGeometry = await page.evaluate(() => {
+ const rect = selector => {
+ const element = document.querySelector(selector);
+ if (!element) return null;
+ const box = element.getBoundingClientRect();
+ return { top: box.top, right: box.right, bottom: box.bottom, left: box.left };
+ };
+ const overlaps = (first, second) => Boolean(
+ first && second
+ && first.left < second.right
+ && first.right > second.left
+ && first.top < second.bottom
+ && first.bottom > second.top
+ );
+ const score = rect('.score-panel');
+ const points = rect('.mobile-round-points-indicator');
+ const playedAreas = ['top', 'right', 'bottom', 'left']
+ .map(position => rect(`.played-cards-${position}.has-cards`))
+ .filter(Boolean);
+ return {
+ score,
+ points,
+ pointsOverlapScore: overlaps(points, score),
+ pointsOverlapPlayedCards: playedAreas.some(area => overlaps(points, area))
+ };
+ });
+ expect(playingGeometry.pointsOverlapScore).toBe(true);
+ expect(playingGeometry.pointsOverlapPlayedCards).toBe(false);
+ expect(playingGeometry.points.left).toBeGreaterThanOrEqual(playingGeometry.score.left - 1);
+ expect(playingGeometry.points.right).toBeLessThanOrEqual(playingGeometry.score.right + 1);
+ expect(playingGeometry.points.top).toBeGreaterThanOrEqual(playingGeometry.score.top - 1);
+ expect(playingGeometry.points.bottom).toBeLessThanOrEqual(playingGeometry.score.bottom + 1);
+ await page.screenshot({ path: testInfo.outputPath('mobile-landscape-round-score.png') });
+ } finally {
+ await context.close();
+ }
+});
+
+test('mobile landscape keeps an active skill and all core actions inside the control bar', async ({ browser }) => {
+ test.setTimeout(120_000);
+ const { context, pages } = await openTestModeGame(browser, '时间倒流');
+
+ try {
+ await Promise.all(pages.map(page => page.setViewportSize({ width: 667, height: 375 })));
+ const dealerPageIndex = await finishDealerBury(pages);
+ const dealerPage = pages[dealerPageIndex];
+
+ await expect(dealerPage.locator('.active-skill-button')).toBeVisible();
+ await expect(dealerPage.getByRole('button', { name: '全选', exact: true })).toHaveCount(0);
+
+ const controlsGeometry = await dealerPage.locator('.inline-controls').evaluate((element) => ({
+ clientWidth: element.clientWidth,
+ scrollWidth: element.scrollWidth,
+ viewportWidth: window.innerWidth,
+ right: element.getBoundingClientRect().right
+ }));
+ expect(controlsGeometry.scrollWidth).toBeLessThanOrEqual(controlsGeometry.clientWidth + 1);
+ expect(controlsGeometry.right).toBeLessThanOrEqual(controlsGeometry.viewportWidth + 1);
+ } finally {
+ await context.close();
+ }
+});
+
+test('甩牌失败预览在桌面与移动横屏都停在主视角正前方', async ({ browser }) => {
+ test.setTimeout(120_000);
+ const { context, pages } = await openTestModeGame(browser, '昭然若揭');
+
+ try {
+ await Promise.all(pages.map(page => page.setViewportSize({ width: 1440, height: 900 })));
+ await finishDealerBury(pages);
+ await Promise.all(pages.map(async page => {
+ const musicToggle = page.getByRole('button', { name: '关闭背景音乐《情缘》' });
+ if (await musicToggle.count()) await musicToggle.click();
+ }));
+
+ const measureThrowPreviewGeometry = page => page.evaluate(async () => {
+ const { useGameStore } = await import('/src/store/gameStore.js');
+ const socketService = (await import('/src/services/socket.js')).default;
+ const state = useGameStore.getState();
+ const previewKey = `${Date.now()}`;
+ const attemptedCardObjects = ['A', 'K', 'Q', 'J', '10'].map((rank, index) => ({
+ id: `throw-preview-${previewKey}-${index}`,
+ suit: 'hearts',
+ rank
+ }));
+ socketService.socket.listeners('throw_failed').forEach(listener => listener({
+ playerId: state.currentPlayer.id,
+ playerName: state.currentPlayer.name,
+ message: '甩牌失败',
+ attemptedCardObjects,
+ forcedCards: [attemptedCardObjects[4]]
+ }));
+ // 抖动动画结束后仍处于一秒停留期,此时牌组中心必须回到玩家中心。
+ await new Promise(resolve => setTimeout(resolve, 320));
+
+ const preview = document.querySelector('.played-cards-bottom.throw-failed-preview')?.getBoundingClientRect();
+ const player = document.querySelector('.player-bottom')?.getBoundingClientRect();
+ return {
+ hasCenterTableFeature: document.querySelector('.game-table')?.classList.contains('has-center-table-feature'),
+ previewCenter: preview ? preview.left + preview.width / 2 : null,
+ playerCenter: player ? player.left + player.width / 2 : null
+ };
+ });
+ const expectCenteredPreview = geometry => {
+ expect(geometry.hasCenterTableFeature).toBe(true);
+ expect(geometry.previewCenter).not.toBeNull();
+ expect(Math.abs(geometry.previewCenter - geometry.playerCenter)).toBeLessThanOrEqual(1);
+ };
+
+ expectCenteredPreview(await measureThrowPreviewGeometry(pages[0]));
+ await pages[0].setViewportSize({ width: 667, height: 375 });
+ expectCenteredPreview(await measureThrowPreviewGeometry(pages[0]));
+ } finally {
+ await context.close();
+ }
+});
+
+test('mobile landscape does not reserve a dock for removed second-battlefield public cards', async ({ browser }, testInfo) => {
+ test.setTimeout(120_000);
+ const { context, pages } = await openTestModeGame(browser, '第二战场');
+ const page = pages[0];
+
+ try {
+ await finishDealerBury(pages);
+ await page.setViewportSize({ width: 667, height: 375 });
+
+ await expect(page.getByTestId('second-battlefield-tray')).toHaveCount(0);
+ await expect(page.getByTestId('second-battlefield-showdown')).toHaveCount(0);
+ await expect(page.locator('.game-table')).not.toHaveClass(/has-center-table-feature/);
+ await page.screenshot({ path: testInfo.outputPath('mobile-landscape-second-battlefield.png') });
+ } finally {
+ await context.close();
+ }
+});
+
+test('路线摇摆和昼夜轮转状态独占一行', async ({ browser }, testInfo) => {
+ test.setTimeout(180_000);
+
+ const routeGame = await openTestModeGame(browser, '路线摇摆');
+ try {
+ await Promise.all(routeGame.pages.map(page => page.setViewportSize({ width: 1440, height: 900 })));
+ await finishDealerBury(routeGame.pages);
+ await Promise.all(routeGame.pages.map(async page => {
+ const status = page.getByTestId('route-direction-status');
+ await expect(status).toBeVisible();
+ await expect(status).toContainText('当前:逆时针');
+ await expect(status).toHaveAttribute('data-direction', 'counter-clockwise');
+ const description = page.getByText(
+ '有单张价值不小于10分的牌被打出的轮次结束后,顺时针与逆时针出牌顺序互换。',
+ { exact: true }
+ );
+ await expect(description).toBeVisible();
+ const [statusBox, descriptionBox] = await Promise.all([
+ status.boundingBox(),
+ description.boundingBox()
+ ]);
+ expect(descriptionBox.y).toBeGreaterThanOrEqual(statusBox.y + statusBox.height - 1);
+ }));
+ await routeGame.pages[0].locator('.game-table').screenshot({
+ path: testInfo.outputPath('route-swing-direction-status.png')
+ });
+ } finally {
+ await routeGame.context.close();
+ }
+
+ const dayNightGame = await openTestModeGame(browser, '昼夜轮转');
+ try {
+ await Promise.all(dayNightGame.pages.map(page => page.setViewportSize({ width: 1440, height: 900 })));
+ await finishDealerBury(dayNightGame.pages);
+ await Promise.all(dayNightGame.pages.map(async page => {
+ const status = page.getByTestId('day-night-rank-status');
+ await expect(status).toBeVisible();
+ await expect(status).toContainText('第1轮');
+ // 首局级牌为2,第1轮本应轮到2,因此跳过提升并显示普通牌A最大。
+ await expect(status).toContainText('当前最大点数:A');
+ await expect(status).toHaveAttribute('data-round', '1');
+ await expect(status).toHaveAttribute('data-highest-rank', 'A');
+ const description = page.getByText(
+ '第x轮将点数x % 13 + 1提升为相应花色内最大,其他牌序不变;若轮转到级牌则仍按A最大。闲家开局为20分。',
+ { exact: true }
+ );
+ await expect(description).toBeVisible();
+ const [statusBox, descriptionBox] = await Promise.all([
+ status.boundingBox(),
+ description.boundingBox()
+ ]);
+ expect(descriptionBox.y).toBeGreaterThanOrEqual(statusBox.y + statusBox.height - 1);
+ }));
+ await dayNightGame.pages[0].locator('.game-table').screenshot({
+ path: testInfo.outputPath('day-night-highest-rank-status.png')
+ });
+ } finally {
+ await dayNightGame.context.close();
+ }
+});
+
+test('计划经济埋底前封存20张,第一轮结束后四家各摸1张并播放动画', async ({ browser }, testInfo) => {
+ test.setTimeout(150_000);
+ const { context, pages } = await openTestModeGame(browser, '计划经济', {
+ initialHandCount: 20
+ });
+
+ try {
+ await Promise.all(pages.map(page => page.setViewportSize({ width: 1440, height: 900 })));
+ await Promise.all(pages.map(async page => {
+ const status = page.getByTestId('planned-economy-status');
+ await expect(status).toContainText('20 / 20');
+ await expect(status).toContainText('埋底完成前不摸牌');
+ await expect(page.locator('.my-hand .card')).toHaveCount(20);
+ }));
+
+ await finishDealerBury(pages, { initialHandCount: 20 });
+ await Promise.all(pages.map(async page => {
+ await expect(page.getByTestId('planned-economy-status')).toContainText('每轮结束四家各摸1张');
+ await expect(page.locator('.my-hand .card')).toHaveCount(20);
+ }));
+
+ for (let playIndex = 0; playIndex < 4; playIndex += 1) {
+ let turnPage = null;
+ await expect.poll(async () => {
+ for (const page of pages) {
+ if (await page.locator('.player-bottom.current-turn').isVisible().catch(() => false)) {
+ turnPage = page;
+ return true;
+ }
+ }
+ return false;
+ }).toBe(true);
+ await playLegalSingle(turnPage);
+ }
+
+ const drawLayer = pages[0].locator('.planned-economy-draw-layer');
+ await expect(drawLayer).toBeVisible();
+ await expect(drawLayer.locator('.exchange-flying-card')).toHaveCount(4);
+ await expect(drawLayer).toContainText('第1轮补牌 · 剩余16张');
+ await pages[0].locator('.game-table').screenshot({
+ path: testInfo.outputPath('planned-economy-round-draw.png')
+ });
+ await expect(drawLayer).toBeHidden({ timeout: 5_000 });
+
+ await Promise.all(pages.map(async page => {
+ await expect(page.locator('.my-hand .card')).toHaveCount(20);
+ await expect(page.getByTestId('planned-economy-status')).toContainText('16 / 20');
+ }));
+ } finally {
+ await context.close();
+ }
+});
+
+test('等价互惠由一号位点选玩家、双方暗选拼点牌并完成交换', async ({ browser }, testInfo) => {
+ test.setTimeout(150_000);
+ const { context, pages } = await openTestModeGame(browser, '等价互惠');
+
+ try {
+ await Promise.all(pages.map(page => page.setViewportSize({ width: 1440, height: 900 })));
+ const firstPlayerIndex = await finishDealerBury(pages);
+ const targetPlayerIndex = (firstPlayerIndex + 1) % 4;
+ const firstPlayerPage = pages[firstPlayerIndex];
+ const targetPlayerPage = pages[targetPlayerIndex];
+ const skillButton = firstPlayerPage.getByRole('button', { name: '等价互惠', exact: true });
+
+ await expect(skillButton).toBeEnabled();
+ await expect(skillButton).toHaveClass(/is-ready/);
+ await skillButton.click();
+ await expect(skillButton).toHaveClass(/is-armed/);
+ await expect(firstPlayerPage.locator('.player-area.skill-targetable')).toHaveCount(3);
+
+ await firstPlayerPage.locator('.player-right.skill-targetable').click();
+ const confirmation = firstPlayerPage.getByRole('dialog', { name: '等价互惠', exact: true });
+ await expect(confirmation).toBeVisible();
+ await expect(confirmation).toContainText('主牌大于副牌');
+ await confirmation.getByRole('button', { name: '否,重新选择', exact: true }).click();
+ await expect(skillButton).toBeEnabled();
+ await expect(skillButton).toHaveClass(/is-armed/);
+
+ await firstPlayerPage.locator('.player-right.skill-targetable').click();
+ await confirmation.getByRole('button', { name: '是,与其拼点', exact: true }).click();
+
+ const firstSelection = firstPlayerPage.getByRole('dialog', { name: '等价互惠 · 选择拼点牌' });
+ const targetSelection = targetPlayerPage.getByRole('dialog', { name: '等价互惠 · 选择拼点牌' });
+ await expect(firstSelection).toBeVisible();
+ await expect(targetSelection).toBeVisible();
+ const firstCard = firstSelection.locator('.equivalent-reciprocity-hand-picker .card').first();
+ const targetCard = targetSelection.locator('.equivalent-reciprocity-hand-picker .card').first();
+ const firstCardId = await firstCard.getAttribute('data-card-id');
+ const targetCardId = await targetCard.getAttribute('data-card-id');
+ await firstCard.click();
+ await targetCard.click();
+ await firstSelection.getByRole('button', { name: '确认拼点牌', exact: true }).click();
+ await expect(firstSelection.getByRole('button', { name: '已暗置,等待对方', exact: true })).toBeDisabled();
+ await targetSelection.getByRole('button', { name: '确认拼点牌', exact: true }).click();
+
+ await Promise.all(pages.map(async page => {
+ const result = page.locator('.equivalent-reciprocity-result');
+ await expect(result).toBeVisible();
+ await expect(result.locator('.card')).toHaveCount(2);
+ await expect(page.locator('.card-exchange-animation-layer .exchange-flying-card')).toHaveCount(2);
+ }));
+ await firstPlayerPage.locator('.game-table').screenshot({
+ path: testInfo.outputPath('equivalent-reciprocity-reveal-and-exchange.png')
+ });
+
+ await expect(firstPlayerPage.locator(`.my-hand [data-card-id="${targetCardId}"]`)).toHaveCount(1, { timeout: 5_000 });
+ await expect(targetPlayerPage.locator(`.my-hand [data-card-id="${firstCardId}"]`)).toHaveCount(1, { timeout: 5_000 });
+ await expect(skillButton).toBeDisabled();
+ await expect(skillButton).toHaveClass(/is-used/);
+ } finally {
+ await context.close();
+ }
+});
+
+test('一带一路未发动时可尝试普通甩牌,发动后切换为小甩牌', async ({ browser }, testInfo) => {
+ test.setTimeout(120_000);
+ const beltAndRoadGame = await openTestModeGame(browser, '一带一路');
+ try {
+ await Promise.all(beltAndRoadGame.pages.map(page => page.setViewportSize({ width: 1440, height: 900 })));
+ const leaderPageIndex = await finishDealerBury(beltAndRoadGame.pages);
+ const leaderPage = beltAndRoadGame.pages[leaderPageIndex];
+ const skillButton = leaderPage.getByRole('button', { name: '一带一路', exact: true });
+ await expect(skillButton).toBeVisible();
+ await expect(skillButton).toBeEnabled();
+ await expect(skillButton).toHaveAttribute('aria-pressed', 'false');
+
+ const handCards = leaderPage.locator('.my-hand .card');
+ const candidateIndexes = await handCards.evaluateAll(cards => {
+ const groups = new Map();
+ cards.forEach((card, index) => {
+ if (card.querySelector('.trump-badge')) return;
+ const suit = card.querySelector('.card-corner.top-left .card-suit')?.textContent?.trim();
+ const rank = card.querySelector('.card-corner.top-left .card-rank')?.textContent?.trim();
+ if (!suit || !rank) return;
+ const entries = groups.get(suit) || [];
+ entries.push({ index, rank });
+ groups.set(suit, entries);
+ });
+ for (const entries of groups.values()) {
+ for (let first = 0; first < entries.length; first += 1) {
+ for (let second = first + 1; second < entries.length; second += 1) {
+ if (entries[first].rank !== entries[second].rank) {
+ return [entries[first].index, entries[second].index];
+ }
+ }
+ }
+ }
+ return null;
+ });
+ expect(candidateIndexes).not.toBeNull();
+ for (const cardIndex of candidateIndexes) {
+ await handCards.nth(cardIndex).dispatchEvent('click');
+ }
+ await expect(leaderPage.getByRole('button', { name: '出牌(2)', exact: true })).toBeEnabled();
+ await expect(skillButton).toHaveAttribute('aria-pressed', 'false');
+
+ await skillButton.click();
+ await expect(skillButton).toHaveAttribute('aria-pressed', 'true');
+ await expect(skillButton).toHaveClass(/is-armed/);
+ await expect(leaderPage.getByRole('button', { name: '出牌(0)', exact: true })).toBeVisible();
+ await expect(leaderPage.getByRole('button', { name: /垫牌/ })).toHaveCount(0);
+ for (const cardIndex of candidateIndexes) {
+ await handCards.nth(cardIndex).dispatchEvent('click');
+ }
+ await expect(leaderPage.getByRole('button', { name: '出牌(2)', exact: true })).toBeEnabled();
+ await leaderPage.locator('.game-table').screenshot({
+ path: testInfo.outputPath('belt-and-road-active-skill-button.png')
+ });
+ } finally {
+ await beltAndRoadGame.context.close();
+ }
+});
+
+test('禁术秘法可由非一号位预备、轮首确认,发动后原主牌须先转成副牌', async ({ browser }, testInfo) => {
+ test.setTimeout(120_000);
+ const { context, pages } = await openTestModeGame(browser, '禁术秘法');
+
+ try {
+ await Promise.all(pages.map(page => page.setViewportSize({ width: 1440, height: 900 })));
+ const firstPlayerIndex = await finishDealerBury(pages);
+ const declarerIndex = (firstPlayerIndex + 1) % pages.length;
+ const latePlayerIndex = (firstPlayerIndex + 2) % pages.length;
+ const declarerPage = pages[declarerIndex];
+ const skillButton = declarerPage.getByRole('button', { name: '禁术秘法', exact: true });
+
+ await expect(declarerPage.locator('.player-bottom.current-turn')).toHaveCount(0);
+ await expect(skillButton).toBeEnabled();
+ await skillButton.click();
+ const activationDialog = declarerPage.getByRole('dialog', {
+ name: '禁术秘法',
+ exact: true
+ });
+ await expect(activationDialog).toBeVisible();
+ await expect(activationDialog.getByText(/本局结束,不能撤销/)).toBeVisible();
+ await activationDialog.getByRole('button', { name: '确定发动' }).click();
+ await expect(skillButton).toHaveAttribute('aria-pressed', 'true');
+ await expect(skillButton).toHaveClass(/is-armed/);
+ await expect(skillButton).toBeDisabled();
+
+ await expect(declarerPage.locator('.my-hand .card.forbidden-magic-demoted')).toHaveCount(0);
+ const transformableCards = declarerPage.locator(
+ '.my-hand .card:has(button[aria-label="转化此牌"])'
+ );
+ await expect(transformableCards.first()).toBeVisible();
+ const transformButton = transformableCards.first().getByRole('button', { name: '转化此牌' });
+ // 手牌采用扇形叠放,按钮可能被相邻牌的透明区域覆盖;派发点击验证实际处理器。
+ await transformButton.dispatchEvent('click');
+
+ const transformDialog = declarerPage.getByRole('dialog', {
+ name: '禁术秘法 · 选择目标牌面'
+ });
+ await expect(transformDialog).toBeVisible();
+ await transformDialog.locator('.transformation-suit-option').first().click();
+ if (await transformDialog.isVisible().catch(() => false)) {
+ await transformDialog.locator('.transformation-rank-option').first().click({ force: true });
+ }
+
+ const transformedCard = declarerPage.locator('.my-hand .card.forbidden-magic-transformed');
+ await expect(transformedCard).toHaveCount(1);
+ await expect(transformedCard.getByRole('button', { name: '取消转化,还原原牌' })).toBeVisible();
+ await declarerPage.locator('.game-table').screenshot({
+ path: testInfo.outputPath('forbidden-magic-demotion-and-transformation.png')
+ });
+ await transformedCard.getByRole('button', { name: '取消转化,还原原牌' }).dispatchEvent('click');
+ await expect(declarerPage.locator('.my-hand .card.forbidden-magic-transformed')).toHaveCount(0);
+ await expect(transformableCards.first().getByRole('button', { name: '转化此牌' })).toBeVisible();
+
+ await playLegalSingle(pages[firstPlayerIndex]);
+ const lateSkillButton = pages[latePlayerIndex].getByRole('button', {
+ name: '禁术秘法',
+ exact: true
+ });
+ await expect(lateSkillButton).toBeEnabled();
+ await lateSkillButton.click();
+ await expect(lateSkillButton).toBeDisabled();
+ await expect(lateSkillButton).toHaveAttribute('aria-pressed', 'true');
+ await expect(lateSkillButton).toHaveAttribute('title', /第2轮开始时确认/);
+ } finally {
+ await context.close();
+ }
+});
+
+test('规则选择发生在牌桌内,且庄家可随时查看底牌', async ({ page }, testInfo) => {
+ test.setTimeout(90_000);
+ const roomId = await createRoom(page);
+ const pages = [page];
+
+ for (let playerIndex = 2; playerIndex <= 4; playerIndex += 1) {
+ const playerPage = await page.context().newPage();
+ pages.push(playerPage);
+ await joinRoom(playerPage, roomId, `玩家${playerIndex}`);
+ }
+
+ await expect(page.getByText('玩家数: 4 / 4')).toBeVisible();
+ await page.getByRole('button', { name: '开始游戏' }).click();
+ await Promise.all(pages.map((playerPage) =>
+ expect(playerPage.locator('.game-board')).toBeVisible()
+ ));
+
+ const getRuleDialog = (playerPage) =>
+ playerPage.getByRole('dialog', { name: '选择本局特殊规则' });
+ await expect.poll(async () => {
+ const visibleStates = await Promise.all(pages.map(async (playerPage) =>
+ getRuleDialog(playerPage).isVisible().catch(() => false)
+ ));
+ return visibleStates.filter(Boolean).length;
+ }).toBe(1);
+
+ let chooserPage = null;
+ for (const playerPage of pages) {
+ if (await getRuleDialog(playerPage).isVisible().catch(() => false)) {
+ chooserPage = playerPage;
+ break;
+ }
+ }
+
+ const ruleDialog = getRuleDialog(chooserPage);
+ const ruleOptions = ruleDialog.locator('.rule-option');
+ await expect(ruleOptions).toHaveCount(2);
+ const ruleColumnMetrics = await ruleOptions.evaluateAll((options) => options.map((option) => {
+ const nameRect = option.querySelector('.rule-option-name').getBoundingClientRect();
+ const descriptionRect = option.querySelector('.rule-option-description').getBoundingClientRect();
+ return { nameRight: nameRect.right, descriptionLeft: descriptionRect.left };
+ }));
+ expect(Math.max(...ruleColumnMetrics.map(({ nameRight }) => nameRight)) -
+ Math.min(...ruleColumnMetrics.map(({ nameRight }) => nameRight))).toBeLessThan(1);
+ expect(Math.max(...ruleColumnMetrics.map(({ descriptionLeft }) => descriptionLeft)) -
+ Math.min(...ruleColumnMetrics.map(({ descriptionLeft }) => descriptionLeft))).toBeLessThan(1);
+ await ruleDialog.locator('.ant-modal-content').screenshot({
+ path: testInfo.outputPath('rule-selector-aligned.png'),
+ });
+ const optionNames = await ruleOptions.locator('.rule-option-name').allTextContents();
+ const chosenOptionIndex = optionNames.findIndex((name) => name.trim() !== '与民同乐');
+ const bottomCardsByRuleName = {
+ 五谷丰登: 12,
+ 极限挑战: 16,
+ 江山半壁: 4,
+ 与民同乐: 0
+ };
+ const chosenRuleName = optionNames[chosenOptionIndex].trim();
+ const chosenBottomCardsCount = bottomCardsByRuleName[chosenRuleName] ?? 8;
+ await ruleOptions.nth(chosenOptionIndex).click();
+
+ await Promise.all(pages.map((playerPage) =>
+ playerPage.getByRole('button', { name: /准\s*备/ }).click()
+ ));
+ await expect(page.locator('.my-hand .card').first()).toBeVisible({ timeout: 15_000 });
+
+ if (openingExchangeRules[chosenRuleName]) {
+ await finishOpeningExchange(pages);
+ }
+
+ let dealerPageIndex = -1;
+ await expect.poll(async () => {
+ for (let index = 0; index < pages.length; index += 1) {
+ const dealerBadge = pages[index].locator('.player-bottom').getByText('庄', { exact: true });
+ if (await dealerBadge.isVisible().catch(() => false)) return index;
+ }
+ return -1;
+ }, { timeout: 25_000 }).toBeGreaterThanOrEqual(0);
+
+ for (let index = 0; index < pages.length; index += 1) {
+ const dealerBadge = pages[index].locator('.player-bottom').getByText('庄', { exact: true });
+ if (await dealerBadge.isVisible().catch(() => false)) {
+ dealerPageIndex = index;
+ break;
+ }
+ }
+
+ const dealerPage = pages[dealerPageIndex];
+ const dealerCards = dealerPage.locator('.my-hand .card');
+ const expectedDealerCardCount = ((108 - chosenBottomCardsCount) / 4) + chosenBottomCardsCount;
+ await expect(dealerCards).toHaveCount(expectedDealerCardCount);
+ const buryButton = dealerPage.locator('button', { hasText: '埋底(' });
+ await expect(buryButton).toBeDisabled();
+ expect(await buryButton.evaluate((button) => getComputedStyle(button).cursor)).toBe('default');
+ for (let cardIndex = 0; cardIndex < chosenBottomCardsCount; cardIndex += 1) {
+ // 手牌有意横向叠放;直接触发目标牌的点击,避免后续牌面拦截坐标点击。
+ await dealerCards.nth(cardIndex).click({ force: true });
+ }
+ const readyBuryButton = dealerPage.locator('button', {
+ hasText: `埋底(${chosenBottomCardsCount}/${chosenBottomCardsCount})`
+ });
+ await expect(readyBuryButton).toBeEnabled();
+ await readyBuryButton.click();
+
+ const viewBottomButton = dealerPage.getByRole('button', { name: '查看底牌' });
+ await expect(viewBottomButton).toBeVisible();
+ for (let index = 0; index < pages.length; index += 1) {
+ if (index !== dealerPageIndex) {
+ await expect(pages[index].getByRole('button', { name: '查看底牌' })).toHaveCount(0);
+ }
+ }
+
+ await viewBottomButton.click();
+ const bottomDialog = dealerPage.getByRole('dialog', { name: '庄家底牌' });
+ await expect(bottomDialog.locator('.card')).toHaveCount(chosenBottomCardsCount);
+ await expect(bottomDialog.getByText(/底牌分数:\d+ 分/)).toBeVisible();
+ await expect(dealerPage.locator('.ant-message-notice')).toHaveCount(0, { timeout: 6_000 });
+ await bottomDialog.locator('.ant-modal-content').screenshot({
+ path: testInfo.outputPath('dealer-bottom-cards.png'),
+ });
+ await bottomDialog.getByRole('button', { name: /关\s*闭/ }).click();
+
+ await Promise.all(pages.slice(1).map((playerPage) => playerPage.close()));
+});
+
+test('规则测试模式可在创建房间时直接指定规则并跳过随机二选一', async ({ page }) => {
+ test.setTimeout(45_000);
+ const roomName = '公开规则测试房';
+ const roomId = await createRoom(page, roomName, { testRuleName: '算无遗策' });
+ const pages = [page];
+
+ for (let playerIndex = 2; playerIndex <= 4; playerIndex += 1) {
+ const playerPage = await page.context().newPage();
+ pages.push(playerPage);
+ await joinRoom(playerPage, roomId, `玩家${playerIndex}`);
+ }
+
+ await expect(page.getByText('规则模式: 测试模式(算无遗策)')).toBeVisible();
+ await page.getByRole('button', { name: '开始游戏' }).click();
+ await Promise.all(pages.map(async (playerPage) => {
+ await expect(playerPage.locator('.game-board')).toBeVisible();
+ await expect(playerPage.locator('.game-table').getByText('算无遗策', { exact: true })).toBeVisible();
+ await expect(playerPage.getByRole('dialog', { name: '选择本局特殊规则' })).toHaveCount(0);
+ }));
+
+ await Promise.all(pages.slice(1).map((playerPage) => playerPage.close()));
+});
+
+test('近期实现规则均可在规则测试模式中直接选择', async ({ page }) => {
+ await page.goto('/');
+ await page.getByRole('button', { name: '创建房间' }).click();
+ const dialog = page.getByRole('dialog', { name: '创建房间' });
+ await dialog.getByRole('checkbox', { name: '规则测试模式' }).check();
+
+ for (const ruleName of [
+ '势如破竹',
+ '单步调试',
+ '改革开放',
+ '李代桃僵',
+ '六六大顺',
+ '太极四象',
+ '一马当先',
+ '迷雾重重',
+ '暗度陈仓',
+ '红颜祸水',
+ '偷梁换柱',
+ '斗转星移',
+ '绝处逢生',
+ '后发制人',
+ '随波逐流',
+ '频繁波动',
+ '微小扰动',
+ '弃掷逦迤',
+ '礼崩乐坏',
+ '举贤任能',
+ '意外保险',
+ '三权分立',
+ '君子一言',
+ '再衰三竭',
+ '焦点人物',
+ '冷却时间',
+ '时间冷却',
+ '平均池化',
+ '梦中杀人',
+ '珠联璧合',
+ '神兵天降',
+ '魔术戏法',
+ '戛然而止',
+ '聚类分析',
+ '无独有偶',
+ '政治审查',
+ '无人生还',
+ '调虎离山'
+ ]) {
+ await dialog.locator('.ant-select-selector').click();
+ const ruleInput = dialog.getByLabel('测试规则');
+ await ruleInput.fill(ruleName);
+ const option = page.locator('.ant-select-dropdown:visible').getByText(ruleName, { exact: true });
+ await expect(option).toBeVisible();
+ await option.click();
+ await expect(dialog.locator('.ant-select-selection-item')).toHaveText(ruleName);
+ await ruleInput.fill('');
+ }
+});
+
+test('请君入瓮、老骥伏枥和Trump wins可在规则测试模式中直接选择', async ({ page }) => {
+ await page.goto('/');
+ await page.getByRole('button', { name: '创建房间' }).click();
+ const dialog = page.getByRole('dialog', { name: '创建房间' });
+ await dialog.getByRole('checkbox', { name: '规则测试模式' }).check();
+
+ for (const ruleName of ['请君入瓮', '老骥伏枥', 'Trump wins']) {
+ await dialog.locator('.ant-select-selector').click();
+ const ruleInput = dialog.getByLabel('测试规则');
+ await ruleInput.fill(ruleName);
+ const option = page.locator('.ant-select-dropdown:visible').getByText(ruleName, { exact: true });
+ await expect(option).toBeVisible();
+ await option.click();
+ await expect(dialog.locator('.ant-select-selection-item')).toHaveText(ruleName);
+ await ruleInput.fill('');
+ }
+});
+
+test('魔术戏法只能暗选两名其他玩家,选择只回执给发动者', async ({ browser }, testInfo) => {
+ test.setTimeout(120_000);
+ const { context, pages } = await openTestModeGame(browser, '魔术戏法');
+
+ try {
+ const firstPlayerIndex = await finishDealerBury(pages);
+ const firstPlayerPage = pages[firstPlayerIndex];
+ const skillButton = firstPlayerPage.getByRole('button', { name: '魔术戏法', exact: true });
+ await expect(skillButton).toBeEnabled();
+ await skillButton.click();
+
+ await expect(firstPlayerPage.locator('.player-bottom.skill-targetable')).toHaveCount(0);
+ const firstTarget = firstPlayerPage.locator('.player-top.skill-targetable');
+ const secondTarget = firstPlayerPage.locator('.player-right.skill-targetable');
+ await expect(firstTarget).toBeVisible();
+ await expect(secondTarget).toBeVisible();
+ await firstTarget.click();
+ await secondTarget.click();
+
+ const confirmation = firstPlayerPage.getByRole('dialog', { name: '魔术戏法', exact: true });
+ await expect(confirmation).toBeVisible();
+ await confirmation.getByRole('button', { name: '确认暗选' }).click();
+ await expect(firstPlayerPage.getByText(/魔术戏法已暗中准备/)).toBeVisible();
+ await expect(firstPlayerPage.locator('.active-skill-button.is-armed')).toHaveText('魔术戏法');
+
+ await Promise.all(pages.map(async (playerPage, pageIndex) => {
+ if (pageIndex === firstPlayerIndex) return;
+ await expect(playerPage.getByText(/魔术戏法已暗中准备/)).toHaveCount(0);
+ }));
+ } finally {
+ await context.close();
+ await testInfo.attach('魔术戏法-暗选完成', {
+ body: Buffer.from('发动者只能选择两名其他玩家,未向其他客户端泄露暗选。'),
+ contentType: 'text/plain'
+ });
+ }
+});
+
+test('聚类分析把转化编辑与正常选牌分离,熄灭技能后仍可逐张还原', async ({ browser }) => {
+ test.setTimeout(120_000);
+ const { context, pages } = await openTestModeGame(browser, '聚类分析');
+
+ try {
+ const firstPlayerIndex = await finishDealerBury(pages);
+ const firstPlayerPage = pages[firstPlayerIndex];
+ await firstPlayerPage
+ .getByRole('button', { name: '聚类分析', exact: true })
+ .dispatchEvent('click');
+
+ const injectedCardIds = await firstPlayerPage.evaluate(async () => {
+ const socketService = (await import('/src/services/socket.js')).default;
+ const cards = [
+ ...[0, 1].map(copyIndex => ({
+ id: `cluster-heart-j-${copyIndex}`,
+ suit: 'hearts',
+ rank: 'J',
+ copyIndex
+ })),
+ { id: 'cluster-original-heart-q', suit: 'hearts', rank: 'Q', copyIndex: 0 },
+ { id: 'cluster-original-heart-k', suit: 'hearts', rank: 'K', copyIndex: 0 }
+ ];
+ const listeners = socketService.socket.listeners('card_dealt');
+ cards.forEach(card => listeners.forEach(listener => listener({ card })));
+ return {
+ sourceCardIds: cards.slice(0, 2).map(card => card.id),
+ originalQId: cards[2].id,
+ originalKId: cards[3].id
+ };
+ });
+
+ const dialog = firstPlayerPage.getByRole('dialog', { name: '聚类分析 · 选择目标点数' });
+ for (const sourceCardId of injectedCardIds.sourceCardIds) {
+ await firstPlayerPage
+ .locator(`.my-hand .card[data-card-id="${sourceCardId}"]`)
+ .getByRole('button', { name: '转化此牌' })
+ .click();
+ await expect(dialog).toBeVisible();
+ await dialog.getByRole('button', { name: 'Q', exact: true }).click();
+ }
+
+ const transformedCards = firstPlayerPage.locator('.my-hand .card.cluster-analysis-transformed');
+ await expect(transformedCards).toHaveCount(2);
+ await expect(firstPlayerPage.locator('.my-hand .card.selected')).toHaveCount(0);
+
+ // 技能仍点亮时,点牌面只做正常选牌;原生 Q 不得再被强制打开 Q→J 的转化框。
+ await firstPlayerPage
+ .locator(`.my-hand .card[data-card-id="${injectedCardIds.sourceCardIds[0]}"]`)
+ .click({ position: { x: 8, y: 18 } });
+ await firstPlayerPage
+ .locator(`.my-hand .card[data-card-id="${injectedCardIds.originalQId}"]`)
+ .click({ position: { x: 8, y: 18 } });
+ await expect(firstPlayerPage.locator('.my-hand .card.selected')).toHaveCount(2);
+ await expect(dialog).toBeHidden();
+
+ // 熄灭按钮只退出转化编辑,不清空牌面转化和已经选择的牌。
+ await firstPlayerPage
+ .getByRole('button', { name: '聚类分析', exact: true })
+ .dispatchEvent('click');
+ await expect(transformedCards).toHaveCount(2);
+ await expect(firstPlayerPage.locator('.my-hand .card.selected')).toHaveCount(2);
+ await firstPlayerPage
+ .locator(`.my-hand .card[data-card-id="${injectedCardIds.originalKId}"]`)
+ .click({ position: { x: 8, y: 18 } });
+ await expect(firstPlayerPage.locator('.my-hand .card.selected')).toHaveCount(3);
+
+ await firstPlayerPage
+ .locator(`.my-hand .card[data-card-id="${injectedCardIds.sourceCardIds[0]}"]`)
+ .getByRole('button', { name: '取消转化' })
+ .click();
+ await expect(transformedCards).toHaveCount(1);
+ await firstPlayerPage
+ .locator(`.my-hand .card[data-card-id="${injectedCardIds.sourceCardIds[1]}"]`)
+ .getByRole('button', { name: '取消转化' })
+ .click();
+ await expect(transformedCards).toHaveCount(0);
+ await expect(firstPlayerPage.locator('.my-hand .card.selected')).toHaveCount(2);
+ } finally {
+ await context.close();
+ }
+});
+
+test('戛然而止结算展示庄家最后全部四张手牌且不溢出', async ({ browser }, testInfo) => {
+ test.setTimeout(120_000);
+ const { context, pages } = await openTestModeGame(browser, '戛然而止');
+
+ try {
+ await finishDealerBury(pages);
+ const settlementPage = pages[0];
+ await settlementPage.evaluate(async () => {
+ const socketService = (await import('/src/services/socket.js')).default;
+ const viewerSocketId = socketService.socket.id;
+ const room = {
+ id: 'abrupt-stop-e2e-room',
+ name: '戛然而止测试房',
+ hostId: viewerSocketId,
+ config: { bottomCardsCount: 8 },
+ players: [0, 1, 2, 3].map(index => ({
+ id: `abrupt-player-${index}`,
+ socketId: index === 0 ? viewerSocketId : `other-socket-${index}`,
+ name: index === 0 ? '庄家' : `玩家${index + 1}`,
+ level: 2,
+ cardsCount: 0
+ })),
+ gameState: {
+ phase: 'revealing',
+ selectedRule: {
+ id: 'abrupt_stop',
+ name: '戛然而止',
+ content: '完整轮结束后检查终局。'
+ },
+ trumpSuit: 'no_trump',
+ trumpRank: '2',
+ team1Level: 2,
+ team2Level: 2,
+ dealerPlayerIndex: 0,
+ currentPlayerIndex: 0,
+ attackerScore: 47.5
+ }
+ };
+ socketService.socket.listeners('room_updated').forEach(listener => listener({ room }));
+ });
+ await expect(settlementPage.locator('.game-table')).toBeVisible();
+ await expect.poll(() => settlementPage.evaluate(async () => {
+ const socketService = (await import('/src/services/socket.js')).default;
+ return socketService.socket.listeners('bottom_revealed').length;
+ })).toBeGreaterThan(0);
+ await settlementPage.evaluate(async () => {
+ const socketService = (await import('/src/services/socket.js')).default;
+ const bottomCards = ['3', '4', '6', '7', '8', '9', 'J', 'Q'].map((rank, index) => ({
+ id: `abrupt-bottom-${index}`,
+ suit: index < 4 ? 'diamonds' : 'clubs',
+ rank,
+ copyIndex: index
+ }));
+ const dealerRemainingCards = ['K', '5', '7', '8'].map((rank, index) => ({
+ id: `abrupt-dealer-${index}`,
+ suit: index < 2 ? 'clubs' : 'spades',
+ rank,
+ copyIndex: index
+ }));
+ socketService.socket.listeners('bottom_revealed').forEach(listener => listener({
+ bottomCards,
+ bottomScoreResult: {
+ attackerWonBottom: true,
+ resultText: '闲家拿底',
+ bottomPoints: 20,
+ bottomMultiplier: 2,
+ bottomScoreGained: 40,
+ totalScore: 47.5,
+ collectedPointCards: [],
+ abruptStop: {
+ dealerPlayerId: 'abrupt-dealer',
+ dealerPlayerName: '庄家',
+ dealerRemainingCards,
+ dealerRemainingPoints: 15,
+ attackerBonus: 7.5
+ }
+ },
+ upgradeResult: {
+ attackerWon: false,
+ oldDealerLevel: 2,
+ newDealerLevel: 3,
+ dealerLevelUp: 1,
+ oldAttackerLevel: 2,
+ newAttackerLevel: 2,
+ attackerLevelUp: 0,
+ nextDealerName: '庄家',
+ nextDealerLevel: 3
+ }
+ }));
+ });
+
+ const settlement = settlementPage.getByTestId('abrupt-stop-settlement');
+ await expect(settlement).toBeVisible();
+ await expect(settlement.locator('.card')).toHaveCount(4);
+ await expect(settlement).toContainText('剩余牌面分 15');
+ await expect(settlement).toContainText('闲家获得一半 +7.5 分');
+ expect(await settlement.evaluate((element) => {
+ const container = element.getBoundingClientRect();
+ return [...element.querySelectorAll('.card')].every(card => {
+ const bounds = card.getBoundingClientRect();
+ return bounds.left >= container.left - 1 && bounds.right <= container.right + 1;
+ });
+ })).toBe(true);
+ await settlementPage.locator('.settlement-panel').screenshot({
+ path: testInfo.outputPath('abrupt-stop-final-hand.png')
+ });
+ } finally {
+ await context.close();
+ }
+});
+
+test('偷梁换柱把王转成方片 J 后移入 J 牌组,并在落桌后标明实体原牌', async ({ browser }, testInfo) => {
+ test.setTimeout(120_000);
+ const { context, pages } = await openTestModeGame(browser, '偷梁换柱');
+
+ try {
+ const firstPlayerIndex = await finishDealerBury(pages);
+ const firstPlayerPage = pages[firstPlayerIndex];
+
+ const [sourceCardId, queenCardId, jackCardId] = await firstPlayerPage.evaluate(async () => {
+ const socketService = (await import('/src/services/socket.js')).default;
+ const cards = [
+ { id: 'stealing-sort-joker', suit: 'joker', rank: 'big_joker', copyIndex: 0 },
+ { id: 'stealing-sort-queen', suit: 'diamonds', rank: 'Q', copyIndex: 0 },
+ { id: 'stealing-sort-jack', suit: 'diamonds', rank: 'J', copyIndex: 0 }
+ ];
+ const listeners = socketService.socket.listeners('card_dealt');
+ cards.forEach(card => listeners.forEach(listener => listener({ card })));
+ return cards.map(card => card.id);
+ });
+
+ await firstPlayerPage.getByRole('button', { name: '偷梁换柱', exact: true }).click();
+ await firstPlayerPage
+ .locator(`.my-hand .card[data-card-id="${sourceCardId}"]`)
+ .getByRole('button', { name: '转化此牌' })
+ .dispatchEvent('click');
+
+ const dialog = firstPlayerPage.getByRole('dialog', { name: '偷梁换柱 · 选择目标牌面' });
+ await expect(dialog).toBeVisible();
+ await dialog.getByRole('button', { name: '♦ 方片' }).click();
+ await expect(dialog.getByText(/第二步/)).toBeVisible();
+ await dialog.getByRole('button', { name: 'J', exact: true }).click();
+
+ const transformedCard = firstPlayerPage.locator('.my-hand .card:has(.joker-substitution-card-badge)');
+ await expect(transformedCard).toHaveCount(1);
+ await expect(transformedCard).not.toHaveClass(/selected/);
+ await expect(transformedCard).toHaveAttribute('draggable', 'false');
+ const previewOrder = await firstPlayerPage.locator('.my-hand .card').evaluateAll(
+ elements => elements.map(element => ({
+ id: element.dataset.cardId,
+ rank: element.querySelector('.top-left .card-rank')?.textContent?.trim(),
+ suit: element.querySelector('.top-left .card-suit')?.textContent?.trim()
+ }))
+ );
+ const queenIndex = previewOrder.findIndex(card => card.id === queenCardId);
+ const sourceIndex = previewOrder.findIndex(card => card.id === sourceCardId);
+ const jackIndex = previewOrder.findIndex(card => card.id === jackCardId);
+ expect(queenIndex).toBeLessThan(sourceIndex);
+ expect(queenIndex).toBeLessThan(jackIndex);
+ expect(previewOrder[sourceIndex]).toMatchObject({ rank: 'J', suit: '♦' });
+ expect(previewOrder[jackIndex]).toMatchObject({ rank: 'J', suit: '♦' });
+ const firstJackIndex = Math.min(sourceIndex, jackIndex);
+ const lastJackIndex = Math.max(sourceIndex, jackIndex);
+ expect(previewOrder.slice(firstJackIndex, lastJackIndex + 1).every(card => (
+ card.rank === 'J' && card.suit === '♦'
+ ))).toBe(true);
+ const ordinaryCard = firstPlayerPage.locator(
+ `.my-hand .card[data-card-id="${queenCardId}"]`
+ );
+ await ordinaryCard.dispatchEvent('click');
+ const ordinaryPlayButton = firstPlayerPage.locator('.play-controls button').filter({
+ hasText: /\(1\)$/
+ }).first();
+ await expect(ordinaryPlayButton).toBeEnabled();
+ await ordinaryCard.dispatchEvent('click');
+ await transformedCard.dispatchEvent('click');
+ await expect(transformedCard).toHaveClass(/selected/);
+ await transformedCard.getByRole('button', { name: '取消转化' }).dispatchEvent('click');
+ await expect(firstPlayerPage.locator('.my-hand .joker-substitution-card-badge')).toHaveCount(0);
+ await expect(firstPlayerPage.locator('.my-hand .card.selected')).toHaveCount(0);
+
+ await firstPlayerPage.evaluate(async () => {
+ const socketService = (await import('/src/services/socket.js')).default;
+ const { useGameStore } = await import('/src/store/gameStore.js');
+ const state = useGameStore.getState();
+ const player = state.currentRoom.players[0];
+ const transformedTableCard = {
+ id: 'played-big-joker-as-diamond-j',
+ suit: 'diamonds',
+ rank: 'J',
+ originalSuit: 'joker',
+ originalRank: 'big_joker',
+ isJokerSubstitution: true,
+ copyIndex: 0
+ };
+ socketService.socket.listeners('cards_played').forEach(listener => listener({
+ playerId: player.id,
+ playerName: player.name,
+ cards: [transformedTableCard],
+ removedCardIds: [transformedTableCard.id],
+ currentWinningPlayerId: player.id,
+ jokerSubstitutions: [{
+ cardId: transformedTableCard.id,
+ suit: 'diamonds',
+ rank: 'J'
+ }]
+ }));
+ });
+
+ const tableCard = firstPlayerPage.locator(
+ '.played-cards-area .card[data-card-id="played-big-joker-as-diamond-j"]'
+ );
+ await expect(tableCard).toBeVisible();
+ await expect(tableCard.locator('.original-face-badge')).toHaveText('原大王');
+ await expect(tableCard.locator('.original-face-badge')).toHaveAttribute(
+ 'aria-label',
+ '实体原牌:大王,当前牌面:♦J'
+ );
+ await expect(firstPlayerPage.locator('.joker-substitution-badge')).toContainText('大王→J♦');
+ await firstPlayerPage.locator('.game-table').screenshot({
+ path: testInfo.outputPath('transformed-card-original-face.png')
+ });
+ } finally {
+ await context.close();
+ }
+});
+
+test('跟牌提交后等待服务器确认期间不会重新选中剩余手牌', async ({ browser }) => {
+ test.setTimeout(120_000);
+ const { context, pages } = await openTestModeGame(browser, '记录在案');
+
+ try {
+ const fixturePageIndex = await finishDealerBury(pages);
+ const followerPage = pages[fixturePageIndex];
+ await followerPage.evaluate(async () => {
+ const { useGameStore } = await import('/src/store/gameStore.js');
+ const state = useGameStore.getState();
+ const ownIndex = state.currentRoom.players.findIndex(
+ player => player.id === state.currentPlayer.id
+ );
+ const leaderIndex = (ownIndex + state.currentRoom.players.length - 1)
+ % state.currentRoom.players.length;
+ const cards = [
+ { id: 'pending-follow-club', suit: 'clubs', rank: 'A', copyIndex: 80 },
+ { id: 'pending-follow-diamond-3', suit: 'diamonds', rank: '3', copyIndex: 81 },
+ { id: 'pending-follow-diamond-4', suit: 'diamonds', rank: '4', copyIndex: 82 }
+ ];
+ useGameStore.setState({
+ myCards: cards,
+ selectedCards: [],
+ trumpSuit: 'spades',
+ trumpRank: '2',
+ currentRoom: {
+ ...state.currentRoom,
+ players: state.currentRoom.players.map((player, index) => ({
+ ...player,
+ cardsCount: index === ownIndex ? cards.length : player.cardsCount
+ })),
+ gameState: {
+ ...state.currentRoom.gameState,
+ phase: 'playing',
+ trumpSuit: 'spades',
+ trumpRank: '2',
+ currentPlayerIndex: ownIndex,
+ currentRoundPlays: 1,
+ playersPlayedThisRound: [leaderIndex],
+ leadingPattern: {
+ type: 'pair',
+ suit: 'clubs',
+ length: 2,
+ cards: [
+ { id: 'leader-club-k-0', suit: 'clubs', rank: 'K', copyIndex: 0 },
+ { id: 'leader-club-k-1', suit: 'clubs', rank: 'K', copyIndex: 1 }
+ ]
+ }
+ }
+ }
+ });
+ });
+
+ const selectedCards = followerPage.locator('.my-hand .card.selected');
+ await expect(selectedCards).toHaveCount(1);
+ await expect(selectedCards).toHaveAttribute('data-card-id', 'pending-follow-club');
+ await followerPage
+ .locator('.my-hand .card[data-card-id="pending-follow-diamond-3"]')
+ .dispatchEvent('click');
+ const playButton = followerPage.getByRole('button', { name: '出牌(2)' });
+ await expect(playButton).toBeEnabled();
+
+ // 模拟网络延迟:吞掉本次出牌请求,让旧快照继续显示仍由自己行动。
+ // 清空选择若会重新触发自动跟牌,这里就会立刻再次抬起剩余同花色牌。
+ await followerPage.evaluate(async () => {
+ const socketService = (await import('/src/services/socket.js')).default;
+ const socket = socketService.socket;
+ const originalEmit = socket.emit.bind(socket);
+ socket.emit = (event, ...args) => {
+ if (event === 'play_cards') {
+ window.__pendingPlayAcknowledgement = args.find(
+ argument => typeof argument === 'function'
+ );
+ return socket;
+ }
+ return originalEmit(event, ...args);
+ };
+ });
+
+ await playButton.click();
+ await expect(selectedCards).toHaveCount(0);
+ await followerPage.waitForTimeout(350);
+ await expect(selectedCards).toHaveCount(0);
+
+ // 拒绝回执会解除等待状态,仍轮到自己时可重新得到必要的跟牌提示。
+ await followerPage.evaluate(() => {
+ window.__pendingPlayAcknowledgement?.({ ok: false, message: '测试拒绝' });
+ });
+ await expect(selectedCards).toHaveCount(1);
+ await expect(selectedCards).toHaveAttribute('data-card-id', 'pending-follow-club');
+ } finally {
+ await context.close();
+ }
+});
+
+test('拆开两对出牌后提前到达的回合事件不会瞬间选中剩余对子', async ({ browser }) => {
+ test.setTimeout(120_000);
+ const { context, pages } = await openTestModeGame(browser, '记录在案');
+
+ try {
+ const fixturePageIndex = await finishDealerBury(pages);
+ const followerPage = pages[fixturePageIndex];
+ await followerPage.evaluate(async () => {
+ const { useGameStore } = await import('/src/store/gameStore.js');
+ const state = useGameStore.getState();
+ const ownIndex = state.currentRoom.players.findIndex(
+ player => player.id === state.currentPlayer.id
+ );
+ const leaderIndex = (ownIndex + state.currentRoom.players.length - 1)
+ % state.currentRoom.players.length;
+ const cards = [
+ { id: 'split-pair-club-a-0', suit: 'clubs', rank: 'A', copyIndex: 80 },
+ { id: 'split-pair-club-a-1', suit: 'clubs', rank: 'A', copyIndex: 81 },
+ { id: 'split-pair-club-k-0', suit: 'clubs', rank: 'K', copyIndex: 82 },
+ { id: 'split-pair-club-k-1', suit: 'clubs', rank: 'K', copyIndex: 83 },
+ { id: 'split-pair-diamond-3', suit: 'diamonds', rank: '3', copyIndex: 84 }
+ ];
+ useGameStore.setState({
+ myCards: cards,
+ selectedCards: [],
+ trumpSuit: 'spades',
+ trumpRank: '2',
+ currentRoom: {
+ ...state.currentRoom,
+ players: state.currentRoom.players.map((player, index) => ({
+ ...player,
+ cardsCount: index === ownIndex ? cards.length : player.cardsCount
+ })),
+ gameState: {
+ ...state.currentRoom.gameState,
+ phase: 'playing',
+ trumpSuit: 'spades',
+ trumpRank: '2',
+ currentPlayerIndex: ownIndex,
+ currentRoundPlays: 1,
+ playersPlayedThisRound: [leaderIndex],
+ leadingPattern: {
+ type: 'pair',
+ suit: 'clubs',
+ length: 2,
+ cards: [
+ { id: 'leader-club-q-0', suit: 'clubs', rank: 'Q', copyIndex: 0 },
+ { id: 'leader-club-q-1', suit: 'clubs', rank: 'Q', copyIndex: 1 }
+ ]
+ }
+ }
+ }
+ });
+
+ const socketService = (await import('/src/services/socket.js')).default;
+ const socket = socketService.socket;
+ const originalEmit = socket.emit.bind(socket);
+ socket.emit = (event, ...args) => {
+ if (event === 'play_cards') {
+ window.__splitPairPlayAcknowledgement = args.find(
+ argument => typeof argument === 'function'
+ );
+ return socket;
+ }
+ return originalEmit(event, ...args);
+ };
+ });
+
+ const selectedCards = followerPage.locator('.my-hand .card.selected');
+ await expect(selectedCards).toHaveCount(0);
+ await followerPage
+ .locator('.my-hand .card[data-card-id="split-pair-club-k-0"]')
+ .dispatchEvent('click');
+ await followerPage
+ .locator('.my-hand .card[data-card-id="split-pair-club-k-1"]')
+ .dispatchEvent('click');
+ const playButton = followerPage.getByRole('button', { name: '出牌(2)' });
+ await expect(playButton).toBeEnabled();
+ await playButton.click();
+ await expect(selectedCards).toHaveCount(0);
+
+ // 服务端的实际顺序是回执、cards_played、round_updated、room_updated。
+ // 前三个消息连续到达时,Zustand 会先同步删牌;剩余唯一对子仍不得被抬起。
+ await followerPage.evaluate(async () => {
+ const { useGameStore } = await import('/src/store/gameStore.js');
+ const socketService = (await import('/src/services/socket.js')).default;
+ const socket = socketService.socket;
+ const state = useGameStore.getState();
+ const ownIndex = state.currentRoom.players.findIndex(
+ player => player.id === state.currentPlayer.id
+ );
+ const nextPlayerIndex = (ownIndex + 1) % state.currentRoom.players.length;
+ const playedCards = state.myCards.filter(card => (
+ card.id === 'split-pair-club-k-0' || card.id === 'split-pair-club-k-1'
+ ));
+
+ window.__splitPairPlayAcknowledgement?.({ ok: true, pending: false });
+ socket.listeners('cards_played').forEach(listener => listener({
+ playerId: state.currentPlayer.id,
+ playerName: state.currentPlayer.name,
+ cards: playedCards,
+ removedCardIds: playedCards.map(card => card.id),
+ cardsCount: playedCards.length,
+ currentWinningPlayerId: state.currentPlayer.id
+ }));
+ socket.listeners('round_updated').forEach(listener => listener({
+ type: 'turn_changed',
+ currentPlayerIndex: nextPlayerIndex
+ }));
+ });
+
+ await expect(followerPage.locator('.my-hand .card')).toHaveCount(3);
+ await followerPage.waitForTimeout(350);
+ await expect(selectedCards).toHaveCount(0);
+
+ // 若自己赢得本墩,下一轮的 currentPlayerIndex 可能不变;仍要以 currentRound
+ // 的权威快照解除保护,不能因此把玩家永久锁死。
+ await followerPage.evaluate(async () => {
+ const { useGameStore } = await import('/src/store/gameStore.js');
+ const socketService = (await import('/src/services/socket.js')).default;
+ const state = useGameStore.getState();
+ const ownIndex = state.currentRoom.players.findIndex(
+ player => player.id === state.currentPlayer.id
+ );
+ const room = {
+ ...state.currentRoom,
+ gameState: {
+ ...state.currentRoom.gameState,
+ currentRound: (state.currentRoom.gameState.currentRound || 1) + 1,
+ currentPlayerIndex: ownIndex,
+ currentRoundPlays: 0,
+ playersPlayedThisRound: [],
+ leadingPattern: null
+ }
+ };
+ socketService.socket.listeners('room_updated').forEach(listener => listener({ room }));
+ });
+ const nextRoundCard = followerPage.locator(
+ '.my-hand .card[data-card-id="split-pair-club-a-0"]'
+ );
+ await nextRoundCard.dispatchEvent('click');
+ await expect(nextRoundCard).toHaveClass(/selected/);
+ } finally {
+ await context.close();
+ }
+});
+
+test('冷却点数和花色会在所有玩家主视角中整轮灰显', async ({ browser }, testInfo) => {
+ test.setTimeout(120_000);
+ const { context, pages } = await openTestModeGame(browser, '冷却时间');
+
+ try {
+ await finishDealerBury(pages);
+
+ const injectOwnRestriction = async (playerPage, { ruleId, ruleName, type, value }) => {
+ await playerPage.evaluate(async (restriction) => {
+ const { useGameStore } = await import('/src/store/gameStore.js');
+ const socketService = (await import('/src/services/socket.js')).default;
+ const state = useGameStore.getState();
+ const playerId = state.currentPlayer.id;
+ const room = {
+ ...state.currentRoom,
+ gameState: {
+ ...state.currentRoom.gameState,
+ currentRound: 2,
+ selectedRule: {
+ id: restriction.ruleId,
+ name: restriction.ruleName,
+ content: '测试冷却牌灰显'
+ },
+ cardCooldown: {
+ type: restriction.type,
+ valuesByPlayerId: { [playerId]: [restriction.value] }
+ }
+ }
+ };
+ socketService.socket.listeners('room_updated').forEach(listener => listener({ room }));
+ }, { ruleId, ruleName, type, value });
+ };
+
+ for (const playerPage of pages) {
+ const rank = await playerPage
+ .locator('.my-hand .card:not(.joker-card) .top-left .card-rank')
+ .first()
+ .textContent();
+ await injectOwnRestriction(playerPage, {
+ ruleId: 'cooldown_time',
+ ruleName: '冷却时间',
+ type: 'rank',
+ value: rank
+ });
+ await expect.poll(() => playerPage.locator('.my-hand .card.rule-disabled').count()).toBeGreaterThan(0);
+ const titles = await playerPage.locator('.my-hand .card.rule-disabled').evaluateAll(cards => (
+ cards.map(card => card.getAttribute('title'))
+ ));
+ expect(titles.every(title => title?.includes('该点数在本轮冷却中'))).toBe(true);
+ }
+ await pages[0].locator('.my-hand').screenshot({
+ path: testInfo.outputPath('rank-cooldown-disabled-cards.png')
+ });
+
+ const suitSymbols = { '♥': 'hearts', '♦': 'diamonds', '♣': 'clubs', '♠': 'spades' };
+ for (const playerPage of pages) {
+ const suitSymbol = await playerPage
+ .locator('.my-hand .card:not(.joker-card) .top-left .card-suit')
+ .first()
+ .textContent();
+ await injectOwnRestriction(playerPage, {
+ ruleId: 'time_cooling',
+ ruleName: '时间冷却',
+ type: 'suit',
+ value: suitSymbols[suitSymbol]
+ });
+ await expect.poll(() => playerPage.locator('.my-hand .card.rule-disabled').count()).toBeGreaterThan(0);
+ const titles = await playerPage.locator('.my-hand .card.rule-disabled').evaluateAll(cards => (
+ cards.map(card => card.getAttribute('title'))
+ ));
+ expect(titles.every(title => title?.includes('该花色在本轮冷却中'))).toBe(true);
+ }
+ await pages[0].locator('.my-hand').screenshot({
+ path: testInfo.outputPath('suit-cooldown-disabled-cards.png')
+ });
+
+ let pagesWithTrumpCards = 0;
+ for (const playerPage of pages) {
+ const trumpCards = playerPage.locator('.my-hand .card:has(.trump-badge)');
+ if (await trumpCards.count() === 0) continue;
+ pagesWithTrumpCards += 1;
+ await injectOwnRestriction(playerPage, {
+ ruleId: 'time_cooling',
+ ruleName: '时间冷却',
+ type: 'suit',
+ value: 'trump'
+ });
+ await expect(trumpCards.first()).toHaveClass(/rule-disabled/);
+ await expect(playerPage.locator('.my-hand .card:has(.trump-badge):not(.rule-disabled)')).toHaveCount(0);
+ }
+ expect(pagesWithTrumpCards).toBeGreaterThan(0);
+ await pages[0].locator('.my-hand').screenshot({
+ path: testInfo.outputPath('trump-cooldown-disabled-cards.png')
+ });
+ } finally {
+ await context.close();
+ }
+});
+
+test('无独有偶规则框随当前轮次标记零分或双倍,轮末清桌前保持本轮性质', async ({ browser }) => {
+ test.setTimeout(120_000);
+ const { context, pages } = await openTestModeGame(browser, '无独有偶');
+
+ try {
+ await finishDealerBury(pages);
+ for (const playerPage of pages) {
+ const status = playerPage.getByTestId('odd-even-round-status');
+ await expect(status).toHaveAttribute('data-round', '1');
+ await expect(status).toHaveAttribute('data-parity', 'odd');
+ await expect(status).toContainText('奇数轮');
+ await expect(status).toContainText('本轮0分');
+ }
+
+ const heldRoundSnapshot = await pages[0].evaluate(async () => {
+ const { useGameStore } = await import('/src/store/gameStore.js');
+ const socketService = (await import('/src/services/socket.js')).default;
+ const state = useGameStore.getState();
+ const player = state.currentRoom.players[0];
+ socketService.socket.listeners('cards_played').forEach(listener => listener({
+ playerId: player.id,
+ playerName: player.name,
+ cards: [{ id: 'odd-round-ten', suit: 'hearts', rank: '10' }],
+ cardsCount: 1,
+ remainingCount: 24
+ }));
+ socketService.socket.listeners('round_updated').forEach(listener => listener({
+ type: 'round_ended',
+ round: 1,
+ roundWinner: {
+ playerIndex: 0,
+ playerId: player.id,
+ playerName: player.name
+ },
+ scoreInfo: { hidden: true }
+ }));
+ const room = {
+ ...state.currentRoom,
+ gameState: {
+ ...state.currentRoom.gameState,
+ currentRound: 2
+ }
+ };
+ socketService.socket.listeners('room_updated').forEach(listener => listener({ room }));
+ await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)));
+ const status = document.querySelector('[data-testid="odd-even-round-status"]');
+ return {
+ round: status?.getAttribute('data-round'),
+ parity: status?.getAttribute('data-parity'),
+ pointsIndicatorCount: document.querySelectorAll('.round-points-indicator').length
+ };
+ });
+
+ expect(heldRoundSnapshot).toEqual({
+ round: '1',
+ parity: 'odd',
+ pointsIndicatorCount: 0
+ });
+
+ const status = pages[0].getByTestId('odd-even-round-status');
+ await expect(status).toHaveAttribute('data-round', '2', { timeout: 3_000 });
+ await expect(status).toHaveAttribute('data-parity', 'even');
+ await expect(status).toContainText('偶数轮');
+ await expect(status).toContainText('本轮双倍');
+ } finally {
+ await context.close();
+ }
+});
+
+test('烛尽天明在轮末事件分帧到达时也只在清桌后切换烛态', async ({ browser }) => {
+ test.setTimeout(120_000);
+ const { context, pages } = await openTestModeGame(browser, '记录在案');
+
+ try {
+ await finishDealerBury(pages);
+ const playerPage = pages[0];
+ await playerPage.evaluate(async () => {
+ const { useGameStore } = await import('/src/store/gameStore.js');
+ const state = useGameStore.getState();
+ useGameStore.setState({
+ currentRoom: {
+ ...state.currentRoom,
+ gameState: {
+ ...state.currentRoom.gameState,
+ phase: 'playing',
+ currentRound: 1,
+ selectedRule: {
+ id: 'candle_to_dawn',
+ name: '烛尽天明',
+ content: '轮末按第四手颜色决定下一轮烛态。'
+ },
+ candleToDawn: {
+ selectorPlayerId: null,
+ isSelectionPending: false,
+ isLit: true,
+ lastTransition: null
+ }
+ }
+ }
+ });
+ });
+
+ const status = playerPage.getByTestId('candle-to-dawn-status');
+ await expect(status).toHaveAttribute('data-round', '1');
+ await expect(status).toHaveAttribute('data-candle-state', 'lit');
+
+ const statesBeforeRoundEvent = await playerPage.evaluate(async () => {
+ const { useGameStore } = await import('/src/store/gameStore.js');
+ const socketService = (await import('/src/services/socket.js')).default;
+ const socket = socketService.socket;
+ const state = useGameStore.getState();
+ const statusElement = document.querySelector('[data-testid="candle-to-dawn-status"]');
+ window.__candleStateChanges = [];
+ const recordState = () => {
+ window.__candleStateChanges.push({
+ round: statusElement?.getAttribute('data-round'),
+ state: statusElement?.getAttribute('data-candle-state')
+ });
+ };
+ recordState();
+ const observer = new MutationObserver(recordState);
+ observer.observe(statusElement, {
+ attributes: true,
+ attributeFilter: ['data-round', 'data-candle-state']
+ });
+ window.__candleStateObserver = observer;
+
+ const cardsPlayedListeners = socket.listeners('cards_played');
+ state.currentRoom.players.forEach((player, index) => {
+ cardsPlayedListeners.forEach(listener => listener({
+ playerId: player.id,
+ playerName: player.name,
+ cards: [{
+ id: `candle-hold-${index}`,
+ suit: index === 3 ? 'clubs' : 'hearts',
+ rank: '5',
+ copyIndex: 70 + index
+ }],
+ cardsCount: 1,
+ remainingCount: 24,
+ currentWinningPlayerId: player.id
+ }));
+ });
+
+ // 故意让“已切到下一轮、但尚未附带 transition”的房间快照先到一帧。
+ // 第四手到达时冻结的权威旧烛态必须挡住这次提前更新。
+ const nextRoomWithoutTransition = {
+ ...state.currentRoom,
+ gameState: {
+ ...state.currentRoom.gameState,
+ phase: 'playing',
+ currentRound: 2,
+ selectedRule: {
+ id: 'candle_to_dawn',
+ name: '烛尽天明',
+ content: '轮末按第四手颜色决定下一轮烛态。'
+ },
+ candleToDawn: {
+ selectorPlayerId: null,
+ isSelectionPending: false,
+ isLit: false,
+ lastTransition: null
+ }
+ }
+ };
+ socket.listeners('room_updated').forEach(listener => listener({
+ room: nextRoomWithoutTransition
+ }));
+ await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)));
+ return [...window.__candleStateChanges];
+ });
+ expect(statesBeforeRoundEvent.every(change => (
+ change.round === '1' && change.state === 'lit'
+ ))).toBe(true);
+
+ await playerPage.evaluate(async () => {
+ const { useGameStore } = await import('/src/store/gameStore.js');
+ const socketService = (await import('/src/services/socket.js')).default;
+ const socket = socketService.socket;
+ const transition = {
+ round: 1,
+ previousLit: true,
+ nextLit: false,
+ changed: true,
+ triggerColor: 'black'
+ };
+ socket.listeners('round_updated').forEach(listener => listener({
+ type: 'round_ended',
+ round: 1,
+ candleTransition: transition
+ }));
+ const state = useGameStore.getState();
+ socket.listeners('room_updated').forEach(listener => listener({
+ room: {
+ ...state.currentRoom,
+ gameState: {
+ ...state.currentRoom.gameState,
+ currentRound: 2,
+ candleToDawn: {
+ ...state.currentRoom.gameState.candleToDawn,
+ isLit: false,
+ lastTransition: transition
+ }
+ }
+ }
+ }));
+ await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)));
+ });
+ await expect(status).toHaveAttribute('data-round', '1');
+ await expect(status).toHaveAttribute('data-candle-state', 'lit');
+
+ await expect(status).toHaveAttribute('data-round', '2', { timeout: 3_000 });
+ await expect(status).toHaveAttribute('data-candle-state', 'unlit');
+ const allObservedStates = await playerPage.evaluate(() => {
+ window.__candleStateObserver?.disconnect();
+ return window.__candleStateChanges;
+ });
+ expect(allObservedStates.findIndex(change => change.state === 'unlit')).toBe(
+ allObservedStates.length - 1
+ );
+ } finally {
+ await context.close();
+ }
+});
+
+test('第二战场不再显示公共牌,只在四家桌前显示各自累计牌', async ({ browser }, testInfo) => {
+ test.setTimeout(180_000);
+ const { context, pages } = await openTestModeGame(browser, '第二战场');
+
+ try {
+ const viewportSizes = [
+ { width: 1870, height: 1100 },
+ { width: 1600, height: 900 },
+ { width: 1440, height: 900 },
+ { width: 1280, height: 720 }
+ ];
+ await Promise.all(pages.map((page, index) => page.setViewportSize(viewportSizes[index])));
+ await finishDealerBury(pages);
+
+ const bgmAudio = pages[0].locator('.game-bgm-audio');
+ const bgmToggle = pages[0].getByRole('button', { name: '关闭背景音乐《情缘》' });
+ await expect(bgmAudio).toHaveAttribute('src', '/audio/qingyuan-xu-peidong.mp3');
+ await expect(bgmAudio).toHaveAttribute('loop', '');
+ await expect(bgmAudio).toHaveAttribute('preload', 'auto');
+ await expect(bgmToggle).toBeVisible();
+ await expect(bgmToggle).toHaveAttribute('aria-pressed', 'true');
+ await bgmToggle.click();
+ await expect(pages[0].getByRole('button', { name: '播放背景音乐《情缘》' })).toHaveAttribute('aria-pressed', 'false');
+ await pages[0].getByRole('button', { name: '播放背景音乐《情缘》' }).click();
+ await expect(pages[0].getByRole('button', { name: '关闭背景音乐《情缘》' })).toHaveAttribute('aria-pressed', 'true');
+ // 后续是四页面长流程布局测试,关闭测试环境中的实际音频解码,避免影响牌桌断言耗时。
+ await Promise.all(pages.map(async playerPage => {
+ const enabledToggle = playerPage.getByRole('button', { name: '关闭背景音乐《情缘》' });
+ await expect(enabledToggle).toHaveAttribute('aria-pressed', 'true');
+ await enabledToggle.click();
+ }));
+
+ for (const playerPage of pages) {
+ await expect(playerPage.getByTestId('second-battlefield-tray')).toHaveCount(0);
+
+ await playerPage.evaluate(async () => {
+ const { useGameStore } = await import('/src/store/gameStore.js');
+ const socketService = (await import('/src/services/socket.js')).default;
+ const state = useGameStore.getState();
+ const accumulatedCardsByPlayerId = Object.fromEntries(
+ state.currentRoom.players.map((player, playerIndex) => [
+ player.id,
+ Array.from({ length: 7 }, (_, cardIndex) => ({
+ id: `staged-${player.id}-${cardIndex}`,
+ suit: ['hearts', 'diamonds', 'clubs', 'spades'][playerIndex],
+ rank: String(cardIndex + 2)
+ }))
+ ])
+ );
+ const room = {
+ ...state.currentRoom,
+ gameState: {
+ ...state.currentRoom.gameState,
+ secondBattlefield: {
+ ...state.currentRoom.gameState.secondBattlefield,
+ accumulatedCardsByPlayerId
+ }
+ }
+ };
+ socketService.socket.listeners('room_updated').forEach(listener => listener({ room }));
+ const scoringPlayer = state.currentRoom.players[0];
+ socketService.socket.listeners('cards_played').forEach(listener => listener({
+ playerId: scoringPlayer.id,
+ playerName: scoringPlayer.name,
+ cards: [{ id: `round-point-${scoringPlayer.id}`, suit: 'hearts', rank: '10' }],
+ cardsCount: 1,
+ remainingCount: scoringPlayer.cardsCount - 1
+ }));
+ });
+ await expect(playerPage.locator('.second-battlefield-staged-cards')).toHaveCount(4);
+ await expect(playerPage.locator('.second-battlefield-staged-card-row')).toHaveCount(8);
+ await expect(playerPage.locator('.second-battlefield-staged-card-row .card')).toHaveCount(28);
+ await expect(playerPage.locator('.second-battlefield-staged-card-row .card.small')).toHaveCount(28);
+ await expect(playerPage.locator('.second-battlefield-staged-cards[data-position="top"]')).toHaveCount(1);
+ await expect(playerPage.locator('.second-battlefield-staged-cards[data-position="bottom"]')).toHaveCount(1);
+ await expect(playerPage.locator('.second-battlefield-side-player .second-battlefield-staged-cards')).toHaveCount(2);
+ await expect(playerPage.locator('.second-battlefield-round-points')).toHaveCount(0);
+ await expect(playerPage.locator('.round-points-indicator')).toHaveCount(1);
+
+ const overlappingAreas = await playerPage.evaluate(() => {
+ const rect = selector => {
+ const element = document.querySelector(selector);
+ if (!element) return null;
+ const bounds = element.getBoundingClientRect();
+ return {
+ left: bounds.left,
+ right: bounds.right,
+ top: bounds.top,
+ bottom: bounds.bottom
+ };
+ };
+ const overlaps = (first, second) => Boolean(
+ first && second
+ && first.left < second.right
+ && first.right > second.left
+ && first.top < second.bottom
+ && first.bottom > second.top
+ );
+ const positions = ['top', 'right', 'bottom', 'left'];
+ const blockedPositions = positions.filter(position => {
+ const staged = rect(`.second-battlefield-staged-${position}`);
+ const currentPlay = rect(`.played-cards-${position}`);
+ return overlaps(staged, currentPlay);
+ });
+ const stagedPairs = [];
+ positions.forEach((position, index) => {
+ positions.slice(index + 1).forEach(otherPosition => {
+ if (overlaps(
+ rect(`.second-battlefield-staged-${position}`),
+ rect(`.second-battlefield-staged-${otherPosition}`)
+ )) {
+ stagedPairs.push(`${position}-${otherPosition}`);
+ }
+ });
+ });
+ return {
+ blockedPositions,
+ stagedPairs,
+ bgmOverlapsTopPlayer: overlaps(
+ rect('.game-bgm-control'),
+ rect('.position-top .player-top')
+ ),
+ bgmOverlapsRulePanel: overlaps(
+ rect('.game-bgm-control'),
+ rect('.center-content.table-tools')
+ ),
+ topOverlapsScorePanel: overlaps(
+ rect('.second-battlefield-staged-top'),
+ rect('.score-panel')
+ )
+ };
+ });
+ expect(overlappingAreas).toEqual({
+ blockedPositions: [],
+ stagedPairs: [],
+ bgmOverlapsTopPlayer: false,
+ bgmOverlapsRulePanel: false,
+ topOverlapsScorePanel: false
+ });
+ }
+
+ await pages[0].evaluate(async () => {
+ const { useGameStore } = await import('/src/store/gameStore.js');
+ const socketService = (await import('/src/services/socket.js')).default;
+ const state = useGameStore.getState();
+ const triggerRound = state.currentRoom.gameState.currentRound;
+ const players = state.currentRoom.players.map((player, playerIndex) => ({
+ playerId: player.id,
+ playerName: player.name,
+ categoryName: playerIndex === 0 ? '同花顺' : '高牌',
+ bestFive: Array.from({ length: 5 }, (_, cardIndex) => cardIndex === 4 && playerIndex === 0
+ ? {
+ id: `resolved-joker-${player.id}`,
+ suit: 'hearts',
+ rank: 'A',
+ originalSuit: 'joker',
+ originalRank: 'big_joker',
+ secondBattlefieldWildcard: true
+ }
+ : {
+ id: `showdown-${player.id}-${cardIndex}`,
+ suit: ['hearts', 'diamonds', 'clubs', 'spades'][playerIndex],
+ rank: String(cardIndex + 2)
+ })
+ }));
+ const result = {
+ triggered: true,
+ triggerRound,
+ showdownNumber: 1,
+ players,
+ winnerPlayerIds: [state.currentRoom.players[0].id],
+ winnerPlayerNames: [state.currentRoom.players[0].name],
+ winningCategoryName: '同花顺',
+ scoreDelta: -5
+ };
+ const room = {
+ ...state.currentRoom,
+ gameState: {
+ ...state.currentRoom.gameState,
+ currentRound: triggerRound + 1,
+ secondBattlefield: {
+ ...state.currentRoom.gameState.secondBattlefield,
+ lastResult: result
+ }
+ }
+ };
+ socketService.socket.listeners('room_updated').forEach(listener => listener({ room }));
+ await new Promise(resolve => setTimeout(resolve, 50));
+ socketService.socket.listeners('round_updated').forEach(listener => listener({
+ type: 'round_ended',
+ round: triggerRound,
+ secondBattlefield: result
+ }));
+ });
+ const showdown = pages[0].getByTestId('second-battlefield-showdown');
+ await expect(showdown).toBeVisible();
+ await expect(showdown).toContainText('同花顺最大');
+ await expect(pages[0].locator('.second-battlefield-staged-cards.is-winner')).toHaveCount(1);
+ await expect(pages[0].locator('.second-battlefield-staged-cards .original-face-badge')).toHaveCount(1);
+ await pages[0].waitForTimeout(1500);
+ await expect(showdown).toBeVisible();
+ await expect(showdown).toHaveCount(0, { timeout: 1800 });
+ await pages[0].screenshot({ path: testInfo.outputPath('second-battlefield-staged-layout.png') });
+ } finally {
+ await context.close();
+ }
+});
+
+test('君子一言只询问并列最短花色,完成后四视角公开声明', async ({ browser }, testInfo) => {
+ test.setTimeout(150_000);
+ const { context, pages } = await openTestModeGame(browser, '君子一言');
+
+ try {
+ await Promise.all(pages.map(page => page.setViewportSize({ width: 1440, height: 900 })));
+ await finishDealerBury(pages);
+
+ for (const playerPage of pages) {
+ const isPending = await playerPage.evaluate(async () => {
+ const { useGameStore } = await import('/src/store/gameStore.js');
+ const state = useGameStore.getState();
+ return state.currentRoom.gameState.gentlemanPromise?.pendingPlayerIds
+ ?.includes(state.currentPlayer.id) || false;
+ });
+ const dialog = playerPage.getByRole('dialog', { name: '君子一言 · 声明最短花色' });
+ if (!isPending) {
+ await expect(dialog).toHaveCount(0);
+ continue;
+ }
+ await expect(dialog).toBeVisible();
+ const options = dialog.locator('.gentleman-promise-suit-grid .ant-btn');
+ expect(await options.count()).toBeGreaterThan(1);
+ await options.first().click();
+ await dialog.getByRole('button', { name: /^声明 / }).click();
+ }
+
+ await Promise.all(pages.map(async playerPage => {
+ await expect(playerPage.getByRole('dialog', { name: '君子一言 · 声明最短花色' })).toHaveCount(0);
+ const status = playerPage.getByTestId('gentleman-promise-status');
+ await expect(status).toBeVisible();
+ await expect(status.locator('.gentleman-promise-status-grid > div')).toHaveCount(4);
+ await expect(status.locator('.gentleman-promise-status-grid .is-pending')).toHaveCount(0);
+ }));
+ await pages[0].getByTestId('gentleman-promise-status').screenshot({
+ path: testInfo.outputPath('gentleman-promise-declarations.png')
+ });
+ } finally {
+ await context.close();
+ }
+});
+
+test('焦点人物只在队内循环表决,牌中隐藏总分并在终局展示逐人明细', async ({ browser }, testInfo) => {
+ test.setTimeout(150_000);
+ const { context, pages } = await openTestModeGame(browser, '焦点人物');
+
+ try {
+ await Promise.all(pages.map(page => page.setViewportSize({ width: 1440, height: 900 })));
+ await finishDealerBury(pages);
+
+ const dialogs = pages.map(page => page.getByRole('dialog', { name: /焦点人物 · 队伍\d表决/ }));
+ await Promise.all(dialogs.map(dialog => expect(dialog).toBeVisible()));
+ const teamNumbers = await Promise.all(dialogs.map(async dialog => {
+ const title = await dialog.locator('.ant-modal-title').textContent();
+ return Number(title.match(/队伍(\d)/)?.[1]);
+ }));
+ const teamOneIndexes = teamNumbers.map((team, index) => team === 1 ? index : -1).filter(index => index >= 0);
+ const teamTwoIndexes = teamNumbers.map((team, index) => team === 2 ? index : -1).filter(index => index >= 0);
+ expect(teamOneIndexes).toHaveLength(2);
+ expect(teamTwoIndexes).toHaveLength(2);
+ const [teamOneFirst, teamOneSecond] = teamOneIndexes;
+ const [teamTwoFirst, teamTwoSecond] = teamTwoIndexes;
+ const teamOneInitial = await dialogs[teamOneFirst].locator('.player-decision-primary-text strong').textContent();
+ const teamOnePartnerView = await dialogs[teamOneSecond].locator('.player-decision-primary-text strong').textContent();
+ const teamTwoInitial = await dialogs[teamTwoFirst].locator('.player-decision-primary-text strong').textContent();
+ const teamTwoPartnerView = await dialogs[teamTwoSecond].locator('.player-decision-primary-text strong').textContent();
+ expect(teamOnePartnerView).toBe(teamOneInitial);
+ expect(teamTwoPartnerView).toBe(teamTwoInitial);
+
+ await pages[teamOneFirst].locator('.focus-figure-vote-modal-wrap .ant-modal-footer .ant-btn-default').click({ force: true });
+ let remainingTeamOneIndex = -1;
+ await expect.poll(async () => {
+ for (let index = 0; index < pages.length; index += 1) {
+ if (index === teamOneFirst) continue;
+ const dialog = dialogs[index];
+ if (!await dialog.isVisible().catch(() => false)) continue;
+ if ((await dialog.locator('.ant-modal-title').textContent()).includes('队伍1')) {
+ remainingTeamOneIndex = index;
+ return index;
+ }
+ }
+ return -1;
+ }).toBeGreaterThanOrEqual(0);
+ await pages[remainingTeamOneIndex].locator('.focus-figure-vote-modal-wrap .ant-modal-footer .ant-btn-primary').click({ force: true });
+ await Promise.all(teamOneIndexes.map(index => expect(dialogs[index]).toBeVisible()));
+ const teamOneSwitched = await dialogs[teamOneFirst].locator('.player-decision-primary-text strong').textContent();
+ expect(teamOneSwitched).not.toBe(teamOneInitial);
+ await expect(dialogs[teamOneSecond].locator('.player-decision-primary-text strong')).toHaveText(teamOneSwitched);
+
+ await Promise.all([
+ pages[teamTwoFirst].locator('.focus-figure-vote-modal-wrap .ant-modal-footer .ant-btn-primary').click({ force: true }),
+ pages[teamTwoSecond].locator('.focus-figure-vote-modal-wrap .ant-modal-footer .ant-btn-primary').click({ force: true })
+ ]);
+ await Promise.all([
+ pages[teamOneFirst].locator('.focus-figure-vote-modal-wrap .ant-modal-footer .ant-btn-primary').click({ force: true }),
+ pages[teamOneSecond].locator('.focus-figure-vote-modal-wrap .ant-modal-footer .ant-btn-primary').click({ force: true })
+ ]);
+
+ await Promise.all(pages.map(async playerPage => {
+ await expect(playerPage.getByRole('dialog', { name: /焦点人物 · 队伍\d表决/ })).toHaveCount(0);
+ await expect(playerPage.locator('.score-panel')).toContainText('实际得分终局揭晓');
+ await expect(playerPage.locator('.score-panel')).not.toContainText(/闲家得分\s*\d+\s*分/);
+ await expect(playerPage.locator('.focus-figure-player-badge')).toHaveCount(1);
+ await expect(playerPage.locator('.focus-figure-captured-points')).toHaveCount(4);
+ await expect(playerPage.locator('.focus-figure-captured-points')).toHaveText([
+ '被闲家收走 0 分',
+ '被闲家收走 0 分',
+ '被闲家收走 0 分',
+ '被闲家收走 0 分'
+ ]);
+ const publicFocus = await playerPage.evaluate(async () => {
+ const { useGameStore } = await import('/src/store/gameStore.js');
+ return useGameStore.getState().currentRoom.gameState.focusFigure;
+ });
+ expect(publicFocus.isVotingPending).toBe(false);
+ expect(publicFocus.isRevealed).toBe(false);
+ expect(publicFocus.teams).toBeUndefined();
+ expect(Object.values(publicFocus.capturedPointsByPlayerId)).toEqual([0, 0, 0, 0]);
+ }));
+ await pages[0].evaluate(async () => {
+ const { useGameStore } = await import('/src/store/gameStore.js');
+ const state = useGameStore.getState();
+ const capturedPointsByPlayerId = Object.fromEntries(
+ state.currentRoom.players.map((player, index) => [player.id, [5, 10, 15, 20][index]])
+ );
+ useGameStore.setState({
+ currentRoom: {
+ ...state.currentRoom,
+ gameState: {
+ ...state.currentRoom.gameState,
+ focusFigure: {
+ ...state.currentRoom.gameState.focusFigure,
+ capturedPointsByPlayerId
+ }
+ }
+ }
+ });
+ });
+ await expect(pages[0].locator('.focus-figure-captured-points')).toHaveText([
+ /被闲家收走 (5|10|15|20) 分/,
+ /被闲家收走 (5|10|15|20) 分/,
+ /被闲家收走 (5|10|15|20) 分/,
+ /被闲家收走 (5|10|15|20) 分/
+ ]);
+ expect(
+ (await pages[0].locator('.focus-figure-captured-points').allTextContents())
+ .map(text => Number(text.match(/\d+/)?.[0]))
+ .sort((a, b) => a - b)
+ ).toEqual([5, 10, 15, 20]);
+ await pages[0].locator('.game-table').screenshot({
+ path: testInfo.outputPath('focus-figure-private-marker-and-captured-points.png')
+ });
+ await pages[0].locator('.score-panel').screenshot({
+ path: testInfo.outputPath('focus-figure-hidden-score.png')
+ });
+
+ await pages[0].evaluate(async () => {
+ const { useGameStore } = await import('/src/store/gameStore.js');
+ const socketService = (await import('/src/services/socket.js')).default;
+ const state = useGameStore.getState();
+ const players = state.currentRoom.players;
+ useGameStore.setState({
+ currentRoom: {
+ ...state.currentRoom,
+ gameState: { ...state.currentRoom.gameState, phase: 'revealing', attackerScore: 50 }
+ }
+ });
+ const bottomCards = ['5', '2', '3', '4', '6', '7', '8', '9'].map((rank, index) => ({
+ id: `focus-bottom-${index}`,
+ suit: index % 2 === 0 ? 'diamonds' : 'clubs',
+ rank,
+ copyIndex: index
+ }));
+ socketService.socket.listeners('bottom_revealed').forEach(listener => listener({
+ bottomCards,
+ bottomScoreResult: {
+ attackerWonBottom: true,
+ resultText: '闲家拿底',
+ bottomPoints: 5,
+ bottomMultiplier: 2,
+ bottomScoreGained: 10,
+ baseScore: 40,
+ totalScore: 50,
+ collectedPointCards: [],
+ focusFigure: {
+ teams: [
+ { team: 1, side: 'dealer', focusPlayerId: players[2].id, focusPlayerName: players[2].name },
+ { team: 2, side: 'attacker', focusPlayerId: players[1].id, focusPlayerName: players[1].name }
+ ],
+ players: players.map((player, index) => ({
+ playerId: player.id,
+ playerName: player.name,
+ isFocus: index === 1 || index === 2,
+ capturedPoints: [5, 10, 10, 15][index],
+ countedPoints: index === 1 || index === 2 ? [5, 10, 10, 15][index] * 2 : 0
+ })),
+ focusTrickScore: 40,
+ normalBottomScore: 10,
+ totalScore: 50
+ }
+ },
+ upgradeResult: {
+ attackerWon: false,
+ oldDealerLevel: 2,
+ newDealerLevel: 3,
+ dealerLevelUp: 1,
+ oldAttackerLevel: 2,
+ newAttackerLevel: 2,
+ attackerLevelUp: 0,
+ nextDealerName: players[0].name,
+ nextDealerLevel: 3
+ }
+ }));
+ });
+
+ const settlement = pages[0].getByTestId('focus-figure-settlement');
+ await expect(settlement).toBeVisible();
+ await expect(settlement).toContainText('焦点人物 · 终局揭晓');
+ await expect(settlement).toContainText('焦点逐墩 40 分');
+ await expect(settlement).toContainText('正常底牌 10 分');
+ await expect(pages[0].locator('.settlement-bottom-result')).toContainText('闲家总分:50 分');
+ await pages[0].locator('.settlement-panel').screenshot({
+ path: testInfo.outputPath('focus-figure-settlement.png')
+ });
+ } finally {
+ await context.close();
+ }
+});
+
+test('再衰三竭在规则框显示连续轮数与下一次罚分', async ({ browser }, testInfo) => {
+ test.setTimeout(120_000);
+ const { context, pages } = await openTestModeGame(browser, '再衰三竭');
+
+ try {
+ await finishDealerBury(pages);
+ await Promise.all(pages.map(playerPage => playerPage.evaluate(async () => {
+ const { useGameStore } = await import('/src/store/gameStore.js');
+ const socketService = (await import('/src/services/socket.js')).default;
+ const state = useGameStore.getState();
+ const leader = state.currentRoom.players[0];
+ const room = {
+ ...state.currentRoom,
+ gameState: {
+ ...state.currentRoom.gameState,
+ repeatedExhaustion: {
+ playerId: leader.id,
+ streak: 3,
+ lastPenalty: 5,
+ lastScoreDelta: 5
+ }
+ }
+ };
+ socketService.socket.listeners('room_updated').forEach(listener => listener({ room }));
+ })));
+
+ await Promise.all(pages.map(async playerPage => {
+ const status = playerPage.getByTestId('repeated-exhaustion-status');
+ await expect(status).toBeVisible();
+ await expect(status).toContainText('连续 3 轮');
+ await expect(status).toContainText('再赢扣10分');
+ }));
+ await pages[0].getByTestId('repeated-exhaustion-status').screenshot({
+ path: testInfo.outputPath('repeated-exhaustion-status.png')
+ });
+ } finally {
+ await context.close();
+ }
+});
+
+test('暗牌轮末揭示、整手交换和绝处逢生提示具有完整牌桌反馈', async ({ browser }, testInfo) => {
+ test.setTimeout(120_000);
+ const { context, pages } = await openTestModeGame(browser, '暗度陈仓');
+ const page = pages[0];
+
+ try {
+ await page.setViewportSize({ width: 1440, height: 900 });
+ await page.emulateMedia({ reducedMotion: 'no-preference' });
+ // 等庄家埋底完成后再注入牌局事件,避免发牌阶段最后一次房间快照清空测试牌面。
+ await finishDealerBury(pages);
+ await page.evaluate(async () => {
+ const { useGameStore } = await import('/src/store/gameStore.js');
+ const socketService = (await import('/src/services/socket.js')).default;
+ const players = useGameStore.getState().currentRoom.players;
+ const hiddenPlayer = players[1];
+
+ socketService.socket.listeners('cards_played').forEach(listener => listener({
+ playerId: hiddenPlayer.id,
+ playerName: hiddenPlayer.name,
+ cards: [],
+ cardsCount: 3,
+ concealed: true,
+ activeSkillId: 'concealed_passage',
+ activeSkillName: '暗度陈仓',
+ currentWinningPlayerId: null
+ }));
+ });
+
+ const concealedStack = page.locator('.concealed-play-stack');
+ await expect(concealedStack).toBeVisible();
+ await expect(concealedStack.locator('.concealed-play-card')).toHaveCount(3);
+ await expect(concealedStack.locator('.concealed-play-label')).toHaveText('暗置 · 3');
+ await page.locator('.game-table').screenshot({
+ path: testInfo.outputPath('concealed-passage-card-backs.png')
+ });
+
+ await page.evaluate(async () => {
+ const { useGameStore } = await import('/src/store/gameStore.js');
+ const socketService = (await import('/src/services/socket.js')).default;
+ const hiddenPlayer = useGameStore.getState().currentRoom.players[1];
+ socketService.socket.listeners('concealed_plays_revealed').forEach(listener => listener({
+ plays: [{
+ playerId: hiddenPlayer.id,
+ concealed: true,
+ cards: [
+ { id: 'revealed-spade-7', suit: 'hearts', originalSuit: 'spades', rank: '7', copyIndex: 0 },
+ { id: 'revealed-heart-7', suit: 'hearts', rank: '7', copyIndex: 0 },
+ { id: 'revealed-heart-8', suit: 'hearts', rank: '8', copyIndex: 0 }
+ ]
+ }]
+ }));
+ });
+
+ await expect(concealedStack).toHaveCount(0);
+ await expect(page.locator('.concealed-just-revealed .card')).toHaveCount(3);
+ await expect(page.locator('.concealed-just-revealed .converted-spade-badge')).toHaveText('♠→♥');
+
+ await page.evaluate(async () => {
+ const { useGameStore } = await import('/src/store/gameStore.js');
+ const socketService = (await import('/src/services/socket.js')).default;
+ const players = useGameStore.getState().currentRoom.players;
+ const transfers = players.map((player, index) => ({
+ fromPlayerId: player.id,
+ fromPlayerName: player.name,
+ toPlayerId: players[(index + 2) % players.length].id,
+ toPlayerName: players[(index + 2) % players.length].name,
+ cardsCount: 12
+ }));
+ socketService.socket.listeners('whole_hand_exchange_resolved').forEach(listener => listener({
+ ruleName: '斗转星移',
+ transfers,
+ animationDuration: 1600
+ }));
+ });
+
+ const exchangeLayer = page.locator('.card-exchange-animation-layer');
+ await expect(exchangeLayer).toBeVisible();
+ await expect(exchangeLayer.locator('.exchange-flying-card')).toHaveCount(28);
+ await expect(exchangeLayer.locator('.card-exchange-animation-title')).toHaveText('斗转星移');
+ await expect(page.getByRole('button', { name: /斗转星移 · 整手交换中/ })).toBeDisabled();
+ await expect(page.locator('.my-hand .card').first()).toHaveClass(/disabled/);
+ await page.screenshot({
+ path: testInfo.outputPath('whole-hand-exchange-animation.png'),
+ animations: 'allow'
+ });
+
+ await expect(exchangeLayer).toBeHidden({ timeout: 5_000 });
+ await page.evaluate(async () => {
+ const { useGameStore } = await import('/src/store/gameStore.js');
+ const socketService = (await import('/src/services/socket.js')).default;
+ const room = useGameStore.getState().currentRoom;
+ const players = room.players;
+ const targetByPlayerId = Object.fromEntries(players.map((player, index) => [
+ player.id,
+ players[(index + 1) % players.length].id
+ ]));
+ socketService.socket.listeners('room_updated').forEach(listener => listener({
+ room: {
+ ...room,
+ gameState: {
+ ...room.gameState,
+ cardExchange: {
+ stage: 'round',
+ triggerRound: 1,
+ ruleId: 'frequent_fluctuation',
+ ruleName: '频繁波动',
+ requiredCards: 1,
+ targetByPlayerId,
+ submittedPlayerIds: []
+ }
+ }
+ }
+ }));
+ });
+
+ const roundExchangeButton = page.getByRole('button', { name: '确认交牌(0/1)' });
+ await expect(roundExchangeButton).toBeVisible();
+ await expect(page.locator('.card-exchange-status')).toContainText('请选择 1 张牌交给');
+ await page.locator('.my-hand .card').first().dispatchEvent('click');
+ await expect(page.getByRole('button', { name: '确认交牌(1/1)' })).toBeEnabled();
+ await page.evaluate(async () => {
+ const { useGameStore } = await import('/src/store/gameStore.js');
+ const socketService = (await import('/src/services/socket.js')).default;
+ const players = useGameStore.getState().currentRoom.players;
+ const transfers = players.map((player, index) => ({
+ fromPlayerId: player.id,
+ fromPlayerName: player.name,
+ toPlayerId: players[(index + 1) % players.length].id,
+ toPlayerName: players[(index + 1) % players.length].name,
+ cardsCount: 1
+ }));
+ socketService.socket.listeners('card_exchange_resolved').forEach(listener => listener({
+ ruleName: '频繁波动',
+ transfers,
+ animationDuration: 1600
+ }));
+ });
+ await expect(exchangeLayer).toBeVisible();
+ await expect(exchangeLayer.locator('.exchange-flying-card')).toHaveCount(4);
+ await expect(page.getByRole('button', { name: /频繁波动 · 交换中/ })).toBeDisabled();
+ await page.screenshot({
+ path: testInfo.outputPath('round-single-card-exchange-animation.png'),
+ animations: 'allow'
+ });
+
+ await expect(exchangeLayer).toBeHidden({ timeout: 5_000 });
+ await page.evaluate(async () => {
+ const { useGameStore } = await import('/src/store/gameStore.js');
+ const socketService = (await import('/src/services/socket.js')).default;
+ const room = useGameStore.getState().currentRoom;
+ const players = room.players;
+ const targetByPlayerId = Object.fromEntries(players.map(player => [player.id, null]));
+ const exchange = {
+ stage: 'round',
+ operation: 'discard',
+ triggerRound: 2,
+ ruleId: 'lingering_discard',
+ ruleName: '弃掷逦迤',
+ requiredCards: 1,
+ targetByPlayerId,
+ submittedPlayerIds: []
+ };
+ socketService.socket.listeners('room_updated').forEach(listener => listener({
+ room: { ...room, gameState: { ...room.gameState, cardExchange: exchange } }
+ }));
+ socketService.socket.listeners('card_exchange_started').forEach(listener => listener({
+ ...exchange,
+ transfers: players.map(player => ({
+ fromPlayerId: player.id,
+ fromPlayerName: player.name,
+ toPlayerId: null,
+ toPlayerName: '弃牌区',
+ cardsCount: 1
+ }))
+ }));
+ });
+
+ await expect(page.locator('.card-exchange-status')).toContainText('请选择 1 张牌暗中弃置');
+ await expect(page.getByRole('button', { name: '确认弃牌(0/1)' })).toBeVisible();
+ await page.locator('.my-hand .card').first().dispatchEvent('click');
+ await expect(page.getByRole('button', { name: '确认弃牌(1/1)' })).toBeEnabled();
+ await page.evaluate(async () => {
+ const { useGameStore } = await import('/src/store/gameStore.js');
+ const socketService = (await import('/src/services/socket.js')).default;
+ const players = useGameStore.getState().currentRoom.players;
+ socketService.socket.listeners('card_exchange_resolved').forEach(listener => listener({
+ ruleName: '弃掷逦迤',
+ operation: 'discard',
+ transfers: players.map(player => ({
+ fromPlayerId: player.id,
+ fromPlayerName: player.name,
+ toPlayerId: null,
+ toPlayerName: '弃牌区',
+ cardsCount: 1
+ })),
+ animationDuration: 1600
+ }));
+ });
+ await expect(exchangeLayer).toBeVisible();
+ await expect(exchangeLayer).toHaveAttribute('aria-label', '弃掷逦迤 弃牌动画');
+ await expect(exchangeLayer.locator('.exchange-flying-card')).toHaveCount(4);
+ await expect(page.getByRole('button', { name: /弃掷逦迤 · 暗弃中/ })).toBeDisabled();
+ await page.screenshot({
+ path: testInfo.outputPath('lingering-discard-animation.png'),
+ animations: 'allow'
+ });
+
+ await page.evaluate(async () => {
+ const socketService = (await import('/src/services/socket.js')).default;
+ socketService.socket.listeners('last_stand_decision_required').forEach(listener => listener({
+ cardsCount: 7,
+ suit: 'diamonds'
+ }));
+ });
+ const lastStandDialog = page.getByRole('dialog', { name: '绝处逢生' });
+ await expect(lastStandDialog).toBeVisible();
+ const lastStandBox = await lastStandDialog.boundingBox();
+ expect(lastStandBox.y).toBeGreaterThan(360);
+ expect(lastStandBox.height).toBeLessThan(260);
+ await expect(lastStandDialog).toContainText('7 张同花色手牌且没有主牌');
+ await expect(lastStandDialog.getByRole('button', { name: /^发\s*动$/ })).toBeVisible();
+ await expect(lastStandDialog.getByRole('button', { name: /^暂\s*不\s*发\s*动$/ })).toBeVisible();
+ await lastStandDialog.locator('.ant-modal-content').screenshot({
+ path: testInfo.outputPath('last-stand-decision.png')
+ });
+ } finally {
+ await context.close();
+ }
+});
+
+test('无人生还在本人视角始终明置自己的跟牌,并只在轮末显示本轮分数', async ({ browser }) => {
+ test.setTimeout(120_000);
+ const { context, pages } = await openTestModeGame(browser, '无人生还');
+ const page = pages[0];
+
+ try {
+ await finishDealerBury(pages);
+ await page.evaluate(async () => {
+ const { useGameStore } = await import('/src/store/gameStore.js');
+ const socketService = (await import('/src/services/socket.js')).default;
+ const state = useGameStore.getState();
+ const players = state.currentRoom.players;
+ const emitLocal = (event, payload) => {
+ socketService.socket.listeners(event).forEach(listener => listener(payload));
+ };
+ const cards = [
+ { id: 'no-one-leader-5', suit: 'hearts', rank: '5', copyIndex: 300 },
+ { id: 'no-one-own-10', suit: 'hearts', rank: '10', copyIndex: 301 },
+ { id: 'no-one-third-3', suit: 'hearts', rank: '3', copyIndex: 302 },
+ { id: 'no-one-fourth-4', suit: 'hearts', rank: '4', copyIndex: 303 }
+ ];
+
+ emitLocal('cards_played', {
+ playerId: players[1].id,
+ playerName: players[1].name,
+ cards: [cards[0]],
+ cardsCount: 1,
+ concealed: false,
+ currentWinningPlayerId: players[1].id
+ });
+ emitLocal('concealed_cards_played_private', {
+ playerId: players[0].id,
+ cards: [cards[1]]
+ });
+ emitLocal('cards_played', {
+ playerId: players[0].id,
+ playerName: players[0].name,
+ cards: [],
+ removedCardIds: [cards[1].id],
+ cardsCount: 1,
+ concealed: true,
+ currentWinningPlayerId: null
+ });
+ [2, 3].forEach((playerIndex, offset) => emitLocal('cards_played', {
+ playerId: players[playerIndex].id,
+ playerName: players[playerIndex].name,
+ cards: [],
+ removedCardIds: [cards[offset + 2].id],
+ cardsCount: 1,
+ concealed: true,
+ currentWinningPlayerId: null
+ }));
+
+ window.__noOneSurvivesReveal = () => emitLocal('concealed_plays_revealed', {
+ plays: players.map((player, index) => ({
+ playerId: player.id,
+ concealed: index !== 1,
+ cards: [cards[index === 0 ? 1 : index === 1 ? 0 : index]]
+ }))
+ });
+ });
+
+ const ownPlay = page.locator('.played-cards-bottom');
+ await expect(ownPlay.locator('.card')).toHaveCount(1);
+ await expect(ownPlay.locator('.concealed-play-stack')).toHaveCount(0);
+ await expect(page.locator('.round-points-indicator')).toHaveCount(0);
+
+ await page.evaluate(() => window.__noOneSurvivesReveal());
+
+ await expect(page.locator('.round-points-value')).toHaveText('15');
+ await expect(ownPlay).not.toHaveClass(/concealed-just-revealed/);
+ await expect(page.locator('.concealed-just-revealed')).toHaveCount(2);
+ } finally {
+ await context.close();
+ }
+});
+
+test('暗度陈仓和偷梁换柱发动后主操作仍称为出牌', async ({ browser }) => {
+ test.setTimeout(180_000);
+
+ const concealedGame = await openTestModeGame(browser, '暗度陈仓');
+ try {
+ const dealerPageIndex = await finishDealerBury(concealedGame.pages);
+ const dealerPage = concealedGame.pages[dealerPageIndex];
+ await dealerPage.locator('.my-hand .card').first().dispatchEvent('click');
+ const dealerPlayButton = dealerPage.getByRole('button', { name: '出牌(1)', exact: true });
+ await expect(dealerPlayButton).toBeEnabled();
+ await dealerPlayButton.click();
+
+ const followerPage = concealedGame.pages[(dealerPageIndex + 1) % 4];
+ const concealedSkillButton = followerPage.getByRole('button', { name: '暗度陈仓', exact: true });
+ await expect(concealedSkillButton).toBeEnabled();
+ await concealedSkillButton.click();
+ await expect(followerPage.getByRole('button', { name: '出牌(0)', exact: true })).toBeVisible();
+ await expect(followerPage.getByRole('button', { name: /垫牌\(/ })).toHaveCount(0);
+ } finally {
+ await concealedGame.context.close();
+ }
+
+ const stealingGame = await openTestModeGame(browser, '偷梁换柱');
+ try {
+ const dealerPageIndex = await finishDealerBury(stealingGame.pages);
+ const dealerPage = stealingGame.pages[dealerPageIndex];
+ const stealingSkillButton = dealerPage.getByRole('button', { name: '偷梁换柱', exact: true });
+ await expect(stealingSkillButton).toBeEnabled();
+ await stealingSkillButton.click();
+ await expect(dealerPage.getByRole('button', { name: '出牌(0)', exact: true })).toBeVisible();
+ await expect(dealerPage.getByRole('button', { name: /垫牌\(/ })).toHaveCount(0);
+ } finally {
+ await stealingGame.context.close();
+ }
+});
+
+test('后发制人在三号位确认后改为四号先出,取消不消耗机会', async ({ browser }, testInfo) => {
+ test.setTimeout(180_000);
+ const { context, pages } = await openTestModeGame(browser, '后发制人');
+
+ try {
+ await Promise.all(pages.map(page => page.setViewportSize({ width: 1440, height: 900 })));
+ const firstPlayerIndex = await finishDealerBury(pages);
+ const secondPlayerIndex = (firstPlayerIndex + 1) % 4;
+ const thirdPlayerIndex = (firstPlayerIndex + 2) % 4;
+ const fourthPlayerIndex = (firstPlayerIndex + 3) % 4;
+
+ await playLegalSingle(pages[firstPlayerIndex]);
+ await playLegalSingle(pages[secondPlayerIndex]);
+
+ const thirdPage = pages[thirdPlayerIndex];
+ const fourthPage = pages[fourthPlayerIndex];
+ const skillButton = thirdPage.getByRole('button', { name: '后发制人', exact: true });
+ await expect(skillButton).toBeEnabled();
+ await expect(skillButton).toHaveClass(/is-ready/);
+
+ await skillButton.click();
+ const dialog = thirdPage.getByRole('dialog', { name: '后发制人' });
+ await expect(dialog).toBeVisible();
+ await expect(dialog).toContainText('只改变牌序,不改变牌的大小');
+ const dialogBox = await dialog.boundingBox();
+ expect(dialogBox.y).toBeGreaterThan(400);
+ expect(dialogBox.height).toBeLessThan(240);
+ await dialog.screenshot({ path: testInfo.outputPath('late-mover-decision.png') });
+
+ await dialog.getByRole('button', { name: '否,保持当前牌序', exact: true }).click();
+ await expect(dialog).toBeHidden();
+ await expect(skillButton).toBeEnabled();
+ await expect(skillButton).toHaveClass(/is-ready/);
+ await expect(skillButton).not.toHaveClass(/is-used/);
+
+ await skillButton.click();
+ await dialog.getByRole('button', { name: '是,让下家先出', exact: true }).click();
+ await expect(fourthPage.locator('.player-bottom.current-turn')).toBeVisible();
+ await expect(thirdPage.locator('.player-bottom.current-turn')).toHaveCount(0);
+ await expect(skillButton).toBeDisabled();
+ await expect(skillButton).toHaveClass(/is-used/);
+
+ await playLegalSingle(fourthPage);
+ await expect(thirdPage.locator('.player-bottom.current-turn')).toBeVisible();
+ await expect(fourthPage.locator('.player-bottom.current-turn')).toHaveCount(0);
+ await playLegalSingle(thirdPage);
+ } finally {
+ await context.close();
+ }
+});
+
+test('一马当先只把首轮出牌权交给庄家队友,庄家仍保留底牌权限', async ({ browser }, testInfo) => {
+ test.setTimeout(120_000);
+ const { context, pages } = await openTestModeGame(browser, '一马当先');
+
+ try {
+ let dealerPageIndex = -1;
+ await expect.poll(async () => {
+ for (let index = 0; index < pages.length; index += 1) {
+ if (await pages[index].locator('.player-bottom').getByText('庄', { exact: true }).isVisible().catch(() => false)) {
+ return index;
+ }
+ }
+ return -1;
+ }, { timeout: 25_000 }).toBeGreaterThanOrEqual(0);
+ for (let index = 0; index < pages.length; index += 1) {
+ if (await pages[index].locator('.player-bottom').getByText('庄', { exact: true }).isVisible().catch(() => false)) {
+ dealerPageIndex = index;
+ break;
+ }
+ }
+
+ const dealerPage = pages[dealerPageIndex];
+ const teammatePage = pages[(dealerPageIndex + 2) % 4];
+ const dealerCards = dealerPage.locator('.my-hand .card');
+ await expect(dealerCards).toHaveCount(33);
+ for (let cardIndex = 0; cardIndex < 8; cardIndex += 1) {
+ await dealerCards.nth(cardIndex).dispatchEvent('click');
+ }
+ await dealerPage.getByRole('button', { name: '埋底(8/8)' }).click();
+
+ await expect(teammatePage.locator('.player-bottom.current-turn')).toBeVisible();
+ await expect(dealerPage.locator('.player-bottom.current-turn')).toHaveCount(0);
+ await expect(dealerPage.locator('.player-bottom').getByText('庄', { exact: true })).toBeVisible();
+ await expect(dealerPage.getByRole('button', { name: '查看底牌' })).toBeVisible();
+ await expect(teammatePage.getByRole('button', { name: '查看底牌' })).toHaveCount(0);
+ await dealerPage.locator('.game-table').screenshot({
+ path: testInfo.outputPath('one-horse-dealer-remains-dealer.png')
+ });
+ } finally {
+ await context.close();
+ }
+});
+
+test('迷雾牌只在终局结算中以紧凑横栏公开并补分', async ({ browser }, testInfo) => {
+ test.setTimeout(120_000);
+ const { context, pages } = await openTestModeGame(browser, '迷雾重重', {
+ initialHandCount: 23
+ });
+
+ try {
+ await Promise.all(pages.map(page => page.setViewportSize({ width: 1440, height: 900 })));
+ await finishDealerBury(pages, { initialHandCount: 23 });
+ await pages[0].evaluate(async () => {
+ const { useGameStore } = await import('/src/store/gameStore.js');
+ const socketService = (await import('/src/services/socket.js')).default;
+ const state = useGameStore.getState();
+ useGameStore.setState({
+ currentRoom: {
+ ...state.currentRoom,
+ gameState: {
+ ...state.currentRoom.gameState,
+ phase: 'revealing',
+ attackerScore: 82.5
+ }
+ }
+ });
+ const bottomCards = ['5', '2', '3', '4', '6', '7', '8', '9'].map((rank, index) => ({
+ id: `fog-bottom-${index}`,
+ suit: index % 2 === 0 ? 'diamonds' : 'clubs',
+ rank,
+ copyIndex: index
+ }));
+ const mistyFogCards = ['5', '10', 'K', '3', '4', '6', '8', 'Q'].map((rank, index) => ({
+ id: `fog-hidden-${index}`,
+ suit: index < 4 ? 'hearts' : 'spades',
+ rank,
+ copyIndex: index
+ }));
+ socketService.socket.listeners('bottom_revealed').forEach(listener => listener({
+ bottomCards,
+ bottomScoreResult: {
+ attackerWonBottom: true,
+ resultText: '闲家拿底',
+ bottomPoints: 5,
+ bottomMultiplier: 2,
+ bottomScoreGained: 10,
+ scoreBeforeMistyFog: 70,
+ mistyFogCards,
+ mistyFogPoints: 25,
+ mistyFogBonus: 12.5,
+ totalScore: 82.5,
+ collectedPointCards: []
+ },
+ upgradeResult: {
+ attackerWon: true,
+ oldDealerLevel: 2,
+ newDealerLevel: 2,
+ dealerLevelUp: 0,
+ oldAttackerLevel: 2,
+ newAttackerLevel: 2,
+ attackerLevelUp: 0,
+ nextDealerName: '玩家2',
+ nextDealerLevel: 2
+ }
+ }));
+ });
+
+ const settlementCenter = pages[0].locator('.settlement-table-center');
+ const settlementPanel = pages[0].locator('.settlement-panel');
+ const fogSummary = pages[0].locator('.settlement-misty-fog');
+ await expect(fogSummary).toBeVisible();
+ await expect(fogSummary).toContainText('迷雾牌 · 终局公开');
+ await expect(fogSummary).toContainText('牌面 25 分');
+ await expect(fogSummary).toContainText('闲家补 +12.5 分');
+ await expect(fogSummary).toContainText('最终 82.5 分');
+ await expect(fogSummary.locator('.card')).toHaveCount(8);
+ await expect.poll(async () => fogSummary.evaluate((element) => {
+ const cards = [...element.querySelectorAll('.card')];
+ const containerRight = element.getBoundingClientRect().right;
+ const rightmostCard = cards.at(-1).getBoundingClientRect();
+ return rightmostCard.right <= containerRight - 8
+ && cards.slice(0, -1).every((card, index) => {
+ const corner = card.querySelector('.card-corner.top-left');
+ const centerSuit = card.querySelector('.card-center');
+ const nextCard = cards[index + 1];
+ const nextCardLeft = nextCard.getBoundingClientRect().left;
+ const centerSuitRect = centerSuit.getBoundingClientRect();
+ return corner.getBoundingClientRect().right <= nextCardLeft
+ && nextCardLeft <= centerSuitRect.left + centerSuitRect.width / 2;
+ });
+ })).toBe(true);
+ await expect(pages[0].locator('.settlement-bottom-result')).toContainText('闲家总分:70 分');
+ await expect.poll(async () => {
+ const centerBox = await settlementCenter.boundingBox();
+ const panelBox = await settlementPanel.boundingBox();
+ const fogBox = await fogSummary.boundingBox();
+ const fogContentFits = await fogSummary.evaluate(element => {
+ const container = element.getBoundingClientRect();
+ return [...element.querySelectorAll('.card, .settlement-misty-fog-summary')]
+ .every((child) => {
+ const rect = child.getBoundingClientRect();
+ return rect.left >= container.left - 1
+ && rect.right <= container.right + 1
+ && rect.top >= container.top - 1
+ && rect.bottom <= container.bottom + 1;
+ });
+ });
+ if (!centerBox || !panelBox || !fogBox) return false;
+ return panelBox.width <= 520
+ && panelBox.y >= centerBox.y
+ && panelBox.y + panelBox.height <= centerBox.y + centerBox.height
+ && fogBox.x >= panelBox.x
+ && fogBox.x + fogBox.width <= panelBox.x + panelBox.width
+ && fogContentFits;
+ }).toBe(true);
+ await pages[0].locator('.game-table').screenshot({
+ path: testInfo.outputPath('heavy-fog-settlement.png')
+ });
+
+ await pages[0].evaluate(async () => {
+ const socketService = (await import('/src/services/socket.js')).default;
+ const bottomCards = ['5', '2', '3', '4', '6', '7', '8', '9'].map((rank, index) => ({
+ id: `discard-bottom-${index}`,
+ suit: index % 2 === 0 ? 'diamonds' : 'clubs',
+ rank,
+ copyIndex: index
+ }));
+ const lingeringDiscardCards = ['10', '5', 'K'].map((rank, index) => ({
+ id: `dealer-discard-${index}`,
+ suit: index < 4 ? 'hearts' : 'spades',
+ rank,
+ copyIndex: index
+ }));
+ socketService.socket.listeners('bottom_revealed').forEach(listener => listener({
+ bottomCards,
+ bottomScoreResult: {
+ attackerWonBottom: false,
+ resultText: '庄家守底',
+ bottomPoints: 5,
+ bottomMultiplier: 2,
+ bottomScoreGained: 0,
+ scoreBeforeLingeringDiscard: 70,
+ lingeringDiscardCards,
+ lingeringDiscardPoints: 25,
+ lingeringDiscardBonus: 25,
+ totalScore: 95,
+ collectedPointCards: []
+ },
+ upgradeResult: {
+ attackerWon: true,
+ oldDealerLevel: 2,
+ newDealerLevel: 2,
+ dealerLevelUp: 0,
+ oldAttackerLevel: 2,
+ newAttackerLevel: 2,
+ attackerLevelUp: 0,
+ nextDealerName: '玩家2',
+ nextDealerLevel: 2
+ }
+ }));
+ });
+ const discardSummary = pages[0].locator('.settlement-lingering-discard');
+ await expect(discardSummary).toBeVisible();
+ await expect(discardSummary).toContainText('弃掷逦迤 · 分牌公开');
+ await expect(discardSummary).toContainText('闲家补 +25 分');
+ await expect(discardSummary.locator('.card')).toHaveCount(3);
+ await expect(pages[0].locator('.settlement-bottom-result')).toContainText('闲家总分:70 分');
+ await expect.poll(async () => discardSummary.evaluate(element => {
+ const container = element.getBoundingClientRect();
+ return [...element.querySelectorAll('.card, .settlement-misty-fog-summary')]
+ .every((child) => {
+ const rect = child.getBoundingClientRect();
+ return rect.left >= container.left - 1
+ && rect.right <= container.right + 1
+ && rect.top >= container.top - 1
+ && rect.bottom <= container.bottom + 1;
+ });
+ })).toBe(true);
+ await pages[0].locator('.game-table').screenshot({
+ path: testInfo.outputPath('lingering-discard-settlement.png')
+ });
+ } finally {
+ await context.close();
+ }
+});
+
+test('改革开放由庄家队友接过八张底牌再埋,最后仍由庄家首发', async ({ browser }, testInfo) => {
+ test.setTimeout(120_000);
+ const { context, pages } = await openTestModeGame(browser, '改革开放');
+
+ try {
+ await Promise.all(pages.map(page => page.setViewportSize({ width: 1440, height: 900 })));
+ let dealerPageIndex = -1;
+ await expect.poll(async () => {
+ for (let index = 0; index < pages.length; index += 1) {
+ const dealerBadge = pages[index].locator('.player-bottom').getByText('庄', { exact: true });
+ if (await dealerBadge.isVisible().catch(() => false)) return index;
+ }
+ return -1;
+ }, { timeout: 25_000 }).toBeGreaterThanOrEqual(0);
+ for (let index = 0; index < pages.length; index += 1) {
+ if (await pages[index].locator('.player-bottom').getByText('庄', { exact: true }).isVisible().catch(() => false)) {
+ dealerPageIndex = index;
+ break;
+ }
+ }
+
+ const teammatePageIndex = (dealerPageIndex + 2) % 4;
+ const dealerPage = pages[dealerPageIndex];
+ const teammatePage = pages[teammatePageIndex];
+ await Promise.all(pages.map(page =>
+ expect(page.locator('.score-panel').getByText('40 分', { exact: true })).toBeVisible()
+ ));
+ await expect(dealerPage.locator('.my-hand .card')).toHaveCount(33);
+ await expect(teammatePage.locator('.my-hand .card')).toHaveCount(25);
+
+ const dealerCards = dealerPage.locator('.my-hand .card');
+ const firstBottomIds = [];
+ for (let index = 0; index < 8; index += 1) {
+ firstBottomIds.push(await dealerCards.nth(index).getAttribute('data-card-id'));
+ await dealerCards.nth(index).dispatchEvent('click');
+ }
+ await dealerPage.getByRole('button', { name: '埋底(8/8)' }).click();
+
+ await Promise.all(pages.map(async page => {
+ const animation = page.locator('.card-exchange-animation-layer');
+ await expect(animation).toBeVisible();
+ await expect(animation.locator('.exchange-flying-card')).toHaveCount(8);
+ await expect(animation).toHaveAttribute('aria-label', '改革开放 · 底牌交接 换牌动画');
+ }));
+ await teammatePage.locator('.card-exchange-animation-layer').screenshot({
+ path: testInfo.outputPath('reform-bottom-transfer.png')
+ });
+ await expect(teammatePage.locator('.my-hand .card')).toHaveCount(33);
+ const teammateHandAfterTransfer = await teammatePage.locator('.my-hand .card').evaluateAll(cards =>
+ cards.map(card => card.dataset.cardId)
+ );
+ firstBottomIds.forEach(cardId => expect(teammateHandAfterTransfer).toContain(cardId));
+ await Promise.all(pages.map(page =>
+ expect(page.locator('.card-exchange-animation-layer')).toBeHidden({ timeout: 5_000 })
+ ));
+
+ await expect(teammatePage.getByRole('button', { name: '再埋底(0/8)' })).toBeDisabled();
+ await expect(dealerPage.getByRole('button', { name: /等待 .* 再埋底/ })).toBeDisabled();
+ const teammateCards = teammatePage.locator('.my-hand .card');
+ for (let index = 0; index < 8; index += 1) {
+ await teammateCards.nth(index).dispatchEvent('click');
+ }
+ await teammatePage.getByRole('button', { name: '再埋底(8/8)' }).click();
+
+ await Promise.all(pages.map(page =>
+ expect(page.locator('.my-hand .card')).toHaveCount(25)
+ ));
+ await expect(dealerPage.locator('.player-bottom.current-turn')).toBeVisible();
+ await expect(teammatePage.locator('.player-bottom.current-turn')).toHaveCount(0);
+ await expect(dealerPage.getByRole('button', { name: /^出牌\(0\)$/ })).toBeVisible();
+ } finally {
+ await context.close();
+ }
+});
+
+test('李代桃僵以灰色主动技能按钮发动,任意垫出的主牌仍视为小', async ({ browser }, testInfo) => {
+ test.setTimeout(120_000);
+ const { context, pages } = await openTestModeGame(browser, '李代桃僵');
+
+ try {
+ await Promise.all(pages.map(page => page.setViewportSize({ width: 1440, height: 900 })));
+ const dealerPageIndex = await finishDealerBury(pages);
+ const followerPageIndex = (dealerPageIndex + 1) % 4;
+ const dealerPage = pages[dealerPageIndex];
+ const followerPage = pages[followerPageIndex];
+
+ const dealerCards = await dealerPage.locator('.my-hand .card').evaluateAll(cards => cards.map(card => ({
+ id: card.dataset.cardId,
+ suit: card.dataset.cardId.split('-')[0],
+ isTrump: Boolean(card.querySelector('.trump-badge'))
+ })));
+ const followerCards = await followerPage.locator('.my-hand .card').evaluateAll(cards => cards.map(card => ({
+ id: card.dataset.cardId,
+ suit: card.dataset.cardId.split('-')[0],
+ isTrump: Boolean(card.querySelector('.trump-badge'))
+ })));
+ const trumpDiscard = followerCards.find(card => card.isTrump);
+ const leadCard = dealerCards.find(card =>
+ !card.isTrump && followerCards.some(followerCard =>
+ !followerCard.isTrump && followerCard.suit === card.suit
+ )
+ );
+ expect(trumpDiscard).toBeTruthy();
+ expect(leadCard).toBeTruthy();
+ const protectedFollower = followerCards.find(card =>
+ !card.isTrump && card.suit === leadCard.suit
+ );
+
+ const dealerSkillButton = dealerPage.getByRole('button', { name: '李代桃僵', exact: true });
+ await expect(dealerSkillButton).toBeDisabled();
+ await expect(dealerSkillButton).toHaveAttribute('aria-pressed', 'false');
+ await dealerPage.locator(`.my-hand .card[data-card-id="${leadCard.id}"]`).dispatchEvent('click');
+ await dealerPage.getByRole('button', { name: '出牌(1)', exact: true }).click();
+
+ await expect(followerPage.locator('.player-bottom.current-turn')).toBeVisible();
+ const skillButton = followerPage.getByRole('button', { name: '李代桃僵', exact: true });
+ await expect(skillButton).toBeEnabled();
+ await expect(skillButton).toHaveAttribute('aria-pressed', 'false');
+ await expect(skillButton).not.toHaveClass(/is-armed/);
+ const idleSkillBackground = await skillButton.evaluate(button => getComputedStyle(button).backgroundImage);
+
+ for (const selectedCard of await followerPage.locator('.my-hand .card.selected').all()) {
+ await selectedCard.dispatchEvent('click');
+ }
+ await followerPage.locator(`.my-hand .card[data-card-id="${trumpDiscard.id}"]`).dispatchEvent('click');
+ await expect(followerPage.getByRole('button', { name: '出牌(1)', exact: true })).toBeDisabled();
+
+ await skillButton.click();
+ await expect(skillButton).toHaveClass(/is-armed/);
+ await expect(skillButton).toHaveAttribute('aria-pressed', 'true');
+ expect(await skillButton.evaluate(button => getComputedStyle(button).backgroundImage)).not.toBe(idleSkillBackground);
+ await expect(followerPage.getByRole('button', { name: '垫牌(0)', exact: true })).toBeDisabled();
+ await followerPage.locator(`.my-hand .card[data-card-id="${trumpDiscard.id}"]`).dispatchEvent('click');
+ const discardButton = followerPage.getByRole('button', { name: '垫牌(1)', exact: true });
+ await expect(discardButton).toBeEnabled();
+ await followerPage.locator('.player-bottom').screenshot({
+ path: testInfo.outputPath('substitute-sacrifice-armed-button.png')
+ });
+ await discardButton.click();
+
+ await Promise.all(pages.map(page =>
+ expect(page.locator('.active-skill-activation')).toBeVisible()
+ ));
+ await followerPage.waitForTimeout(260);
+ await followerPage.screenshot({
+ path: testInfo.outputPath('substitute-sacrifice-activation.png'),
+ animations: 'allow'
+ });
+ await expect(followerPage.locator('.position-bottom .played-cards-area')).toHaveAttribute('data-treated-as-small', 'true');
+ await expect(followerPage.locator('.position-bottom .active-skill-play-badge')).toHaveText('李代桃僵 · 小');
+ await expect(dealerPage.locator('.position-bottom .played-cards-area')).toHaveClass(/winning-play/);
+ await expect(followerPage.locator(`.my-hand .card[data-card-id="${protectedFollower.id}"]`)).toHaveCount(1);
+ await expect(skillButton).toBeDisabled();
+ await expect(skillButton).toHaveClass(/is-used/);
+ await expect(followerPage.locator('.active-skill-activation-overlay')).toBeHidden({ timeout: 3_000 });
+ await followerPage.locator('.game-table').screenshot({
+ path: testInfo.outputPath('substitute-sacrifice-treated-small.png')
+ });
+ } finally {
+ await context.close();
+ }
+});
+
+test('时间倒流允许多人预备,并可在第四家出完后的窗口加入', async ({ browser }, testInfo) => {
+ test.setTimeout(150_000);
+ const { context, pages } = await openTestModeGame(browser, '时间倒流');
+
+ try {
+ await Promise.all(pages.map(page => page.setViewportSize({ width: 1440, height: 900 })));
+ const dealerPageIndex = await finishDealerBury(pages);
+ const activatorPageIndex = (dealerPageIndex + 2) % pages.length;
+ const lateActivatorPageIndex = (dealerPageIndex + 1) % pages.length;
+ const activatorPage = pages[activatorPageIndex];
+ const lateActivatorPage = pages[lateActivatorPageIndex];
+ const dealerPage = pages[dealerPageIndex];
+ const skillButton = activatorPage.getByRole('button', { name: '时间倒流', exact: true });
+
+ await expect(dealerPage.locator('.player-bottom.current-turn')).toBeVisible();
+ await expect(activatorPage.locator('.player-bottom.current-turn')).toHaveCount(0);
+ await expect(skillButton).toBeEnabled();
+ await skillButton.click();
+ await expect(skillButton).toHaveAttribute('aria-pressed', 'true');
+ await expect(skillButton).toHaveClass(/is-armed/);
+ await expect(lateActivatorPage.getByRole('button', { name: '时间倒流', exact: true })).toBeEnabled();
+
+ for (let playIndex = 0; playIndex < 4; playIndex += 1) {
+ let turnPage = null;
+ await expect.poll(async () => {
+ for (const page of pages) {
+ if (await page.locator('.player-bottom.current-turn').isVisible().catch(() => false)) {
+ turnPage = page;
+ return true;
+ }
+ }
+ return false;
+ }).toBe(true);
+ await playLegalSingle(turnPage);
+ if (playIndex < 3) {
+ await expect(turnPage.locator('.player-bottom.current-turn')).toBeHidden();
+ }
+ }
+
+ await expect(activatorPage.locator('.played-cards-area .card')).toHaveCount(4);
+ await activatorPage.waitForTimeout(1050);
+ // 普通规则在 1 秒时已经清桌;时间倒流此时仍处于刚结束这一轮的预备窗口。
+ await expect(activatorPage.locator('.played-cards-area .card')).toHaveCount(4);
+ const lateSkillButton = lateActivatorPage.getByRole('button', { name: '时间倒流', exact: true });
+ await expect(lateSkillButton).toBeEnabled();
+ await lateSkillButton.click();
+ await expect(lateSkillButton).toHaveAttribute('aria-pressed', 'true');
+ await expect(lateSkillButton).toHaveClass(/is-armed/);
+
+ const dialog = activatorPage.getByRole('dialog', { name: '时间倒流' });
+ const lateDialog = lateActivatorPage.getByRole('dialog', { name: '时间倒流' });
+ await expect(dialog).toBeVisible({ timeout: 3_000 });
+ await expect(lateDialog).toBeVisible({ timeout: 3_000 });
+ let pendingTurnPage = null;
+ await expect.poll(async () => {
+ for (const page of pages) {
+ if (await page.locator('.player-bottom.current-turn').isVisible().catch(() => false)) {
+ pendingTurnPage = page;
+ return true;
+ }
+ }
+ return false;
+ }).toBe(true);
+ const pendingHandCount = await pendingTurnPage.locator('.my-hand .card').count();
+ await expect(pendingTurnPage.getByRole('button', { name: /^出牌\(\d+\)$/ })).toBeDisabled();
+ await expect(pendingTurnPage.locator('.my-hand .card').first()).toHaveAttribute('aria-disabled', 'true');
+ await pendingTurnPage.locator('.my-hand .card').first().dispatchEvent('click');
+ await expect(pendingTurnPage.locator('.my-hand .card.selected')).toHaveCount(0);
+ await expect(pendingTurnPage.locator('.my-hand .card')).toHaveCount(pendingHandCount);
+ await expect(dialog).toContainText('收回本轮四家的出牌并重新出牌');
+ const dialogBox = await dialog.boundingBox();
+ expect(dialogBox.y).toBeGreaterThan(350);
+ await activatorPage.screenshot({
+ path: testInfo.outputPath('time-reversal-decision.png')
+ });
+
+ await lateDialog.getByRole('button', { name: '倒流,重打本轮' }).click();
+ await expect(dialog).toBeHidden();
+ await Promise.all(pages.map(page =>
+ expect(page.locator('.my-hand .card')).toHaveCount(25)
+ ));
+ await Promise.all(pages.map(page =>
+ expect(page.locator('.played-cards-area .card')).toHaveCount(0)
+ ));
+ await expect(dealerPage.locator('.player-bottom.current-turn')).toBeVisible();
+ await expect(skillButton).toBeDisabled();
+ await expect(skillButton).not.toHaveClass(/is-used/);
+ await expect(lateSkillButton).toBeDisabled();
+ await expect(lateSkillButton).toHaveClass(/is-used/);
+ } finally {
+ await context.close();
+ }
+});
+
+test.describe.serial('三条开局换牌规则', () => {
+ for (const [ruleName, rule] of Object.entries(openingExchangeRules)) {
+ test(`${ruleName} 在四个真实页面中完成选牌、动画和手牌交换`, async ({ browser }, testInfo) => {
+ test.setTimeout(180_000);
+ const { context, pages } = await openGameOfferingRule(browser, ruleName, rule.id);
+
+ try {
+ await Promise.all(pages.map((playerPage) =>
+ playerPage.getByRole('button', { name: /准\s*备/ }).click()
+ ));
+ await Promise.all(pages.map((playerPage) =>
+ expect(playerPage.locator('.my-hand .card')).toHaveCount(25, { timeout: 15_000 })
+ ));
+
+ const initialHands = await Promise.all(pages.map((playerPage) =>
+ playerPage.locator('.my-hand .card').evaluateAll((cards) =>
+ cards.map((card) => card.dataset.cardId)
+ )
+ ));
+ const sentCards = initialHands.map((hand) => hand.slice(0, 2));
+
+ for (let playerIndex = 0; playerIndex < pages.length; playerIndex += 1) {
+ const targetIndex = (playerIndex + rule.targetOffset + pages.length) % pages.length;
+ await expect(pages[playerIndex].getByText(
+ `请选择 2 张牌交给 玩家${targetIndex + 1}`,
+ { exact: true }
+ )).toBeVisible({ timeout: 20_000 });
+ await expect(pages[playerIndex].locator('.declaration-slot.active')).toHaveCount(0);
+ const cards = pages[playerIndex].locator('.my-hand .card');
+ // 手牌有重叠,普通坐标点击可能命中盖在上面的牌;直接向目标 DOM 分发点击。
+ await cards.nth(0).dispatchEvent('click');
+ await cards.nth(1).dispatchEvent('click');
+ const selectedIds = await pages[playerIndex]
+ .locator('.my-hand .card.selected')
+ .evaluateAll((cards) => cards.map((card) => card.dataset.cardId));
+ expect(new Set(selectedIds)).toEqual(new Set(sentCards[playerIndex]));
+ await expect(pages[playerIndex].getByRole('button', { name: '确认换牌(2/2)' })).toBeEnabled();
+ }
+
+ for (let playerIndex = 0; playerIndex < pages.length - 1; playerIndex += 1) {
+ await pages[playerIndex].getByRole('button', { name: '确认换牌(2/2)' }).click();
+ }
+ await expect(pages[0].getByText(/等待其他玩家 · 3\/4/)).toBeVisible();
+
+ await pages[3].getByRole('button', { name: '确认换牌(2/2)' }).click();
+ await Promise.all(pages.map((playerPage) =>
+ expect(playerPage.locator('.card-exchange-animation-layer')).toBeVisible()
+ ));
+ await Promise.all(pages.map((playerPage) =>
+ expect(playerPage.locator('.exchange-flying-card')).toHaveCount(8)
+ ));
+
+ const ownOutgoingPath = await pages[0].locator('.exchange-flying-card').evaluateAll((cards) =>
+ cards.map((card) => {
+ const style = getComputedStyle(card);
+ return {
+ startX: style.getPropertyValue('--exchange-start-x').trim(),
+ startY: style.getPropertyValue('--exchange-start-y').trim(),
+ endX: style.getPropertyValue('--exchange-end-x').trim(),
+ endY: style.getPropertyValue('--exchange-end-y').trim()
+ };
+ }).filter(({ startX, startY }) => startX === '50%' && startY === '91%')
+ );
+ expect(ownOutgoingPath).toHaveLength(2);
+ expect(ownOutgoingPath.every(({ endX, endY }) =>
+ endX === rule.endX && endY === rule.endY
+ )).toBe(true);
+
+ await pages[0].locator('.game-table').screenshot({
+ path: testInfo.outputPath(`${ruleName}-card-exchange.png`),
+ animations: 'allow'
+ });
+ await Promise.all(pages.map((playerPage) =>
+ expect(playerPage.locator('.card-exchange-animation-layer')).toBeHidden({ timeout: 5_000 })
+ ));
+
+ const finalHands = await Promise.all(pages.map((playerPage) =>
+ playerPage.locator('.my-hand .card').evaluateAll((cards) =>
+ cards.map((card) => card.dataset.cardId)
+ )
+ ));
+ let dealerPageIndex = -1;
+ for (let index = 0; index < pages.length; index += 1) {
+ if (await pages[index].locator('.player-bottom').getByText('庄', { exact: true }).isVisible().catch(() => false)) {
+ dealerPageIndex = index;
+ break;
+ }
+ }
+ expect(dealerPageIndex).toBeGreaterThanOrEqual(0);
+ finalHands.forEach((hand, playerIndex) => {
+ expect(hand).toHaveLength(playerIndex === dealerPageIndex ? 33 : 25);
+ sentCards[playerIndex].forEach((cardId) => expect(hand).not.toContain(cardId));
+ const senderIndex = (playerIndex + rule.incomingOffset + pages.length) % pages.length;
+ sentCards[senderIndex].forEach((cardId) => expect(hand).toContain(cardId));
+ });
+ } finally {
+ await context.close();
+ }
+ });
+ }
+});
+
+test('算无遗策在四个页面中展示差异化明牌,并由庄家代打', async ({ browser }, testInfo) => {
+ test.setTimeout(180_000);
+ const { context, pages } = await openGameOfferingRule(browser, '算无遗策', 'perfect_strategy');
+
+ try {
+ await Promise.all(pages.map((playerPage) => playerPage.setViewportSize({ width: 1600, height: 1000 })));
+ await Promise.all(pages.map((playerPage) =>
+ playerPage.getByRole('button', { name: /准\s*备/ }).click()
+ ));
+
+ let dealerPageIndex = -1;
+ await expect.poll(async () => {
+ for (let index = 0; index < pages.length; index += 1) {
+ if (await pages[index].locator('.player-bottom').getByText('庄', { exact: true }).isVisible().catch(() => false)) {
+ return index;
+ }
+ }
+ return -1;
+ }, { timeout: 25_000 }).toBeGreaterThanOrEqual(0);
+
+ for (let index = 0; index < pages.length; index += 1) {
+ if (await pages[index].locator('.player-bottom').getByText('庄', { exact: true }).isVisible().catch(() => false)) {
+ dealerPageIndex = index;
+ break;
+ }
+ }
+
+ const dealerPage = pages[dealerPageIndex];
+ const openPlayerIndex = (dealerPageIndex + 2) % 4;
+ const openPlayerPage = pages[openPlayerIndex];
+ const firstAttackerIndex = (dealerPageIndex + 1) % 4;
+ const secondAttackerIndex = (dealerPageIndex + 3) % 4;
+ const firstAttackerPage = pages[firstAttackerIndex];
+ const secondAttackerPage = pages[secondAttackerIndex];
+ const openPlayerName = `玩家${openPlayerIndex + 1}`;
+
+ // 埋底阶段任何视角都不能看到明手身份或牌面。
+ await Promise.all(pages.map(async (playerPage) => {
+ await expect(playerPage.locator('.open-hand-panel')).toHaveCount(0);
+ await expect(playerPage.locator('.open-hand-self-status')).toHaveCount(0);
+ await expect(playerPage.locator('.open-hand-avatar-badge')).toHaveCount(0);
+ }));
+ await expect(dealerPage.locator('.score-panel').getByText('10 分', { exact: true })).toBeVisible();
+
+ const dealerCards = dealerPage.locator('.my-hand .card');
+ await expect(dealerCards).toHaveCount(33);
+ for (let cardIndex = 0; cardIndex < 8; cardIndex += 1) {
+ await dealerCards.nth(cardIndex).dispatchEvent('click');
+ }
+ await dealerPage.getByRole('button', { name: '埋底(8/8)' }).click();
+
+ // 正式进入出牌阶段后才公开庄家队友的手牌。
+ const dealerOpenHand = dealerPage.locator('[data-open-hand-position="top"]');
+ await expect(dealerOpenHand).toBeVisible();
+ await expect(dealerOpenHand.locator('.card')).toHaveCount(25);
+ await dealerPage.setViewportSize({ width: 667, height: 375 });
+ const mobileOpenHandGeometry = await dealerPage.evaluate(() => {
+ const rect = selector => {
+ const element = document.querySelector(selector);
+ if (!element) return null;
+ const box = element.getBoundingClientRect();
+ return { top: box.top, right: box.right, bottom: box.bottom, left: box.left };
+ };
+ const overlaps = (first, second) => Boolean(
+ first && second
+ && first.left < second.right
+ && first.right > second.left
+ && first.top < second.bottom
+ && first.bottom > second.top
+ );
+ const panel = rect('[data-open-hand-position="top"]');
+ const cards = Array.from(document.querySelectorAll('[data-open-hand-position="top"] .card-wrapper'))
+ .map(card => {
+ const box = card.getBoundingClientRect();
+ return { top: box.top, right: box.right, bottom: box.bottom, left: box.left };
+ });
+ return {
+ panel,
+ cards,
+ viewport: { width: window.innerWidth, height: window.innerHeight },
+ documentWidth: document.documentElement.scrollWidth,
+ scrollX: window.scrollX,
+ overlapsScore: overlaps(panel, rect('.score-panel')),
+ overlapsRules: overlaps(panel, rect('.center-content.table-tools')),
+ overlapsBottomPlayer: overlaps(panel, rect('.player-bottom')),
+ overlapsTopPlay: overlaps(panel, rect('.played-cards-top'))
+ };
+ });
+ expect(mobileOpenHandGeometry.panel.left).toBeGreaterThanOrEqual(0);
+ expect(mobileOpenHandGeometry.panel.right).toBeLessThanOrEqual(mobileOpenHandGeometry.viewport.width);
+ expect(mobileOpenHandGeometry.documentWidth).toBeLessThanOrEqual(mobileOpenHandGeometry.viewport.width + 1);
+ expect(mobileOpenHandGeometry.scrollX).toBe(0);
+ expect(mobileOpenHandGeometry.overlapsScore).toBe(false);
+ expect(mobileOpenHandGeometry.overlapsRules).toBe(false);
+ expect(mobileOpenHandGeometry.overlapsBottomPlayer).toBe(false);
+ expect(mobileOpenHandGeometry.overlapsTopPlay).toBe(false);
+ mobileOpenHandGeometry.cards.forEach(card => {
+ expect(card.left).toBeGreaterThanOrEqual(mobileOpenHandGeometry.panel.left - 1);
+ expect(card.right).toBeLessThanOrEqual(mobileOpenHandGeometry.panel.right + 1);
+ expect(card.top).toBeGreaterThanOrEqual(mobileOpenHandGeometry.panel.top - 1);
+ expect(card.bottom).toBeLessThanOrEqual(mobileOpenHandGeometry.panel.bottom + 1);
+ });
+ await dealerPage.screenshot({ path: testInfo.outputPath('perfect-strategy-mobile-landscape.png') });
+ await dealerPage.setViewportSize({ width: 1600, height: 1000 });
+ await expect(dealerOpenHand).toContainText(`${openPlayerName} · 明牌`);
+
+ await expect(openPlayerPage.locator('.open-hand-panel')).toHaveCount(0);
+ await expect(openPlayerPage.locator('.open-hand-self-status')).toContainText('由');
+ await expect(openPlayerPage.locator('.my-hand .card.disabled')).toHaveCount(25);
+
+ // 同时覆盖常见笔记本视口,避免侧边明牌在较窄牌桌上重新压住玩家框。
+ await firstAttackerPage.setViewportSize({ width: 1366, height: 768 });
+ const sideCases = [
+ [firstAttackerPage, 'right'],
+ [secondAttackerPage, 'left']
+ ];
+ for (const [attackerPage, position] of sideCases) {
+ const panel = attackerPage.locator(`[data-open-hand-position="${position}"]`);
+ await expect(panel).toBeVisible();
+ await expect(panel.locator('.card.micro')).toHaveCount(25);
+ await expect(panel.locator('.open-hand-group')).toHaveCount(await panel.locator('.open-hand-group').count());
+ expect(await panel.locator('.open-hand-group').count()).toBeGreaterThanOrEqual(3);
+
+ const cardBoxes = await panel.locator('.open-hand-mini-card').evaluateAll((cards) => cards.map((card) => {
+ const rect = card.getBoundingClientRect();
+ return { left: rect.left, top: rect.top, right: rect.right, bottom: rect.bottom };
+ }));
+ expect(cardBoxes).toHaveLength(25);
+ for (let first = 0; first < cardBoxes.length; first += 1) {
+ for (let second = first + 1; second < cardBoxes.length; second += 1) {
+ const a = cardBoxes[first];
+ const b = cardBoxes[second];
+ const separated = a.right <= b.left || b.right <= a.left || a.bottom <= b.top || b.bottom <= a.top;
+ expect(separated).toBe(true);
+ }
+ }
+
+ const [panelBox, playerBox, playedBox, tableBox] = await Promise.all([
+ panel.boundingBox(),
+ attackerPage.locator(`.player-${position}`).boundingBox(),
+ attackerPage.locator(`.played-cards-${position}`).boundingBox(),
+ attackerPage.locator('.game-table').boundingBox()
+ ]);
+ cardBoxes.forEach((cardBox) => {
+ expect(cardBox.left).toBeGreaterThanOrEqual(panelBox.x);
+ expect(cardBox.right).toBeLessThanOrEqual(panelBox.x + panelBox.width);
+ expect(cardBox.top).toBeGreaterThanOrEqual(panelBox.y);
+ expect(cardBox.bottom).toBeLessThanOrEqual(panelBox.y + panelBox.height);
+ });
+ expect(panelBox.x).toBeGreaterThanOrEqual(tableBox.x);
+ expect(panelBox.x + panelBox.width).toBeLessThanOrEqual(tableBox.x + tableBox.width);
+ if (position === 'left') {
+ expect(panelBox.x).toBeGreaterThanOrEqual(playerBox.x + playerBox.width - 1);
+ expect(playedBox.x).toBeGreaterThanOrEqual(panelBox.x + panelBox.width - 1);
+ } else {
+ expect(panelBox.x).toBeGreaterThanOrEqual(playedBox.x + playedBox.width - 1);
+ expect(playerBox.x).toBeGreaterThanOrEqual(panelBox.x + panelBox.width - 1);
+ }
+ }
+
+ await firstAttackerPage.setViewportSize({ width: 667, height: 375 });
+ const mobileSideOpenHandGeometry = await firstAttackerPage.evaluate(() => {
+ const rect = selector => {
+ const element = document.querySelector(selector);
+ if (!element) return null;
+ const box = element.getBoundingClientRect();
+ return { top: box.top, right: box.right, bottom: box.bottom, left: box.left };
+ };
+ const overlaps = (first, second) => Boolean(
+ first && second
+ && first.left < second.right
+ && first.right > second.left
+ && first.top < second.bottom
+ && first.bottom > second.top
+ );
+ const panel = rect('[data-open-hand-position="right"]');
+ return {
+ panel,
+ table: rect('.game-table'),
+ viewport: { width: window.innerWidth, height: window.innerHeight },
+ documentWidth: document.documentElement.scrollWidth,
+ scrollX: window.scrollX,
+ overlapsScore: overlaps(panel, rect('.score-panel')),
+ overlapsRules: overlaps(panel, rect('.center-content.table-tools')),
+ overlapsPlayer: overlaps(panel, rect('.player-right')),
+ overlapsPlayedCards: overlaps(panel, rect('.played-cards-right')),
+ overlapsBottomPlayer: overlaps(panel, rect('.player-bottom'))
+ };
+ });
+ expect(mobileSideOpenHandGeometry.panel.left).toBeGreaterThanOrEqual(mobileSideOpenHandGeometry.table.left);
+ expect(mobileSideOpenHandGeometry.panel.right).toBeLessThanOrEqual(mobileSideOpenHandGeometry.table.right);
+ expect(mobileSideOpenHandGeometry.panel.top).toBeGreaterThanOrEqual(mobileSideOpenHandGeometry.table.top);
+ expect(mobileSideOpenHandGeometry.panel.bottom).toBeLessThanOrEqual(mobileSideOpenHandGeometry.table.bottom);
+ expect(mobileSideOpenHandGeometry.documentWidth).toBeLessThanOrEqual(mobileSideOpenHandGeometry.viewport.width + 1);
+ expect(mobileSideOpenHandGeometry.scrollX).toBe(0);
+ expect(mobileSideOpenHandGeometry.overlapsScore).toBe(false);
+ expect(mobileSideOpenHandGeometry.overlapsRules).toBe(false);
+ expect(mobileSideOpenHandGeometry.overlapsPlayer).toBe(false);
+ expect(mobileSideOpenHandGeometry.overlapsPlayedCards).toBe(false);
+ expect(mobileSideOpenHandGeometry.overlapsBottomPlayer).toBe(false);
+ await firstAttackerPage.screenshot({ path: testInfo.outputPath('perfect-strategy-side-mobile-landscape.png') });
+ await firstAttackerPage.setViewportSize({ width: 1366, height: 768 });
+
+ await dealerPage.locator('.game-table').screenshot({
+ path: testInfo.outputPath('perfect-strategy-dealer-view.png')
+ });
+ await firstAttackerPage.locator('.game-table').screenshot({
+ path: testInfo.outputPath('perfect-strategy-side-view.png')
+ });
+
+ await expect(dealerPage.locator('.player-bottom.current-turn')).toBeVisible();
+ await dealerPage.locator('.my-hand .card').first().dispatchEvent('click');
+ await dealerPage.getByRole('button', { name: '出牌(1)' }).click();
+
+ await expect(firstAttackerPage.locator('.player-bottom.current-turn')).toBeVisible();
+ const firstAttackerPlayButton = firstAttackerPage.getByRole('button', { name: /^出牌\(\d+\)$/ });
+ if (await firstAttackerPage.locator('.my-hand .card.selected').count() === 0) {
+ await selectLegalCard(firstAttackerPage.locator('.my-hand .card'), firstAttackerPlayButton);
+ }
+ await expect(firstAttackerPlayButton).toBeEnabled();
+ await firstAttackerPlayButton.click();
+
+ await expect(dealerOpenHand).toHaveClass(/is-interactive/);
+ await expect(dealerOpenHand.getByText('由你代打', { exact: true })).toBeVisible();
+ await expect(dealerPage.locator('.my-hand .card.disabled')).toHaveCount(24);
+ await expect(openPlayerPage.getByRole('button', { name: '出牌(0)' })).toBeDisabled();
+ const proxyPlayButton = dealerPage.getByRole('button', { name: /^出牌\(\d+\)$/ });
+ if (await dealerOpenHand.locator('.card.selected').count() === 0) {
+ await selectLegalCard(dealerOpenHand.locator('.card'), proxyPlayButton);
+ }
+ await expect(proxyPlayButton).toBeEnabled();
+ await proxyPlayButton.click();
+
+ await expect(dealerOpenHand.locator('.card')).toHaveCount(24);
+ await expect(openPlayerPage.locator('.my-hand .card')).toHaveCount(24);
+ await expect(dealerPage.locator('.played-cards-top .card')).toHaveCount(1);
+
+ const undoButton = dealerPage.getByRole('button', { name: /撤\s*回/ });
+ await expect(undoButton).toBeEnabled();
+ await undoButton.click();
+ await expect(dealerOpenHand.locator('.card')).toHaveCount(25);
+ await expect(openPlayerPage.locator('.my-hand .card')).toHaveCount(25);
+ } finally {
+ await context.close();
+ }
+});
+
+test('冰山一角由四名玩家自行选牌,打出明牌后由原玩家补选', async ({ browser }, testInfo) => {
+ test.setTimeout(120_000);
+ const { context, pages } = await openTestModeGame(browser, '冰山一角');
+
+ try {
+ await Promise.all(pages.map((playerPage) =>
+ expect(playerPage.locator('.open-hand-panel')).toHaveCount(0)
+ ));
+ const dealerPageIndex = await finishDealerBury(pages);
+ const dealerPage = pages[dealerPageIndex];
+ const dealerName = `玩家${dealerPageIndex + 1}`;
+ await expect(dealerPage.getByRole('button', { name: '查看底牌' })).toBeVisible();
+
+ await Promise.all(pages.map((playerPage) =>
+ expect(playerPage.getByRole('button', { name: '确认明牌(0/2)' })).toBeVisible()
+ ));
+ await Promise.all(pages.map((playerPage) =>
+ expect(playerPage.locator('.open-hand-panel')).toHaveCount(0)
+ ));
+
+ const chosenIdsByPage = await Promise.all(pages.map(async (playerPage) => {
+ const cards = playerPage.locator('.my-hand .card');
+ const chosenIds = await Promise.all([
+ cards.nth(0).getAttribute('data-card-id'),
+ cards.nth(2).getAttribute('data-card-id')
+ ]);
+ await cards.nth(0).dispatchEvent('click');
+ await cards.nth(2).dispatchEvent('click');
+ await expect(playerPage.getByRole('button', { name: '确认明牌(2/2)' })).toBeEnabled();
+ return chosenIds;
+ }));
+ await Promise.all(pages.map((playerPage) =>
+ playerPage.getByRole('button', { name: '确认明牌(2/2)' }).click()
+ ));
+
+ await Promise.all(pages.map(async (playerPage) => {
+ await expect(playerPage.locator('.open-hand-panel')).toHaveCount(3);
+ await expect(playerPage.locator('.open-hand-panel .card')).toHaveCount(6);
+ await expect(playerPage.locator('.open-hand-self-status')).toHaveText('已明置 2 张');
+ await expect(playerPage.locator('.my-hand .card-wrapper.is-publicly-revealed')).toHaveCount(2);
+ }));
+
+ const dealerInitialRevealedIds = await dealerPage
+ .locator('.my-hand .card-wrapper.is-publicly-revealed .card')
+ .evaluateAll(cards => cards.map(card => card.dataset.cardId));
+ expect(dealerInitialRevealedIds.sort()).toEqual([...chosenIdsByPage[dealerPageIndex]].sort());
+ await expect(dealerPage.locator('.public-card-badge')).toHaveCount(0);
+ const goldBackground = await dealerPage
+ .locator('.my-hand .card-wrapper.is-publicly-revealed .card')
+ .first()
+ .evaluate(card => getComputedStyle(card).backgroundImage);
+ expect(goldBackground).toContain('245, 215, 110');
+
+ const playedRevealedId = dealerInitialRevealedIds[0];
+ await dealerPage.locator(`[data-card-id="${playedRevealedId}"]`).dispatchEvent('click');
+ await dealerPage.getByRole('button', { name: '出牌(1)' }).click();
+
+ // 从庄家对家的视角验收:长期明牌和本轮出牌各占一条轨道,牌面不能互相覆盖。
+ const oppositeObserverPage = pages[(dealerPageIndex + 2) % 4];
+ const oppositeRevealedPanel = oppositeObserverPage.locator('[data-open-hand-position="top"]', {
+ hasText: `${dealerName} · 冰山`
+ });
+ const oppositePlayedCards = oppositeObserverPage.locator('.played-cards-top');
+ await expect(oppositeRevealedPanel).toBeVisible();
+ await expect(oppositePlayedCards.locator('.card')).toHaveCount(1);
+ const [revealedPanelBox, playedCardsBox] = await Promise.all([
+ oppositeRevealedPanel.boundingBox(),
+ oppositePlayedCards.boundingBox()
+ ]);
+ expect(revealedPanelBox.y + revealedPanelBox.height).toBeLessThanOrEqual(playedCardsBox.y);
+
+ await expect(dealerPage.getByRole('button', { name: '确认明牌(0/1)' })).toBeVisible();
+ await expect(dealerPage.locator('.my-hand .card-wrapper.is-publicly-revealed')).toHaveCount(1);
+ const observerPage = pages[(dealerPageIndex + 1) % 4];
+ await expect(observerPage.getByRole('button', { name: new RegExp(`等待 ${dealerName} 选择明牌`) })).toBeVisible();
+
+ const replacementCard = dealerPage.locator('.my-hand .card-wrapper:not(.is-publicly-revealed) .card').first();
+ const replacementCardId = await replacementCard.getAttribute('data-card-id');
+ await replacementCard.dispatchEvent('click');
+ await dealerPage.getByRole('button', { name: '确认明牌(1/1)' }).click();
+
+ await expect.poll(async () => dealerPage
+ .locator('.my-hand .card-wrapper.is-publicly-revealed .card')
+ .evaluateAll(cards => cards.map(card => card.dataset.cardId))
+ ).toContain(replacementCardId);
+ await expect(dealerPage.locator('.my-hand .card-wrapper.is-publicly-revealed')).toHaveCount(2);
+
+ const dealerPanel = observerPage.locator('.open-hand-panel', { hasText: `${dealerName} · 冰山` });
+ await expect(dealerPanel.locator('.card')).toHaveCount(2);
+ await expect(dealerPanel.locator(`[data-card-id="${playedRevealedId}"]`)).toHaveCount(0);
+ await expect(dealerPanel.locator(`[data-card-id="${replacementCardId}"]`)).toHaveCount(1);
+ await observerPage.locator('.game-table').screenshot({
+ path: testInfo.outputPath('tip-of-iceberg-four-views.png')
+ });
+ } finally {
+ await context.close();
+ }
+});
+
+test('开局早投降时用四宫格完整公开四家的大量剩余手牌', async ({ browser }, testInfo) => {
+ test.setTimeout(120_000);
+ const { context, pages } = await openTestModeGame(browser, '世事无常');
+
+ try {
+ await Promise.all(pages.map(page => page.setViewportSize({ width: 1440, height: 900 })));
+ await finishDealerBury(pages);
+ const page = pages[0];
+
+ await page.evaluate(async () => {
+ const { useGameStore } = await import('/src/store/gameStore.js');
+ const socketService = (await import('/src/services/socket.js')).default;
+ const state = useGameStore.getState();
+ const roomPlayers = state.currentRoom.players;
+ const dealerIndex = state.currentRoom.gameState.dealerPlayerIndex ?? 0;
+ const ranks = ['A', 'K', 'Q', 'J', '10', '9', '8', '7', '6', '5', '4', '3', '2'];
+ const suits = ['spades', 'hearts', 'clubs', 'diamonds'];
+ const revealedHands = roomPlayers.map((player, playerIndex) => ({
+ playerId: player.id,
+ playerName: player.name,
+ seatIndex: playerIndex,
+ isDealer: playerIndex === dealerIndex,
+ side: playerIndex % 2 === dealerIndex % 2 ? 'dealer' : 'attacker',
+ cards: Array.from({ length: 25 }, (_, cardIndex) => {
+ if (cardIndex < 2) {
+ return {
+ id: `showdown-${playerIndex}-joker-${cardIndex}`,
+ suit: 'joker',
+ rank: cardIndex === 0 ? 'big_joker' : 'small_joker',
+ copyIndex: cardIndex
+ };
+ }
+ return {
+ id: `showdown-${playerIndex}-${cardIndex}`,
+ suit: suits[(cardIndex + playerIndex) % suits.length],
+ rank: ranks[(cardIndex * 3 + playerIndex) % ranks.length],
+ copyIndex: cardIndex % 2
+ };
+ })
+ }));
+ const bottomCards = Array.from({ length: 8 }, (_, index) => ({
+ id: `surrender-bottom-${index}`,
+ suit: suits[index % suits.length],
+ rank: ranks[index],
+ copyIndex: index % 2
+ }));
+
+ const settlementSnapshot = {
+ resultText: 'surrender settlement',
+ bottomPoints: 0,
+ bottomMultiplier: 0,
+ bottomScoreGained: 0,
+ totalScore: 80,
+ collectedPointCards: [],
+ bottomCards,
+ currentGameTrumpSuit: 'hearts',
+ currentGameTrumpRank: '2',
+ surrender: {
+ accepted: true,
+ winningSide: 'attacker',
+ revealedHands
+ }
+ };
+ useGameStore.setState({
+ currentRoom: {
+ ...state.currentRoom,
+ players: roomPlayers.map((player, playerIndex) => ({
+ ...player,
+ isReadyForNext: playerIndex === 0
+ })),
+ gameState: {
+ ...state.currentRoom.gameState,
+ phase: 'revealing',
+ attackerScore: 80,
+ collectedPointCards: [],
+ revealedBottomCards: bottomCards,
+ bottomScoreResult: settlementSnapshot,
+ upgradeResult: null
+ }
+ }
+ });
+ socketService.socket.listeners('bottom_revealed').forEach(listener => listener({
+ bottomCards,
+ bottomScoreResult: {
+ resultText: '玩家1一方投降,闲家方获胜',
+ bottomPoints: 0,
+ bottomMultiplier: 0,
+ bottomScoreGained: 0,
+ totalScore: 80,
+ collectedPointCards: [],
+ currentGameTrumpSuit: 'hearts',
+ currentGameTrumpRank: '2',
+ surrender: {
+ accepted: true,
+ winningSide: 'attacker',
+ revealedHands
+ }
+ },
+ upgradeResult: null
+ }));
+ });
+
+ const showdown = page.locator('[data-testid="surrender-showdown"]');
+ const handPanels = showdown.locator('.surrender-showdown-hand');
+ await expect(showdown).toBeVisible();
+ await expect(showdown).toContainText('共 100 张');
+ await expect(handPanels).toHaveCount(4);
+ await expect(showdown.locator('.card')).toHaveCount(100);
+ await expect(page.locator('.player-left')).toBeVisible();
+ await expect(page.locator('.player-right')).toBeVisible();
+ await expect(page.locator('.ready-badge')).toHaveCount(3);
+
+ const handGeometry = await handPanels.evaluateAll(panels => panels.map(panel => {
+ const panelRect = panel.getBoundingClientRect();
+ const cards = [...panel.querySelectorAll('.card')].map(card => {
+ const rect = card.getBoundingClientRect();
+ return { left: rect.left, top: rect.top, right: rect.right, bottom: rect.bottom };
+ });
+ return {
+ panel: {
+ left: panelRect.left,
+ top: panelRect.top,
+ right: panelRect.right,
+ bottom: panelRect.bottom
+ },
+ cards
+ };
+ }));
+ handGeometry.forEach(({ panel, cards }) => {
+ expect(cards).toHaveLength(25);
+ cards.forEach(card => {
+ expect(card.left).toBeGreaterThanOrEqual(panel.left);
+ expect(card.top).toBeGreaterThanOrEqual(panel.top);
+ expect(card.right).toBeLessThanOrEqual(panel.right);
+ expect(card.bottom).toBeLessThanOrEqual(panel.bottom);
+ });
+ });
+
+ const [centerBox, settlementBox] = await Promise.all([
+ page.locator('.settlement-table-center').boundingBox(),
+ page.locator('.settlement-panel.has-surrender-showdown').boundingBox()
+ ]);
+ expect(settlementBox.x).toBeGreaterThanOrEqual(centerBox.x);
+ expect(settlementBox.x + settlementBox.width).toBeLessThanOrEqual(centerBox.x + centerBox.width);
+ expect(settlementBox.height).toBeLessThanOrEqual(centerBox.height);
+
+ // Returning to the room unmounts GameBoard. Re-entering must rebuild the
+ // settlement from the room snapshot instead of relying on a past socket event.
+ await page.locator('.return-room-button').click();
+ await expect(page.locator('.room-resume-panel')).toBeVisible();
+ await page.locator('.room-resume-panel .ant-btn-primary').click();
+ await expect(page.locator('[data-testid="surrender-showdown"]')).toBeVisible();
+ await expect(page.locator('[data-testid="surrender-showdown"] .surrender-showdown-hand')).toHaveCount(4);
+ await expect(page.locator('.player-left')).toBeVisible();
+ await expect(page.locator('.player-right')).toBeVisible();
+
+ await page.locator('.game-table').screenshot({
+ path: testInfo.outputPath('surrender-showdown-100-cards.png')
+ });
+ } finally {
+ await context.close();
+ }
+});
+
+test('主视角手牌在发牌与出牌界面都完整落在手牌槽内', async ({ browser }, testInfo) => {
+ test.setTimeout(120_000);
+ const { context, pages } = await openTestModeGame(browser, '单步调试');
+
+ try {
+ // 截图来自高 DPI / 浏览器缩放环境,还要覆盖会命中紧凑高度媒体查询的 CSS 视口。
+ await Promise.all(pages.map(page => page.setViewportSize({ width: 1069, height: 694 })));
+ const page = pages[0];
+
+ const assertCardsInsideTray = async (stage) => {
+ const geometry = await page.locator('.my-hand').evaluate((tray) => {
+ const rectOf = element => {
+ const rect = element.getBoundingClientRect();
+ return {
+ top: rect.top,
+ right: rect.right,
+ bottom: rect.bottom,
+ left: rect.left,
+ width: rect.width,
+ height: rect.height
+ };
+ };
+ return {
+ tray: rectOf(tray),
+ hand: rectOf(tray.querySelector('.hand')),
+ player: rectOf(tray.closest('.player-bottom')),
+ table: rectOf(tray.closest('.game-table')),
+ cards: [...tray.querySelectorAll('.card')].map(rectOf),
+ overflow: getComputedStyle(tray).overflow,
+ viewportHeight: window.innerHeight
+ };
+ });
+ expect(geometry.cards.length).toBeGreaterThan(0);
+ geometry.cards.forEach((card, index) => {
+ expect(
+ card.top,
+ `${stage}第${index + 1}张牌不得高出手牌槽`
+ ).toBeGreaterThanOrEqual(geometry.tray.top);
+ expect(
+ card.bottom,
+ `${stage}第${index + 1}张牌不得低于手牌槽`
+ ).toBeLessThanOrEqual(geometry.tray.bottom);
+ expect(
+ card.bottom,
+ `${stage}第${index + 1}张牌不得伸出底部玩家框`
+ ).toBeLessThanOrEqual(geometry.player.bottom);
+ expect(
+ card.bottom,
+ `${stage}第${index + 1}张牌不得伸出牌桌`
+ ).toBeLessThanOrEqual(Math.min(geometry.table.bottom, geometry.viewportHeight));
+ });
+ return geometry;
+ };
+
+ const drawingGeometry = await assertCardsInsideTray('发牌阶段');
+ await page.evaluate(async () => {
+ const { useGameStore } = await import('/src/store/gameStore.js');
+ const state = useGameStore.getState();
+ useGameStore.setState({
+ currentRoom: {
+ ...state.currentRoom,
+ gameState: {
+ ...state.currentRoom.gameState,
+ phase: 'playing',
+ currentPlayerIndex: 0,
+ currentTurnPlayerId: state.currentPlayer.id
+ }
+ }
+ });
+ });
+ await expect(page.getByRole('button', { name: /^\u51fa\u724c/ })).toBeVisible();
+ const playingGeometry = await assertCardsInsideTray('出牌阶段');
+ expect(playingGeometry.tray.height).toBe(drawingGeometry.tray.height);
+
+ await page.evaluate(async () => {
+ const { useGameStore } = await import('/src/store/gameStore.js');
+ const socketService = (await import('/src/services/socket.js')).default;
+ const state = useGameStore.getState();
+ const playedCard = state.myCards[0];
+ socketService.socket.listeners('cards_played').forEach(listener => listener({
+ playerId: state.currentPlayer.id,
+ playerName: state.currentPlayer.name,
+ cards: [playedCard],
+ removedCardIds: [playedCard.id],
+ currentWinningPlayerId: state.currentPlayer.id,
+ cardsCount: 1
+ }));
+ });
+ const ownPlayedArea = page.locator('.player-bottom > .played-cards-area.has-cards');
+ await expect(ownPlayedArea.locator('.card')).toHaveCount(1);
+ const [ownPlayedBox, playerBox, tableBox] = await Promise.all([
+ ownPlayedArea.boundingBox(),
+ page.locator('.player-bottom').boundingBox(),
+ page.locator('.game-table').boundingBox()
+ ]);
+ expect(ownPlayedBox.y + ownPlayedBox.height).toBeLessThanOrEqual(playerBox.y);
+ expect(ownPlayedBox.y).toBeGreaterThanOrEqual(tableBox.y);
+ await assertCardsInsideTray('已出牌后');
+
+ await page.locator('.player-bottom').screenshot({
+ path: testInfo.outputPath('bottom-hand-contained.png')
+ });
+ } finally {
+ await context.close();
+ }
+});
+
+test('亮主牌标位于玩家框内且三六九等同时清楚显示主劣', async ({ browser }, testInfo) => {
+ test.setTimeout(120_000);
+ const { context, pages } = await openTestModeGame(browser, '三六九等');
+
+ try {
+ await Promise.all(pages.map(page => page.setViewportSize({ width: 1440, height: 900 })));
+ const rightPlayerHeightBefore = await pages[0]
+ .locator('.player-right')
+ .evaluate(element => element.getBoundingClientRect().height);
+
+ await Promise.all(pages.map(page => page.evaluate(async () => {
+ const { useGameStore } = await import('/src/store/gameStore.js');
+ const state = useGameStore.getState();
+ const declaringPlayer = state.currentRoom.players[1];
+ const trumpDeclaration = {
+ playerId: declaringPlayer.id,
+ playerName: declaringPlayer.name,
+ suit: 'spades',
+ count: 2,
+ declarationType: 'pair',
+ declarationRole: 'trump',
+ cards: [
+ { id: 'trump-badge-spade-2-a', suit: 'spades', rank: '2', copyIndex: 0 },
+ { id: 'trump-badge-spade-2-b', suit: 'spades', rank: '2', copyIndex: 1 }
+ ]
+ };
+ const inferiorDeclaration = {
+ playerId: declaringPlayer.id,
+ playerName: declaringPlayer.name,
+ suit: 'clubs',
+ count: 2,
+ declarationType: 'pair',
+ declarationRole: 'inferior',
+ cards: [
+ { id: 'trump-badge-club-2-a', suit: 'clubs', rank: '2', copyIndex: 0 },
+ { id: 'trump-badge-club-2-b', suit: 'clubs', rank: '2', copyIndex: 1 }
+ ]
+ };
+ useGameStore.setState({
+ currentRoom: {
+ ...state.currentRoom,
+ gameState: {
+ ...state.currentRoom.gameState,
+ trumpSuit: 'spades',
+ trumpRank: '2',
+ currentTrumpDeclaration: trumpDeclaration,
+ currentInferiorDeclaration: inferiorDeclaration,
+ threeSixNine: {
+ ...(state.currentRoom.gameState.threeSixNine || {}),
+ trumpSuit: 'spades',
+ trumpRank: '2',
+ inferiorSuit: 'clubs',
+ currentTrumpDeclaration: trumpDeclaration,
+ currentInferiorDeclaration: inferiorDeclaration
+ }
+ }
+ }
+ });
+ })));
+
+ // 走一遍真实发牌阶段的亮主事件链:trump_declared 与 trump_updated
+ // 不得再各弹一条全局消息遮住顶部玩家的亮牌。
+ await pages[3].evaluate(async () => {
+ const { useGameStore } = await import('/src/store/gameStore.js');
+ const socketService = (await import('/src/services/socket.js')).default;
+ const state = useGameStore.getState();
+ const declaringPlayer = state.currentRoom.players[1];
+ useGameStore.setState({
+ currentRoom: {
+ ...state.currentRoom,
+ gameState: {
+ ...state.currentRoom.gameState,
+ phase: 'drawing'
+ }
+ }
+ });
+ const declarationPayload = {
+ playerId: declaringPlayer.id,
+ playerName: declaringPlayer.name,
+ suit: 'spades',
+ count: 2,
+ declarationType: 'pair',
+ strength: 2,
+ isCounter: false,
+ declarationRole: 'trump',
+ cards: [
+ { id: 'drawing-trump-spade-a', suit: 'spades', rank: '2', copyIndex: 0 },
+ { id: 'drawing-trump-spade-b', suit: 'spades', rank: '2', copyIndex: 1 }
+ ]
+ };
+ socketService.socket.listeners('trump_declared').forEach(listener => listener(declarationPayload));
+ socketService.socket.listeners('trump_updated').forEach(listener => listener({
+ trumpSuit: 'spades',
+ trumpRank: '2',
+ inferiorSuit: 'clubs'
+ }));
+ });
+ await pages[3].waitForTimeout(350);
+ await expect(pages[3].locator('.ant-message').getByText(/\u4eae主|\u4e3b牌已设置/)).toHaveCount(0);
+
+ const rightSidecar = pages[0].locator('.declaration-sidecar-right');
+ await expect(rightSidecar.locator('.declaration-card-slot')).toHaveCount(2);
+ await expect(rightSidecar.locator('.declaration-card-slot-label')).toHaveCount(0);
+ await expect(rightSidecar.locator('.card')).toHaveCount(4);
+ await expect(
+ rightSidecar.locator('.declaration-card-slot.is-trump .trump-badge')
+ ).toHaveCount(2);
+ await expect(
+ rightSidecar.locator('.declaration-card-slot.is-inferior .trump-badge')
+ ).toHaveCount(2);
+ await expect(
+ rightSidecar.locator('.declaration-card-slot.is-inferior .inferior-badge:visible')
+ ).toHaveCount(0);
+ await expect(
+ rightSidecar.locator('.card-corner.top-left .card-suit:visible')
+ ).toHaveCount(0);
+ await expect(
+ rightSidecar.locator('.card-center .suit-symbol:visible')
+ ).toHaveCount(4);
+ const rightPlayerHeightAfter = await pages[0]
+ .locator('.player-right')
+ .evaluate(element => element.getBoundingClientRect().height);
+ expect(rightPlayerHeightAfter).toBe(rightPlayerHeightBefore);
+ expect(rightPlayerHeightAfter).toBeGreaterThanOrEqual(108);
+
+ const [rightIdentityBox, rightDeclarationCardBoxes] = await Promise.all([
+ pages[0].locator('.player-right > .player-info > div:first-child').boundingBox(),
+ rightSidecar.locator('.declaration-card-slot .card').evaluateAll(cards => cards.map(card => {
+ const rect = card.getBoundingClientRect();
+ return { top: rect.top, bottom: rect.bottom };
+ }))
+ ]);
+ expect(Math.min(...rightDeclarationCardBoxes.map(box => box.top))).toBeGreaterThanOrEqual(
+ rightIdentityBox.y + rightIdentityBox.height + 8
+ );
+
+ const leftSidecar = pages[2].locator('.declaration-sidecar-left');
+ const topSidecar = pages[3].locator('.declaration-sidecar-top');
+ const bottomDock = pages[1].locator('.bottom-declaration-dock');
+ await expect(leftSidecar.locator('.declaration-card-slot')).toHaveCount(2);
+ await expect(topSidecar.locator('.declaration-card-slot')).toHaveCount(2);
+ await expect(bottomDock.locator('.declaration-card-slot')).toHaveCount(2);
+ const [bottomTrumpDockBox, bottomInferiorDockBox, bottomHandBox] = await Promise.all([
+ pages[1].locator('.bottom-declaration-dock.is-trump').boundingBox(),
+ pages[1].locator('.bottom-declaration-dock.is-inferior').boundingBox(),
+ pages[1].locator('.my-hand').boundingBox()
+ ]);
+ expect(bottomTrumpDockBox.x).toBeLessThan(bottomInferiorDockBox.x);
+ expect(bottomTrumpDockBox.x + bottomTrumpDockBox.width).toBeLessThanOrEqual(bottomHandBox.x);
+ expect(bottomInferiorDockBox.x).toBeGreaterThanOrEqual(bottomHandBox.x + bottomHandBox.width);
+
+ const [topPlayerBox, topSidecarBox, topCountBox] = await Promise.all([
+ pages[3].locator('.player-top').boundingBox(),
+ topSidecar.boundingBox(),
+ pages[3].locator('.player-top .player-hand-count').boundingBox()
+ ]);
+ expect(topSidecarBox.x).toBeGreaterThanOrEqual(topPlayerBox.x);
+ expect(topSidecarBox.y).toBeGreaterThanOrEqual(topPlayerBox.y);
+ expect(topSidecarBox.x + topSidecarBox.width).toBeLessThanOrEqual(topPlayerBox.x + topPlayerBox.width);
+ expect(topSidecarBox.y + topSidecarBox.height).toBeLessThanOrEqual(topPlayerBox.y + topPlayerBox.height);
+ expect(topCountBox.y).toBeLessThan(topPlayerBox.y);
+ expect(topCountBox.y + topCountBox.height).toBeGreaterThan(topPlayerBox.y);
+
+ const [trumpSlotBox, inferiorSlotBox] = await Promise.all([
+ rightSidecar.locator('.declaration-card-slot.is-trump').boundingBox(),
+ rightSidecar.locator('.declaration-card-slot.is-inferior').boundingBox()
+ ]);
+ expect(trumpSlotBox.x).toBeLessThan(inferiorSlotBox.x);
+ const cardPairs = await rightSidecar.locator('.declaration-card-pair').evaluateAll(pairs => (
+ pairs.map(pair => [...pair.querySelectorAll('.card')].map(card => {
+ const rect = card.getBoundingClientRect();
+ return { left: rect.left, right: rect.right };
+ }))
+ ));
+ cardPairs.forEach(cards => {
+ expect(cards).toHaveLength(2);
+ const overlap = cards[0].right - cards[1].left;
+ expect(overlap).toBeGreaterThanOrEqual(4);
+ expect(overlap).toBeLessThanOrEqual(8);
+ });
+
+ const [spadeColor, clubColor] = await Promise.all([
+ rightSidecar.locator('.card-suit-spades .suit-symbol').first().evaluate(element => getComputedStyle(element).color),
+ rightSidecar.locator('.card-suit-clubs .suit-symbol').first().evaluate(element => getComputedStyle(element).color)
+ ]);
+ expect(spadeColor).not.toBe(clubColor);
+
+ await pages[0].locator('.game-table').screenshot({
+ path: testInfo.outputPath('trump-badge-right-dual.png')
+ });
+ await pages[3].locator('.game-table').screenshot({
+ path: testInfo.outputPath('trump-badge-top-dual.png')
+ });
+ await pages[1].locator('.game-table').screenshot({
+ path: testInfo.outputPath('trump-badge-bottom-dual.png')
+ });
+ await pages[1].locator('.my-hand-container').screenshot({
+ path: testInfo.outputPath('trump-badge-bottom-dock.png')
+ });
+ } finally {
+ await context.close();
+ }
+});
+
+test('互通有无只在每个页面公开自己的对家手牌', async ({ browser }, testInfo) => {
+ test.setTimeout(120_000);
+ const { context, pages } = await openTestModeGame(browser, '互通有无');
+
+ try {
+ await Promise.all(pages.map((playerPage) =>
+ expect(playerPage.locator('.open-hand-panel')).toHaveCount(0)
+ ));
+ const dealerPageIndex = await finishDealerBury(pages);
+
+ for (let viewerIndex = 0; viewerIndex < pages.length; viewerIndex += 1) {
+ const teammateIndex = (viewerIndex + 2) % 4;
+ const teammateName = `玩家${teammateIndex + 1}`;
+ const playerPage = pages[viewerIndex];
+ const teammatePanel = playerPage.locator('[data-open-hand-position="top"]');
+ await expect(playerPage.locator('.open-hand-panel')).toHaveCount(1);
+ await expect(teammatePanel).toContainText(`${teammateName} · 队友手牌`);
+ await expect(teammatePanel.locator('.card')).toHaveCount(25);
+ await expect(playerPage.locator('[data-open-hand-position="left"], [data-open-hand-position="right"]')).toHaveCount(0);
+ }
+
+ const dealerPage = pages[dealerPageIndex];
+ await dealerPage.locator('.my-hand .card').first().dispatchEvent('click');
+ await dealerPage.getByRole('button', { name: '出牌(1)' }).click();
+ const dealerTeammatePage = pages[(dealerPageIndex + 2) % 4];
+ await expect(dealerTeammatePage.locator('[data-open-hand-position="top"] .card')).toHaveCount(24);
+ await dealerTeammatePage.locator('.game-table').screenshot({
+ path: testInfo.outputPath('mutual-visibility-private-view.png')
+ });
+ } finally {
+ await context.close();
+ }
+});
+
+test('为人坦荡触发前隐藏、触发后展示四家剩余手牌布局', async ({ browser }, testInfo) => {
+ test.setTimeout(120_000);
+ const { context, pages } = await openTestModeGame(browser, '为人坦荡');
+
+ try {
+ await finishDealerBury(pages);
+ await Promise.all(pages.map((playerPage) =>
+ expect(playerPage.locator('.open-hand-panel')).toHaveCount(0)
+ ));
+
+ // 服务端阈值由规则单测覆盖;这里向已注册的真实客户端监听器注入一次触发快照,验收四视角布局。
+ await Promise.all(pages.map((playerPage) => playerPage.evaluate(async () => {
+ const { useGameStore } = await import('/src/store/gameStore.js');
+ const socketService = (await import('/src/services/socket.js')).default;
+ const players = useGameStore.getState().currentRoom.players;
+ const suits = ['spades', 'hearts', 'clubs', 'diamonds'];
+ const ranks = ['A', 'K', 'Q', 'J', '10'];
+ const hands = players.map((player, playerIndex) => ({
+ playerId: player.id,
+ playerName: player.name,
+ kind: 'public',
+ label: '全员明牌',
+ cards: ranks.map((rank, copyIndex) => ({
+ id: `${suits[playerIndex]}-${rank}-${copyIndex}`,
+ suit: suits[playerIndex],
+ rank,
+ copyIndex
+ }))
+ }));
+ socketService.socket.listeners('rule_visible_hands_updated').forEach(listener => listener({
+ ruleId: 'open_and_honest',
+ hands,
+ announcement: '为人坦荡:本轮结束,所有玩家同时明置剩余手牌'
+ }));
+ })));
+
+ await Promise.all(pages.map(async (playerPage) => {
+ await expect(playerPage.locator('.open-hand-panel')).toHaveCount(3);
+ await expect(playerPage.locator('.open-hand-panel .card')).toHaveCount(15);
+ await expect(playerPage.locator('.open-hand-self-status')).toHaveText('全员明牌');
+ }));
+ await pages[0].locator('.game-table').screenshot({
+ path: testInfo.outputPath('open-and-honest-all-hands.png')
+ });
+ } finally {
+ await context.close();
+ }
+});
+
+test('十面埋伏只让庄家队友暗选,并在首次出现时向全场揭晓', async ({ browser }, testInfo) => {
+ test.setTimeout(120_000);
+ const { context, pages } = await openTestModeGame(browser, '十面埋伏');
+
+ try {
+ const dealerPageIndex = await finishDealerBury(pages);
+ const selectorPageIndex = (dealerPageIndex + 2) % 4;
+ const selectorPage = pages[selectorPageIndex];
+ const selectorDialog = selectorPage.getByRole('dialog', { name: '十面埋伏 · 暗选点数' });
+
+ await expect(selectorDialog).toBeVisible();
+ for (let index = 0; index < pages.length; index += 1) {
+ if (index !== selectorPageIndex) {
+ await expect(pages[index].getByRole('dialog', { name: '十面埋伏 · 暗选点数' })).toHaveCount(0);
+ }
+ }
+ await expect(selectorDialog.locator('.ten-sided-ambush-rank-grid .ant-btn')).toHaveCount(9);
+ await expect(selectorDialog.getByRole('button', { name: '请选择伏击点数' })).toHaveCSS('color', 'rgb(255, 255, 255)');
+ for (const forbiddenRank of ['2', '5', '10', 'K']) {
+ await expect(selectorDialog.getByRole('button', { name: forbiddenRank, exact: true })).toHaveCount(0);
+ }
+ const selectorModalContent = selectorDialog.locator('.ant-modal-content');
+ await expect.poll(async () => (await selectorModalContent.boundingBox())?.width || 0).toBeGreaterThan(420);
+ await selectorPage.screenshot({
+ path: testInfo.outputPath('ten-sided-ambush-private-selection.png'),
+ animations: 'allow'
+ });
+
+ await selectorDialog.getByRole('button', { name: '7', exact: true }).click();
+ await selectorDialog.getByRole('button', { name: '确认伏击 7' }).click();
+ await expect(selectorDialog).toBeHidden();
+
+ const selectorStatus = selectorPage.locator('.ten-sided-ambush-status');
+ await expect(selectorStatus.locator('strong')).toHaveText('7');
+ await expect(selectorStatus).toContainText('仅你可见');
+ for (let index = 0; index < pages.length; index += 1) {
+ if (index === selectorPageIndex) continue;
+ const hiddenStatus = pages[index].locator('.ten-sided-ambush-status');
+ await expect(hiddenStatus.locator('strong')).toHaveCount(0);
+ await expect(hiddenStatus.locator('.ten-sided-ambush-hidden-rank')).toHaveText('?');
+ await expect(hiddenStatus).toContainText('首次出现时揭晓');
+ }
+
+ // 首次出现与计分由服务端规则单测覆盖;这里向真实监听器注入公开事件,验收四视角状态和揭晓动画。
+ await Promise.all(pages.map((playerPage) => playerPage.evaluate(async () => {
+ const { useGameStore } = await import('/src/store/gameStore.js');
+ const socketService = (await import('/src/services/socket.js')).default;
+ const state = useGameStore.getState();
+ useGameStore.setState({
+ currentRoom: {
+ ...state.currentRoom,
+ gameState: {
+ ...state.currentRoom.gameState,
+ tenSidedAmbush: {
+ ...state.currentRoom.gameState.tenSidedAmbush,
+ isSelectionPending: false,
+ isRevealed: true,
+ rank: '7',
+ attackerNetCardCount: 2
+ }
+ }
+ }
+ });
+ socketService.socket.listeners('ten_sided_ambush_revealed').forEach(listener => listener({
+ rank: '7',
+ source: 'play',
+ playerName: '玩家1'
+ }));
+ })));
+
+ await expect(pages[0].locator('.ten-sided-ambush-reveal')).toBeVisible();
+ await pages[0].waitForTimeout(380);
+ await pages[0].screenshot({
+ path: testInfo.outputPath('ten-sided-ambush-reveal-animation.png')
+ });
+ await Promise.all(pages.map(async (playerPage) => {
+ await expect(playerPage.locator('.ten-sided-ambush-status strong')).toHaveText('7');
+ await expect(playerPage.locator('.ten-sided-ambush-status')).toContainText('已向全场揭晓');
+ await expect(playerPage.locator('.ten-sided-ambush-reveal')).toBeVisible();
+ await expect(playerPage.locator('.ten-sided-ambush-score-counter')).toContainText('伏击 7');
+ await expect(playerPage.locator('.ten-sided-ambush-score-counter')).toContainText('闲家净拿 +2 张');
+ }));
+ await pages[0].locator('.game-table').screenshot({
+ path: testInfo.outputPath('ten-sided-ambush-public-rank.png')
+ });
+
+ // 结算布局与揭晓动画分别验收;隐藏测试注入的动画层,避免它遮挡结算截图。
+ await pages[0].locator('.ten-sided-ambush-reveal-overlay').evaluate(element => {
+ element.style.display = 'none';
+ });
+ await pages[0].evaluate(async () => {
+ const { useGameStore } = await import('/src/store/gameStore.js');
+ const socketService = (await import('/src/services/socket.js')).default;
+ const state = useGameStore.getState();
+ useGameStore.setState({
+ currentRoom: {
+ ...state.currentRoom,
+ gameState: {
+ ...state.currentRoom.gameState,
+ phase: 'revealing',
+ attackerScore: 40,
+ tenSidedAmbush: {
+ ...state.currentRoom.gameState.tenSidedAmbush,
+ attackerNetCardCount: -4
+ }
+ }
+ }
+ });
+ const bottomCards = ['4', '8', '4', '6', '7', '2', '6', '7'].map((rank, index) => ({
+ id: `settlement-${index}`,
+ suit: index < 2 ? 'hearts' : index < 5 ? 'clubs' : 'spades',
+ rank,
+ copyIndex: index
+ }));
+ socketService.socket.listeners('bottom_revealed').forEach(listener => listener({
+ bottomCards,
+ bottomScoreResult: {
+ attackerWonBottom: false,
+ resultText: '庄家守底',
+ bottomPoints: 0,
+ bottomMultiplier: 2,
+ bottomScoreGained: 20,
+ ambushRank: '7',
+ ambushCardCount: 2,
+ ambushScoreDelta: 20,
+ totalScore: 40,
+ collectedPointCards: []
+ },
+ upgradeResult: {
+ attackerWon: false,
+ oldDealerLevel: 3,
+ newDealerLevel: 4,
+ dealerLevelUp: 1,
+ oldAttackerLevel: 2,
+ newAttackerLevel: 2,
+ attackerLevelUp: 0,
+ nextDealerName: '玩家1',
+ nextDealerLevel: 4
+ }
+ }));
+ });
+
+ const settlementCenter = pages[0].locator('.settlement-table-center');
+ const settlementPanel = pages[0].locator('.settlement-panel');
+ const settlementBottomResult = pages[0].locator('.settlement-bottom-result');
+ const settlementResult = pages[0].locator('.settlement-upgrade-result');
+ await expect(settlementResult).toBeVisible();
+ await expect(pages[0].locator('.ten-sided-ambush-score-counter')).toContainText('闲家净拿 -4 张');
+ await expect.poll(async () => {
+ const centerBox = await settlementCenter.boundingBox();
+ const panelBox = await settlementPanel.boundingBox();
+ const bottomResultBox = await settlementBottomResult.boundingBox();
+ const upgradeResultBox = await settlementResult.boundingBox();
+ if (!centerBox || !panelBox || !bottomResultBox || !upgradeResultBox) return false;
+ return panelBox.y >= centerBox.y
+ && panelBox.y + panelBox.height <= centerBox.y + centerBox.height
+ && panelBox.width <= 520
+ && bottomResultBox.y + bottomResultBox.height <= upgradeResultBox.y;
+ }).toBe(true);
+ await pages[0].locator('.game-table').screenshot({
+ path: testInfo.outputPath('ten-sided-ambush-settlement.png')
+ });
+ } finally {
+ await context.close();
+ }
+});
+
+test('三权分立由二三四号位分别暗选,重复点数首次出现后同步公开', async ({ browser }, testInfo) => {
+ test.setTimeout(120_000);
+ const { context, pages } = await openTestModeGame(browser, '三权分立');
+
+ try {
+ const dealerPageIndex = await finishDealerBury(pages);
+ const selectorCases = [
+ { pageIndex: (dealerPageIndex + 1) % 4, sourceRank: '10' },
+ { pageIndex: (dealerPageIndex + 2) % 4, sourceRank: '5' },
+ { pageIndex: (dealerPageIndex + 3) % 4, sourceRank: 'K' }
+ ];
+
+ await expect(pages[dealerPageIndex].getByRole('dialog', { name: /三权分立/ })).toHaveCount(0);
+ await Promise.all(selectorCases.map(async ({ pageIndex, sourceRank }) => {
+ const dialog = pages[pageIndex].getByRole('dialog', {
+ name: `三权分立 · 重载原${sourceRank}分牌`
+ });
+ await expect(dialog).toBeVisible();
+ await expect(dialog.locator('.ten-sided-ambush-rank-grid .ant-btn')).toHaveCount(12);
+ await expect(dialog.getByRole('button', { name: '2', exact: true })).toHaveCount(0);
+ for (const allowedRank of ['5', '10', 'K']) {
+ await expect(dialog.getByRole('button', { name: allowedRank, exact: true })).toBeVisible();
+ }
+ }));
+
+ await Promise.all(pages.map(async (playerPage) => {
+ const status = playerPage.locator('[data-testid="three-powers-status"]');
+ await expect(status).toBeVisible();
+ await expect(status.locator('.three-powers-slot')).toHaveCount(3);
+ await expect(status.locator('.three-powers-slot strong')).toHaveText(['?', '?', '?']);
+ }));
+
+ for (const { pageIndex, sourceRank } of selectorCases) {
+ const dialog = pages[pageIndex].getByRole('dialog', {
+ name: `三权分立 · 重载原${sourceRank}分牌`
+ });
+ await dialog.getByRole('button', { name: '7', exact: true }).click();
+ await dialog.getByRole('button', { name: `确认用 7 重载原${sourceRank}分牌` }).click();
+ await expect(dialog).toBeHidden();
+ const ownSlot = pages[pageIndex].locator(`.three-powers-slot[data-source-rank="${sourceRank}"]`);
+ await expect(ownSlot.locator('strong')).toHaveText('7');
+ await expect(ownSlot).toContainText('仅你可见');
+ }
+
+ await expect(
+ pages[dealerPageIndex].locator('.three-powers-slot strong')
+ ).toHaveText(['?', '?', '?']);
+ for (const { pageIndex, sourceRank } of selectorCases) {
+ const status = pages[pageIndex].locator('[data-testid="three-powers-status"]');
+ await expect(status.locator('.three-powers-slot strong')).toHaveText(
+ ['5', '10', 'K'].map(rank => rank === sourceRank ? '7' : '?')
+ );
+ }
+
+ await Promise.all(pages.map((playerPage) => playerPage.evaluate(async () => {
+ const { useGameStore } = await import('/src/store/gameStore.js');
+ const socketService = (await import('/src/services/socket.js')).default;
+ const state = useGameStore.getState();
+ const slots = state.currentRoom.gameState.threePowers.slots.map(slot => ({
+ ...slot,
+ isSelected: true,
+ isRevealed: true,
+ rank: '7'
+ }));
+ useGameStore.setState({
+ currentRoom: {
+ ...state.currentRoom,
+ gameState: {
+ ...state.currentRoom.gameState,
+ threePowers: {
+ ...state.currentRoom.gameState.threePowers,
+ isSelectionPending: false,
+ pendingPlayerIds: [],
+ slots
+ }
+ }
+ }
+ });
+ socketService.socket.listeners('three_powers_revealed').forEach(listener => listener({
+ slots: slots.map(slot => ({
+ sourceRank: slot.sourceRank,
+ pointValue: slot.pointValue,
+ rank: slot.rank
+ })),
+ source: 'play',
+ playerName: '玩家1'
+ }));
+ })));
+
+ await Promise.all(pages.map(async (playerPage) => {
+ const status = playerPage.locator('[data-testid="three-powers-status"]');
+ await expect(status.locator('.three-powers-slot strong')).toHaveText(['7', '7', '7']);
+ await expect(status.locator('.three-powers-slot.is-revealed')).toHaveCount(3);
+ await expect(playerPage.locator('.three-powers-reveal')).toBeVisible();
+ }));
+
+ // 一张重载后的7应实时显示25分;同时打出的原K若未被选择则不再贡献固定10分。
+ await Promise.all(pages.map((playerPage) => playerPage.evaluate(async () => {
+ const { useGameStore } = await import('/src/store/gameStore.js');
+ const socketService = (await import('/src/services/socket.js')).default;
+ const player = useGameStore.getState().currentRoom.players[0];
+ socketService.socket.listeners('cards_played').forEach(listener => listener({
+ playerId: player.id,
+ playerName: player.name,
+ cards: [
+ { id: 'three-powers-live-7', suit: 'clubs', rank: '7', copyIndex: 90 },
+ { id: 'three-powers-live-k', suit: 'clubs', rank: 'K', copyIndex: 91 }
+ ],
+ cardsCount: 2,
+ currentWinningPlayerId: player.id
+ }));
+ })));
+ await Promise.all(pages.map(async (playerPage) => {
+ await expect(playerPage.locator('.round-points-value')).toHaveText('25');
+ }));
+
+ await pages[0].waitForTimeout(380);
+ await pages[0].screenshot({
+ path: testInfo.outputPath('three-powers-public-reveal.png')
+ });
+ } finally {
+ await context.close();
+ }
+});
+
+test('Joker 保持竖直且没有冗余标记', async ({ page }, testInfo) => {
+ await page.goto('/test/fixtures/joker-preview.html');
+ const jokers = page.locator('[data-testid="joker-preview"] .joker-card');
+ await expect(jokers).toHaveCount(2);
+
+ for (const joker of await jokers.all()) {
+ await expect(joker.locator('.joker-emblem')).toHaveCount(0);
+ await expect(joker.locator('.trump-badge')).toHaveCount(1);
+ await expect(joker.locator('.trump-badge .trump-star')).toHaveText('★');
+ await expect(joker.locator('.card-corner')).toHaveCount(2);
+ await expect(joker.locator('.card-corner .card-rank')).toHaveText(['♛', '♛']);
+ await expect(joker.locator('.card-corner .card-suit')).toHaveCount(0);
+
+ const letterLayout = await joker.locator('.joker-letter').evaluateAll((letters) =>
+ letters.map((letter) => {
+ const rect = letter.getBoundingClientRect();
+ return { x: rect.x + rect.width / 2, y: rect.y };
+ })
+ );
+ expect(letterLayout).toHaveLength(5);
+ expect(Math.max(...letterLayout.map(({ x }) => x)) - Math.min(...letterLayout.map(({ x }) => x))).toBeLessThan(1);
+ expect(letterLayout.map(({ y }) => y)).toEqual([...letterLayout.map(({ y }) => y)].sort((a, b) => a - b));
+ expect(await joker.locator('.joker-center').evaluate((element) => getComputedStyle(element).transform)).toBe('none');
+ }
+
+ await page.locator('[data-testid="joker-preview"]').screenshot({
+ path: testInfo.outputPath('jokers-upright.png')
+ });
+});
+
+test('相邻玩家八张出牌只展开到点数完整可见并与玩家框留有间隔', async ({ page }, testInfo) => {
+ await page.setViewportSize({ width: 1600, height: 900 });
+ await page.goto('/test/fixtures/table-layout-preview.html');
+
+ for (const position of ['left', 'right']) {
+ const area = page.locator(`.played-cards-${position}`);
+ await expect(area).toBeVisible();
+ const areaWidth = await area.evaluate((element) => element.getBoundingClientRect().width);
+ expect(areaWidth).toBeGreaterThanOrEqual(280);
+ expect(areaWidth).toBeLessThanOrEqual(300);
+
+ const cardXs = await area.locator('.card').evaluateAll((cards) =>
+ cards.map((card) => card.getBoundingClientRect().x)
+ );
+ expect(cardXs).toHaveLength(8);
+ const steps = cardXs.slice(1).map((x, index) => x - cardXs[index]);
+ expect(Math.min(...steps)).toBeGreaterThanOrEqual(29.5);
+ expect(Math.max(...steps)).toBeLessThanOrEqual(31);
+
+ const rankVisibility = await area.locator('.card').evaluateAll((cards) =>
+ cards.slice(0, -1).map((card, index) => {
+ const rank = card.querySelector('.card-corner.top-left .card-rank');
+ return rank.getBoundingClientRect().right <= cards[index + 1].getBoundingClientRect().left + 0.5;
+ })
+ );
+ expect(rankVisibility.every(Boolean)).toBe(true);
+
+ const playerBox = page.locator(`.player-${position}`);
+ const [areaRect, playerRect] = await Promise.all([
+ area.boundingBox(),
+ playerBox.boundingBox()
+ ]);
+ const gap = position === 'left'
+ ? areaRect.x - (playerRect.x + playerRect.width)
+ : playerRect.x - (areaRect.x + areaRect.width);
+ expect(gap).toBeGreaterThanOrEqual(16);
+ }
+
+ const declaredCard = page.locator('.bottom-trump-zone .card');
+ const handCard = page.locator('.my-hand .card').first();
+ const [declaredBox, handBox] = await Promise.all([
+ declaredCard.boundingBox(),
+ handCard.boundingBox()
+ ]);
+ expect(declaredBox.width).toBe(handBox.width);
+ expect(declaredBox.height).toBe(handBox.height);
+
+ await page.locator('[data-testid="table-layout-preview"]').screenshot({
+ path: testInfo.outputPath('side-plays-compact.png')
+ });
+});
+
+test('木牛流马与本人亮主区并排显示且不覆盖操作按钮或手牌', async ({ page }, testInfo) => {
+ await page.setViewportSize({ width: 1600, height: 900 });
+ await page.goto('/test/fixtures/wooden-ox-layout-preview.html');
+
+ const tray = page.locator('.wooden-ox-card-tray');
+ const trumpZone = page.locator('.bottom-trump-zone');
+ const controls = page.locator('.inline-controls');
+ const hand = page.locator('.my-hand');
+ await expect(tray).toBeVisible();
+ await expect(trumpZone).toBeVisible();
+ await expect(trumpZone.locator('.card')).toHaveCount(2);
+ await expect(controls).toBeVisible();
+ await expect(hand).toBeVisible();
+
+ const [trayBox, trumpZoneBox, controlsBox, handBox] = await Promise.all([
+ tray.boundingBox(),
+ trumpZone.boundingBox(),
+ controls.boundingBox(),
+ hand.boundingBox()
+ ]);
+ const overlaps = (left, right) => !(
+ left.x + left.width <= right.x
+ || right.x + right.width <= left.x
+ || left.y + left.height <= right.y
+ || right.y + right.height <= left.y
+ );
+ expect(overlaps(trayBox, trumpZoneBox)).toBe(false);
+ expect(overlaps(trayBox, controlsBox)).toBe(false);
+ expect(overlaps(trayBox, handBox)).toBe(false);
+ expect(overlaps(trumpZoneBox, controlsBox)).toBe(false);
+ expect(overlaps(trumpZoneBox, handBox)).toBe(false);
+ expect(trayBox.x + trayBox.width).toBeLessThanOrEqual(trumpZoneBox.x);
+ expect(trumpZoneBox.x + trumpZoneBox.width).toBeLessThanOrEqual(handBox.x);
+
+ await page.locator('[data-testid="wooden-ox-layout-preview"]').screenshot({
+ path: testInfo.outputPath('wooden-ox-and-trump-zones.png')
+ });
+});
+
+test('经久不衰在四个方位显示紧凑的上轮牌力来源且不溢出牌桌', async ({ page }, testInfo) => {
+ await page.setViewportSize({ width: 1600, height: 900 });
+ await page.goto('/test/fixtures/enduring-preview.html');
+
+ const tableBox = await page.locator('.game-table').boundingBox();
+ for (const position of ['top', 'left', 'right', 'bottom']) {
+ const area = page.locator(`.played-cards-${position}`);
+ await expect(area).toHaveAttribute('data-enduring-inherited', 'true');
+ const ribbon = area.locator('.enduring-inheritance-ribbon');
+ await expect(ribbon).toBeVisible();
+ await expect(ribbon.locator('.enduring-inheritance-seal')).toHaveText('承');
+ await expect(ribbon.locator('.enduring-inheritance-label')).toHaveText('上轮牌力');
+ const ribbonBox = await ribbon.boundingBox();
+ expect(ribbonBox.width).toBeLessThanOrEqual(218.5);
+ expect(ribbonBox.x).toBeGreaterThanOrEqual(tableBox.x);
+ expect(ribbonBox.x + ribbonBox.width).toBeLessThanOrEqual(tableBox.x + tableBox.width);
+ }
+
+ await expect(page.locator('.played-cards-top .enduring-inheritance-cards .card')).toHaveCount(6);
+ await expect(page.locator('.played-cards-top .enduring-inheritance-more')).toHaveText('+2');
+ await page.locator('[data-testid="enduring-preview"]').screenshot({
+ path: testInfo.outputPath('enduring-inheritance-layout.png')
+ });
+});
diff --git a/tractor-game-simulator/client/test/fixtures/enduring-preview.html b/tractor-game-simulator/client/test/fixtures/enduring-preview.html
new file mode 100644
index 0000000..4ad67f0
--- /dev/null
+++ b/tractor-game-simulator/client/test/fixtures/enduring-preview.html
@@ -0,0 +1,78 @@
+
+
+
+
+
+
Enduring rule visual fixture
+
+
+
+
+
+
+
diff --git a/tractor-game-simulator/client/test/fixtures/joker-preview.html b/tractor-game-simulator/client/test/fixtures/joker-preview.html
new file mode 100644
index 0000000..fa59c0e
--- /dev/null
+++ b/tractor-game-simulator/client/test/fixtures/joker-preview.html
@@ -0,0 +1,46 @@
+
+
+
+
+
+
Joker visual fixture
+
+
+
+
+
+
+
diff --git a/tractor-game-simulator/client/test/fixtures/table-layout-preview.html b/tractor-game-simulator/client/test/fixtures/table-layout-preview.html
new file mode 100644
index 0000000..73327e3
--- /dev/null
+++ b/tractor-game-simulator/client/test/fixtures/table-layout-preview.html
@@ -0,0 +1,75 @@
+
+
+
+
+
+
Table layout visual fixture
+
+
+
+
+
+
+
diff --git a/tractor-game-simulator/client/test/fixtures/wooden-ox-layout-preview.html b/tractor-game-simulator/client/test/fixtures/wooden-ox-layout-preview.html
new file mode 100644
index 0000000..2a289d4
--- /dev/null
+++ b/tractor-game-simulator/client/test/fixtures/wooden-ox-layout-preview.html
@@ -0,0 +1,96 @@
+
+
+
+
+
+
Wooden ox layout visual fixture
+
+
+
+
+
+
+
diff --git a/tractor-game-simulator/client/test/gameViewUtils.test.mjs b/tractor-game-simulator/client/test/gameViewUtils.test.mjs
new file mode 100644
index 0000000..53eb621
--- /dev/null
+++ b/tractor-game-simulator/client/test/gameViewUtils.test.mjs
@@ -0,0 +1,411 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+
+import {
+ formatLevel,
+ getCanonicalOpenHandCards,
+ getCurrentRoundPlayedCards,
+ getCurrentRoundPlayHistory,
+ getDestroyDykeDisplayState,
+ getDisplayedDefenseAsOffense,
+ getPendingPoliticalReviewDecision,
+ getRecordOnFileTrackerView,
+ getRuleSelectionAccess,
+ getStriveUpstreamActionOrder,
+ getThrowFailedPreview,
+ mergeLivePlayerCardCounts,
+ mergeTransferredHandCards,
+ retainUnplayedCardTransformations,
+ shouldShowGameBoard,
+ THROW_FAILED_PREVIEW_DURATION_MS
+} from '../src/utils/gameViewUtils.js';
+
+test('房间快照可重建本墩桌面和撤回历史,暗置牌只恢复牌背张数', () => {
+ const gameState = {
+ currentRoundTable: [{
+ playerId: 'visible-player',
+ playerName: '明牌玩家',
+ controllerPlayerId: 'visible-player',
+ cards: [{ id: 'clubs-K-0', suit: 'clubs', rank: 'K' }],
+ cardsCount: 1,
+ ironEvidenceMode: 'big'
+ }, {
+ playerId: 'hidden-player',
+ controllerPlayerId: 'controller-player',
+ controllerPlayerName: '代打玩家',
+ isProxy: true,
+ cards: [],
+ cardsCount: 2,
+ concealed: true
+ }]
+ };
+
+ const restored = getCurrentRoundPlayedCards(gameState, [
+ { id: 'hidden-player', name: '暗牌玩家' }
+ ]);
+ assert.deepEqual(restored['visible-player'].cards, [{
+ id: 'clubs-K-0',
+ suit: 'clubs',
+ rank: 'K',
+ ironEvidenceMode: 'big'
+ }]);
+ assert.equal(restored['hidden-player'].playerName, '暗牌玩家');
+ assert.equal(restored['hidden-player'].cardsCount, 2);
+ assert.deepEqual(restored['hidden-player'].cards, []);
+ assert.equal(restored['hidden-player'].concealed, true);
+
+ assert.deepEqual(getCurrentRoundPlayHistory(gameState).map(play => ({
+ playerId: play.playerId,
+ controllerPlayerId: play.controllerPlayerId,
+ isProxy: play.isProxy
+ })), [{
+ playerId: 'visible-player',
+ controllerPlayerId: 'visible-player',
+ isProxy: false
+ }, {
+ playerId: 'hidden-player',
+ controllerPlayerId: 'controller-player',
+ isProxy: true
+ }]);
+});
+
+test('显式转化只清理实际离手的牌,并保留未打出的后续预设', () => {
+ const transformations = {
+ played: { kind: 'forbidden_magic', cardId: 'played', suit: 'clubs', rank: 'A' },
+ prepared: { kind: 'forbidden_magic', cardId: 'prepared', suit: 'spades', rank: '6' },
+ cluster: { kind: 'cluster', cardId: 'cluster', suit: 'diamonds', fromRank: '8', toRank: '9' }
+ };
+
+ assert.deepEqual(
+ retainUnplayedCardTransformations(transformations, {
+ removedCardIds: ['played']
+ }),
+ {
+ prepared: transformations.prepared,
+ cluster: transformations.cluster
+ }
+ );
+});
+
+test('偷梁换柱未实际发动时保留预设,真正消耗后清掉剩余王的预设', () => {
+ const transformations = {
+ playedNormalCard: {
+ kind: 'forbidden_magic',
+ cardId: 'playedNormalCard',
+ suit: 'clubs',
+ rank: 'A'
+ },
+ firstJoker: { kind: 'joker', cardId: 'firstJoker', suit: 'hearts', rank: 'Q' },
+ secondJoker: { kind: 'joker', cardId: 'secondJoker', suit: 'spades', rank: 'K' },
+ cluster: { kind: 'cluster', cardId: 'cluster', suit: 'diamonds', fromRank: '8', toRank: '9' }
+ };
+
+ assert.deepEqual(
+ retainUnplayedCardTransformations(transformations, {
+ removedCardIds: ['playedNormalCard']
+ }),
+ {
+ firstJoker: transformations.firstJoker,
+ secondJoker: transformations.secondJoker,
+ cluster: transformations.cluster
+ }
+ );
+
+ assert.deepEqual(
+ retainUnplayedCardTransformations(transformations, {
+ removedCardIds: ['firstJoker'],
+ consumedActiveSkillId: 'stealing_beams'
+ }),
+ {
+ playedNormalCard: transformations.playedNormalCard,
+ cluster: transformations.cluster
+ }
+ );
+});
+
+test('等级显示使用对应牌面而不是 11 至 14 的内部编号', () => {
+ assert.deepEqual(
+ [2, 9, 10, 11, 12, 13, 14].map(formatLevel),
+ ['2', '9', '10', 'J', 'Q', 'K', 'A']
+ );
+ assert.equal(formatLevel('12'), 'Q');
+});
+
+test('以守为攻的玩家标签与轮末停留牌桌属于同一轮', () => {
+ const gameState = {
+ currentRound: 3,
+ defenseAsOffense: { round: 3, playerId: 'player-1', delta: 2 },
+ defenseAsOffenseLastRound: { round: 2, playerId: 'player-0', delta: 1 }
+ };
+
+ assert.deepEqual(getDisplayedDefenseAsOffense(gameState, 2), {
+ round: 2,
+ playerId: 'player-0',
+ delta: 1
+ });
+ assert.deepEqual(getDisplayedDefenseAsOffense(gameState, 3), {
+ round: 3,
+ playerId: 'player-1',
+ delta: 2
+ });
+ assert.equal(getDisplayedDefenseAsOffense(gameState, 1), null);
+});
+
+test('力争上游按服务端座位队列标记每名玩家的行动次序', () => {
+ const players = [0, 1, 2, 3].map(index => ({ id: `player-${index}` }));
+ const gameState = {
+ selectedRule: { id: 'strive_upstream' },
+ striveUpstreamPlayOrder: [2, 0, 3, 1]
+ };
+
+ assert.deepEqual(
+ players.map(player => getStriveUpstreamActionOrder(players, gameState, player.id)),
+ [2, 4, 1, 3]
+ );
+ assert.equal(
+ getStriveUpstreamActionOrder(players, { ...gameState, selectedRule: { id: 'normal_game' } }, 'player-0'),
+ null
+ );
+});
+
+test('下一局等待选规则时继续显示牌桌', () => {
+ assert.equal(shouldShowGameBoard({
+ phase: 'waiting',
+ isWaitingForReady: false,
+ isRuleSelectionPending: true
+ }), true);
+});
+
+test('尚未开始游戏的普通等待状态显示房间界面', () => {
+ assert.equal(shouldShowGameBoard({
+ phase: 'waiting',
+ isWaitingForReady: false,
+ isRuleSelectionPending: false
+ }), false);
+});
+
+test('规则候选对所有玩家可见,但只有指定玩家可以选择', () => {
+ const gameState = {
+ isRuleSelectionPending: true,
+ ruleChooserPlayerId: 'player-2'
+ };
+
+ assert.deepEqual(getRuleSelectionAccess(gameState, 'player-1'), {
+ canView: true,
+ canChoose: false
+ });
+ assert.deepEqual(getRuleSelectionAccess(gameState, 'player-2'), {
+ canView: true,
+ canChoose: true
+ });
+ assert.deepEqual(getRuleSelectionAccess({
+ ...gameState,
+ isRuleSelectionPending: false
+ }, 'player-2'), {
+ canView: false,
+ canChoose: false
+ });
+});
+
+test('算无遗策的明手本人使用服务端公开手牌作为权威牌面', () => {
+ const cards = [
+ { id: 'hearts-K-0', suit: 'hearts', rank: 'K' },
+ { id: 'hearts-J-0', suit: 'hearts', rank: 'J' }
+ ];
+ const gameState = {
+ openHand: {
+ playerId: 'open-hand',
+ cards
+ }
+ };
+
+ assert.equal(getCanonicalOpenHandCards(gameState, 'open-hand'), cards);
+ assert.equal(getCanonicalOpenHandCards(gameState, 'dealer'), null);
+ assert.equal(getCanonicalOpenHandCards({ openHand: null }, 'open-hand'), null);
+});
+
+test('政治审查的审查者断线重连后可从房间快照恢复待决询问', () => {
+ const pending = {
+ id: 'political-review-3-1',
+ round: 3,
+ reviewerPlayerId: 'reviewer',
+ reviewerPlayerName: '审查者',
+ teammatePlayerId: 'teammate',
+ teammatePlayerName: '队友',
+ cards: [
+ { id: 'spades-Q-0', suit: 'spades', rank: 'Q' },
+ { id: 'spades-K-0', suit: 'spades', rank: 'K' }
+ ]
+ };
+ const gameState = {
+ politicalReview: { pending }
+ };
+
+ assert.equal(
+ getPendingPoliticalReviewDecision(gameState, 'reviewer'),
+ pending
+ );
+ assert.equal(
+ getPendingPoliticalReviewDecision(gameState, 'teammate'),
+ null
+ );
+ assert.equal(
+ getPendingPoliticalReviewDecision({ politicalReview: { pending: null } }, 'reviewer'),
+ null
+ );
+});
+
+test('甩牌失败完整牌面保留一秒,再露出服务端强制打出的最小组件', () => {
+ const attemptedCardObjects = [
+ { id: 'hearts-A-0', suit: 'hearts', rank: 'A' },
+ { id: 'hearts-K-0', suit: 'hearts', rank: 'K' },
+ { id: 'hearts-9-0', suit: 'hearts', rank: '9' }
+ ];
+
+ assert.equal(THROW_FAILED_PREVIEW_DURATION_MS, 1000);
+ assert.deepEqual(
+ getThrowFailedPreview('玩家1', attemptedCardObjects, 'preview-1'),
+ {
+ previewKey: 'preview-1',
+ playerName: '玩家1',
+ cards: attemptedCardObjects,
+ cardsCount: 3,
+ throwFailedAttempt: true
+ }
+ );
+ assert.equal(getThrowFailedPreview('玩家1', [], 'preview-2'), null);
+});
+
+test('毁堤淹田未发动时使用紧凑待命状态,不渲染突兀的大号数值', () => {
+ assert.deepEqual(getDestroyDykeDisplayState({
+ used: false,
+ pending: null,
+ disaster: null,
+ lastResult: null
+ }), {
+ tone: 'idle',
+ badge: '待命',
+ value: null,
+ detail: '闲家赢墩后可发动 · 本局一次',
+ progress: null
+ });
+});
+
+test('毁堤淹田灾期状态给出轮次、封存分和安全范围内的进度', () => {
+ assert.deepEqual(getDestroyDykeDisplayState({
+ used: true,
+ disaster: {
+ roundsElapsed: 2,
+ disasterAttackerPoints: 15,
+ voidedPoints: 20
+ }
+ }), {
+ tone: 'disaster',
+ badge: '灾期 2/3',
+ value: '15/20',
+ detail: '已封存 20 分',
+ progress: 75
+ });
+
+ assert.equal(getDestroyDykeDisplayState({
+ disaster: { disasterAttackerPoints: 30 }
+ }).progress, 100);
+});
+
+test('发牌阶段用逐张进度更新各家手牌数,离开发牌阶段后不沿用旧计数', () => {
+ const players = [
+ { id: 'player-1', cardsCount: 0 },
+ { id: 'player-2', cardsCount: 0 }
+ ];
+ const liveCounts = { 'player-1': 3, 'player-2': 2 };
+
+ assert.deepEqual(
+ mergeLivePlayerCardCounts(players, liveCounts, true).map(player => player.cardsCount),
+ [3, 2]
+ );
+ assert.equal(mergeLivePlayerCardCounts(players, liveCounts, false), players);
+});
+
+test('换牌落入手牌时只重排一次,并按牌 ID 去除旧副本', () => {
+ const currentCards = [
+ { id: 'spades-2-0', suit: 'spades', rank: '2' },
+ { id: 'hearts-5-0', suit: 'hearts', rank: '5' },
+ { id: 'clubs-K-0', suit: 'clubs', rank: 'K' }
+ ];
+ const receivedCards = [
+ { id: 'diamonds-A-1', suit: 'diamonds', rank: 'A' },
+ // 模拟重复推送:已有牌不能在手牌中出现两次。
+ { id: 'clubs-K-0', suit: 'clubs', rank: 'K' }
+ ];
+
+ assert.deepEqual(
+ mergeTransferredHandCards(currentCards, ['hearts-5-0'], receivedCards),
+ [
+ currentCards[0],
+ receivedCards[0],
+ receivedCards[1]
+ ]
+ );
+ assert.deepEqual(mergeTransferredHandCards(null, null, null), []);
+});
+
+test('记录在案用共用点数轴压缩四种花色,并精确保留两副牌计数和普通王、白王', () => {
+ const view = getRecordOnFileTrackerView({
+ activeRound: 3,
+ lastActiveRound: null,
+ playedCardCount: 7,
+ counts: {
+ spades: { K: 1 },
+ hearts: { '10': 2 },
+ clubs: {},
+ diamonds: { '5': 1 },
+ joker: {
+ small_joker: 1,
+ big_joker: 2,
+ county_prince_joker: 1,
+ prince_joker: 2,
+ white_joker: 1
+ }
+ }
+ }, 3);
+
+ assert.equal(view.ranks.length, 13);
+ assert.equal(view.suits.length, 4);
+ assert.equal(view.suits.find(suit => suit.id === 'hearts').counts[8], 2);
+ assert.equal(view.suits.find(suit => suit.id === 'spades').counts[11], 1);
+ assert.deepEqual(view.jokers, [1, 2]);
+ assert.equal(view.showWhiteJoker, false);
+
+ const kingOverWhiteView = getRecordOnFileTrackerView({
+ activeRound: 3,
+ lastActiveRound: null,
+ playedCardCount: 7,
+ counts: {
+ joker: { small_joker: 1, big_joker: 2, white_joker: 1 }
+ }
+ }, 3, { showWhiteJoker: true });
+ assert.deepEqual(kingOverWhiteView.jokers, [1, 2, 1]);
+ assert.equal(kingOverWhiteView.showWhiteJoker, true);
+
+ const eightKingsView = getRecordOnFileTrackerView({
+ activeRound: 3,
+ lastActiveRound: null,
+ playedCardCount: 9,
+ counts: {
+ joker: {
+ small_joker: 1,
+ big_joker: 2,
+ county_prince_joker: 1,
+ prince_joker: 2,
+ white_joker: 1
+ }
+ }
+ }, 3, { showRoyalJokers: true, showWhiteJoker: true });
+ assert.deepEqual(eightKingsView.jokers, [1, 2, 1, 2, 1]);
+ assert.equal(eightKingsView.showRoyalJokers, true);
+
+ assert.equal(getRecordOnFileTrackerView({
+ activeRound: 3,
+ lastActiveRound: null
+ }, 4), null);
+});
diff --git a/tractor-game-simulator/client/test/global-setup.js b/tractor-game-simulator/client/test/global-setup.js
new file mode 100644
index 0000000..f57fa44
--- /dev/null
+++ b/tractor-game-simulator/client/test/global-setup.js
@@ -0,0 +1,77 @@
+import { spawn } from 'node:child_process';
+import { once } from 'node:events';
+import { fileURLToPath } from 'node:url';
+import path from 'node:path';
+import { createServer as createViteServer } from 'vite';
+
+const clientRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
+const serverRoot = path.resolve(clientRoot, '../server');
+const clientUrl = 'http://127.0.0.1:3002';
+const serverUrl = 'http://127.0.0.1:5002';
+const serverHealthUrl = `${serverUrl}/health`;
+
+async function isReachable(url) {
+ try {
+ const response = await fetch(url);
+ return response.ok;
+ } catch {
+ return false;
+ }
+}
+
+async function waitUntilReachable(url, timeoutMs = 20_000) {
+ const deadline = Date.now() + timeoutMs;
+ while (Date.now() < deadline) {
+ if (await isReachable(url)) return;
+ await new Promise((resolve) => setTimeout(resolve, 100));
+ }
+ throw new Error(`Timed out waiting for ${url}`);
+}
+
+export default async function globalSetup() {
+ let serverProcess = null;
+ let viteServer = null;
+
+ if (await isReachable(serverHealthUrl)) {
+ throw new Error('Playwright 需要独占 5002 端口;请先停止已有测试服务');
+ }
+ serverProcess = spawn(process.execPath, ['src/index.js'], {
+ cwd: serverRoot,
+ env: {
+ ...process.env,
+ NODE_ENV: 'test',
+ PORT: '5002',
+ CLIENT_URL: clientUrl
+ },
+ stdio: process.env.DEBUG_E2E_SERVER ? 'inherit' : 'ignore'
+ });
+ await waitUntilReachable(serverHealthUrl);
+
+ if (!await isReachable(clientUrl)) {
+ viteServer = await createViteServer({
+ root: clientRoot,
+ server: {
+ host: '127.0.0.1',
+ port: 3002,
+ strictPort: true
+ },
+ define: {
+ __TRACTOR_SERVER_URL__: JSON.stringify(serverUrl)
+ }
+ });
+ await viteServer.listen();
+ }
+
+ return async () => {
+ if (viteServer) {
+ await viteServer.close();
+ }
+ if (serverProcess && !serverProcess.killed) {
+ serverProcess.kill();
+ await Promise.race([
+ once(serverProcess, 'exit'),
+ new Promise((resolve) => setTimeout(resolve, 5_000))
+ ]);
+ }
+ };
+}
diff --git a/tractor-game-simulator/client/test/ruleCatalog.test.mjs b/tractor-game-simulator/client/test/ruleCatalog.test.mjs
new file mode 100644
index 0000000..6eacf10
--- /dev/null
+++ b/tractor-game-simulator/client/test/ruleCatalog.test.mjs
@@ -0,0 +1,97 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+
+import {
+ IMPLEMENTED_RULES,
+ RULE_SELECT_OPTIONS,
+ ruleIncludesId
+} from '../src/utils/ruleCatalog.js';
+import { getImplementedRules } from '../../server/src/rules/ruleRegistry.js';
+
+test('前端规则测试目录与服务端已实现规则保持一致', () => {
+ const byId = (left, right) => left.id.localeCompare(right.id);
+ assert.deepEqual(
+ IMPLEMENTED_RULES.map(({ id, name }) => ({ id, name })).sort(byId),
+ getImplementedRules().map(({ id, name }) => ({ id, name })).sort(byId)
+ );
+});
+
+test('近期实现的规则已进入规则测试模式目录', () => {
+ const expectedRules = [
+ { id: 'divine_weapon', name: '神兵天降' },
+ { id: 'magic_trick', name: '魔术戏法' },
+ { id: 'abrupt_stop', name: '戛然而止' },
+ { id: 'cluster_analysis', name: '聚类分析' },
+ { id: 'forbidden_magic', name: '禁术秘法' },
+ { id: 'meticulous_accounting', name: '锱铢必较' },
+ { id: 'lost_in_fog', name: '如堕云雾' },
+ { id: 'birds_gone_bow_hidden', name: '鸟尽弓藏' },
+ { id: 'odd_even_scoring', name: '无独有偶' },
+ { id: 'second_battlefield', name: '第二战场' },
+ { id: 'one_country_two_systems', name: '一国两制' },
+ { id: 'wooden_ox_flowing_horse', name: '木牛流马' },
+ { id: 'strength_compensation', name: '取长补短' },
+ { id: 'unarmed', name: '手无寸铁' },
+ { id: 'mutual_support', name: '同舟共济' },
+ { id: 'candle_to_dawn', name: '烛尽天明' },
+ { id: 'cultural_revolution', name: '文化革命' },
+ { id: 'three_tigers', name: '三人成虎' },
+ { id: 'invite_into_urn', name: '请君入瓮' },
+ { id: 'old_horse_still_has_strength', name: '老骥伏枥' },
+ { id: 'trump_wins', name: 'Trump wins' },
+ { id: 'openly_revealed', name: '昭然若揭' },
+ { id: 'straw_boat_borrowing_arrows', name: '草船借箭' },
+ { id: 'bush_gate', name: '布什戈门' },
+ { id: 'teammate_cheer', name: '队友加油' },
+ { id: 'illusion_and_reality', name: '虚虚实实' },
+ { id: 'outward_harmony_inner_division', name: '貌合神离' },
+ { id: 'ambiguous', name: '模棱两可' },
+ { id: 'two_ghosts_knock_door', name: '二鬼拍门' },
+ { id: 'people_commune', name: '人民公社' },
+ { id: 'remove_firewood_from_under_cauldron', name: '釜底抽薪' },
+ { id: 'mainstay', name: '中流砥柱' },
+ { id: 'happy_twins', name: '欢乐成双' },
+ { id: 'encircle_three_missing_one', name: '围三阙一' },
+ { id: 'three_six_nine_grades', name: '三六九等' },
+ { id: 'iron_evidence', name: '铁证如山' },
+ { id: 'waiting_rabbit', name: '守株待兔' },
+ { id: 'hidden_dragon_in_abyss', name: '潜龙在渊' },
+ { id: 'administrative_review', name: '行政审查' },
+ { id: 'political_review', name: '政治审查' },
+ { id: 'no_one_survives', name: '无人生还' },
+ { id: 'lure_tiger_from_mountain', name: '调虎离山' },
+ { id: 'defense_as_offense', name: '以守为攻' },
+ { id: 'antinomy', name: '二律背反' },
+ { id: 'change_rice_to_mulberry', name: '改稻为桑' },
+ { id: 'destroy_dyke_flood_fields', name: '毁堤淹田' },
+ { id: 'record_on_file', name: '记录在案' },
+ { id: 'weighing_thousand_jin', name: '上称千斤' },
+ { id: 'king_over_white', name: '王上加白' },
+ { id: 'fear_of_breaking_vase', name: '投鼠忌器' },
+ { id: 'eight_kings_council', name: '八王议政' },
+ { id: 'nine_princes_succession', name: '九子夺嫡' },
+ { id: 'double_happiness', name: '双喜临门' }
+ ];
+
+ expectedRules.forEach(rule => {
+ assert.deepEqual(IMPLEMENTED_RULES.find(item => item.id === rule.id), rule);
+ assert.deepEqual(
+ RULE_SELECT_OPTIONS.find(option => option.value === rule.id),
+ { value: rule.id, label: rule.name }
+ );
+ });
+});
+
+test('双喜临门组合规则可同时识别两条子规则', () => {
+ const rule = {
+ id: 'double_happiness',
+ rules: [
+ { id: 'reverse_rank_order' },
+ { id: 'single_step_debug' }
+ ]
+ };
+ assert.equal(ruleIncludesId(rule, 'double_happiness'), true);
+ assert.equal(ruleIncludesId(rule, 'reverse_rank_order'), true);
+ assert.equal(ruleIncludesId(rule, 'single_step_debug'), true);
+ assert.equal(ruleIncludesId(rule, 'normal_game'), false);
+});
diff --git a/tractor-game-simulator/client/test/ruleDisplayContent.test.mjs b/tractor-game-simulator/client/test/ruleDisplayContent.test.mjs
new file mode 100644
index 0000000..df5fdf8
--- /dev/null
+++ b/tractor-game-simulator/client/test/ruleDisplayContent.test.mjs
@@ -0,0 +1,85 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+
+import {
+ getRuleTableContent,
+ ORIGINAL_RULE_CONTENT_BY_NAME,
+ TABLE_RULE_CONTENT_BY_NAME
+} from '../src/utils/ruleDisplayContent.js';
+import { getImplementedRules } from '../../server/src/rules/ruleRegistry.js';
+
+test('牌桌规则说明采用准确且适合局中阅读的文案', () => {
+ assert.equal(
+ getRuleTableContent({
+ name: '时间倒流',
+ content: '这是一段服务端为了精确实现而保留的很长说明'
+ }),
+ '每名玩家限一次,可在本轮及轮末停留时预备;轮末依次询问,首个确认者令牌局回到本轮开始前。'
+ );
+ assert.equal(
+ getRuleTableContent({
+ name: '欢乐成双',
+ content: '详细说明'
+ }),
+ '庄家锁定后与上家换位,但原队伍不变,牌局结束后恢复座次;庄家方胜则由庄家的固定队友上庄,闲家方胜则由换位后庄家的下家上庄。'
+ );
+ assert.equal(
+ getRuleTableContent({
+ name: '取长补短',
+ content: '包含郡王、亲王与完整牌力链的实现说明'
+ }),
+ '以庄家为0号位逆时针编号。第x轮,x%4号位全部牌升一级,(x+2)%4号位降一级;副牌不跨入主牌,升降可越过牌序端点,实体分值不变。'
+ );
+ assert.equal(Object.keys(TABLE_RULE_CONTENT_BY_NAME).length, 117);
+ assert.equal(Object.keys(ORIGINAL_RULE_CONTENT_BY_NAME).length, 117);
+});
+
+test('所有已实现规则都有牌桌文案,已知失真条件不再沿用历史短版', () => {
+ getImplementedRules().forEach(rule => {
+ assert.equal(typeof TABLE_RULE_CONTENT_BY_NAME[rule.name], 'string', rule.name);
+ assert.ok(TABLE_RULE_CONTENT_BY_NAME[rule.name].length > 0, rule.name);
+ });
+
+ assert.match(TABLE_RULE_CONTENT_BY_NAME['一马当先'], /仅第一轮/);
+ assert.match(TABLE_RULE_CONTENT_BY_NAME['焦点人物'], /秘密表决/);
+ assert.doesNotMatch(TABLE_RULE_CONTENT_BY_NAME['焦点人物'], /随机/);
+ assert.match(TABLE_RULE_CONTENT_BY_NAME['文化革命'], /包括10、K/);
+ assert.doesNotMatch(TABLE_RULE_CONTENT_BY_NAME['文化革命'], /不能为10\/K/);
+ assert.match(TABLE_RULE_CONTENT_BY_NAME['三六九等'], /王只能亮主/);
+ assert.match(TABLE_RULE_CONTENT_BY_NAME['三六九等'], /自然无主仍保留/);
+ assert.match(TABLE_RULE_CONTENT_BY_NAME['守株待兔'], /可选5、10、K/);
+ assert.match(TABLE_RULE_CONTENT_BY_NAME['中流砥柱'], /^庄家完成埋底后/);
+ assert.match(TABLE_RULE_CONTENT_BY_NAME['模棱两可'], /仅本轮二、三号位/);
+ assert.match(TABLE_RULE_CONTENT_BY_NAME['改稻为桑'], /副牌变同花色A,主牌变大王/);
+ assert.match(TABLE_RULE_CONTENT_BY_NAME['偷梁换柱'], /普通牌/);
+ assert.doesNotMatch(TABLE_RULE_CONTENT_BY_NAME['偷梁换柱'], /数字牌/);
+ assert.match(TABLE_RULE_CONTENT_BY_NAME['神兵天降'], /未发动则保留/);
+ assert.match(TABLE_RULE_CONTENT_BY_NAME['回光返照'], /首次出牌前/);
+ assert.match(TABLE_RULE_CONTENT_BY_NAME['回光返照'], /一次出牌后仍有手牌/);
+ assert.match(TABLE_RULE_CONTENT_BY_NAME['欢乐成双'], /固定队友上庄/);
+ assert.match(TABLE_RULE_CONTENT_BY_NAME['铁证如山'], /第二张大王只影响下一轮/);
+ assert.match(TABLE_RULE_CONTENT_BY_NAME['记录在案'], /重新独立显示一轮/);
+ assert.match(TABLE_RULE_CONTENT_BY_NAME['记录在案'], /初始牌堆确实含有/);
+ assert.match(TABLE_RULE_CONTENT_BY_NAME['九子夺嫡'], /白王/);
+});
+
+test('双喜临门分别显示两条子规则的短文案', () => {
+ assert.equal(
+ getRuleTableContent({
+ name: '双喜临门',
+ content: '冗长的组合规则说明',
+ rules: [
+ { name: '世事无常', content: '详细说明一' },
+ { name: '王上加白', content: '详细说明二' }
+ ]
+ }),
+ '世事无常:正常对局。;王上加白:本局随机一张王牌变为全局最大的白王(皇)。'
+ );
+});
+
+test('未收录规则仍回退到服务端提供的说明', () => {
+ assert.equal(
+ getRuleTableContent({ name: '未来规则', content: '未来规则说明' }),
+ '未来规则说明'
+ );
+});
diff --git a/tractor-game-simulator/client/test/scoringUtils.test.mjs b/tractor-game-simulator/client/test/scoringUtils.test.mjs
new file mode 100644
index 0000000..bc5997a
--- /dev/null
+++ b/tractor-game-simulator/client/test/scoringUtils.test.mjs
@@ -0,0 +1,205 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+
+import {
+ calculateCardPoints,
+ getCandleCardColor,
+ getCandleToDawnCardPoints,
+ getDisplayedCandleState,
+ getScoringDisplayCard,
+ getOddEvenRoundMultiplier,
+ getCardPoints,
+ getMeticulousAccountingCardPoints
+} from '../src/utils/scoringUtils.js';
+
+test('左上角计分区把取长补短牌还原为实体牌面', () => {
+ const transformedCard = {
+ id: 'spades-A-0',
+ suit: 'hearts',
+ rank: '2',
+ originalSuit: 'spades',
+ originalRank: 'A',
+ isStrengthCompensated: true,
+ strengthCompensationDelta: 1
+ };
+
+ const displayedCard = getScoringDisplayCard(transformedCard);
+
+ assert.equal(displayedCard.suit, 'spades');
+ assert.equal(displayedCard.rank, 'A');
+ assert.equal(displayedCard.isStrengthCompensated, false);
+ assert.equal(displayedCard.strengthCompensationDelta, 0);
+ assert.equal(displayedCard.originalSuit, null);
+ assert.equal(displayedCard.originalRank, null);
+ assert.equal(transformedCard.suit, 'hearts');
+ assert.equal(transformedCard.rank, '2');
+
+ const ordinaryCard = { id: 'diamonds-5-0', suit: 'diamonds', rank: '5' };
+ assert.equal(getScoringDisplayCard(ordinaryCard), ordinaryCard);
+});
+
+test('九子夺嫡永久改牌面但计分区和分值仍读取开局实体牌面', () => {
+ const promotedCard = {
+ id: 'hearts-4-0',
+ suit: 'hearts',
+ rank: '5',
+ isNinePrincesPromoted: true,
+ ninePrincesPromotionCount: 1,
+ ninePrincesPermanentSuit: 'hearts',
+ ninePrincesPermanentRank: '5',
+ ninePrincesScoringSuit: 'hearts',
+ ninePrincesScoringRank: '4'
+ };
+
+ assert.equal(getCardPoints(promotedCard), 0);
+ assert.equal(getScoringDisplayCard(promotedCard).rank, '4');
+});
+
+test('三人成虎只改本轮牌力,中央计分仍读取实体牌点', () => {
+ const transformedFive = {
+ id: 'hearts-5-0',
+ suit: 'hearts',
+ rank: '1',
+ originalSuit: 'hearts',
+ originalRank: '5',
+ isThreeTigersTransformed: true,
+ isThreeTigersTrump: true
+ };
+ const transformedTen = {
+ id: 'hearts-10-0',
+ suit: 'hearts',
+ rank: '6',
+ originalSuit: 'hearts',
+ originalRank: '10',
+ isThreeTigersTransformed: true,
+ isThreeTigersTrump: true
+ };
+
+ assert.equal(getCardPoints(transformedFive), 5);
+ assert.equal(getCardPoints(transformedTen), 10);
+ assert.equal(calculateCardPoints([transformedFive, transformedTen]), 15);
+});
+
+test('改稻为桑牌无论新牌面为何都永久计0分', () => {
+ const transformedAce = {
+ suit: 'clubs',
+ rank: 'A',
+ originalSuit: 'clubs',
+ originalRank: 'K',
+ isRiceToMulberryTransformed: true
+ };
+ const transformedBigJoker = {
+ suit: 'joker',
+ rank: 'big_joker',
+ originalSuit: 'hearts',
+ originalRank: '10',
+ isRiceToMulberryTransformed: true
+ };
+
+ assert.equal(getCardPoints(transformedAce), 0);
+ assert.equal(getCardPoints(transformedBigJoker), 0);
+ assert.equal(calculateCardPoints([transformedAce, transformedBigJoker]), 0);
+});
+
+test('锱铢必较在客户端按A至7分别显示1至7分', () => {
+ const cards = ['A', '2', '3', '4', '5', '6', '7', '8', '10', 'K']
+ .map((rank, index) => ({ id: `hearts-${rank}-${index}`, suit: 'hearts', rank }));
+
+ assert.deepEqual(
+ cards.map(getMeticulousAccountingCardPoints),
+ [1, 2, 3, 4, 5, 6, 7, 0, 0, 0]
+ );
+ assert.equal(calculateCardPoints(cards, getMeticulousAccountingCardPoints), 28);
+});
+
+test('无独有偶的奇数轮为0倍、偶数轮为2倍,其他规则保持原分', () => {
+ const rule = { id: 'odd_even_scoring' };
+ assert.equal(getOddEvenRoundMultiplier(rule, 1), 0);
+ assert.equal(getOddEvenRoundMultiplier(rule, 2), 2);
+ assert.equal(getOddEvenRoundMultiplier(rule, 17), 0);
+ assert.equal(getOddEvenRoundMultiplier(rule, 18), 2);
+ assert.equal(getOddEvenRoundMultiplier({ id: 'normal_game' }, 1), 1);
+});
+
+test('烛尽天明按烛态给红黑分牌每张加减5分,并正确识别大小王颜色', () => {
+ const redFive = { suit: 'hearts', rank: '5' };
+ const blackFive = { suit: 'clubs', rank: '5' };
+ const redKing = { suit: 'diamonds', rank: 'K' };
+ const blackTen = { suit: 'spades', rank: '10' };
+
+ assert.equal(getCandleToDawnCardPoints(redFive, true), 10);
+ assert.equal(getCandleToDawnCardPoints(blackFive, true), 0);
+ assert.equal(getCandleToDawnCardPoints(redKing, true), 15);
+ assert.equal(getCandleToDawnCardPoints(blackTen, true), 5);
+ assert.equal(getCandleToDawnCardPoints(redFive, false), 0);
+ assert.equal(getCandleToDawnCardPoints(blackFive, false), 10);
+ assert.equal(getCandleCardColor({ suit: 'joker', rank: 'small_joker' }), 'black');
+ assert.equal(getCandleCardColor({ suit: 'joker', rank: 'big_joker' }), 'red');
+});
+
+test('烛态在轮末四家牌面清除前保持旧值,清桌后才切换到下轮', () => {
+ const candleToDawn = {
+ isLit: false,
+ lastTransition: {
+ round: 1,
+ previousLit: true,
+ nextLit: false,
+ changed: true
+ }
+ };
+
+ assert.deepEqual(
+ getDisplayedCandleState({
+ candleToDawn,
+ currentRound: 2,
+ visiblePlayCount: 4,
+ playerCount: 4
+ }),
+ { round: 1, isLit: true, holdsCompletedRound: true }
+ );
+ // room_updated 先促使重渲染、第四手的 React state 尚未提交时,
+ // 同步轮次快照仍必须将中央分数锁在第1轮烛态。
+ assert.deepEqual(
+ getDisplayedCandleState({
+ candleToDawn,
+ currentRound: 2,
+ displayRoundNumber: 1,
+ visiblePlayCount: 3,
+ playerCount: 4
+ }),
+ { round: 1, isLit: true, holdsCompletedRound: true }
+ );
+ assert.deepEqual(
+ getDisplayedCandleState({
+ candleToDawn,
+ currentRound: 2,
+ heldRoundCandle: { round: 1, isLit: true },
+ visiblePlayCount: 1,
+ playerCount: 4
+ }),
+ { round: 1, isLit: true, holdsCompletedRound: true }
+ );
+ assert.deepEqual(
+ getDisplayedCandleState({
+ candleToDawn: {
+ isLit: false,
+ lastTransition: null
+ },
+ currentRound: 2,
+ displayRoundNumber: 2,
+ heldRoundCandle: { round: 1, isLit: true },
+ visiblePlayCount: 0,
+ playerCount: 4
+ }),
+ { round: 1, isLit: true, holdsCompletedRound: true }
+ );
+ assert.deepEqual(
+ getDisplayedCandleState({
+ candleToDawn,
+ currentRound: 2,
+ visiblePlayCount: 0,
+ playerCount: 4
+ }),
+ { round: 2, isLit: false, holdsCompletedRound: false }
+ );
+});
diff --git a/tractor-game-simulator/server/.env b/tractor-game-simulator/server/.env
deleted file mode 100644
index d7a586f..0000000
--- a/tractor-game-simulator/server/.env
+++ /dev/null
@@ -1,8 +0,0 @@
-# 服务器端口(改为5001以避免与macOS AirPlay冲突)
-PORT=5001
-
-# 客户端URL(CORS配置)
-CLIENT_URL=http://localhost:3000
-
-# 日志级别 (ERROR, WARN, INFO, DEBUG)
-LOG_LEVEL=INFO
diff --git a/tractor-game-simulator/server/.env.example b/tractor-game-simulator/server/.env.example
new file mode 100644
index 0000000..7c4b2ca
--- /dev/null
+++ b/tractor-game-simulator/server/.env.example
@@ -0,0 +1,4 @@
+# Local development defaults. Railway injects PORT and its public domain.
+PORT=5001
+CLIENT_URL=http://localhost:3000
+LOG_LEVEL=INFO
diff --git a/tractor-game-simulator/server/package-lock.json b/tractor-game-simulator/server/package-lock.json
new file mode 100644
index 0000000..2efbe51
--- /dev/null
+++ b/tractor-game-simulator/server/package-lock.json
@@ -0,0 +1,1491 @@
+{
+ "name": "tractor-game-server",
+ "version": "1.0.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "tractor-game-server",
+ "version": "1.0.0",
+ "license": "MIT",
+ "dependencies": {
+ "cors": "^2.8.5",
+ "dotenv": "^16.3.1",
+ "express": "^4.18.2",
+ "socket.io": "^4.7.2",
+ "uuid": "^9.0.1"
+ },
+ "devDependencies": {
+ "nodemon": "^3.0.2"
+ }
+ },
+ "node_modules/@socket.io/component-emitter": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmmirror.com/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz",
+ "integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==",
+ "license": "MIT"
+ },
+ "node_modules/@types/cors": {
+ "version": "2.8.19",
+ "resolved": "https://registry.npmmirror.com/@types/cors/-/cors-2.8.19.tgz",
+ "integrity": "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/node": "*"
+ }
+ },
+ "node_modules/@types/node": {
+ "version": "24.10.1",
+ "resolved": "https://registry.npmmirror.com/@types/node/-/node-24.10.1.tgz",
+ "integrity": "sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ==",
+ "license": "MIT",
+ "dependencies": {
+ "undici-types": "~7.16.0"
+ }
+ },
+ "node_modules/accepts": {
+ "version": "1.3.8",
+ "resolved": "https://registry.npmmirror.com/accepts/-/accepts-1.3.8.tgz",
+ "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==",
+ "license": "MIT",
+ "dependencies": {
+ "mime-types": "~2.1.34",
+ "negotiator": "0.6.3"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/anymatch": {
+ "version": "3.1.3",
+ "resolved": "https://registry.npmmirror.com/anymatch/-/anymatch-3.1.3.tgz",
+ "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "normalize-path": "^3.0.0",
+ "picomatch": "^2.0.4"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/array-flatten": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmmirror.com/array-flatten/-/array-flatten-1.1.1.tgz",
+ "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
+ "license": "MIT"
+ },
+ "node_modules/balanced-match": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmmirror.com/balanced-match/-/balanced-match-1.0.2.tgz",
+ "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/base64id": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmmirror.com/base64id/-/base64id-2.0.0.tgz",
+ "integrity": "sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog==",
+ "license": "MIT",
+ "engines": {
+ "node": "^4.5.0 || >= 5.9"
+ }
+ },
+ "node_modules/binary-extensions": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmmirror.com/binary-extensions/-/binary-extensions-2.3.0.tgz",
+ "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/body-parser": {
+ "version": "1.20.3",
+ "resolved": "https://registry.npmmirror.com/body-parser/-/body-parser-1.20.3.tgz",
+ "integrity": "sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g==",
+ "license": "MIT",
+ "dependencies": {
+ "bytes": "3.1.2",
+ "content-type": "~1.0.5",
+ "debug": "2.6.9",
+ "depd": "2.0.0",
+ "destroy": "1.2.0",
+ "http-errors": "2.0.0",
+ "iconv-lite": "0.4.24",
+ "on-finished": "2.4.1",
+ "qs": "6.13.0",
+ "raw-body": "2.5.2",
+ "type-is": "~1.6.18",
+ "unpipe": "1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.8",
+ "npm": "1.2.8000 || >= 1.4.16"
+ }
+ },
+ "node_modules/brace-expansion": {
+ "version": "1.1.12",
+ "resolved": "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-1.1.12.tgz",
+ "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "balanced-match": "^1.0.0",
+ "concat-map": "0.0.1"
+ }
+ },
+ "node_modules/braces": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmmirror.com/braces/-/braces-3.0.3.tgz",
+ "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fill-range": "^7.1.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/bytes": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmmirror.com/bytes/-/bytes-3.1.2.tgz",
+ "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/call-bind-apply-helpers": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmmirror.com/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
+ "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "function-bind": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/call-bound": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmmirror.com/call-bound/-/call-bound-1.0.4.tgz",
+ "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.2",
+ "get-intrinsic": "^1.3.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/chokidar": {
+ "version": "3.6.0",
+ "resolved": "https://registry.npmmirror.com/chokidar/-/chokidar-3.6.0.tgz",
+ "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "anymatch": "~3.1.2",
+ "braces": "~3.0.2",
+ "glob-parent": "~5.1.2",
+ "is-binary-path": "~2.1.0",
+ "is-glob": "~4.0.1",
+ "normalize-path": "~3.0.0",
+ "readdirp": "~3.6.0"
+ },
+ "engines": {
+ "node": ">= 8.10.0"
+ },
+ "funding": {
+ "url": "https://paulmillr.com/funding/"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.2"
+ }
+ },
+ "node_modules/concat-map": {
+ "version": "0.0.1",
+ "resolved": "https://registry.npmmirror.com/concat-map/-/concat-map-0.0.1.tgz",
+ "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/content-disposition": {
+ "version": "0.5.4",
+ "resolved": "https://registry.npmmirror.com/content-disposition/-/content-disposition-0.5.4.tgz",
+ "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==",
+ "license": "MIT",
+ "dependencies": {
+ "safe-buffer": "5.2.1"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/content-type": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmmirror.com/content-type/-/content-type-1.0.5.tgz",
+ "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/cookie": {
+ "version": "0.7.1",
+ "resolved": "https://registry.npmmirror.com/cookie/-/cookie-0.7.1.tgz",
+ "integrity": "sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/cookie-signature": {
+ "version": "1.0.6",
+ "resolved": "https://registry.npmmirror.com/cookie-signature/-/cookie-signature-1.0.6.tgz",
+ "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==",
+ "license": "MIT"
+ },
+ "node_modules/cors": {
+ "version": "2.8.5",
+ "resolved": "https://registry.npmmirror.com/cors/-/cors-2.8.5.tgz",
+ "integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==",
+ "license": "MIT",
+ "dependencies": {
+ "object-assign": "^4",
+ "vary": "^1"
+ },
+ "engines": {
+ "node": ">= 0.10"
+ }
+ },
+ "node_modules/debug": {
+ "version": "2.6.9",
+ "resolved": "https://registry.npmmirror.com/debug/-/debug-2.6.9.tgz",
+ "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
+ "license": "MIT",
+ "dependencies": {
+ "ms": "2.0.0"
+ }
+ },
+ "node_modules/depd": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmmirror.com/depd/-/depd-2.0.0.tgz",
+ "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/destroy": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmmirror.com/destroy/-/destroy-1.2.0.tgz",
+ "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8",
+ "npm": "1.2.8000 || >= 1.4.16"
+ }
+ },
+ "node_modules/dotenv": {
+ "version": "16.6.1",
+ "resolved": "https://registry.npmmirror.com/dotenv/-/dotenv-16.6.1.tgz",
+ "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==",
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://dotenvx.com"
+ }
+ },
+ "node_modules/dunder-proto": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmmirror.com/dunder-proto/-/dunder-proto-1.0.1.tgz",
+ "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.1",
+ "es-errors": "^1.3.0",
+ "gopd": "^1.2.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/ee-first": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmmirror.com/ee-first/-/ee-first-1.1.1.tgz",
+ "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
+ "license": "MIT"
+ },
+ "node_modules/encodeurl": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmmirror.com/encodeurl/-/encodeurl-2.0.0.tgz",
+ "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/engine.io": {
+ "version": "6.6.4",
+ "resolved": "https://registry.npmmirror.com/engine.io/-/engine.io-6.6.4.tgz",
+ "integrity": "sha512-ZCkIjSYNDyGn0R6ewHDtXgns/Zre/NT6Agvq1/WobF7JXgFff4SeDroKiCO3fNJreU9YG429Sc81o4w5ok/W5g==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/cors": "^2.8.12",
+ "@types/node": ">=10.0.0",
+ "accepts": "~1.3.4",
+ "base64id": "2.0.0",
+ "cookie": "~0.7.2",
+ "cors": "~2.8.5",
+ "debug": "~4.3.1",
+ "engine.io-parser": "~5.2.1",
+ "ws": "~8.17.1"
+ },
+ "engines": {
+ "node": ">=10.2.0"
+ }
+ },
+ "node_modules/engine.io-parser": {
+ "version": "5.2.3",
+ "resolved": "https://registry.npmmirror.com/engine.io-parser/-/engine.io-parser-5.2.3.tgz",
+ "integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=10.0.0"
+ }
+ },
+ "node_modules/engine.io/node_modules/cookie": {
+ "version": "0.7.2",
+ "resolved": "https://registry.npmmirror.com/cookie/-/cookie-0.7.2.tgz",
+ "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/engine.io/node_modules/debug": {
+ "version": "4.3.7",
+ "resolved": "https://registry.npmmirror.com/debug/-/debug-4.3.7.tgz",
+ "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==",
+ "license": "MIT",
+ "dependencies": {
+ "ms": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/engine.io/node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "license": "MIT"
+ },
+ "node_modules/es-define-property": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmmirror.com/es-define-property/-/es-define-property-1.0.1.tgz",
+ "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-errors": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmmirror.com/es-errors/-/es-errors-1.3.0.tgz",
+ "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-object-atoms": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmmirror.com/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
+ "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/escape-html": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmmirror.com/escape-html/-/escape-html-1.0.3.tgz",
+ "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
+ "license": "MIT"
+ },
+ "node_modules/etag": {
+ "version": "1.8.1",
+ "resolved": "https://registry.npmmirror.com/etag/-/etag-1.8.1.tgz",
+ "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/express": {
+ "version": "4.21.2",
+ "resolved": "https://registry.npmmirror.com/express/-/express-4.21.2.tgz",
+ "integrity": "sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==",
+ "license": "MIT",
+ "dependencies": {
+ "accepts": "~1.3.8",
+ "array-flatten": "1.1.1",
+ "body-parser": "1.20.3",
+ "content-disposition": "0.5.4",
+ "content-type": "~1.0.4",
+ "cookie": "0.7.1",
+ "cookie-signature": "1.0.6",
+ "debug": "2.6.9",
+ "depd": "2.0.0",
+ "encodeurl": "~2.0.0",
+ "escape-html": "~1.0.3",
+ "etag": "~1.8.1",
+ "finalhandler": "1.3.1",
+ "fresh": "0.5.2",
+ "http-errors": "2.0.0",
+ "merge-descriptors": "1.0.3",
+ "methods": "~1.1.2",
+ "on-finished": "2.4.1",
+ "parseurl": "~1.3.3",
+ "path-to-regexp": "0.1.12",
+ "proxy-addr": "~2.0.7",
+ "qs": "6.13.0",
+ "range-parser": "~1.2.1",
+ "safe-buffer": "5.2.1",
+ "send": "0.19.0",
+ "serve-static": "1.16.2",
+ "setprototypeof": "1.2.0",
+ "statuses": "2.0.1",
+ "type-is": "~1.6.18",
+ "utils-merge": "1.0.1",
+ "vary": "~1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.10.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/fill-range": {
+ "version": "7.1.1",
+ "resolved": "https://registry.npmmirror.com/fill-range/-/fill-range-7.1.1.tgz",
+ "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "to-regex-range": "^5.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/finalhandler": {
+ "version": "1.3.1",
+ "resolved": "https://registry.npmmirror.com/finalhandler/-/finalhandler-1.3.1.tgz",
+ "integrity": "sha512-6BN9trH7bp3qvnrRyzsBz+g3lZxTNZTbVO2EV1CS0WIcDbawYVdYvGflME/9QP0h0pYlCDBCTjYa9nZzMDpyxQ==",
+ "license": "MIT",
+ "dependencies": {
+ "debug": "2.6.9",
+ "encodeurl": "~2.0.0",
+ "escape-html": "~1.0.3",
+ "on-finished": "2.4.1",
+ "parseurl": "~1.3.3",
+ "statuses": "2.0.1",
+ "unpipe": "~1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/forwarded": {
+ "version": "0.2.0",
+ "resolved": "https://registry.npmmirror.com/forwarded/-/forwarded-0.2.0.tgz",
+ "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/fresh": {
+ "version": "0.5.2",
+ "resolved": "https://registry.npmmirror.com/fresh/-/fresh-0.5.2.tgz",
+ "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/fsevents": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.3.tgz",
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/function-bind": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmmirror.com/function-bind/-/function-bind-1.1.2.tgz",
+ "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/get-intrinsic": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmmirror.com/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
+ "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.2",
+ "es-define-property": "^1.0.1",
+ "es-errors": "^1.3.0",
+ "es-object-atoms": "^1.1.1",
+ "function-bind": "^1.1.2",
+ "get-proto": "^1.0.1",
+ "gopd": "^1.2.0",
+ "has-symbols": "^1.1.0",
+ "hasown": "^2.0.2",
+ "math-intrinsics": "^1.1.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/get-proto": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmmirror.com/get-proto/-/get-proto-1.0.1.tgz",
+ "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
+ "license": "MIT",
+ "dependencies": {
+ "dunder-proto": "^1.0.1",
+ "es-object-atoms": "^1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/glob-parent": {
+ "version": "5.1.2",
+ "resolved": "https://registry.npmmirror.com/glob-parent/-/glob-parent-5.1.2.tgz",
+ "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "is-glob": "^4.0.1"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/gopd": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmmirror.com/gopd/-/gopd-1.2.0.tgz",
+ "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/has-flag": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmmirror.com/has-flag/-/has-flag-3.0.0.tgz",
+ "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/has-symbols": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmmirror.com/has-symbols/-/has-symbols-1.1.0.tgz",
+ "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/hasown": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmmirror.com/hasown/-/hasown-2.0.2.tgz",
+ "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
+ "license": "MIT",
+ "dependencies": {
+ "function-bind": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/http-errors": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmmirror.com/http-errors/-/http-errors-2.0.0.tgz",
+ "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==",
+ "license": "MIT",
+ "dependencies": {
+ "depd": "2.0.0",
+ "inherits": "2.0.4",
+ "setprototypeof": "1.2.0",
+ "statuses": "2.0.1",
+ "toidentifier": "1.0.1"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/iconv-lite": {
+ "version": "0.4.24",
+ "resolved": "https://registry.npmmirror.com/iconv-lite/-/iconv-lite-0.4.24.tgz",
+ "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
+ "license": "MIT",
+ "dependencies": {
+ "safer-buffer": ">= 2.1.2 < 3"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/ignore-by-default": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmmirror.com/ignore-by-default/-/ignore-by-default-1.0.1.tgz",
+ "integrity": "sha512-Ius2VYcGNk7T90CppJqcIkS5ooHUZyIQK+ClZfMfMNFEF9VSE73Fq+906u/CWu92x4gzZMWOwfFYckPObzdEbA==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/inherits": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmmirror.com/inherits/-/inherits-2.0.4.tgz",
+ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
+ "license": "ISC"
+ },
+ "node_modules/ipaddr.js": {
+ "version": "1.9.1",
+ "resolved": "https://registry.npmmirror.com/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
+ "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.10"
+ }
+ },
+ "node_modules/is-binary-path": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmmirror.com/is-binary-path/-/is-binary-path-2.1.0.tgz",
+ "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "binary-extensions": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/is-extglob": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmmirror.com/is-extglob/-/is-extglob-2.1.1.tgz",
+ "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/is-glob": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmmirror.com/is-glob/-/is-glob-4.0.3.tgz",
+ "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "is-extglob": "^2.1.1"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/is-number": {
+ "version": "7.0.0",
+ "resolved": "https://registry.npmmirror.com/is-number/-/is-number-7.0.0.tgz",
+ "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.12.0"
+ }
+ },
+ "node_modules/math-intrinsics": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmmirror.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
+ "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/media-typer": {
+ "version": "0.3.0",
+ "resolved": "https://registry.npmmirror.com/media-typer/-/media-typer-0.3.0.tgz",
+ "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/merge-descriptors": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmmirror.com/merge-descriptors/-/merge-descriptors-1.0.3.tgz",
+ "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/methods": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmmirror.com/methods/-/methods-1.1.2.tgz",
+ "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/mime": {
+ "version": "1.6.0",
+ "resolved": "https://registry.npmmirror.com/mime/-/mime-1.6.0.tgz",
+ "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==",
+ "license": "MIT",
+ "bin": {
+ "mime": "cli.js"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/mime-db": {
+ "version": "1.52.0",
+ "resolved": "https://registry.npmmirror.com/mime-db/-/mime-db-1.52.0.tgz",
+ "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/mime-types": {
+ "version": "2.1.35",
+ "resolved": "https://registry.npmmirror.com/mime-types/-/mime-types-2.1.35.tgz",
+ "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
+ "license": "MIT",
+ "dependencies": {
+ "mime-db": "1.52.0"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/minimatch": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmmirror.com/minimatch/-/minimatch-3.1.2.tgz",
+ "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "brace-expansion": "^1.1.7"
+ },
+ "engines": {
+ "node": "*"
+ }
+ },
+ "node_modules/ms": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmmirror.com/ms/-/ms-2.0.0.tgz",
+ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
+ "license": "MIT"
+ },
+ "node_modules/negotiator": {
+ "version": "0.6.3",
+ "resolved": "https://registry.npmmirror.com/negotiator/-/negotiator-0.6.3.tgz",
+ "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/nodemon": {
+ "version": "3.1.11",
+ "resolved": "https://registry.npmmirror.com/nodemon/-/nodemon-3.1.11.tgz",
+ "integrity": "sha512-is96t8F/1//UHAjNPHpbsNY46ELPpftGUoSVNXwUfMk/qdjSylYrWSu1XavVTBOn526kFiOR733ATgNBCQyH0g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "chokidar": "^3.5.2",
+ "debug": "^4",
+ "ignore-by-default": "^1.0.1",
+ "minimatch": "^3.1.2",
+ "pstree.remy": "^1.1.8",
+ "semver": "^7.5.3",
+ "simple-update-notifier": "^2.0.0",
+ "supports-color": "^5.5.0",
+ "touch": "^3.1.0",
+ "undefsafe": "^2.0.5"
+ },
+ "bin": {
+ "nodemon": "bin/nodemon.js"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/nodemon"
+ }
+ },
+ "node_modules/nodemon/node_modules/debug": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmmirror.com/debug/-/debug-4.4.3.tgz",
+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ms": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/nodemon/node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/normalize-path": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmmirror.com/normalize-path/-/normalize-path-3.0.0.tgz",
+ "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/object-assign": {
+ "version": "4.1.1",
+ "resolved": "https://registry.npmmirror.com/object-assign/-/object-assign-4.1.1.tgz",
+ "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/object-inspect": {
+ "version": "1.13.4",
+ "resolved": "https://registry.npmmirror.com/object-inspect/-/object-inspect-1.13.4.tgz",
+ "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/on-finished": {
+ "version": "2.4.1",
+ "resolved": "https://registry.npmmirror.com/on-finished/-/on-finished-2.4.1.tgz",
+ "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
+ "license": "MIT",
+ "dependencies": {
+ "ee-first": "1.1.1"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/parseurl": {
+ "version": "1.3.3",
+ "resolved": "https://registry.npmmirror.com/parseurl/-/parseurl-1.3.3.tgz",
+ "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/path-to-regexp": {
+ "version": "0.1.12",
+ "resolved": "https://registry.npmmirror.com/path-to-regexp/-/path-to-regexp-0.1.12.tgz",
+ "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==",
+ "license": "MIT"
+ },
+ "node_modules/picomatch": {
+ "version": "2.3.1",
+ "resolved": "https://registry.npmmirror.com/picomatch/-/picomatch-2.3.1.tgz",
+ "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8.6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/proxy-addr": {
+ "version": "2.0.7",
+ "resolved": "https://registry.npmmirror.com/proxy-addr/-/proxy-addr-2.0.7.tgz",
+ "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==",
+ "license": "MIT",
+ "dependencies": {
+ "forwarded": "0.2.0",
+ "ipaddr.js": "1.9.1"
+ },
+ "engines": {
+ "node": ">= 0.10"
+ }
+ },
+ "node_modules/pstree.remy": {
+ "version": "1.1.8",
+ "resolved": "https://registry.npmmirror.com/pstree.remy/-/pstree.remy-1.1.8.tgz",
+ "integrity": "sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/qs": {
+ "version": "6.13.0",
+ "resolved": "https://registry.npmmirror.com/qs/-/qs-6.13.0.tgz",
+ "integrity": "sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "side-channel": "^1.0.6"
+ },
+ "engines": {
+ "node": ">=0.6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/range-parser": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmmirror.com/range-parser/-/range-parser-1.2.1.tgz",
+ "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/raw-body": {
+ "version": "2.5.2",
+ "resolved": "https://registry.npmmirror.com/raw-body/-/raw-body-2.5.2.tgz",
+ "integrity": "sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==",
+ "license": "MIT",
+ "dependencies": {
+ "bytes": "3.1.2",
+ "http-errors": "2.0.0",
+ "iconv-lite": "0.4.24",
+ "unpipe": "1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/readdirp": {
+ "version": "3.6.0",
+ "resolved": "https://registry.npmmirror.com/readdirp/-/readdirp-3.6.0.tgz",
+ "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "picomatch": "^2.2.1"
+ },
+ "engines": {
+ "node": ">=8.10.0"
+ }
+ },
+ "node_modules/safe-buffer": {
+ "version": "5.2.1",
+ "resolved": "https://registry.npmmirror.com/safe-buffer/-/safe-buffer-5.2.1.tgz",
+ "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT"
+ },
+ "node_modules/safer-buffer": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmmirror.com/safer-buffer/-/safer-buffer-2.1.2.tgz",
+ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
+ "license": "MIT"
+ },
+ "node_modules/semver": {
+ "version": "7.7.3",
+ "resolved": "https://registry.npmmirror.com/semver/-/semver-7.7.3.tgz",
+ "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==",
+ "dev": true,
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/send": {
+ "version": "0.19.0",
+ "resolved": "https://registry.npmmirror.com/send/-/send-0.19.0.tgz",
+ "integrity": "sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw==",
+ "license": "MIT",
+ "dependencies": {
+ "debug": "2.6.9",
+ "depd": "2.0.0",
+ "destroy": "1.2.0",
+ "encodeurl": "~1.0.2",
+ "escape-html": "~1.0.3",
+ "etag": "~1.8.1",
+ "fresh": "0.5.2",
+ "http-errors": "2.0.0",
+ "mime": "1.6.0",
+ "ms": "2.1.3",
+ "on-finished": "2.4.1",
+ "range-parser": "~1.2.1",
+ "statuses": "2.0.1"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/send/node_modules/encodeurl": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmmirror.com/encodeurl/-/encodeurl-1.0.2.tgz",
+ "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/send/node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "license": "MIT"
+ },
+ "node_modules/serve-static": {
+ "version": "1.16.2",
+ "resolved": "https://registry.npmmirror.com/serve-static/-/serve-static-1.16.2.tgz",
+ "integrity": "sha512-VqpjJZKadQB/PEbEwvFdO43Ax5dFBZ2UECszz8bQ7pi7wt//PWe1P6MN7eCnjsatYtBT6EuiClbjSWP2WrIoTw==",
+ "license": "MIT",
+ "dependencies": {
+ "encodeurl": "~2.0.0",
+ "escape-html": "~1.0.3",
+ "parseurl": "~1.3.3",
+ "send": "0.19.0"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/setprototypeof": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmmirror.com/setprototypeof/-/setprototypeof-1.2.0.tgz",
+ "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
+ "license": "ISC"
+ },
+ "node_modules/side-channel": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmmirror.com/side-channel/-/side-channel-1.1.0.tgz",
+ "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "object-inspect": "^1.13.3",
+ "side-channel-list": "^1.0.0",
+ "side-channel-map": "^1.0.1",
+ "side-channel-weakmap": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/side-channel-list": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmmirror.com/side-channel-list/-/side-channel-list-1.0.0.tgz",
+ "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "object-inspect": "^1.13.3"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/side-channel-map": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmmirror.com/side-channel-map/-/side-channel-map-1.0.1.tgz",
+ "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "es-errors": "^1.3.0",
+ "get-intrinsic": "^1.2.5",
+ "object-inspect": "^1.13.3"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/side-channel-weakmap": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmmirror.com/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
+ "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "es-errors": "^1.3.0",
+ "get-intrinsic": "^1.2.5",
+ "object-inspect": "^1.13.3",
+ "side-channel-map": "^1.0.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/simple-update-notifier": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmmirror.com/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz",
+ "integrity": "sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "semver": "^7.5.3"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/socket.io": {
+ "version": "4.8.1",
+ "resolved": "https://registry.npmmirror.com/socket.io/-/socket.io-4.8.1.tgz",
+ "integrity": "sha512-oZ7iUCxph8WYRHHcjBEc9unw3adt5CmSNlppj/5Q4k2RIrhl8Z5yY2Xr4j9zj0+wzVZ0bxmYoGSzKJnRl6A4yg==",
+ "license": "MIT",
+ "dependencies": {
+ "accepts": "~1.3.4",
+ "base64id": "~2.0.0",
+ "cors": "~2.8.5",
+ "debug": "~4.3.2",
+ "engine.io": "~6.6.0",
+ "socket.io-adapter": "~2.5.2",
+ "socket.io-parser": "~4.2.4"
+ },
+ "engines": {
+ "node": ">=10.2.0"
+ }
+ },
+ "node_modules/socket.io-adapter": {
+ "version": "2.5.5",
+ "resolved": "https://registry.npmmirror.com/socket.io-adapter/-/socket.io-adapter-2.5.5.tgz",
+ "integrity": "sha512-eLDQas5dzPgOWCk9GuuJC2lBqItuhKI4uxGgo9aIV7MYbk2h9Q6uULEh8WBzThoI7l+qU9Ast9fVUmkqPP9wYg==",
+ "license": "MIT",
+ "dependencies": {
+ "debug": "~4.3.4",
+ "ws": "~8.17.1"
+ }
+ },
+ "node_modules/socket.io-adapter/node_modules/debug": {
+ "version": "4.3.7",
+ "resolved": "https://registry.npmmirror.com/debug/-/debug-4.3.7.tgz",
+ "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==",
+ "license": "MIT",
+ "dependencies": {
+ "ms": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/socket.io-adapter/node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "license": "MIT"
+ },
+ "node_modules/socket.io-parser": {
+ "version": "4.2.4",
+ "resolved": "https://registry.npmmirror.com/socket.io-parser/-/socket.io-parser-4.2.4.tgz",
+ "integrity": "sha512-/GbIKmo8ioc+NIWIhwdecY0ge+qVBSMdgxGygevmdHj24bsfgtCmcUUcQ5ZzcylGFHsN3k4HB4Cgkl96KVnuew==",
+ "license": "MIT",
+ "dependencies": {
+ "@socket.io/component-emitter": "~3.1.0",
+ "debug": "~4.3.1"
+ },
+ "engines": {
+ "node": ">=10.0.0"
+ }
+ },
+ "node_modules/socket.io-parser/node_modules/debug": {
+ "version": "4.3.7",
+ "resolved": "https://registry.npmmirror.com/debug/-/debug-4.3.7.tgz",
+ "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==",
+ "license": "MIT",
+ "dependencies": {
+ "ms": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/socket.io-parser/node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "license": "MIT"
+ },
+ "node_modules/socket.io/node_modules/debug": {
+ "version": "4.3.7",
+ "resolved": "https://registry.npmmirror.com/debug/-/debug-4.3.7.tgz",
+ "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==",
+ "license": "MIT",
+ "dependencies": {
+ "ms": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/socket.io/node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "license": "MIT"
+ },
+ "node_modules/statuses": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmmirror.com/statuses/-/statuses-2.0.1.tgz",
+ "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/supports-color": {
+ "version": "5.5.0",
+ "resolved": "https://registry.npmmirror.com/supports-color/-/supports-color-5.5.0.tgz",
+ "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "has-flag": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/to-regex-range": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmmirror.com/to-regex-range/-/to-regex-range-5.0.1.tgz",
+ "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "is-number": "^7.0.0"
+ },
+ "engines": {
+ "node": ">=8.0"
+ }
+ },
+ "node_modules/toidentifier": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmmirror.com/toidentifier/-/toidentifier-1.0.1.tgz",
+ "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.6"
+ }
+ },
+ "node_modules/touch": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmmirror.com/touch/-/touch-3.1.1.tgz",
+ "integrity": "sha512-r0eojU4bI8MnHr8c5bNo7lJDdI2qXlWWJk6a9EAFG7vbhTjElYhBVS3/miuE0uOuoLdb8Mc/rVfsmm6eo5o9GA==",
+ "dev": true,
+ "license": "ISC",
+ "bin": {
+ "nodetouch": "bin/nodetouch.js"
+ }
+ },
+ "node_modules/type-is": {
+ "version": "1.6.18",
+ "resolved": "https://registry.npmmirror.com/type-is/-/type-is-1.6.18.tgz",
+ "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",
+ "license": "MIT",
+ "dependencies": {
+ "media-typer": "0.3.0",
+ "mime-types": "~2.1.24"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/undefsafe": {
+ "version": "2.0.5",
+ "resolved": "https://registry.npmmirror.com/undefsafe/-/undefsafe-2.0.5.tgz",
+ "integrity": "sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/undici-types": {
+ "version": "7.16.0",
+ "resolved": "https://registry.npmmirror.com/undici-types/-/undici-types-7.16.0.tgz",
+ "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==",
+ "license": "MIT"
+ },
+ "node_modules/unpipe": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmmirror.com/unpipe/-/unpipe-1.0.0.tgz",
+ "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/utils-merge": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmmirror.com/utils-merge/-/utils-merge-1.0.1.tgz",
+ "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4.0"
+ }
+ },
+ "node_modules/uuid": {
+ "version": "9.0.1",
+ "resolved": "https://registry.npmmirror.com/uuid/-/uuid-9.0.1.tgz",
+ "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==",
+ "funding": [
+ "https://github.com/sponsors/broofa",
+ "https://github.com/sponsors/ctavan"
+ ],
+ "license": "MIT",
+ "bin": {
+ "uuid": "dist/bin/uuid"
+ }
+ },
+ "node_modules/vary": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmmirror.com/vary/-/vary-1.1.2.tgz",
+ "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/ws": {
+ "version": "8.17.1",
+ "resolved": "https://registry.npmmirror.com/ws/-/ws-8.17.1.tgz",
+ "integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=10.0.0"
+ },
+ "peerDependencies": {
+ "bufferutil": "^4.0.1",
+ "utf-8-validate": ">=5.0.2"
+ },
+ "peerDependenciesMeta": {
+ "bufferutil": {
+ "optional": true
+ },
+ "utf-8-validate": {
+ "optional": true
+ }
+ }
+ }
+ }
+}
diff --git a/tractor-game-simulator/server/package.json b/tractor-game-simulator/server/package.json
index 05bcff2..4679bad 100644
--- a/tractor-game-simulator/server/package.json
+++ b/tractor-game-simulator/server/package.json
@@ -7,7 +7,9 @@
"scripts": {
"start": "node src/index.js",
"dev": "nodemon src/index.js",
- "test": "echo \"Error: no test specified\" && exit 1"
+ "test": "npm run test:rules && npm run test:bot",
+ "test:bot": "node test/botIntegration.mjs",
+ "test:rules": "node --test test/specialRules.test.mjs test/whoDesignedStrategy.test.mjs test/roomReconnect.test.mjs test/privateStateSync.test.mjs test/surrender.test.mjs"
},
"keywords": ["tractor", "card-game", "socket.io"],
"author": "",
diff --git a/tractor-game-simulator/server/src/index.js b/tractor-game-simulator/server/src/index.js
index ffec182..9db4984 100644
--- a/tractor-game-simulator/server/src/index.js
+++ b/tractor-game-simulator/server/src/index.js
@@ -13,8 +13,12 @@ import logger from './utils/logger.js';
dotenv.config();
const PORT = process.env.PORT || 5000;
-const CLIENT_URL = process.env.CLIENT_URL || 'http://localhost:3000';
-const NODE_ENV = process.env.NODE_ENV || 'development';
+const RAILWAY_PUBLIC_URL = process.env.RAILWAY_PUBLIC_DOMAIN
+ ? `https://${process.env.RAILWAY_PUBLIC_DOMAIN}`
+ : null;
+const CLIENT_URL = process.env.CLIENT_URL || RAILWAY_PUBLIC_URL || 'http://localhost:3000';
+const NODE_ENV = process.env.NODE_ENV
+ || (process.env.RAILWAY_ENVIRONMENT ? 'production' : 'development');
// 获取当前文件的目录路径
const __filename = fileURLToPath(import.meta.url);
diff --git a/tractor-game-simulator/server/src/models/Card.js b/tractor-game-simulator/server/src/models/Card.js
index 9cfd8f0..d9e8133 100644
--- a/tractor-game-simulator/server/src/models/Card.js
+++ b/tractor-game-simulator/server/src/models/Card.js
@@ -8,6 +8,25 @@ export class Card {
this.value = this.calculateValue();
this.displayOrder = 0;
this.isShown = false;
+ this.originalSuit = null;
+ this.originalRank = null;
+ this.isLastStandTrump = false;
+ this.isDivineWeaponTransformed = false;
+ this.divineWeaponCardId = null;
+ this.isStrengthCompensated = false;
+ this.strengthCompensationDelta = 0;
+ this.isDefenseAsOffenseBoosted = false;
+ this.defenseAsOffenseDelta = 0;
+ this.isTeammateCheered = false;
+ this.isAfterglowBoosted = false;
+ this.isUnarmed = false;
+ this.isRiceToMulberryTransformed = false;
+ this.isNinePrincesPromoted = false;
+ this.ninePrincesPromotionCount = 0;
+ this.ninePrincesPermanentSuit = null;
+ this.ninePrincesPermanentRank = null;
+ this.ninePrincesScoringSuit = null;
+ this.ninePrincesScoringRank = null;
}
calculateValue() {
@@ -23,7 +42,32 @@ export class Card {
rank: this.rank,
value: this.value,
displayOrder: this.displayOrder,
- isShown: this.isShown
+ isShown: this.isShown,
+ ...(this.originalSuit ? { originalSuit: this.originalSuit } : {}),
+ ...(this.originalRank ? { originalRank: this.originalRank } : {}),
+ ...(this.isDivineWeaponTransformed ? { isDivineWeaponTransformed: true } : {}),
+ ...(this.divineWeaponCardId ? { divineWeaponCardId: this.divineWeaponCardId } : {}),
+ ...(this.isStrengthCompensated ? {
+ isStrengthCompensated: true,
+ strengthCompensationDelta: this.strengthCompensationDelta
+ } : {}),
+ ...(this.isDefenseAsOffenseBoosted ? {
+ isDefenseAsOffenseBoosted: true,
+ defenseAsOffenseDelta: this.defenseAsOffenseDelta
+ } : {}),
+ ...(this.isTeammateCheered ? { isTeammateCheered: true } : {}),
+ ...(this.isAfterglowBoosted ? { isAfterglowBoosted: true } : {}),
+ ...(this.isUnarmed ? { isUnarmed: true } : {}),
+ ...(this.isRiceToMulberryTransformed ? { isRiceToMulberryTransformed: true } : {}),
+ ...(this.isNinePrincesPromoted ? {
+ isNinePrincesPromoted: true,
+ ninePrincesPromotionCount: this.ninePrincesPromotionCount,
+ ninePrincesPermanentSuit: this.ninePrincesPermanentSuit,
+ ninePrincesPermanentRank: this.ninePrincesPermanentRank,
+ ninePrincesScoringSuit: this.ninePrincesScoringSuit,
+ ninePrincesScoringRank: this.ninePrincesScoringRank
+ } : {}),
+ ...(this.isLastStandTrump ? { isLastStandTrump: true } : {})
};
}
}
diff --git a/tractor-game-simulator/server/src/models/GameState.js b/tractor-game-simulator/server/src/models/GameState.js
index 76edec4..2fba32f 100644
--- a/tractor-game-simulator/server/src/models/GameState.js
+++ b/tractor-game-simulator/server/src/models/GameState.js
@@ -1,4 +1,48 @@
-import { GamePhases, PlayModes, Ranks, levelToRank } from '../utils/constants.js';
+import { DEFAULT_CONFIG, GamePhases, PlayModes, Ranks, TurnOrders, levelToRank } from '../utils/constants.js';
+import {
+ getDayNightHighestRank,
+ isAdministrativeReviewRule,
+ isAntinomyRule,
+ isAmbiguousRule,
+ isAfterglowRule,
+ isBushGateRule,
+ isCandleToDawnRule,
+ isChangeRiceToMulberryRule,
+ isCulturalRevolutionRule,
+ isDayNightRotationRule,
+ isDefenseAsOffenseRule,
+ isDestroyDykeFloodFieldsRule,
+ isEncircleThreeMissingOneRule,
+ isHappyTwinsRule,
+ isHiddenDragonInAbyssRule,
+ isIronEvidenceRule,
+ isInviteIntoUrnRule,
+ isLostInFogRule,
+ isLureTigerFromMountainRule,
+ isMainstayRule,
+ isMutualSupportRule,
+ isOpenlyRevealedRule,
+ isOldHorseStillHasStrengthRule,
+ isOneCountryTwoSystemsRule,
+ isPeopleCommuneRule,
+ isPoliticalReviewRule,
+ isRecordOnFileRule,
+ isRemoveFirewoodRule,
+ isSecondBattlefieldRule,
+ isStrengthCompensationRule,
+ isStrawBoatBorrowingArrowsRule,
+ isStriveUpstreamRule,
+ isTeammateCheerRule,
+ isThreeTigersRule,
+ isThreeSixNineGradesRule,
+ isTwoGhostsKnockDoorRule,
+ isTrumpWinsRule,
+ isWaitingRabbitRule,
+ isWoodenOxFlowingHorseRule,
+ ruleIncludesId
+} from '../rules/ruleRegistry.js';
+import { getOneCountryPublicState } from '../utils/oneCountryTwoSystemsUtils.js';
+import { getIronEvidenceRoundMode } from '../utils/ironEvidenceUtils.js';
export class GameState {
constructor() {
@@ -6,12 +50,68 @@ export class GameState {
this.playMode = PlayModes.ORDERED;
this.deck = [];
this.bottomCards = [];
+ // “迷雾重重”从牌堆顶暗中移除的八张牌;只在终局结算事件中公开。
+ this.mistyFogCards = [];
+ // “弃掷逦迤”的弃牌只保存在服务端;终局前不进入公开快照。
+ this.lingeringDiscardedCards = [];
+ this.bottomCardsCount = DEFAULT_CONFIG.bottomCardsCount;
this.buryingPlayerId = null;
+ this.secondaryBuryingPlayerId = null;
+ // “改革开放”的二次埋底者在完成后仍需与庄家共同拥有最终底牌查看权。
+ this.reformAndOpeningUpTeammatePlayerId = null;
+ // “人民公社”四家依次各埋两张;具体牌面只保存在服务端 Map 中,终局统一公开。
+ this.peopleCommuneBuryingOrder = [];
+ this.peopleCommuneCurrentBuryingPlayerId = null;
+ this.peopleCommuneBuriedCardsByPlayerId = new Map();
+ this.activeSkillUsesByPlayerId = new Map();
+ // 牌桌默认按逆时针行动;“路线摇摆”在符合条件的整轮结算后翻转。
+ this.turnDirection = TurnOrders.COUNTER_CLOCKWISE;
+ // “时间倒流”允许多人预备;轮末确认时首个选择发动的人获得回溯权。
+ this.timeReversalReservations = new Map();
+ this.timeReversalDecisionState = null; // holding | awaiting_response
+ this.timeReversalWindowRound = null;
+ this.timeReversalLockedRounds = new Set();
+ this.wholeHandExchangeTriggers = new Set();
+ this.lastStandActivatedPlayerIds = new Set();
+ this.lastStandPendingPlayerIds = new Set();
+ this.fatalBeautyTrumpBonusApplied = false;
this.firstPlayerId = null;
this.currentPlayerIndex = null;
this.currentRound = 0;
this.roundStartPlayerIndex = null;
this.playersPlayedThisRound = new Set();
+ // 投降申请可在一墩中的任意时刻提出,但只在完整墩结算后按庄家起顺序处理。
+ this.surrenderRequests = new Map();
+ this.surrenderDecisionQueue = [];
+ this.surrenderCurrentDecision = null;
+ this.surrenderLastResult = null;
+ this.surrenderFinishGameAfterReview = false;
+ // 「力争上游」下,当前轮四家按上轮牌力由大到小的行动顺序。
+ this.striveUpstreamPlayOrder = [];
+ // “以守为攻”保存上一轮首家在当前轮获得的牌面加成。
+ this.defenseAsOffense = null;
+ // 轮末停留仍展示刚结束的牌桌,因此额外保留该轮实际生效的加成。
+ this.defenseAsOffenseLastRound = null;
+ // “二律背反”的当前公开声明;暗选中的新牌面只保存在 GameEngine,避免提前泄露。
+ this.antinomyDeclarationsByPlayerId = new Map();
+ this.antinomyPendingPlayerIds = new Set();
+ this.antinomySelectionStage = null;
+ this.antinomyTriggerRound = null;
+ // “改稻为桑”只公开选择进度,具体被改造的手牌仍属于私有信息。
+ this.riceToMulberryPendingPlayerIds = new Set();
+ this.riceToMulberryCompletedPlayerIds = new Set();
+ // “毁堤淹田”的最终一手在庄家决定前只保存在服务端;公共状态只展示分数与灾期进度。
+ this.destroyDykeUsed = false;
+ this.destroyDykeDecision = null;
+ this.destroyDykeDisaster = null;
+ this.destroyDykeLastResult = null;
+ // “记录在案”只公开当前生效轮;lastActiveRound 用于客户端轮末停牌的一秒多展示。
+ this.recordOnFileActiveRound = null;
+ this.recordOnFileLastActiveRound = null;
+ // “九子夺嫡”的候选手牌只对获胜者私发;公共状态仅说明正在等待谁以及规则是否已终止。
+ this.ninePrincesDecision = null;
+ this.ninePrincesResolved = false;
+ this.ninePrincesLastResult = null;
this.playHistory = [];
this.drawingIndex = 0;
this.startTime = null;
@@ -19,8 +119,188 @@ export class GameState {
this.trumpSuit = null; // 主牌花色
this.trumpRank = Ranks.TWO; // 主牌点数,默认为2
this.currentTrumpDeclaration = null; // 当前亮主信息 {playerId, playerName, suit, count, declarationType, strength, jokerType}
+ // “三六九等”的亮劣与亮主各自反亮,但四种实体级牌花色全桌共用。
+ this.currentInferiorDeclaration = null;
+ this.threeSixNineClaimedSuits = new Map();
+ this.inferiorSuit = null;
+ // “铁证如山”在每轮开始时锁定倍增/清零模式;已打出的大王按实体牌ID记录。
+ this.ironEvidencePlayedBigJokerIds = new Set();
+ this.ironEvidenceRoundMode = null;
+ this.ironEvidenceLastResult = null;
+ // “守株待兔”的牌面声明保密;仅公开谁已完成暗选、谁已成功交换以及当前待响应交换。
+ this.waitingRabbitDeclarationsByPlayerId = new Map();
+ this.waitingRabbitPendingSelectionPlayerIds = new Set();
+ this.waitingRabbitUsedPlayerIds = new Set();
+ // 分牌按实体牌 ID 只在第一次真正上桌时计分。
+ this.waitingRabbitSeenPointCardIds = new Set();
+ this.waitingRabbitDecision = null;
+ this.waitingRabbitLastResult = null;
+ this.waitingRabbitBehaviorRecords = [];
+ // “釜底抽薪”记录每次真正的反主关系;亮主窗口关闭后倒序逐项询问被反主者。
+ this.removeFirewoodCounterPairs = [];
+ this.removeFirewoodExchangeQueue = [];
+ this.removeFirewoodCurrentDecision = null;
+ this.removeFirewoodExchangeResults = [];
+ this.oneCountryDeclarationsByTeam = new Map();
+ this.oneCountryResolved = null;
+ // 每队一具木牛流马;盒中牌仅在服务端保存,公开快照只显示是否有牌。
+ this.woodenOxMulesByTeam = new Map();
+ this.woodenOxRoundWindow = null;
+ this.strengthCompensation = null;
+ this.isTrumpDeclarationLocked = false; // 倒计时结束后锁定,换牌期间不可再反主
+ // 开局衔接阶段;mainstay 在庄家完成埋底后生效,其余值位于摸牌结束至收底牌之间。
+ this.postDrawStage = null; // dealing | trump_window | card_exchange | remove_firewood_exchange | mainstay
+ this.pendingDealerPlayerId = null; // 已锁定但尚未收底牌的庄家
this.isWaitingForReady = false; // 是否在等待玩家准备
this.selectedRule = null; // 选中的规则 { name, content }
+ this.ruleOptions = []; // 本局由服务端提供的规则候选
+ this.ruleChooserPlayerId = null; // 本局有权选择规则的玩家
+ this.isRuleSelectionPending = false;
+ this.ruleSelectionMode = null; // single | double_happiness
+ // 摸牌结束后的同时换牌状态;具体选牌只保存在 GameEngine,绝不进入公开快照。
+ this.cardExchange = null;
+ // “中流砥柱”按一至四号位串行处理;每个人轮到时才按实时手牌重新计算主牌数。
+ this.mainstayPlayerQueue = [];
+ this.mainstayCurrentAction = null;
+ this.mainstayResults = [];
+ // “欢乐成双”记录本局临时座次与原始顺序;终局按玩家ID恢复,不能只交换索引。
+ this.happyTwins = null;
+ // “围三阙一”按每次合法出牌原子化记录实体普通牌花色;摸牌亮主不进入这份状态。
+ this.encircleThreeMissingOneSeenSuits = [];
+ this.encircleThreeMissingOneLastTransition = null;
+ // “算无遗策”的明手与其唯一操作者。牌面由 Room.toJSON 从玩家实时手牌生成。
+ this.openHandPlayerId = null;
+ this.openHandControllerPlayerId = null;
+ this.icebergRevealedCardIdsByPlayer = new Map();
+ // “二鬼拍门”达到两王门槛后持续公开该玩家仍在手中的王;即使后来只剩一张也不重新隐藏。
+ this.twoGhostsRevealedPlayerIds = new Set();
+ // “冰山一角”只公开谁正在选择,不公开其候选牌或已提交牌面。
+ this.icebergPendingPlayerIds = new Set();
+ this.areAllHandsRevealed = false;
+ // “十面埋伏”的点数在首次出现前只对庄家队友可见。
+ this.tenSidedAmbushSelectorPlayerId = null;
+ this.tenSidedAmbushRank = null;
+ this.isTenSidedAmbushSelectionPending = false;
+ this.isTenSidedAmbushRevealed = false;
+ // 闲家赢得伏击牌记正数,庄家方赢得记负数;底牌按抠底倍数折算。
+ this.tenSidedAmbushAttackerNetCardCount = 0;
+ // “三权分立”的三个分值槽:实际选择仅保存在服务端,首次出现后才进入公共快照。
+ this.threePowersSlots = [];
+ this.threePowersPendingPlayerIds = new Set();
+ // “君子一言”的声明公开,但并列最短花色候选只私发给对应玩家。
+ this.gentlemanPromiseDeclarationsByPlayerId = new Map();
+ this.gentlemanPromisePendingPlayerIds = new Set();
+ // “潜龙在渊”的声明公开;打出历史按牌面原始点数记录,首次降至12张时只结算一次。
+ this.hiddenDragonDeclarationsByPlayerId = new Map();
+ this.hiddenDragonPendingPlayerIds = new Set();
+ this.hiddenDragonPlayedRanksByPlayerId = new Map();
+ this.hiddenDragonEvaluatedPlayerIds = new Set();
+ this.hiddenDragonResults = [];
+ // “行政审查”在公开声明后先出牌,满足条件前底牌始终封存;命中与延迟埋底状态公开。
+ this.administrativeReview = null;
+ // “政治审查”先冻结队友提交的整手牌;放行后只批准这一手,收回则不留下任何禁出限制。
+ this.politicalReviewPending = null;
+ this.politicalReviewApproval = null;
+ this.politicalReviewLastResult = null;
+ // “再衰三竭”只记录当前连续赢墩者;一旦换人即从1重新累计。
+ this.repeatedExhaustionPlayerId = null;
+ this.repeatedExhaustionStreak = 0;
+ this.repeatedExhaustionLastPenalty = 0;
+ this.repeatedExhaustionLastScoreDelta = 0;
+ // “焦点人物”的候选、队内投票和逐人缴获分在终局前均留在服务端。
+ this.focusFigureTeams = [];
+ this.focusFigureCapturedPointsByPlayerId = new Map();
+ this.isFocusFigureVotingStarted = false;
+ this.isFocusFigureRevealed = false;
+ // “计划经济”封存的20张牌不属于底牌,只在正式出牌后的轮末逐张发放。
+ this.plannedEconomyReserveCards = [];
+ this.plannedEconomyDrawRounds = 0;
+ // “等价互惠”拼点期间冻结出牌;双方选牌只在服务端保存,公共快照仅公开参与者与提交进度。
+ this.equivalentReciprocityChallenge = null;
+ // “同舟共济”的即时请求与轮末返还都冻结出牌;选中的具体牌只保存在服务端。
+ this.mutualSupportPendingAction = null;
+ this.mutualSupportRoundTransfers = [];
+ this.mutualSupportReturnQueue = [];
+ // 轮末停留期间由 lastTransition.previousLit 回显刚结算的旧烛态。
+ this.candleSelectorPlayerId = null;
+ this.candleSelectionPending = false;
+ this.candleLit = null;
+ this.candleLastTransition = null;
+ // “文化革命”真正替换当前主定义;原主单独保存,覆盖声明时从原主重新计算。
+ this.culturalRevolution = null;
+ this.culturalRevolutionBaseTrumpSuit = null;
+ this.culturalRevolutionBaseTrumpRank = null;
+ // “三人成虎”只在当前一轮内累计;lastRoundState 供轮末停留期间继续显示旧状态。
+ this.threeTigersRoundState = null;
+ this.threeTigersLastRoundState = null;
+ // “请君入瓮”按声明所在轮次结算;lastResult 供轮末停留及下一轮状态框回显。
+ this.inviteIntoUrnDeclarations = [];
+ this.inviteIntoUrnLastResult = null;
+ // “老骥伏枥”记录哪些玩家曾获得牌权,以及最后一名首次获得者尚未消耗的绝大首发。
+ this.oldHorseRightHolderPlayerIds = new Set();
+ this.oldHorseProtectedPlayerId = null;
+ this.oldHorseLastAbsolutePlay = null;
+ // “Trump wins”只替换下一轮首发者,正常牌力赢家仍单独保留。
+ this.trumpWinsLastResult = null;
+ // “草船借箭”在轮末冻结下一轮出牌;待选的箭是公开牌,弃牌结果同样全场公开。
+ this.strawBoatBorrowingArrowsDecision = null;
+ this.strawBoatBorrowingArrowsLastResult = null;
+ // “布什戈门”只封锁被退回牌在紧接着的那一次重新首发;牌面与限制均全场公开。
+ this.bushGateRestriction = null;
+ this.bushGateLastResult = null;
+ // “队友加油”在每次出牌后询问无主者;确认者给队友施加永久+1牌面 Buff。
+ this.teammateCheerPending = null;
+ this.teammateCheerUsedPlayerIds = new Set();
+ this.teammateCheerBuffedPlayerIds = new Set();
+ this.teammateCheerLastResult = null;
+ // “回光返照”在开局首家出牌前或玩家出牌后询问仅剩1至3张主牌者;发动后只准出主,直至主牌出尽。
+ this.afterglowPending = null;
+ this.afterglowUsedPlayerIds = new Set();
+ this.afterglowActivePlayerIds = new Set();
+ this.afterglowLastResult = null;
+ // “模棱两可”在四家出完后冻结轮末;双方案公开,最终选择按三号位到二号位处理。
+ this.ambiguousRoundDecision = null;
+ // “经久不衰”只保存每名玩家上一个完整轮次的出牌和继承后牌力,不进入公开房间快照。
+ this.enduringLastPlaysByPlayerId = new Map();
+ // “梦中杀人”可反复入梦;成功命中首家花色或点数时立即移出并恢复玩家控制。
+ this.dreamKillingSleepingPlayerIds = new Set();
+ // “神兵天降”使用独立的无王牌堆;只有当前两张进入公开快照。
+ this.divineWeaponReserveCards = [];
+ this.divineWeaponCards = [];
+ this.divineWeaponGeneration = 0;
+ this.divineWeaponUsedThisRound = false;
+ this.divineWeaponUsedByPlayerId = null;
+ this.divineWeaponUsedCardId = null;
+ // “魔术戏法”的暗选在本轮结算前只保存在服务端,绝不进入公开快照。
+ this.magicTrickSelection = null;
+ // “禁术秘法”允许所有玩家随时预备;轮首按座位顺序逐个确认,确认后永久生效。
+ this.forbiddenMagicReservations = new Map();
+ this.forbiddenMagicDecisionQueue = [];
+ this.forbiddenMagicCurrentDecisionPlayerId = null;
+ this.forbiddenMagicDecisionRound = null;
+ this.forbiddenMagicActivePlayerIds = new Set();
+ // “调虎离山”允许双方分别抢占一次;预备不占次数,选定目标才锁定本方。
+ this.lureTigerReservations = new Map();
+ this.lureTigerDecisionQueue = [];
+ this.lureTigerCurrentDecision = null;
+ this.lureTigerUsedTeamIndexes = new Set();
+ this.lureTigerSilencedPlayerIds = new Set();
+ this.lureTigerSilencedRound = null;
+ this.lureTigerRoundActivations = [];
+ // 冷却规则:保存每名玩家上一轮出牌产生的本轮禁用点数/花色。
+ this.cardCooldownType = null;
+ this.cardCooldownValuesByPlayerId = new Map();
+ // “鸟尽弓藏”按实体牌及其有效花色记录传统分牌;某有效花色的分牌齐出后封锁主动首发。
+ this.birdPlayedPointCardIds = new Set();
+ this.birdPlayedPointCardSuitsById = new Map();
+ this.birdExhaustedSuits = new Set();
+ // “第二战场”按各家跨轮累计的实体出牌进行比较,不另设公共牌。
+ this.secondBattlefieldAccumulatedCardsByPlayerId = new Map();
+ this.secondBattlefieldShowdownCount = 0;
+ this.secondBattlefieldFinalStage = false;
+ this.secondBattlefieldLastResult = null;
+ // 跨局保留:上一局最后一轮赢家将为下一局选择规则。
+ this.nextRuleChooserIndex = null;
// 当前轮出牌记录
this.currentRoundPlays = []; // [{ playerIndex, playerId, cards, pattern }]
@@ -30,6 +310,10 @@ export class GameState {
// 得分相关
this.collectedPointCards = []; // 闲家收集的分数牌
this.attackerScore = 0; // 闲家总得分
+ // 终局结算必须保存在权威快照中。bottom_revealed 只负责实时提示,
+ // 刷新、断线重连或暂时返回房间后都要能从这里恢复完整结算界面。
+ this.bottomScoreResult = null;
+ this.upgradeResult = null;
this.lastRoundLeadingPattern = null; // 最后一轮的首发牌型(用于计算底牌倍数)
this.lastRoundWinnerIndex = null; // 最后一轮的获胜者索引
@@ -44,12 +328,53 @@ export class GameState {
this.playMode = PlayModes.ORDERED;
this.deck = [];
this.bottomCards = [];
+ this.mistyFogCards = [];
+ this.lingeringDiscardedCards = [];
+ this.bottomCardsCount = DEFAULT_CONFIG.bottomCardsCount;
this.buryingPlayerId = null;
+ this.secondaryBuryingPlayerId = null;
+ this.reformAndOpeningUpTeammatePlayerId = null;
+ this.peopleCommuneBuryingOrder = [];
+ this.peopleCommuneCurrentBuryingPlayerId = null;
+ this.peopleCommuneBuriedCardsByPlayerId.clear();
+ this.activeSkillUsesByPlayerId.clear();
+ this.turnDirection = TurnOrders.COUNTER_CLOCKWISE;
+ this.timeReversalReservations.clear();
+ this.timeReversalDecisionState = null;
+ this.timeReversalWindowRound = null;
+ this.timeReversalLockedRounds.clear();
+ this.wholeHandExchangeTriggers.clear();
+ this.lastStandActivatedPlayerIds.clear();
+ this.lastStandPendingPlayerIds.clear();
+ this.fatalBeautyTrumpBonusApplied = false;
this.firstPlayerId = null;
this.currentPlayerIndex = null;
this.currentRound = 0;
this.roundStartPlayerIndex = null;
this.playersPlayedThisRound.clear();
+ this.surrenderRequests.clear();
+ this.surrenderDecisionQueue = [];
+ this.surrenderCurrentDecision = null;
+ this.surrenderLastResult = null;
+ this.surrenderFinishGameAfterReview = false;
+ this.striveUpstreamPlayOrder = [];
+ this.defenseAsOffense = null;
+ this.defenseAsOffenseLastRound = null;
+ this.antinomyDeclarationsByPlayerId.clear();
+ this.antinomyPendingPlayerIds.clear();
+ this.antinomySelectionStage = null;
+ this.antinomyTriggerRound = null;
+ this.riceToMulberryPendingPlayerIds.clear();
+ this.riceToMulberryCompletedPlayerIds.clear();
+ this.destroyDykeUsed = false;
+ this.destroyDykeDecision = null;
+ this.destroyDykeDisaster = null;
+ this.destroyDykeLastResult = null;
+ this.recordOnFileActiveRound = null;
+ this.recordOnFileLastActiveRound = null;
+ this.ninePrincesDecision = null;
+ this.ninePrincesResolved = false;
+ this.ninePrincesLastResult = null;
this.playHistory = [];
this.drawingIndex = 0;
this.startTime = null;
@@ -68,8 +393,140 @@ export class GameState {
}
this.currentTrumpDeclaration = null; // 清空亮主信息
+ this.currentInferiorDeclaration = null;
+ this.threeSixNineClaimedSuits.clear();
+ this.inferiorSuit = null;
+ this.ironEvidencePlayedBigJokerIds.clear();
+ this.ironEvidenceRoundMode = null;
+ this.ironEvidenceLastResult = null;
+ this.removeFirewoodCounterPairs = [];
+ this.removeFirewoodExchangeQueue = [];
+ this.removeFirewoodCurrentDecision = null;
+ this.removeFirewoodExchangeResults = [];
+ this.oneCountryDeclarationsByTeam.clear();
+ this.oneCountryResolved = null;
+ this.woodenOxMulesByTeam.clear();
+ this.woodenOxRoundWindow = null;
+ this.strengthCompensation = null;
+ this.isTrumpDeclarationLocked = false;
+ this.postDrawStage = null;
+ this.pendingDealerPlayerId = null;
this.isWaitingForReady = false;
this.selectedRule = null;
+ this.ruleOptions = [];
+ this.ruleChooserPlayerId = null;
+ this.isRuleSelectionPending = false;
+ this.ruleSelectionMode = null;
+ this.cardExchange = null;
+ this.mainstayPlayerQueue = [];
+ this.mainstayCurrentAction = null;
+ this.mainstayResults = [];
+ this.happyTwins = null;
+ this.encircleThreeMissingOneSeenSuits = [];
+ this.encircleThreeMissingOneLastTransition = null;
+ this.waitingRabbitDeclarationsByPlayerId.clear();
+ this.waitingRabbitPendingSelectionPlayerIds.clear();
+ this.waitingRabbitUsedPlayerIds.clear();
+ this.waitingRabbitSeenPointCardIds.clear();
+ this.waitingRabbitDecision = null;
+ this.waitingRabbitLastResult = null;
+ this.waitingRabbitBehaviorRecords = [];
+ this.openHandPlayerId = null;
+ this.openHandControllerPlayerId = null;
+ this.icebergRevealedCardIdsByPlayer.clear();
+ this.twoGhostsRevealedPlayerIds.clear();
+ this.icebergPendingPlayerIds.clear();
+ this.areAllHandsRevealed = false;
+ this.tenSidedAmbushSelectorPlayerId = null;
+ this.tenSidedAmbushRank = null;
+ this.isTenSidedAmbushSelectionPending = false;
+ this.isTenSidedAmbushRevealed = false;
+ this.tenSidedAmbushAttackerNetCardCount = 0;
+ this.threePowersSlots = [];
+ this.threePowersPendingPlayerIds.clear();
+ this.gentlemanPromiseDeclarationsByPlayerId.clear();
+ this.gentlemanPromisePendingPlayerIds.clear();
+ this.hiddenDragonDeclarationsByPlayerId.clear();
+ this.hiddenDragonPendingPlayerIds.clear();
+ this.hiddenDragonPlayedRanksByPlayerId.clear();
+ this.hiddenDragonEvaluatedPlayerIds.clear();
+ this.hiddenDragonResults = [];
+ this.administrativeReview = null;
+ this.politicalReviewPending = null;
+ this.politicalReviewApproval = null;
+ this.politicalReviewLastResult = null;
+ this.repeatedExhaustionPlayerId = null;
+ this.repeatedExhaustionStreak = 0;
+ this.repeatedExhaustionLastPenalty = 0;
+ this.repeatedExhaustionLastScoreDelta = 0;
+ this.focusFigureTeams = [];
+ this.focusFigureCapturedPointsByPlayerId.clear();
+ this.isFocusFigureVotingStarted = false;
+ this.isFocusFigureRevealed = false;
+ this.plannedEconomyReserveCards = [];
+ this.plannedEconomyDrawRounds = 0;
+ this.equivalentReciprocityChallenge = null;
+ this.mutualSupportPendingAction = null;
+ this.mutualSupportRoundTransfers = [];
+ this.mutualSupportReturnQueue = [];
+ this.candleSelectorPlayerId = null;
+ this.candleSelectionPending = false;
+ this.candleLit = null;
+ this.candleLastTransition = null;
+ this.culturalRevolution = null;
+ this.culturalRevolutionBaseTrumpSuit = null;
+ this.culturalRevolutionBaseTrumpRank = null;
+ this.threeTigersRoundState = null;
+ this.threeTigersLastRoundState = null;
+ this.inviteIntoUrnDeclarations = [];
+ this.inviteIntoUrnLastResult = null;
+ this.oldHorseRightHolderPlayerIds.clear();
+ this.oldHorseProtectedPlayerId = null;
+ this.oldHorseLastAbsolutePlay = null;
+ this.trumpWinsLastResult = null;
+ this.strawBoatBorrowingArrowsDecision = null;
+ this.strawBoatBorrowingArrowsLastResult = null;
+ this.bushGateRestriction = null;
+ this.bushGateLastResult = null;
+ this.teammateCheerPending = null;
+ this.teammateCheerUsedPlayerIds.clear();
+ this.teammateCheerBuffedPlayerIds.clear();
+ this.teammateCheerLastResult = null;
+ this.afterglowPending = null;
+ this.afterglowUsedPlayerIds.clear();
+ this.afterglowActivePlayerIds.clear();
+ this.afterglowLastResult = null;
+ this.ambiguousRoundDecision = null;
+ this.enduringLastPlaysByPlayerId.clear();
+ this.dreamKillingSleepingPlayerIds.clear();
+ this.divineWeaponReserveCards = [];
+ this.divineWeaponCards = [];
+ this.divineWeaponGeneration = 0;
+ this.divineWeaponUsedThisRound = false;
+ this.divineWeaponUsedByPlayerId = null;
+ this.divineWeaponUsedCardId = null;
+ this.magicTrickSelection = null;
+ this.forbiddenMagicReservations.clear();
+ this.forbiddenMagicDecisionQueue = [];
+ this.forbiddenMagicCurrentDecisionPlayerId = null;
+ this.forbiddenMagicDecisionRound = null;
+ this.forbiddenMagicActivePlayerIds.clear();
+ this.lureTigerReservations.clear();
+ this.lureTigerDecisionQueue = [];
+ this.lureTigerCurrentDecision = null;
+ this.lureTigerUsedTeamIndexes.clear();
+ this.lureTigerSilencedPlayerIds.clear();
+ this.lureTigerSilencedRound = null;
+ this.lureTigerRoundActivations = [];
+ this.cardCooldownType = null;
+ this.cardCooldownValuesByPlayerId.clear();
+ this.birdPlayedPointCardIds.clear();
+ this.birdPlayedPointCardSuitsById.clear();
+ this.birdExhaustedSuits.clear();
+ this.secondBattlefieldAccumulatedCardsByPlayerId.clear();
+ this.secondBattlefieldShowdownCount = 0;
+ this.secondBattlefieldFinalStage = false;
+ this.secondBattlefieldLastResult = null;
// 重置当前轮出牌记录
this.currentRoundPlays = [];
@@ -79,6 +536,8 @@ export class GameState {
// 重置得分相关
this.collectedPointCards = [];
this.attackerScore = 0;
+ this.bottomScoreResult = null;
+ this.upgradeResult = null;
this.lastRoundLeadingPattern = null;
this.lastRoundWinnerIndex = null;
@@ -86,17 +545,209 @@ export class GameState {
// 这些字段需要在多局游戏中保持
}
+ getRecordOnFilePublicState() {
+ if (!isRecordOnFileRule(this.selectedRule)) return null;
+
+ const hasVisibleWindow = Number.isInteger(this.recordOnFileActiveRound)
+ || Number.isInteger(this.recordOnFileLastActiveRound);
+ const counts = {
+ spades: {},
+ hearts: {},
+ clubs: {},
+ diamonds: {},
+ joker: {}
+ };
+ let playedCardCount = 0;
+
+ if (hasVisibleWindow) {
+ // 暗置牌在本轮统一揭晓前不能通过记牌器反推出牌面;清桌后它们会自然进入统计。
+ const concealedCardIds = new Set(
+ this.currentRoundPlays
+ .filter(play => play.concealed)
+ .flatMap(play => play.originalCards || play.cards || [])
+ .map(card => card.id)
+ );
+ const ordinaryRanks = new Set([
+ '2', '3', '4', '5', '6', '7', '8', '9', '10', 'J', 'Q', 'K', 'A'
+ ]);
+ const ordinarySuits = new Set(['spades', 'hearts', 'clubs', 'diamonds']);
+
+ this.playHistory.forEach(play => {
+ (play.cards || []).forEach(card => {
+ if (concealedCardIds.has(card.id)) return;
+ const suit = card.ninePrincesScoringSuit || card.originalSuit || card.suit;
+ const rank = card.ninePrincesScoringRank || card.originalRank || card.rank;
+ const isOrdinaryCard = ordinarySuits.has(suit) && ordinaryRanks.has(rank);
+ const isJoker = suit === 'joker'
+ && [
+ 'small_joker',
+ 'big_joker',
+ 'county_prince_joker',
+ 'prince_joker',
+ 'white_joker'
+ ].includes(rank);
+ if (!isOrdinaryCard && !isJoker) return;
+ counts[suit][rank] = (counts[suit][rank] || 0) + 1;
+ playedCardCount++;
+ });
+ });
+ }
+
+ return {
+ activeRound: this.recordOnFileActiveRound,
+ lastActiveRound: this.recordOnFileLastActiveRound,
+ playedCardCount,
+ counts
+ };
+ }
+
toJSON() {
+ const isLostInFogScoringHidden = isLostInFogRule(this.selectedRule)
+ && ![GamePhases.REVEALING, GamePhases.FINISHED].includes(this.phase);
+ const recordOnFile = this.getRecordOnFilePublicState();
+ // cards_played 是一次性消息,玩家刷新或暂时返回房间页后无法重放。
+ // 因此把尚未结算的本墩牌作为公开快照一并下发,供客户端重建牌桌。
+ // 暗置牌在揭晓前只公开张数,绝不能借重连快照泄露牌面。
+ const currentRoundHistory = this.playHistory.filter(
+ entry => entry.round === this.currentRound
+ );
+ const currentRoundTable = this.currentRoundPlays.map((play, index) => {
+ const historyEntry = currentRoundHistory[index]
+ || currentRoundHistory.find(entry => entry.playerId === play.playerId)
+ || null;
+ const serializeCard = card => card?.toJSON ? card.toJSON() : card;
+ const visibleCards = play.concealed ? [] : (play.cards || []).map(serializeCard);
+
+ return {
+ playerIndex: play.playerIndex,
+ playerId: play.playerId,
+ playerName: historyEntry?.playerName || null,
+ controllerPlayerId: historyEntry?.controllerPlayerId || play.playerId,
+ controllerPlayerName: historyEntry?.controllerPlayerName || historyEntry?.playerName || null,
+ isProxy: Boolean(historyEntry?.isProxy),
+ cards: visibleCards,
+ cardsCount: (play.cards || []).length,
+ concealed: Boolean(play.concealed),
+ treatedAsSmall: Boolean(play.treatedAsSmall),
+ activeSkillId: play.activeSkillId || null,
+ activeSkillName: play.activeSkillName || null,
+ jokerSubstitutions: play.jokerSubstitutions || [],
+ clusterAnalysisSubstitutions: play.clusterAnalysisSubstitutions || [],
+ forbiddenMagicSubstitutions: play.forbiddenMagicSubstitutions || [],
+ enduringInheritance: play.enduringInheritance || null,
+ dreamKilling: play.dreamKilling || null,
+ oldHorseAbsolute: Boolean(play.oldHorseAbsolute),
+ lureTigerSilenced: Boolean(play.lureTigerSilenced),
+ ironEvidenceMode: play.ironEvidenceMode || null,
+ ambiguousOptions: play.concealed ? null : (play.ambiguousOptions || null),
+ timestamp: historyEntry?.timestamp || null
+ };
+ });
return {
phase: this.phase,
playMode: this.playMode,
- bottomCardsCount: this.bottomCards.length,
+ bottomCardsCount: this.bottomCardsCount,
+ ...(isOpenlyRevealedRule(this.selectedRule) ? {
+ publicBottomCards: this.bottomCards.map(card => card.toJSON())
+ } : {}),
buryingPlayerId: this.buryingPlayerId,
+ secondaryBuryingPlayerId: this.secondaryBuryingPlayerId,
+ reformAndOpeningUpTeammatePlayerId: this.reformAndOpeningUpTeammatePlayerId,
+ peopleCommune: isPeopleCommuneRule(this.selectedRule) ? {
+ requiredCards: 2,
+ currentBuryingPlayerId: this.peopleCommuneCurrentBuryingPlayerId,
+ buryingOrder: [...this.peopleCommuneBuryingOrder],
+ submittedPlayerIds: Array.from(this.peopleCommuneBuriedCardsByPlayerId.keys())
+ } : null,
+ activeSkillUsesByPlayerId: Object.fromEntries(
+ [...this.activeSkillUsesByPlayerId.entries()].map(([playerId, skillIds]) => [
+ playerId,
+ Array.from(skillIds)
+ ])
+ ),
+ turnDirection: this.turnDirection,
+ dayNightHighestRank: isDayNightRotationRule(this.selectedRule)
+ ? getDayNightHighestRank(this.currentRound, this.trumpRank)
+ : null,
+ timeReversal: ruleIncludesId(this.selectedRule, 'time_reversal') ? {
+ reservations: Array.from(this.timeReversalReservations.values(), reservation => ({
+ ...reservation
+ })),
+ decisionState: this.timeReversalDecisionState,
+ windowRound: this.timeReversalWindowRound,
+ lockedRounds: Array.from(this.timeReversalLockedRounds)
+ } : null,
+ wholeHandExchangeTriggers: Array.from(this.wholeHandExchangeTriggers),
+ lastStandActivatedPlayerIds: Array.from(this.lastStandActivatedPlayerIds),
+ lastStandPendingPlayerIds: Array.from(this.lastStandPendingPlayerIds),
firstPlayerId: this.firstPlayerId,
currentPlayerIndex: this.currentPlayerIndex,
currentRound: this.currentRound,
roundStartPlayerIndex: this.roundStartPlayerIndex,
playersPlayedThisRound: Array.from(this.playersPlayedThisRound),
+ surrender: {
+ requestedPlayerIds: Array.from(this.surrenderRequests.keys()),
+ requests: Array.from(this.surrenderRequests.values(), request => ({ ...request })),
+ currentDecision: this.surrenderCurrentDecision
+ ? { ...this.surrenderCurrentDecision }
+ : null,
+ queuedPlayerIds: this.surrenderDecisionQueue.map(
+ decision => decision.initiatorPlayerId
+ ),
+ lastResult: this.surrenderLastResult
+ ? { ...this.surrenderLastResult }
+ : null
+ },
+ striveUpstreamPlayOrder: isStriveUpstreamRule(this.selectedRule)
+ ? [...this.striveUpstreamPlayOrder]
+ : [],
+ defenseAsOffense: isDefenseAsOffenseRule(this.selectedRule)
+ ? (this.defenseAsOffense ? { ...this.defenseAsOffense } : null)
+ : null,
+ defenseAsOffenseLastRound: isDefenseAsOffenseRule(this.selectedRule)
+ ? (this.defenseAsOffenseLastRound ? { ...this.defenseAsOffenseLastRound } : null)
+ : null,
+ antinomy: isAntinomyRule(this.selectedRule) ? {
+ declarationsByPlayerId: Object.fromEntries(
+ Array.from(this.antinomyDeclarationsByPlayerId, ([playerId, declaration]) => [
+ playerId,
+ { ...declaration }
+ ])
+ ),
+ pendingPlayerIds: Array.from(this.antinomyPendingPlayerIds),
+ selectionStage: this.antinomySelectionStage,
+ triggerRound: this.antinomyTriggerRound
+ } : null,
+ riceToMulberry: isChangeRiceToMulberryRule(this.selectedRule) ? {
+ pendingPlayerIds: Array.from(this.riceToMulberryPendingPlayerIds),
+ completedPlayerIds: Array.from(this.riceToMulberryCompletedPlayerIds)
+ } : null,
+ destroyDyke: isDestroyDykeFloodFieldsRule(this.selectedRule) ? {
+ used: this.destroyDykeUsed,
+ pending: this.destroyDykeDecision ? {
+ round: this.destroyDykeDecision.round,
+ dealerPlayerId: this.destroyDykeDecision.dealerPlayerId,
+ dealerPlayerName: this.destroyDykeDecision.dealerPlayerName,
+ winnerPlayerId: this.destroyDykeDecision.winnerPlayerId,
+ winnerPlayerName: this.destroyDykeDecision.winnerPlayerName,
+ roundPoints: this.destroyDykeDecision.roundPoints
+ } : null,
+ disaster: this.destroyDykeDisaster ? { ...this.destroyDykeDisaster } : null,
+ lastResult: this.destroyDykeLastResult ? { ...this.destroyDykeLastResult } : null
+ } : null,
+ recordOnFile,
+ ninePrinces: ruleIncludesId(this.selectedRule, 'nine_princes_succession') ? {
+ resolved: this.ninePrincesResolved,
+ pending: this.ninePrincesDecision ? {
+ decisionId: this.ninePrincesDecision.decisionId,
+ round: this.ninePrincesDecision.round,
+ playerId: this.ninePrincesDecision.playerId,
+ playerName: this.ninePrincesDecision.playerName
+ } : null,
+ lastResult: this.ninePrincesLastResult
+ ? { ...this.ninePrincesLastResult }
+ : null
+ } : null,
playHistoryCount: this.playHistory.length,
drawingProgress: this.drawingIndex,
totalCards: this.deck.length,
@@ -104,14 +755,509 @@ export class GameState {
endTime: this.endTime,
trumpSuit: this.trumpSuit,
trumpRank: this.trumpRank,
+ // 亮主牌是已经公开的牌局记录,不应随着实体牌离开手牌或客户端重连而消失。
+ currentTrumpDeclaration: this.currentTrumpDeclaration
+ ? {
+ ...this.currentTrumpDeclaration,
+ cards: (this.currentTrumpDeclaration.cards || []).map(
+ card => card.toJSON ? card.toJSON() : card
+ )
+ }
+ : null,
+ currentInferiorDeclaration: this.currentInferiorDeclaration
+ ? {
+ ...this.currentInferiorDeclaration,
+ cards: (this.currentInferiorDeclaration.cards || []).map(
+ card => card.toJSON ? card.toJSON() : card
+ )
+ }
+ : null,
+ oneCountryTwoSystems: isOneCountryTwoSystemsRule(this.selectedRule)
+ ? getOneCountryPublicState(this)
+ : null,
+ threeSixNine: isThreeSixNineGradesRule(this.selectedRule) ? {
+ inferiorSuit: this.inferiorSuit,
+ currentTrumpDeclaration: this.currentTrumpDeclaration
+ ? {
+ ...this.currentTrumpDeclaration,
+ cards: (this.currentTrumpDeclaration.cards || []).map(
+ card => card.toJSON ? card.toJSON() : card
+ )
+ }
+ : null,
+ currentInferiorDeclaration: this.currentInferiorDeclaration
+ ? {
+ ...this.currentInferiorDeclaration,
+ cards: (this.currentInferiorDeclaration.cards || []).map(
+ card => card.toJSON ? card.toJSON() : card
+ )
+ }
+ : null,
+ claimedSuits: Object.fromEntries(
+ Array.from(this.threeSixNineClaimedSuits.entries(), ([suit, claim]) => [
+ suit,
+ { ...claim }
+ ])
+ )
+ } : null,
+ ironEvidence: isIronEvidenceRule(this.selectedRule) ? {
+ roundMode: this.ironEvidenceRoundMode || getIronEvidenceRoundMode(
+ this.ironEvidencePlayedBigJokerIds.size
+ ),
+ playedBigJokerCount: this.ironEvidencePlayedBigJokerIds.size,
+ lastResult: this.ironEvidenceLastResult
+ ? { ...this.ironEvidenceLastResult }
+ : null
+ } : null,
+ waitingRabbit: isWaitingRabbitRule(this.selectedRule) ? {
+ pendingSelectionPlayerIds: Array.from(this.waitingRabbitPendingSelectionPlayerIds),
+ selectedPlayerIds: Array.from(this.waitingRabbitDeclarationsByPlayerId.keys()),
+ usedPlayerIds: Array.from(this.waitingRabbitUsedPlayerIds),
+ pendingDecision: this.waitingRabbitDecision ? {
+ id: this.waitingRabbitDecision.id,
+ round: this.waitingRabbitDecision.round,
+ chooserPlayerId: this.waitingRabbitDecision.chooserPlayerId,
+ chooserPlayerName: this.waitingRabbitDecision.chooserPlayerName,
+ sourcePlayerId: this.waitingRabbitDecision.sourcePlayerId,
+ sourcePlayerName: this.waitingRabbitDecision.sourcePlayerName,
+ targetCard: this.waitingRabbitDecision.targetCard?.toJSON
+ ? this.waitingRabbitDecision.targetCard.toJSON()
+ : this.waitingRabbitDecision.targetCard
+ } : null,
+ lastResult: this.waitingRabbitLastResult
+ ? { ...this.waitingRabbitLastResult }
+ : null,
+ behaviorRecords: this.waitingRabbitBehaviorRecords.map(record => ({
+ ...record,
+ targetCard: record.targetCard?.toJSON
+ ? record.targetCard.toJSON()
+ : record.targetCard || null,
+ discardedCard: record.discardedCard?.toJSON
+ ? record.discardedCard.toJSON()
+ : record.discardedCard || null
+ }))
+ } : null,
+ woodenOx: isWoodenOxFlowingHorseRule(this.selectedRule) ? {
+ round: this.woodenOxRoundWindow?.round ?? this.currentRound,
+ pendingPlayerIds: Array.from(this.woodenOxRoundWindow?.pendingPlayerIds || []),
+ requiredTransferPlayerIds: Array.from(
+ this.woodenOxRoundWindow?.requiredTransferPlayerIds || []
+ ),
+ mules: Array.from(this.woodenOxMulesByTeam.values(), mule => ({
+ teamIndex: mule.teamIndex,
+ initialHolderPlayerId: mule.initialHolderPlayerId,
+ holderPlayerId: mule.holderPlayerId,
+ hasStoredCard: Boolean(mule.storedCard),
+ transfersUsed: mule.transfersUsed,
+ maxTransfers: mule.maxTransfers,
+ completedRoundTrips: Math.floor(mule.transfersUsed / 2),
+ maxRoundTrips: 2
+ }))
+ } : null,
+ strengthCompensation: isStrengthCompensationRule(this.selectedRule)
+ ? (this.strengthCompensation ? { ...this.strengthCompensation } : null)
+ : null,
+ isTrumpDeclarationLocked: this.isTrumpDeclarationLocked,
+ postDrawStage: this.postDrawStage,
+ pendingDealerPlayerId: this.pendingDealerPlayerId,
+ removeFirewood: isRemoveFirewoodRule(this.selectedRule) ? {
+ counterCount: this.removeFirewoodCounterPairs.length,
+ currentDecision: this.removeFirewoodCurrentDecision
+ ? { ...this.removeFirewoodCurrentDecision }
+ : null,
+ remainingCount: this.removeFirewoodExchangeQueue.length,
+ resolved: this.removeFirewoodExchangeResults.map(result => ({ ...result }))
+ } : null,
isWaitingForReady: this.isWaitingForReady,
selectedRule: this.selectedRule,
+ ruleOptions: this.ruleOptions,
+ ruleChooserPlayerId: this.ruleChooserPlayerId,
+ isRuleSelectionPending: this.isRuleSelectionPending,
+ ruleSelectionMode: this.ruleSelectionMode,
+ cardExchange: this.cardExchange ? {
+ stage: this.cardExchange.stage || 'opening',
+ operation: this.cardExchange.operation || 'exchange',
+ triggerRound: this.cardExchange.triggerRound ?? null,
+ ruleId: this.cardExchange.ruleId,
+ ruleName: this.cardExchange.ruleName,
+ requiredCards: this.cardExchange.requiredCards,
+ targetByPlayerId: { ...this.cardExchange.targetByPlayerId },
+ submittedPlayerIds: Array.from(this.cardExchange.submittedPlayerIds || [])
+ } : null,
+ mainstay: isMainstayRule(this.selectedRule) ? {
+ currentAction: this.mainstayCurrentAction
+ ? Object.fromEntries(
+ Object.entries(this.mainstayCurrentAction)
+ .filter(([key]) => key !== 'trumpCount')
+ )
+ : null,
+ remainingPlayerIds: [...this.mainstayPlayerQueue],
+ results: this.mainstayResults.map(result => Object.fromEntries(
+ Object.entries(result).filter(([key]) => key !== 'trumpCount')
+ ))
+ } : null,
+ happyTwins: isHappyTwinsRule(this.selectedRule) && this.happyTwins
+ ? {
+ active: this.happyTwins.active === true,
+ restored: this.happyTwins.restored === true,
+ dealerPlayerId: this.happyTwins.dealerPlayerId,
+ upstreamPlayerId: this.happyTwins.upstreamPlayerId,
+ originalDealerIndex: this.happyTwins.originalDealerIndex,
+ originalUpstreamIndex: this.happyTwins.originalUpstreamIndex,
+ swappedDealerIndex: this.happyTwins.swappedDealerIndex,
+ swappedUpstreamIndex: this.happyTwins.swappedUpstreamIndex,
+ teamIndexByPlayerId: Object.fromEntries(
+ (this.happyTwins.originalOrderPlayerIds || []).map((playerId, index) => [
+ playerId,
+ index % 2
+ ])
+ ),
+ nextDealerPlayerId: this.happyTwins.nextDealerPlayerId || null,
+ nextDealerIndex: this.happyTwins.nextDealerIndex ?? null
+ }
+ : null,
+ encircleThreeMissingOne: isEncircleThreeMissingOneRule(this.selectedRule)
+ ? {
+ seenSuits: [...this.encircleThreeMissingOneSeenSuits],
+ lastTransition: this.encircleThreeMissingOneLastTransition
+ ? { ...this.encircleThreeMissingOneLastTransition }
+ : null
+ }
+ : null,
+ openHandPlayerId: this.openHandPlayerId,
+ openHandControllerPlayerId: this.openHandControllerPlayerId,
+ icebergPendingPlayerIds: Array.from(this.icebergPendingPlayerIds),
+ twoGhosts: isTwoGhostsKnockDoorRule(this.selectedRule) ? {
+ revealedPlayerIds: Array.from(this.twoGhostsRevealedPlayerIds)
+ } : null,
+ tenSidedAmbush: this.tenSidedAmbushSelectorPlayerId ? {
+ selectorPlayerId: this.tenSidedAmbushSelectorPlayerId,
+ isSelectionPending: this.isTenSidedAmbushSelectionPending,
+ isRevealed: this.isTenSidedAmbushRevealed,
+ attackerNetCardCount: this.tenSidedAmbushAttackerNetCardCount,
+ // 未公开时绝不把秘密点数放入公共房间快照。
+ rank: this.isTenSidedAmbushRevealed ? this.tenSidedAmbushRank : null
+ } : null,
+ threePowers: this.threePowersSlots.length > 0 ? {
+ isSelectionPending: this.threePowersPendingPlayerIds.size > 0,
+ pendingPlayerIds: Array.from(this.threePowersPendingPlayerIds),
+ slots: this.threePowersSlots.map(slot => ({
+ sourceRank: slot.sourceRank,
+ pointValue: slot.pointValue,
+ selectorPlayerId: slot.selectorPlayerId,
+ isSelected: Boolean(slot.selectedRank),
+ isRevealed: Boolean(slot.isRevealed),
+ // 未揭晓的重载点数绝不进入公共房间快照。
+ rank: slot.isRevealed ? slot.selectedRank : null
+ }))
+ } : null,
+ gentlemanPromise: (
+ this.gentlemanPromiseDeclarationsByPlayerId.size > 0 ||
+ this.gentlemanPromisePendingPlayerIds.size > 0
+ ) ? {
+ pendingPlayerIds: Array.from(this.gentlemanPromisePendingPlayerIds),
+ declarationsByPlayerId: Object.fromEntries(this.gentlemanPromiseDeclarationsByPlayerId)
+ } : null,
+ hiddenDragon: isHiddenDragonInAbyssRule(this.selectedRule) ? {
+ pendingPlayerIds: Array.from(this.hiddenDragonPendingPlayerIds),
+ declarationsByPlayerId: Object.fromEntries(this.hiddenDragonDeclarationsByPlayerId),
+ playedDeclaredRankByPlayerId: Object.fromEntries(
+ Array.from(this.hiddenDragonDeclarationsByPlayerId, ([playerId, rank]) => [
+ playerId,
+ this.hiddenDragonPlayedRanksByPlayerId.get(playerId)?.has(rank) || false
+ ])
+ ),
+ evaluatedPlayerIds: Array.from(this.hiddenDragonEvaluatedPlayerIds),
+ results: this.hiddenDragonResults.map(result => ({ ...result }))
+ } : null,
+ administrativeReview: isAdministrativeReviewRule(this.selectedRule)
+ ? (this.administrativeReview ? { ...this.administrativeReview } : null)
+ : null,
+ politicalReview: isPoliticalReviewRule(this.selectedRule) ? {
+ pending: this.politicalReviewPending ? {
+ id: this.politicalReviewPending.id,
+ round: this.politicalReviewPending.round,
+ reviewerPlayerId: this.politicalReviewPending.reviewerPlayerId,
+ reviewerPlayerName: this.politicalReviewPending.reviewerPlayerName,
+ teammatePlayerId: this.politicalReviewPending.teammatePlayerId,
+ teammatePlayerName: this.politicalReviewPending.teammatePlayerName,
+ cards: this.politicalReviewPending.cards.map(card => ({ ...card }))
+ } : null,
+ usedPlayerIds: [...this.activeSkillUsesByPlayerId.entries()]
+ .filter(([, skillIds]) => skillIds.has('political_review'))
+ .map(([playerId]) => playerId),
+ lastResult: this.politicalReviewLastResult
+ ? {
+ ...this.politicalReviewLastResult,
+ cards: this.politicalReviewLastResult.cards.map(card => ({ ...card }))
+ }
+ : null
+ } : null,
+ repeatedExhaustion: this.repeatedExhaustionPlayerId ? {
+ playerId: this.repeatedExhaustionPlayerId,
+ streak: this.repeatedExhaustionStreak,
+ lastPenalty: this.repeatedExhaustionLastPenalty,
+ lastScoreDelta: this.repeatedExhaustionLastScoreDelta
+ } : null,
+ focusFigure: this.focusFigureTeams.length > 0 ? {
+ isVotingPending: this.isFocusFigureVotingStarted &&
+ this.focusFigureTeams.some(team => !team.isFinalized),
+ finalizedTeamCount: this.focusFigureTeams.filter(team => team.isFinalized).length,
+ isRevealed: this.isFocusFigureRevealed,
+ // 每名玩家打出的分牌被闲家收走多少是公开进度;焦点身份与队内票型仍严格保密。
+ capturedPointsByPlayerId: Object.fromEntries(this.focusFigureCapturedPointsByPlayerId),
+ ...(this.isFocusFigureRevealed ? {
+ teams: this.focusFigureTeams.map(team => ({
+ team: team.team,
+ playerIds: [...team.playerIds],
+ focusPlayerId: team.finalPlayerId
+ }))
+ } : {})
+ } : null,
+ plannedEconomy: ruleIncludesId(this.selectedRule, 'planned_economy') ? {
+ totalReservedCards: 20,
+ remainingCards: this.plannedEconomyReserveCards.length,
+ completedDrawRounds: this.plannedEconomyDrawRounds,
+ isDrawingEnabled: this.phase === GamePhases.PLAYING
+ } : null,
+ equivalentReciprocity: this.equivalentReciprocityChallenge ? {
+ challengeId: this.equivalentReciprocityChallenge.id,
+ initiatorPlayerId: this.equivalentReciprocityChallenge.initiatorPlayerId,
+ targetPlayerId: this.equivalentReciprocityChallenge.targetPlayerId,
+ selectedPlayerIds: Array.from(
+ this.equivalentReciprocityChallenge.selectedCardsByPlayerId?.keys?.() || []
+ )
+ } : null,
+ mutualSupport: isMutualSupportRule(this.selectedRule) ? {
+ pendingAction: this.mutualSupportPendingAction ? {
+ actionId: this.mutualSupportPendingAction.id,
+ stage: this.mutualSupportPendingAction.stage,
+ round: this.mutualSupportPendingAction.round,
+ chooserPlayerId: this.mutualSupportPendingAction.chooserPlayerId,
+ otherPlayerId: this.mutualSupportPendingAction.otherPlayerId,
+ fromPlayerId: this.mutualSupportPendingAction.fromPlayerId,
+ toPlayerId: this.mutualSupportPendingAction.toPlayerId,
+ minCards: this.mutualSupportPendingAction.minCards,
+ maxCards: this.mutualSupportPendingAction.maxCards,
+ requiredCards: this.mutualSupportPendingAction.requiredCards ?? null
+ } : null,
+ pendingReturnCount: this.mutualSupportReturnQueue.length
+ + (this.mutualSupportPendingAction?.stage === 'return' ? 1 : 0),
+ outstandingTransferCount: this.mutualSupportRoundTransfers.length
+ } : null,
+ candleToDawn: isCandleToDawnRule(this.selectedRule) ? {
+ selectorPlayerId: this.candleSelectorPlayerId,
+ isSelectionPending: this.candleSelectionPending,
+ isLit: this.candleLit,
+ lastTransition: this.candleLastTransition
+ ? { ...this.candleLastTransition }
+ : null
+ } : null,
+ culturalRevolution: isCulturalRevolutionRule(this.selectedRule) ? {
+ active: Boolean(this.culturalRevolution),
+ baseTrumpSuit: this.culturalRevolutionBaseTrumpSuit,
+ baseTrumpRank: this.culturalRevolutionBaseTrumpRank,
+ declaration: this.culturalRevolution ? { ...this.culturalRevolution } : null
+ } : null,
+ threeTigers: isThreeTigersRule(this.selectedRule) ? {
+ currentRound: this.threeTigersRoundState
+ ? {
+ ...this.threeTigersRoundState,
+ contributingPlayerIds: [...(this.threeTigersRoundState.contributingPlayerIds || [])],
+ suitCounts: { ...(this.threeTigersRoundState.suitCounts || {}) }
+ }
+ : null,
+ lastRound: this.threeTigersLastRoundState
+ ? {
+ ...this.threeTigersLastRoundState,
+ contributingPlayerIds: [...(this.threeTigersLastRoundState.contributingPlayerIds || [])],
+ suitCounts: { ...(this.threeTigersLastRoundState.suitCounts || {}) }
+ }
+ : null
+ } : null,
+ inviteIntoUrn: isInviteIntoUrnRule(this.selectedRule) ? {
+ declarations: this.inviteIntoUrnDeclarations.map(declaration => ({ ...declaration })),
+ lastResult: this.inviteIntoUrnLastResult
+ ? {
+ ...this.inviteIntoUrnLastResult,
+ declarations: (this.inviteIntoUrnLastResult.declarations || []).map(
+ declaration => ({ ...declaration })
+ )
+ }
+ : null
+ } : null,
+ oldHorse: isOldHorseStillHasStrengthRule(this.selectedRule) ? {
+ rightHolderPlayerIds: Array.from(this.oldHorseRightHolderPlayerIds),
+ protectedPlayerId: this.oldHorseProtectedPlayerId,
+ lastAbsolutePlay: this.oldHorseLastAbsolutePlay
+ ? { ...this.oldHorseLastAbsolutePlay }
+ : null
+ } : null,
+ trumpWins: isTrumpWinsRule(this.selectedRule) ? {
+ lastResult: this.trumpWinsLastResult
+ ? {
+ ...this.trumpWinsLastResult,
+ players: (this.trumpWinsLastResult.players || []).map(player => ({ ...player }))
+ }
+ : null
+ } : null,
+ dreamKilling: ruleIncludesId(this.selectedRule, 'dream_killing') ? {
+ sleepingPlayerIds: Array.from(this.dreamKillingSleepingPlayerIds)
+ } : null,
+ divineWeapon: ruleIncludesId(this.selectedRule, 'divine_weapon') ? {
+ cards: this.divineWeaponCards.map(card => card.toJSON ? card.toJSON() : card),
+ generation: this.divineWeaponGeneration,
+ usedThisRound: this.divineWeaponUsedThisRound,
+ usedByPlayerId: this.divineWeaponUsedByPlayerId,
+ usedCardId: this.divineWeaponUsedCardId
+ } : null,
+ forbiddenMagic: ruleIncludesId(this.selectedRule, 'forbidden_magic') ? {
+ reservations: Array.from(this.forbiddenMagicReservations.values(), reservation => ({
+ ...reservation
+ })),
+ activePlayerIds: Array.from(this.forbiddenMagicActivePlayerIds),
+ decisionPlayerId: this.forbiddenMagicCurrentDecisionPlayerId,
+ decisionRound: this.forbiddenMagicDecisionRound,
+ pendingPlayerIds: [
+ this.forbiddenMagicCurrentDecisionPlayerId,
+ ...this.forbiddenMagicDecisionQueue
+ ].filter(Boolean)
+ } : null,
+ lureTiger: isLureTigerFromMountainRule(this.selectedRule) ? {
+ reservations: Array.from(this.lureTigerReservations.values(), reservation => ({
+ ...reservation
+ })),
+ usedTeamIndexes: Array.from(this.lureTigerUsedTeamIndexes),
+ currentDecision: this.lureTigerCurrentDecision ? {
+ ...this.lureTigerCurrentDecision,
+ eligibleTargetIds: [...(this.lureTigerCurrentDecision.eligibleTargetIds || [])]
+ } : null,
+ pendingPlayerIds: [
+ this.lureTigerCurrentDecision?.playerId,
+ ...this.lureTigerDecisionQueue
+ ].filter(Boolean),
+ silencedPlayerIds: this.lureTigerSilencedRound === this.currentRound
+ ? Array.from(this.lureTigerSilencedPlayerIds)
+ : [],
+ silencedRound: this.lureTigerSilencedRound,
+ roundActivations: this.lureTigerRoundActivations.map(activation => ({ ...activation }))
+ } : null,
+ cardCooldown: this.cardCooldownType ? {
+ type: this.cardCooldownType,
+ valuesByPlayerId: Object.fromEntries(
+ Array.from(this.cardCooldownValuesByPlayerId.entries()).map(([playerId, values]) => (
+ [playerId, [...values]]
+ ))
+ )
+ } : null,
+ birdsGoneBowHidden: ruleIncludesId(this.selectedRule, 'birds_gone_bow_hidden') ? {
+ exhaustedSuits: Array.from(this.birdExhaustedSuits)
+ } : null,
+ secondBattlefield: isSecondBattlefieldRule(this.selectedRule) ? {
+ showdownCount: this.secondBattlefieldShowdownCount,
+ accumulatedCardsByPlayerId: Object.fromEntries(
+ Array.from(this.secondBattlefieldAccumulatedCardsByPlayerId.entries()).map(
+ ([playerId, cards]) => [
+ playerId,
+ cards.map(card => card.toJSON ? card.toJSON() : card)
+ ]
+ )
+ ),
+ accumulatedCountsByPlayerId: Object.fromEntries(
+ Array.from(this.secondBattlefieldAccumulatedCardsByPlayerId.entries()).map(
+ ([playerId, cards]) => [playerId, cards.length]
+ )
+ ),
+ isFinalStage: this.secondBattlefieldFinalStage,
+ lastResult: this.secondBattlefieldLastResult
+ } : null,
+ strawBoatBorrowingArrows: isStrawBoatBorrowingArrowsRule(this.selectedRule) ? {
+ pending: this.strawBoatBorrowingArrowsDecision ? {
+ id: this.strawBoatBorrowingArrowsDecision.id,
+ round: this.strawBoatBorrowingArrowsDecision.round,
+ playerId: this.strawBoatBorrowingArrowsDecision.playerId,
+ playerName: this.strawBoatBorrowingArrowsDecision.playerName,
+ leadingPoints: this.strawBoatBorrowingArrowsDecision.leadingPoints,
+ borrowedCard: this.strawBoatBorrowingArrowsDecision.borrowedCard?.toJSON
+ ? this.strawBoatBorrowingArrowsDecision.borrowedCard.toJSON()
+ : this.strawBoatBorrowingArrowsDecision.borrowedCard
+ } : null,
+ lastResult: this.strawBoatBorrowingArrowsLastResult
+ } : null,
+ bushGate: isBushGateRule(this.selectedRule) ? {
+ restriction: this.bushGateRestriction ? {
+ ...this.bushGateRestriction,
+ forbiddenCardIds: [...(this.bushGateRestriction.forbiddenCardIds || [])],
+ returnedCards: (this.bushGateRestriction.returnedCards || []).map(card => ({ ...card }))
+ } : null,
+ lastResult: this.bushGateLastResult ? {
+ ...this.bushGateLastResult,
+ returnedCards: (this.bushGateLastResult.returnedCards || []).map(card => ({ ...card }))
+ } : null
+ } : null,
+ teammateCheer: isTeammateCheerRule(this.selectedRule) ? {
+ pending: this.teammateCheerPending ? { ...this.teammateCheerPending } : null,
+ usedPlayerIds: Array.from(this.teammateCheerUsedPlayerIds),
+ buffedPlayerIds: Array.from(this.teammateCheerBuffedPlayerIds),
+ lastResult: this.teammateCheerLastResult
+ ? { ...this.teammateCheerLastResult }
+ : null
+ } : null,
+ afterglow: isAfterglowRule(this.selectedRule) ? {
+ pending: this.afterglowPending ? { ...this.afterglowPending } : null,
+ usedPlayerIds: Array.from(this.afterglowUsedPlayerIds),
+ activePlayerIds: Array.from(this.afterglowActivePlayerIds),
+ lastResult: this.afterglowLastResult ? { ...this.afterglowLastResult } : null
+ } : null,
+ ambiguous: isAmbiguousRule(this.selectedRule) ? {
+ activePlayerIds: this.currentRoundPlays
+ .filter(play => play.activeSkillId === 'ambiguous')
+ .map(play => play.playerId),
+ pending: this.ambiguousRoundDecision ? {
+ round: this.ambiguousRoundDecision.round,
+ currentPlayerId: this.ambiguousRoundDecision.currentPlayerId,
+ queuePlayerIds: [...this.ambiguousRoundDecision.queuePlayerIds],
+ selections: this.ambiguousRoundDecision.selections.map(selection => ({
+ playerId: selection.playerId,
+ playerName: selection.playerName,
+ position: selection.position,
+ usageConsumed: selection.usageConsumed,
+ selectedOptionIndex: selection.selectedOptionIndex,
+ options: selection.options.map(option => ({
+ index: option.index,
+ cards: option.cards.map(card => card.toJSON ? card.toJSON() : card)
+ }))
+ }))
+ } : null
+ } : null,
currentRoundPlays: this.currentRoundPlays.length,
+ currentRoundTable,
leadingPattern: this.leadingPattern,
- currentWinnerIndex: this.currentWinnerIndex,
+ // 暗置牌揭牌前,连当前赢牌座位也属于秘密信息。
+ // 否则即使客户端只画牌背,仍可从房间快照反推出暗牌大小。
+ currentWinnerIndex: this.currentRoundPlays.some(play => play.concealed)
+ ? null
+ : this.currentWinnerIndex,
// 得分相关
- collectedPointCards: this.collectedPointCards.map(c => c.toJSON ? c.toJSON() : c),
- attackerScore: this.attackerScore,
+ collectedPointCards: isLostInFogScoringHidden
+ ? null
+ : this.collectedPointCards.map(c => c.toJSON ? c.toJSON() : c),
+ attackerScore: isLostInFogScoringHidden || (
+ ruleIncludesId(this.selectedRule, 'focus_figure') && !this.isFocusFigureRevealed
+ )
+ ? null
+ : this.attackerScore,
+ // 结算阶段所有这些内容都已经公开。把一次性 bottom_revealed 的有效载荷
+ // 同时写入房间快照,客户端重建时不再依赖是否碰巧收到那条 Socket 消息。
+ bottomScoreResult: [GamePhases.REVEALING, GamePhases.FINISHED].includes(this.phase)
+ ? this.bottomScoreResult
+ : null,
+ upgradeResult: [GamePhases.REVEALING, GamePhases.FINISHED].includes(this.phase)
+ ? this.upgradeResult
+ : null,
+ revealedBottomCards: [GamePhases.REVEALING, GamePhases.FINISHED].includes(this.phase)
+ ? this.bottomCards.map(card => card.toJSON ? card.toJSON() : card)
+ : [],
lastRoundWinnerIndex: this.lastRoundWinnerIndex,
// 升级相关
team1Level: this.team1Level,
diff --git a/tractor-game-simulator/server/src/models/Player.js b/tractor-game-simulator/server/src/models/Player.js
index 6085908..0980b14 100644
--- a/tractor-game-simulator/server/src/models/Player.js
+++ b/tractor-game-simulator/server/src/models/Player.js
@@ -4,6 +4,9 @@ import { DEFAULT_PLAYER } from '../utils/constants.js';
export class Player {
constructor(socketId, name, position, isBot = false) {
this.id = uuidv4();
+ // Stable secret used to reclaim this seat after Socket.IO assigns a new id.
+ // It is deliberately omitted from toJSON() and is only returned to its owner.
+ this.resumeToken = isBot ? null : uuidv4();
this.socketId = socketId;
this.name = name;
this.score = DEFAULT_PLAYER.score;
@@ -12,6 +15,7 @@ export class Player {
this.shownCards = new Set();
this.position = position;
this.isReady = false;
+ this.isReadyForNext = false;
this.isOnline = true;
this.hasConfirmedReveal = false;
this.isBot = isBot; // 标记是否为bot
@@ -35,6 +39,7 @@ export class Player {
this.cards = [];
this.shownCards.clear();
this.isReady = false;
+ this.isReadyForNext = false;
this.hasConfirmedReveal = false;
}
@@ -48,6 +53,7 @@ export class Player {
cardsCount: this.cards.length,
position: this.position,
isReady: this.isReady,
+ isReadyForNext: this.isReadyForNext,
isOnline: this.isOnline,
hasConfirmedReveal: this.hasConfirmedReveal,
isBot: this.isBot
diff --git a/tractor-game-simulator/server/src/models/Room.js b/tractor-game-simulator/server/src/models/Room.js
index 0a828dd..695c537 100644
--- a/tractor-game-simulator/server/src/models/Room.js
+++ b/tractor-game-simulator/server/src/models/Room.js
@@ -1,5 +1,6 @@
import { GameState } from './GameState.js';
import { DEFAULT_CONFIG } from '../utils/constants.js';
+import { getRuleById } from '../rules/ruleRegistry.js';
export class Room {
constructor(name, hostSocketId, config = {}) {
@@ -9,7 +10,14 @@ export class Room {
this.hostId = hostSocketId;
this.players = [];
this.gameState = new GameState();
- this.config = { ...DEFAULT_CONFIG, ...config };
+ this.config = this.validateConfig({
+ ...DEFAULT_CONFIG,
+ ...config,
+ bottomCardsCount: DEFAULT_CONFIG.bottomCardsCount,
+ minDealInterval: DEFAULT_CONFIG.minDealInterval,
+ minPlayers: 4,
+ maxPlayers: 4
+ });
this.createdAt = new Date();
this.updatedAt = new Date();
}
@@ -44,13 +52,60 @@ export class Room {
}
updateConfig(newConfig) {
- this.config = { ...this.config, ...newConfig };
+ this.config = this.validateConfig({
+ ...this.config,
+ ...newConfig,
+ bottomCardsCount: DEFAULT_CONFIG.bottomCardsCount,
+ minDealInterval: DEFAULT_CONFIG.minDealInterval,
+ minPlayers: 4,
+ maxPlayers: 4
+ });
this.updatedAt = new Date();
}
+ validateConfig(config) {
+ const testMode = config.testMode === true;
+ const testRule = testMode ? getRuleById(config.testRuleId) : null;
+ if (testMode && !testRule) {
+ throw new Error('测试模式必须选择一条已实现规则');
+ }
+ if (!Number.isInteger(config.bottomCardsCount) ||
+ config.bottomCardsCount < 1 ||
+ config.bottomCardsCount > 20) {
+ throw new Error('底牌数量必须是1-20之间的整数');
+ }
+
+ // 四人局必须保证发牌后四家手牌数相同。
+ if ((108 - config.bottomCardsCount) % 4 !== 0) {
+ throw new Error('底牌数量必须保证剩余牌能由4名玩家平均分配');
+ }
+
+ if (!Number.isFinite(config.dealInterval) ||
+ config.dealInterval < DEFAULT_CONFIG.minDealInterval ||
+ config.dealInterval > 5000) {
+ throw new Error(`发牌间隔必须在${DEFAULT_CONFIG.minDealInterval}-5000毫秒之间`);
+ }
+
+ if (config.turnOrder === 'custom') {
+ const order = config.customTurnOrder;
+ const validOrder = Array.isArray(order) &&
+ order.length === 4 &&
+ new Set(order).size === 4 &&
+ order.every(index => Number.isInteger(index) && index >= 0 && index < 4);
+ if (!validOrder) {
+ throw new Error('自定义出牌顺序必须包含且仅包含玩家索引0、1、2、3');
+ }
+ }
+
+ return {
+ ...config,
+ testMode,
+ testRuleId: testRule?.id || null
+ };
+ }
+
canStart() {
- return this.players.length >= this.config.minPlayers &&
- this.players.length <= this.config.maxPlayers;
+ return this.players.length === 4;
}
resetForNewGame() {
@@ -60,6 +115,31 @@ export class Room {
}
toJSON() {
+ const gameState = this.gameState.toJSON();
+ const openHandPlayer = this.findPlayerById(this.gameState.openHandPlayerId);
+ const openHandController = this.findPlayerById(this.gameState.openHandControllerPlayerId);
+ gameState.openHand = openHandPlayer ? {
+ playerId: openHandPlayer.id,
+ playerName: openHandPlayer.name,
+ controllerPlayerId: openHandController?.id || null,
+ controllerPlayerName: openHandController?.name || null,
+ cards: openHandPlayer.cards.map(card => card.toJSON())
+ } : null;
+ if (gameState.twoGhosts) {
+ gameState.twoGhosts.revealedHands = this.players
+ .filter(player => this.gameState.twoGhostsRevealedPlayerIds.has(player.id))
+ .map(player => ({
+ playerId: player.id,
+ playerName: player.name,
+ kind: 'jokers',
+ label: '二鬼拍门',
+ cards: player.cards
+ .filter(card => card.suit === 'joker')
+ .map(card => card.toJSON())
+ }))
+ .filter(hand => hand.cards.length > 0);
+ }
+
return {
id: this.id,
name: this.name,
@@ -67,7 +147,7 @@ export class Room {
players: this.players.map(p => p.toJSON()),
playerCount: this.players.length,
maxPlayers: this.config.maxPlayers,
- gameState: this.gameState.toJSON(),
+ gameState,
config: this.config,
createdAt: this.createdAt,
updatedAt: this.updatedAt
diff --git a/tractor-game-simulator/server/src/rules/ruleRegistry.js b/tractor-game-simulator/server/src/rules/ruleRegistry.js
new file mode 100644
index 0000000..d053de7
--- /dev/null
+++ b/tractor-game-simulator/server/src/rules/ruleRegistry.js
@@ -0,0 +1,1495 @@
+export const RuleIds = Object.freeze({
+ REVERSE_RANK_ORDER: 'reverse_rank_order',
+ NORMAL_GAME: 'normal_game',
+ ABUNDANT_HARVEST: 'abundant_harvest',
+ EXTREME_CHALLENGE: 'extreme_challenge',
+ HALF_REALM: 'half_realm',
+ SHARED_PROSPERITY: 'shared_prosperity',
+ KNOW_YOURSELF_AND_ENEMY: 'know_yourself_and_enemy',
+ NEWS_MINISTER_I: 'news_minister_i',
+ NEWS_MINISTER_II: 'news_minister_ii',
+ PERFECT_STRATEGY: 'perfect_strategy',
+ ICEBERG_TIP: 'tip_of_iceberg',
+ MUTUAL_VISIBILITY: 'mutual_visibility',
+ DOUBLE_HAPPINESS: 'double_happiness',
+ OPEN_AND_HONEST: 'open_and_honest',
+ TEN_SIDED_AMBUSH: 'ten_sided_ambush',
+ IRRESISTIBLE_FORCE: 'irresistible_force',
+ SINGLE_STEP_DEBUG: 'single_step_debug',
+ REFORM_AND_OPENING_UP: 'reform_and_opening_up',
+ SUBSTITUTE_SACRIFICE: 'substitute_sacrifice',
+ SIX_SIX_GREAT_SUCCESS: 'six_six_great_success',
+ TAI_CHI_FOUR_SYMBOLS: 'tai_chi_four_symbols',
+ ONE_HORSE_LEADS: 'one_horse_leads',
+ HEAVY_FOG: 'heavy_fog',
+ CONCEALED_PASSAGE: 'concealed_passage',
+ FATAL_BEAUTY: 'fatal_beauty',
+ STEALING_BEAMS: 'stealing_beams',
+ COSMIC_SHIFT: 'cosmic_shift',
+ LAST_STAND: 'last_stand',
+ LATE_MOVER_ADVANTAGE: 'late_mover_advantage',
+ GO_WITH_THE_FLOW: 'go_with_the_flow',
+ FREQUENT_FLUCTUATION: 'frequent_fluctuation',
+ MINOR_DISTURBANCE: 'minor_disturbance',
+ LINGERING_DISCARD: 'lingering_discard',
+ TIME_REVERSAL: 'time_reversal',
+ ROUTE_SWING: 'route_swing',
+ BELT_AND_ROAD: 'belt_and_road',
+ DAY_NIGHT_ROTATION: 'day_night_rotation',
+ RESPECT_ELDERS_AND_CHILDREN: 'respect_elders_and_children',
+ RITES_COLLAPSE: 'rites_collapse',
+ RECOMMEND_TALENT: 'recommend_talent',
+ ACCIDENT_INSURANCE: 'accident_insurance',
+ THREE_POWERS: 'three_powers',
+ GENTLEMAN_PROMISE: 'gentleman_promise',
+ REPEATED_EXHAUSTION: 'repeated_exhaustion',
+ FOCUS_FIGURE: 'focus_figure',
+ COOLDOWN_TIME: 'cooldown_time',
+ TIME_COOLING: 'time_cooling',
+ PLANNED_ECONOMY: 'planned_economy',
+ EQUIVALENT_RECIPROCITY: 'equivalent_reciprocity',
+ ENDURING: 'enduring',
+ AVERAGE_POOLING: 'average_pooling',
+ DREAM_KILLING: 'dream_killing',
+ JOINT_HARMONY: 'joint_harmony',
+ DIVINE_WEAPON: 'divine_weapon',
+ MAGIC_TRICK: 'magic_trick',
+ ABRUPT_STOP: 'abrupt_stop',
+ CLUSTER_ANALYSIS: 'cluster_analysis',
+ FORBIDDEN_MAGIC: 'forbidden_magic',
+ METICULOUS_ACCOUNTING: 'meticulous_accounting',
+ LOST_IN_FOG: 'lost_in_fog',
+ BIRDS_GONE_BOW_HIDDEN: 'birds_gone_bow_hidden',
+ ODD_EVEN_SCORING: 'odd_even_scoring',
+ SECOND_BATTLEFIELD: 'second_battlefield',
+ ONE_COUNTRY_TWO_SYSTEMS: 'one_country_two_systems',
+ WOODEN_OX_FLOWING_HORSE: 'wooden_ox_flowing_horse',
+ STRENGTH_COMPENSATION: 'strength_compensation',
+ UNARMED: 'unarmed',
+ MUTUAL_SUPPORT: 'mutual_support',
+ CANDLE_TO_DAWN: 'candle_to_dawn',
+ CULTURAL_REVOLUTION: 'cultural_revolution',
+ THREE_TIGERS: 'three_tigers',
+ INVITE_INTO_URN: 'invite_into_urn',
+ OLD_HORSE_STILL_HAS_STRENGTH: 'old_horse_still_has_strength',
+ TRUMP_WINS: 'trump_wins',
+ OPENLY_REVEALED: 'openly_revealed',
+ STRAW_BOAT_BORROWING_ARROWS: 'straw_boat_borrowing_arrows',
+ BUSH_GATE: 'bush_gate',
+ TEAMMATE_CHEER: 'teammate_cheer',
+ ILLUSION_AND_REALITY: 'illusion_and_reality',
+ STRIVE_UPSTREAM: 'strive_upstream',
+ AFTERGLOW: 'afterglow',
+ OUTWARD_HARMONY_INNER_DIVISION: 'outward_harmony_inner_division',
+ AMBIGUOUS: 'ambiguous',
+ TWO_GHOSTS_KNOCK_DOOR: 'two_ghosts_knock_door',
+ PEOPLE_COMMUNE: 'people_commune',
+ REMOVE_FIREWOOD_FROM_UNDER_CAULDRON: 'remove_firewood_from_under_cauldron',
+ MAINSTAY: 'mainstay',
+ HAPPY_TWINS: 'happy_twins',
+ ENCIRCLE_THREE_MISSING_ONE: 'encircle_three_missing_one',
+ THREE_SIX_NINE_GRADES: 'three_six_nine_grades',
+ IRON_EVIDENCE: 'iron_evidence',
+ WAITING_RABBIT: 'waiting_rabbit',
+ BURN_THE_BOATS: 'burn_the_boats',
+ HIDDEN_DRAGON_IN_ABYSS: 'hidden_dragon_in_abyss',
+ ADMINISTRATIVE_REVIEW: 'administrative_review',
+ POLITICAL_REVIEW: 'political_review',
+ NO_ONE_SURVIVES: 'no_one_survives',
+ LURE_TIGER_FROM_MOUNTAIN: 'lure_tiger_from_mountain',
+ DEFENSE_AS_OFFENSE: 'defense_as_offense',
+ ANTINOMY: 'antinomy',
+ CHANGE_RICE_TO_MULBERRY: 'change_rice_to_mulberry',
+ DESTROY_DYKE_FLOOD_FIELDS: 'destroy_dyke_flood_fields',
+ RECORD_ON_FILE: 'record_on_file',
+ WEIGHING_THOUSAND_JIN: 'weighing_thousand_jin',
+ KING_OVER_WHITE: 'king_over_white',
+ FEAR_OF_BREAKING_VASE: 'fear_of_breaking_vase',
+ EIGHT_KINGS_COUNCIL: 'eight_kings_council',
+ NINE_PRINCES_SUCCESSION: 'nine_princes_succession'
+});
+
+export const ActiveSkillIds = Object.freeze({
+ SUBSTITUTE_SACRIFICE: 'substitute_sacrifice',
+ CONCEALED_PASSAGE: 'concealed_passage',
+ STEALING_BEAMS: 'stealing_beams',
+ LATE_MOVER_ADVANTAGE: 'late_mover_advantage',
+ TIME_REVERSAL: 'time_reversal',
+ BELT_AND_ROAD: 'belt_and_road',
+ RECOMMEND_TALENT: 'recommend_talent',
+ EQUIVALENT_RECIPROCITY: 'equivalent_reciprocity',
+ DREAM_KILLING: 'dream_killing',
+ DIVINE_WEAPON: 'divine_weapon',
+ MAGIC_TRICK: 'magic_trick',
+ CLUSTER_ANALYSIS: 'cluster_analysis',
+ FORBIDDEN_MAGIC: 'forbidden_magic',
+ MUTUAL_SUPPORT: 'mutual_support',
+ CULTURAL_REVOLUTION: 'cultural_revolution',
+ INVITE_INTO_URN: 'invite_into_urn',
+ BUSH_GATE: 'bush_gate',
+ ILLUSION_AND_REALITY: 'illusion_and_reality',
+ AMBIGUOUS: 'ambiguous',
+ LURE_TIGER_FROM_MOUNTAIN: 'lure_tiger_from_mountain'
+});
+
+const DEFAULT_RULE_SETUP = Object.freeze({
+ bottomCardsCount: 8,
+ attackerStartingScore: 0
+});
+
+const RULE_DEFINITIONS = Object.freeze([
+ Object.freeze({
+ id: RuleIds.REVERSE_RANK_ORDER,
+ name: '倒反天罡',
+ content: '除级牌和王外,每个花色内普通牌的大小顺序颠倒。',
+ setup: DEFAULT_RULE_SETUP
+ }),
+ Object.freeze({
+ id: RuleIds.NORMAL_GAME,
+ name: '世事无常',
+ content: '正常对局。',
+ setup: DEFAULT_RULE_SETUP
+ }),
+ Object.freeze({
+ id: RuleIds.ABUNDANT_HARVEST,
+ name: '五谷丰登',
+ content: '庄家12张底牌。闲家开局为30分。',
+ setup: Object.freeze({ bottomCardsCount: 12, attackerStartingScore: 30 })
+ }),
+ Object.freeze({
+ id: RuleIds.EXTREME_CHALLENGE,
+ name: '极限挑战',
+ content: '庄家16张底牌。闲家开局为60分。',
+ setup: Object.freeze({ bottomCardsCount: 16, attackerStartingScore: 60 })
+ }),
+ Object.freeze({
+ id: RuleIds.HALF_REALM,
+ name: '江山半壁',
+ content: '庄家4张底牌。闲家开局为-10分。',
+ setup: Object.freeze({ bottomCardsCount: 4, attackerStartingScore: -10 })
+ }),
+ Object.freeze({
+ id: RuleIds.SHARED_PROSPERITY,
+ name: '与民同乐',
+ content: '没有底牌。闲家开局为-20分。',
+ setup: Object.freeze({ bottomCardsCount: 0, attackerStartingScore: -20 })
+ }),
+ Object.freeze({
+ id: RuleIds.KNOW_YOURSELF_AND_ENEMY,
+ name: '知己知彼',
+ content: '摸牌后,与对家交换两张牌。',
+ setup: DEFAULT_RULE_SETUP,
+ openingCardExchangeOffset: 2
+ }),
+ Object.freeze({
+ id: RuleIds.NEWS_MINISTER_I,
+ name: '新闻部长I',
+ content: '摸牌后,每人给上家两张牌。',
+ setup: DEFAULT_RULE_SETUP,
+ openingCardExchangeOffset: -1
+ }),
+ Object.freeze({
+ id: RuleIds.NEWS_MINISTER_II,
+ name: '新闻部长II',
+ content: '摸牌后,每人给下家两张牌。',
+ setup: DEFAULT_RULE_SETUP,
+ openingCardExchangeOffset: 1
+ }),
+ Object.freeze({
+ id: RuleIds.PERFECT_STRATEGY,
+ name: '算无遗策',
+ content: '庄家埋底后,庄家队友明手且由庄家代为出牌。闲家开局为10分。',
+ setup: Object.freeze({ bottomCardsCount: 8, attackerStartingScore: 10 })
+ }),
+ Object.freeze({
+ id: RuleIds.ICEBERG_TIP,
+ name: '冰山一角',
+ content: '进入出牌阶段后,每名玩家自行选择并始终保持两张手牌明置;不足两张时全部明置。',
+ setup: DEFAULT_RULE_SETUP
+ }),
+ Object.freeze({
+ id: RuleIds.MUTUAL_VISIBILITY,
+ name: '互通有无',
+ content: '进入出牌阶段后,队友的手牌仅对你可见。',
+ setup: DEFAULT_RULE_SETUP
+ }),
+ Object.freeze({
+ id: RuleIds.DOUBLE_HAPPINESS,
+ name: '双喜临门',
+ content: '重新从三个规则中选择两个规则,于本局游戏中同时生效。房主可以刷新其中任意一条候选。',
+ setup: DEFAULT_RULE_SETUP
+ }),
+ Object.freeze({
+ id: RuleIds.OPEN_AND_HONEST,
+ name: '为人坦荡',
+ content: '一轮结束时,若所有玩家手牌均不多于5张,所有玩家同时明置剩余手牌。',
+ setup: DEFAULT_RULE_SETUP
+ }),
+ Object.freeze({
+ id: RuleIds.TEN_SIDED_AMBUSH,
+ name: '十面埋伏',
+ content: '庄家队友暗选非级牌、非分牌点数。闲家赢得每张-5分,庄家方赢得每张给闲家+5分;底牌乘倍数同理,点数首次出现时公开。',
+ setup: DEFAULT_RULE_SETUP
+ }),
+ Object.freeze({
+ id: RuleIds.IRRESISTIBLE_FORCE,
+ name: '势如破竹',
+ content: '本局庄家获胜可以连庄。',
+ setup: DEFAULT_RULE_SETUP
+ }),
+ Object.freeze({
+ id: RuleIds.SINGLE_STEP_DEBUG,
+ name: '单步调试',
+ content: '本局移除甩牌。',
+ setup: DEFAULT_RULE_SETUP
+ }),
+ Object.freeze({
+ id: RuleIds.REFORM_AND_OPENING_UP,
+ name: '改革开放',
+ content: '庄家首次埋底后,庄家队友拿起底牌并重新埋底,随后由庄家先出牌。闲家开局获得40分。',
+ setup: Object.freeze({ bottomCardsCount: 8, attackerStartingScore: 40 })
+ }),
+ Object.freeze({
+ id: RuleIds.SUBSTITUTE_SACRIFICE,
+ name: '李代桃僵',
+ content: '每名玩家限一次,可在跟牌时主动声明垫牌:仍须出相同张数,但可无视花色与牌型要求,且本次出牌始终视为小。',
+ setup: DEFAULT_RULE_SETUP,
+ activeSkill: Object.freeze({
+ id: ActiveSkillIds.SUBSTITUTE_SACRIFICE,
+ name: '李代桃僵',
+ usageLimit: 1,
+ timing: 'following_play',
+ effect: 'free_discard_treated_small'
+ })
+ }),
+ Object.freeze({
+ id: RuleIds.SIX_SIX_GREAT_SUCCESS,
+ name: '六六大顺',
+ content: '新增同花顺牌型,至少六张连续牌;主同花顺可包含级牌和王牌。',
+ setup: DEFAULT_RULE_SETUP
+ }),
+ Object.freeze({
+ id: RuleIds.TAI_CHI_FOUR_SYMBOLS,
+ name: '太极四象',
+ content: '新增牌型:四张花色各不相同且点数相同的牌。四象只按点数比较;没有任何副牌时,可以用四张主牌毙之。',
+ setup: DEFAULT_RULE_SETUP
+ }),
+ Object.freeze({
+ id: RuleIds.ONE_HORSE_LEADS,
+ name: '一马当先',
+ content: '仅第一轮改由庄家队友先出牌;庄家身份、底牌归属及后续轮次均不改变。',
+ setup: DEFAULT_RULE_SETUP
+ }),
+ Object.freeze({
+ id: RuleIds.HEAVY_FOG,
+ name: '迷雾重重',
+ content: '开局暗中移除牌堆顶8张牌;全局出牌及其他分数结算完成后公开,闲家补得其中总分的一半。',
+ setup: DEFAULT_RULE_SETUP
+ }),
+ Object.freeze({
+ id: RuleIds.CONCEALED_PASSAGE,
+ name: '暗度陈仓',
+ content: '每名玩家每局限一次,跟牌时可暗置出牌;本轮结束时同时公开并正常结算。',
+ setup: DEFAULT_RULE_SETUP,
+ activeSkill: Object.freeze({
+ id: ActiveSkillIds.CONCEALED_PASSAGE,
+ name: '暗度陈仓',
+ usageLimit: 1,
+ timing: 'following_play',
+ effect: 'concealed_until_round_end'
+ })
+ }),
+ Object.freeze({
+ id: RuleIds.FATAL_BEAUTY,
+ name: '红颜祸水',
+ content: '黑桃牌视为红桃牌,不新增牌型;闲家开局为20分,红桃为主时为40分。',
+ setup: Object.freeze({ bottomCardsCount: 8, attackerStartingScore: 20 })
+ }),
+ Object.freeze({
+ id: RuleIds.STEALING_BEAMS,
+ name: '偷梁换柱',
+ content: '每名玩家每局限一次;发动后点击王牌,明确选择要转换的花色和点数,再自行选牌按基本规则出牌。转换可取消,王牌单出时仍可保持王牌。',
+ setup: DEFAULT_RULE_SETUP,
+ activeSkill: Object.freeze({
+ id: ActiveSkillIds.STEALING_BEAMS,
+ name: '偷梁换柱',
+ usageLimit: 1,
+ timing: 'any_play',
+ effect: 'joker_wildcards'
+ })
+ }),
+ Object.freeze({
+ id: RuleIds.COSMIC_SHIFT,
+ name: '斗转星移',
+ content: '一轮结束后,若四家手牌均首次不多于12张,所有玩家与队友交换全部手牌。',
+ setup: DEFAULT_RULE_SETUP
+ }),
+ Object.freeze({
+ id: RuleIds.LAST_STAND,
+ name: '绝处逢生',
+ content: '本局不能无主;当玩家无主牌、手牌不少于5张且花色相同时,可令所有手牌视为主牌。',
+ setup: DEFAULT_RULE_SETUP
+ }),
+ Object.freeze({
+ id: RuleIds.LATE_MOVER_ADVANTAGE,
+ name: '后发制人',
+ content: '每名玩家每局限一次;作为本轮三号位出牌前,可令下家先出,自己改为本轮最后出牌。只改变出牌顺序,不改变牌的大小结算。',
+ setup: DEFAULT_RULE_SETUP,
+ activeSkill: Object.freeze({
+ id: ActiveSkillIds.LATE_MOVER_ADVANTAGE,
+ name: '后发制人',
+ usageLimit: 1,
+ timing: 'third_position_before_play',
+ effect: 'yield_turn_to_next_player'
+ })
+ }),
+ Object.freeze({
+ id: RuleIds.GO_WITH_THE_FLOW,
+ name: '随波逐流',
+ content: '一轮结束后,若四家手牌均首次不多于16张则把全部手牌交给下家;均首次不多于9张时再交换一次。',
+ setup: DEFAULT_RULE_SETUP
+ }),
+ Object.freeze({
+ id: RuleIds.FREQUENT_FLUCTUATION,
+ name: '频繁波动',
+ content: '有分数牌被打出的轮次结束后,每名玩家暗选一张牌同时交给下家。闲家开局为-10分。',
+ setup: Object.freeze({ bottomCardsCount: 8, attackerStartingScore: -10 })
+ }),
+ Object.freeze({
+ id: RuleIds.MINOR_DISTURBANCE,
+ name: '微小扰动',
+ content: '没有分数牌被打出的轮次结束后,每名玩家暗选一张牌同时交给队友。',
+ setup: DEFAULT_RULE_SETUP
+ }),
+ Object.freeze({
+ id: RuleIds.LINGERING_DISCARD,
+ name: '弃掷逦迤',
+ content: '有分数牌被打出的轮次结束后,四家各自暗弃一张牌;终局仅公开庄家方两人弃出的分牌,并补给闲家。',
+ setup: DEFAULT_RULE_SETUP
+ }),
+ Object.freeze({
+ id: RuleIds.TIME_REVERSAL,
+ name: '时间倒流',
+ content: '每名玩家每局限一次,可在本轮及轮末停留期间预备;轮末询问所有预备者,首个确认发动者令牌局回溯到本轮开始前。',
+ setup: DEFAULT_RULE_SETUP,
+ activeSkill: Object.freeze({
+ id: ActiveSkillIds.TIME_REVERSAL,
+ name: '时间倒流',
+ usageLimit: 1,
+ timing: 'anytime_during_round',
+ effect: 'rewind_completed_round'
+ })
+ }),
+ Object.freeze({
+ id: RuleIds.ROUTE_SWING,
+ name: '路线摇摆',
+ content: '有单张价值不小于10分的牌被打出的轮次结束后,顺时针与逆时针出牌顺序互换。',
+ setup: DEFAULT_RULE_SETUP
+ }),
+ Object.freeze({
+ id: RuleIds.BELT_AND_ROAD,
+ name: '一带一路',
+ content: '每名玩家每局限一次,首发时可点击“一带一路”,再出同一有效花色的两张非对子单牌,无需满足甩牌必大条件;未发动时仍可按普通甩牌尝试出这两张牌。跟牌或毙牌不消耗次数。',
+ setup: DEFAULT_RULE_SETUP,
+ activeSkill: Object.freeze({
+ id: ActiveSkillIds.BELT_AND_ROAD,
+ name: '一带一路',
+ usageLimit: 1,
+ timing: 'leading_play',
+ effect: 'belt_and_road_lead'
+ })
+ }),
+ Object.freeze({
+ id: RuleIds.DAY_NIGHT_ROTATION,
+ name: '昼夜轮转',
+ content: '第x轮将点数x % 13 + 1提升为相应花色内最大,其他牌序不变;若轮转到级牌则仍按A最大。闲家开局为20分。',
+ setup: Object.freeze({ bottomCardsCount: 8, attackerStartingScore: 20 })
+ }),
+ Object.freeze({
+ id: RuleIds.RESPECT_ELDERS_AND_CHILDREN,
+ name: '尊老爱幼',
+ content: '每轮仍按常规规则结算大牌;另以一号位牌型为准寻找最小牌:同牌型按正常牌力,牌型不一致时先比结构贴合度,异花色牌越多越小(包括主牌),数量相同再从最小牌起按字典序比较,完全相同时后出者更小。最小者下一轮先出牌。',
+ setup: DEFAULT_RULE_SETUP
+ }),
+ Object.freeze({
+ id: RuleIds.RITES_COLLAPSE,
+ name: '礼崩乐坏',
+ content: '每轮一号位主动出牌时不能选择任何A;跟牌时仍可正常打出A。若一号位手中只剩A,则可正常出牌。',
+ setup: DEFAULT_RULE_SETUP
+ }),
+ Object.freeze({
+ id: RuleIds.RECOMMEND_TALENT,
+ name: '举贤任能',
+ content: '每名玩家每局限一次;作为本轮一号位出牌前,可令下家先出,自己改为本轮最后出牌。只改变牌序,不改变牌的大小。',
+ setup: DEFAULT_RULE_SETUP,
+ activeSkill: Object.freeze({
+ id: ActiveSkillIds.RECOMMEND_TALENT,
+ name: '举贤任能',
+ usageLimit: 1,
+ timing: 'first_position_before_play',
+ effect: 'yield_turn_to_next_player'
+ })
+ }),
+ Object.freeze({
+ id: RuleIds.ACCIDENT_INSURANCE,
+ name: '意外保险',
+ content: '单轮牌面分超过30时,超出部分归另一方:闲家赢牌最多计30分;庄家方赢牌则将超出30的部分补给闲家。',
+ setup: DEFAULT_RULE_SETUP
+ }),
+ Object.freeze({
+ id: RuleIds.THREE_POWERS,
+ name: '三权分立',
+ content: '埋底后,2、3、4号位分别暗选一个非级牌点数,依次重载原10、5、K分牌;重复选择可叠加,点数首次出现时公开。',
+ setup: DEFAULT_RULE_SETUP
+ }),
+ Object.freeze({
+ id: RuleIds.GENTLEMAN_PROMISE,
+ name: '君子一言',
+ content: '埋底后,每名玩家公开声明自己手牌最少的有效花色(包括0张);所有主牌统一算作“主”。最少花色唯一时由系统直接声明,并列时由玩家选择。',
+ setup: DEFAULT_RULE_SETUP
+ }),
+ Object.freeze({
+ id: RuleIds.REPEATED_EXHAUSTION,
+ name: '再衰三竭',
+ content: '同一名玩家连续赢得第3、4、5……轮时依次失去5、10、15……分;庄家方失分补给闲家,闲家失分则从闲家总分扣除。',
+ setup: DEFAULT_RULE_SETUP
+ }),
+ Object.freeze({
+ id: RuleIds.FOCUS_FIGURE,
+ name: '焦点人物',
+ content: '埋底后,两队分别秘密表决本队焦点;有人反对就轮换候选并重新表决,直至队内两人一致同意。闲家拿到的焦点人物分牌按双倍计分,非焦点人物分牌不计分;底牌正常结算,焦点与实际总分在终局揭晓。',
+ setup: DEFAULT_RULE_SETUP
+ }),
+ Object.freeze({
+ id: RuleIds.COOLDOWN_TIME,
+ name: '冷却时间',
+ content: '每轮不能打出自己上轮出牌包含的点数;若其余可用牌不足以完成本次出牌,则解除冷却。',
+ setup: DEFAULT_RULE_SETUP
+ }),
+ Object.freeze({
+ id: RuleIds.TIME_COOLING,
+ name: '时间冷却',
+ content: '每轮不能打出自己上轮出牌包含的有效花色,所有主牌统一视为“主”;若其余可用牌不足以完成本次出牌,则解除冷却。',
+ setup: DEFAULT_RULE_SETUP
+ }),
+ Object.freeze({
+ id: RuleIds.PLANNED_ECONOMY,
+ name: '计划经济',
+ content: '起始每人20张手牌,另封存20张;庄家埋底并开始出牌后,每轮结束四家各摸1张,直至封存牌摸完。',
+ setup: DEFAULT_RULE_SETUP
+ }),
+ Object.freeze({
+ id: RuleIds.EQUIVALENT_RECIPROCITY,
+ name: '等价互惠',
+ content: '每名玩家每局限一次;作为本轮一号位出牌前,可与另一名玩家各暗选一张牌拼点。主牌大于副牌,副牌只按点数比较;输家使本方损失5分,平局不失分,随后交换两张拼点牌。',
+ setup: DEFAULT_RULE_SETUP,
+ activeSkill: Object.freeze({
+ id: ActiveSkillIds.EQUIVALENT_RECIPROCITY,
+ name: '等价互惠',
+ usageLimit: 1,
+ timing: 'first_position_before_play',
+ effect: 'compare_and_exchange'
+ })
+ }),
+ Object.freeze({
+ id: RuleIds.ENDURING,
+ name: '经久不衰',
+ content: '每名玩家本轮出牌与自己上轮的有效花色、牌型、张数及组合结构完全一致时,本轮该组合的比较牌力视为两轮中的最大值;主牌统一视为“主”,甩牌必须逐项匹配同样的组件结构。',
+ setup: DEFAULT_RULE_SETUP
+ }),
+ Object.freeze({
+ id: RuleIds.AVERAGE_POOLING,
+ name: '平均池化',
+ content: '单牌或对子的大小按自己与队友本轮有效出牌的平均牌力计算;垫牌和散牌不参与平均。',
+ setup: DEFAULT_RULE_SETUP
+ }),
+ Object.freeze({
+ id: RuleIds.DREAM_KILLING,
+ name: '梦中杀人',
+ content: '没有主牌时可进入梦中:手牌暗置并由系统完全随机出牌,不受跟牌花色或牌型限制。随机出牌与首位玩家有效花色相同或点数相同即视为最大并醒来;多人成功时先出者为大。',
+ setup: DEFAULT_RULE_SETUP,
+ activeSkill: Object.freeze({
+ id: ActiveSkillIds.DREAM_KILLING,
+ name: '梦中杀人',
+ usageLimit: null,
+ timing: 'anytime_no_trump',
+ effect: 'sleep_random_play'
+ })
+ }),
+ Object.freeze({
+ id: RuleIds.JOINT_HARMONY,
+ name: '珠联璧合',
+ content: '若一方两名玩家打出完全相同的牌,该方视为本轮最大并由后出者获得下轮牌权;两队同时达成时按牌面正常结算。',
+ setup: DEFAULT_RULE_SETUP
+ }),
+ Object.freeze({
+ id: RuleIds.DIVINE_WEAPON,
+ name: '神兵天降',
+ content: '每轮亮出从另一副无王牌堆中抽取的2张神兵牌。每名玩家每局限一次,可将一张与所选神兵牌花色或点数相同的手牌当作该牌打出;本轮有人发动后其他人不能再发动,轮末神兵牌作废并换新;本轮无人发动则原神兵牌保留到下一轮。',
+ setup: DEFAULT_RULE_SETUP,
+ activeSkill: Object.freeze({
+ id: ActiveSkillIds.DIVINE_WEAPON,
+ name: '神兵天降',
+ usageLimit: 1,
+ timing: 'any_play',
+ effect: 'transform_matching_card'
+ })
+ }),
+ Object.freeze({
+ id: RuleIds.MAGIC_TRICK,
+ name: '魔术戏法',
+ content: '每名玩家每局限一次;作为一号位出牌前暗选两名其他玩家,仅在本轮结算得分、胜负及下轮牌权时交换二者的出牌结果,不影响实际跟牌义务。',
+ setup: DEFAULT_RULE_SETUP,
+ activeSkill: Object.freeze({
+ id: ActiveSkillIds.MAGIC_TRICK,
+ name: '魔术戏法',
+ usageLimit: 1,
+ timing: 'first_position_before_play',
+ effect: 'swap_two_plays_at_round_end'
+ })
+ }),
+ Object.freeze({
+ id: RuleIds.ABRUPT_STOP,
+ name: '戛然而止',
+ content: '一整轮结束后,若任一玩家手牌少于5张,则该轮成为最后一轮;闲家获得庄家本人剩余手牌牌面分的一半,底牌仍按最后一轮胜负正常结算。',
+ setup: DEFAULT_RULE_SETUP
+ }),
+ Object.freeze({
+ id: RuleIds.CLUSTER_ANALYSIS,
+ name: '聚类分析',
+ content: '发动后可依次点击多张非分牌、非级牌的普通牌,明确选择各自要临时视为的相邻点数,再自行选牌按基本规则出牌;转化前后均不得为分牌或级牌。甩牌时,其他玩家能借此组成更大组件也会令甩牌失败。',
+ setup: DEFAULT_RULE_SETUP,
+ activeSkill: Object.freeze({
+ id: ActiveSkillIds.CLUSTER_ANALYSIS,
+ name: '聚类分析',
+ usageLimit: null,
+ timing: 'any_play',
+ effect: 'adjacent_rank_transform'
+ })
+ }),
+ Object.freeze({
+ id: RuleIds.FORBIDDEN_MAGIC,
+ name: '禁术秘法',
+ content: '每名玩家每局限一次,可在出牌阶段随时预备,并在轮首依次确认是否发动;确认后本局永久生效且不能撤销。此后原主牌不能直接打出,每张要出的原主牌都必须先明确转为一种副花色;非王保持原点数,王另选任意普通点数。有主局不能选择主花色,未打出的转化预设会保留。',
+ setup: DEFAULT_RULE_SETUP,
+ activeSkill: Object.freeze({
+ id: ActiveSkillIds.FORBIDDEN_MAGIC,
+ name: '禁术秘法',
+ usageLimit: 1,
+ timing: 'anytime_prepare_round_start_confirm',
+ effect: 'demote_trumps_and_transform'
+ })
+ }),
+ Object.freeze({
+ id: RuleIds.METICULOUS_ACCOUNTING,
+ name: '锱铢必较',
+ content: '本局的分数牌改为 A、2、3、4、5、6、7,分别计 1、2、3、4、5、6、7 分;闲家以 -10 分开局。',
+ setup: Object.freeze({ bottomCardsCount: 8, attackerStartingScore: -10 })
+ }),
+ Object.freeze({
+ id: RuleIds.LOST_IN_FOG,
+ name: '如堕云雾',
+ content: '出牌阶段不保留上一轮出牌记录,并隐藏本轮分值、闲家总分及已获得的分牌;终局结算时统一公开。',
+ setup: DEFAULT_RULE_SETUP
+ }),
+ Object.freeze({
+ id: RuleIds.BIRDS_GONE_BOW_HIDDEN,
+ name: '鸟尽弓藏',
+ content: '某一有效花色的所有5、10、K分牌全部实际打出后,一号位不能再主动打出该有效花色;跟牌不受影响。带分级牌统一计入主花色,不计入原牌面副花色。若手中只剩已弓藏花色,则解除限制。',
+ setup: DEFAULT_RULE_SETUP
+ }),
+ Object.freeze({
+ id: RuleIds.ODD_EVEN_SCORING,
+ name: '无独有偶',
+ content: '奇数轮的分牌不进入计分区且计0分;偶数轮的分牌进入计分区,并按牌面原分的两倍计入。底牌仍按常规抠底规则结算。',
+ setup: DEFAULT_RULE_SETUP
+ }),
+ Object.freeze({
+ id: RuleIds.SECOND_BATTLEFIELD,
+ name: '第二战场',
+ content: '各家打出且尚未参赛的牌会明置在各自桌前并跨轮累计。每轮结束时,若四家均累计至少5张,则系统仅用各自累计牌选出德州最佳五张进行比较,胜者阵营获得5分,随后收走全部参赛牌并重新累计;若此时四家剩余手牌都少于5张,则延至全部出完后再用最后累计的全部牌判定。王牌由系统自动转换为任意牌面以组成最优牌型;双副牌的重复实体分别计算,五条高于同花顺;同阵营并列只奖励一次,跨阵营并列时双方各得5分。',
+ setup: DEFAULT_RULE_SETUP
+ }),
+ Object.freeze({
+ id: RuleIds.ONE_COUNTRY_TWO_SYSTEMS,
+ name: '一国两制',
+ content: '庄家方与闲家方分别在本方内亮主、反主,双方声明互不覆盖。任意玩家亮一对王,或双方都未亮主时,双方无主;只有一方亮花色时,双方共用该主花色;双方亮出不同花色时,两种花色按阵营互换后统一进行牌型、跟牌、缺门、毙牌和大小判定。',
+ setup: DEFAULT_RULE_SETUP
+ }),
+ Object.freeze({
+ id: RuleIds.WOODEN_OX_FLOWING_HORSE,
+ name: '木牛流马',
+ content: '游戏开始时2、3号位各持有一具木牛流马(每队一具)。每轮首张牌打出前,持有者可以跳过操作、将其中原有的牌原样交给队友,或放入/替换一张手牌并必须立即交给队友;首张牌打出后本轮不得再操作。接收者可以跨轮保留其中的牌,并在自己的正常出牌回合将它如手牌般打出;该牌不计入持有者的跟牌花色义务。若队友已经无牌可出,持有者必须在轮首将木牛流马交给队友以恢复牌数。每队的木牛流马每局最多往返两次(共四次单程传递)。',
+ setup: DEFAULT_RULE_SETUP
+ }),
+ Object.freeze({
+ id: RuleIds.STRENGTH_COMPENSATION,
+ name: '取长补短',
+ content: '以庄家为0号位,逆时针依次为1、2、3号位。第x轮中,x%4号位的全部手牌沿当前牌力序列升一级,(x+2)%4号位降一级,轮末轮换。副牌留在原花色并跳过级牌点数,A之上依次为B、C、D,越过最低普通牌仍依次为1、0、−1、−2;主牌依次为主普通牌、副级牌、主级牌、小王、大王、郡王、亲王、白王(皇),升降不会打乱原有内部次序。牌面变化不改变实体牌原有分值。',
+ setup: DEFAULT_RULE_SETUP
+ }),
+ Object.freeze({
+ id: RuleIds.UNARMED,
+ name: '手无寸铁',
+ content: '开局移除4张王牌。级牌仍可用于亮主、反主;进入出牌阶段后不再因级牌点数成为主牌,而是回到自身花色并按原点数大小参与跟牌、牌型和胜负比较。',
+ setup: DEFAULT_RULE_SETUP
+ }),
+ Object.freeze({
+ id: RuleIds.MUTUAL_SUPPORT,
+ name: '同舟共济',
+ content: '每名玩家每局限一次;轮到自己且尚未出牌时,可以要求队友交给自己0至2张牌,或选择1至2张手牌交给队友。本轮结束时,获得牌的玩家再选择等量手牌返还给原持有者。',
+ setup: DEFAULT_RULE_SETUP,
+ activeSkill: Object.freeze({
+ id: ActiveSkillIds.MUTUAL_SUPPORT,
+ name: '同舟共济',
+ usageLimit: 1,
+ timing: 'own_turn_before_play',
+ effect: 'temporary_teammate_card_transfer'
+ })
+ }),
+ Object.freeze({
+ id: RuleIds.CANDLE_TO_DAWN,
+ name: '烛尽天明',
+ content: '游戏开始时由庄家队友选择点燃或熄灭“烛”。烛点燃的轮次,红色分牌每张+5分、黑色分牌每张-5分;烛熄灭的轮次则相反。每轮结束并完成计分后,若第四手只含红色牌则点燃下轮的烛,只含黑色牌则熄灭,否则不变。小王为黑色,大王为红色;底牌按原分结算。',
+ setup: DEFAULT_RULE_SETUP
+ }),
+ Object.freeze({
+ id: RuleIds.CULTURAL_REVOLUTION,
+ name: '文化革命',
+ content: '每名玩家每局限一次;作为一号位出牌前,先选择“革花色”或“革点数”,再声明一种花色或2至A的一种点数(包括10、K)。发动当轮及下一轮内,所选花色替换原主花色,或所选点数替换原级牌点数;被替换的原主暂按副牌处理。生效期间的新声明会整体覆盖旧声明,并重新持续两轮。',
+ setup: DEFAULT_RULE_SETUP,
+ activeSkill: Object.freeze({
+ id: ActiveSkillIds.CULTURAL_REVOLUTION,
+ name: '文化革命',
+ usageLimit: 1,
+ timing: 'first_position_before_play',
+ effect: 'temporarily_replace_trump'
+ })
+ }),
+ Object.freeze({
+ id: RuleIds.THREE_TIGERS,
+ name: '三人成虎',
+ content: '一轮中,每名玩家本次打出的全部牌均为同一副花色的副牌时,为该花色计一人;同一副花色累计达到三人后立即成虎。主花色牌、级牌和王不计人数,也不转换。该轮桌上及此后打出的该副花色牌全部视为主牌,并沿剔除本局级牌后的普通牌序列降低4级;例如4为级牌时,降级过程跨过4这一档,最低为−2。转换只影响本轮牌力,不改变实体牌原有分值。',
+ setup: DEFAULT_RULE_SETUP
+ }),
+ Object.freeze({
+ id: RuleIds.INVITE_INTO_URN,
+ name: '请君入瓮',
+ content: '每名玩家每局限一次;作为一号位出牌前,可指定一名其他玩家和一种实体牌面。本轮只要目标玩家打出至少一张该牌面,其阵营失去5分;无论同一次打出一张还是多张,都只扣5分。若目标属于庄家方,等价为闲家增加5分。',
+ setup: DEFAULT_RULE_SETUP,
+ activeSkill: Object.freeze({
+ id: ActiveSkillIds.INVITE_INTO_URN,
+ name: '请君入瓮',
+ usageLimit: 1,
+ timing: 'first_position_before_play',
+ effect: 'declare_target_card'
+ })
+ }),
+ Object.freeze({
+ id: RuleIds.OLD_HORSE_STILL_HAS_STRENGTH,
+ name: '老骥伏枥',
+ content: '开局首发者视为已经获得牌权。四名玩家都至少获得过一次牌权时,最后首次获得牌权的玩家,其随后第一次合法首发视为绝对最大,任何后手都不能压过;该次出牌仍须完整遵守首发牌型与甩牌规则。',
+ setup: DEFAULT_RULE_SETUP
+ }),
+ Object.freeze({
+ id: RuleIds.TRUMP_WINS,
+ name: 'Trump wins',
+ content: '每轮仍按正常牌力判定胜者并结算分数,但下一轮改由本轮实体出牌总分最高的玩家先出;若最高分有多人并列,则其中先出牌的玩家获得牌权。',
+ setup: DEFAULT_RULE_SETUP
+ }),
+ Object.freeze({
+ id: RuleIds.OPENLY_REVEALED,
+ name: '昭然若揭',
+ content: '底牌从摸牌开始便在牌桌中央明置;庄家重新埋底后,明置内容同步换成新底牌。整局所有玩家都可随时查看当前底牌。',
+ setup: DEFAULT_RULE_SETUP
+ }),
+ Object.freeze({
+ id: RuleIds.STRAW_BOAT_BORROWING_ARROWS,
+ name: '草船借箭',
+ content: '若首置位玩家本轮打出的实体分牌合计不少于10分,且这些分牌最终被对方阵营获得,轮末该玩家可以选择发动:向全场公开弃置一张剩余手牌中的非分牌,并获得本轮打出的、按当前主牌体系牌力最大的单张非分牌。若手中无可弃牌或本轮没有非分牌,则不能发动。',
+ setup: DEFAULT_RULE_SETUP
+ }),
+ Object.freeze({
+ id: RuleIds.BUSH_GATE,
+ name: '布什戈门',
+ content: '每名玩家每局限一次;作为本轮二号位且尚未出牌时,可以迫使一号位收回本轮首发并重新合法首发。被收回的每一张牌在这次重新首发中均不可使用;重新首发成功后限制立即解除,后续轮次可正常使用。若一号位没有其他手牌则不能发动。',
+ setup: DEFAULT_RULE_SETUP,
+ activeSkill: Object.freeze({
+ id: ActiveSkillIds.BUSH_GATE,
+ name: '布什戈门',
+ usageLimit: 1,
+ timing: 'second_position_after_lead',
+ effect: 'force_leader_replay'
+ })
+ }),
+ Object.freeze({
+ id: RuleIds.TEAMMATE_CHEER,
+ name: '队友加油',
+ content: '每次出牌后,若该玩家还持有至少一张牌、剩余手牌中已经没有主牌且尚未发动过,系统询问是否声明“队友加油”。确认后其队友获得永久加油 Buff,剩余手牌及之后打出的牌沿当前完整牌力序列全部提升一级;副牌不会跨入主牌链,A升为B,大王升为郡王。获得 Buff 的玩家全场标记,实体牌原有分值不变;暂不发动后可在后续满足条件的出牌后再次选择。',
+ setup: DEFAULT_RULE_SETUP
+ }),
+ Object.freeze({
+ id: RuleIds.ILLUSION_AND_REALITY,
+ name: '虚虚实实',
+ content: '每名玩家每局限一次。跟随副花色时,若自己手中该副花色恰好剩余奇数张,可以发动并将这些牌虚置,本次出牌完全视为手中没有该花色;被虚置的牌不能在本次出牌中打出,仍须从其余手牌中打出与首家相同的张数。',
+ setup: DEFAULT_RULE_SETUP,
+ activeSkill: Object.freeze({
+ id: ActiveSkillIds.ILLUSION_AND_REALITY,
+ name: '虚虚实实',
+ usageLimit: 1,
+ timing: 'following_play',
+ effect: 'ignore_odd_led_side_suit'
+ })
+ }),
+ Object.freeze({
+ id: RuleIds.STRIVE_UPSTREAM,
+ name: '力争上游',
+ content: '每轮的出牌顺序按上轮四家出牌由大到小排列。以一号位的牌型为准:同牌型按正常牌力比较;牌型不一致时,先比与首家牌型的贴合程度,异花色牌越多越小(包括主牌),数量相同再从最小牌起按字典序比较;完全相同时后出者更小。',
+ setup: DEFAULT_RULE_SETUP
+ }),
+ Object.freeze({
+ id: RuleIds.AFTERGLOW,
+ name: '回光返照',
+ content: '仅在非无主局生效,每名玩家每局限一次。开局一号位若持有1至3张主牌,可在首次出牌前直接发动;其余情况下,每次出牌后若该玩家仍有手牌且只剩1至3张主牌,系统询问是否发动。确认后剩余主牌立即沿当前完整主牌序列提升一级;从下一次出牌起,只要手中仍有主牌便无视通常的跟牌要求,但每次出牌只能打出主牌、不能混入副牌。主牌出尽后效果结束;实体牌原有分值不变,暂不发动不消耗机会。',
+ setup: DEFAULT_RULE_SETUP
+ }),
+ Object.freeze({
+ id: RuleIds.OUTWARD_HARMONY_INNER_DIVISION,
+ name: '貌合神离',
+ content: '任意一方两名玩家在一轮中若打出的牌型不一致(不考虑花色),则对方阵营获得5分。',
+ setup: DEFAULT_RULE_SETUP
+ }),
+ Object.freeze({
+ id: RuleIds.AMBIGUOUS,
+ name: '模棱两可',
+ content: '每名玩家每局限一次,仅本轮二、三号位可发动:同时公开两种不同且各自合法的出牌方案,四家出完后再选择其中一种结算。若二、三号位都发动,则三号位作为后续发动者不消耗次数,并由三号位先选、二号位后选。',
+ setup: DEFAULT_RULE_SETUP,
+ activeSkill: Object.freeze({
+ id: ActiveSkillIds.AMBIGUOUS,
+ name: '模棱两可',
+ usageLimit: 1,
+ timing: 'middle_positions_play',
+ effect: 'two_legal_plays_choose_at_round_end'
+ })
+ }),
+ Object.freeze({
+ id: RuleIds.TWO_GHOSTS_KNOCK_DOOR,
+ name: '二鬼拍门',
+ content: '摸牌阶段中,任一玩家手牌里的王达到至少两张时,立即将其持有的全部王作为明置手牌公开;之后再摸到的王也继续明置。王仍属于原玩家手牌,打出或离开手牌后便从明置区消失。',
+ setup: DEFAULT_RULE_SETUP
+ }),
+ Object.freeze({
+ id: RuleIds.PEOPLE_COMMUNE,
+ name: '人民公社',
+ content: '摸牌时不留底牌,四名玩家各摸27张,再各自埋两张;每人可随时查看自己埋的两张牌及其分值。闲家开局-20分。抄底时只结算对方阵营埋下的四张牌:闲家拿底获得庄家方埋分;庄家方拿底则抄走闲家埋分,使闲家按底牌倍数扣分。',
+ setup: Object.freeze({ bottomCardsCount: 0, attackerStartingScore: -20 })
+ }),
+ Object.freeze({
+ id: RuleIds.REMOVE_FIREWOOD_FROM_UNDER_CAULDRON,
+ name: '釜底抽薪',
+ content: '每次反主后,被反主的玩家获得一次选择:亮主与反主全部结算后,可以与反主者交换当前全部手牌,也可以不交换。若发生多次反主,按反主发生顺序由后向前依次询问和结算。',
+ setup: DEFAULT_RULE_SETUP
+ }),
+ Object.freeze({
+ id: RuleIds.MAINSTAY,
+ name: '中流砥柱',
+ content: '庄家完成埋底后,若本局不是无主局,则从一号位开始依次处理每名玩家。轮到某玩家时,若其当前主牌数不大于5,可以将包括全部主牌在内的5张牌交给队友,再由队友返还5张牌;每名玩家均按轮到自己时的当前手牌独立判断,因此同队两人都可以发动。',
+ setup: DEFAULT_RULE_SETUP
+ }),
+ Object.freeze({
+ id: RuleIds.HAPPY_TWINS,
+ name: '欢乐成双',
+ content: '庄家锁定后与上家交换位置,但双方原有组队关系不变;本局结束后恢复原座次。若庄家方获胜,下一局仍由庄家的固定队友上庄;若闲家方获胜,则由换位后庄家的下家(即换位前的庄家上家)上庄。',
+ setup: DEFAULT_RULE_SETUP
+ }),
+ Object.freeze({
+ id: RuleIds.ENCIRCLE_THREE_MISSING_ONE,
+ name: '围三阙一',
+ content: '摸牌阶段照常亮主。进入出牌阶段后,忽略王牌和级牌,每次出牌后即时记录本次出现的全部普通花色;若一次更新后四种花色齐全,则立即清空全部记录,并从下一位玩家出牌重新统计。每轮结束时若恰好记录了三种花色,缺少的第四种花色若不是当前主花色,则从下一轮起替换当前主花色;无论是否换主,随后均清空记录并重新统计。',
+ setup: DEFAULT_RULE_SETUP
+ }),
+ Object.freeze({
+ id: RuleIds.THREE_SIX_NINE_GRADES,
+ name: '三六九等',
+ content: '亮牌阶段分为亮主与亮劣两条独立的反亮链,均可用一张或一对本花色级牌并按通常强度加固、反亮;王只能亮主。任一普通花色一旦用于亮主或亮劣,其他玩家便不能再在任一链使用该花色。若以对王亮成无主,则本局同时无劣;若无人亮主而自然无主,已经亮出的劣花色仍然保留;无人亮劣时则没有劣花色。出牌时主牌大于普通副牌,普通副牌可以像主牌毙副牌一样毙劣牌;劣花色级牌小于其他副花色级牌。',
+ setup: DEFAULT_RULE_SETUP
+ }),
+ Object.freeze({
+ id: RuleIds.IRON_EVIDENCE,
+ name: '铁证如山',
+ content: '每轮开始时锁定铁证牌的效果。若此前两张大王尚未全部打出,本轮每出现一张小王、红桃Q、黑桃J或梅花J,本轮牌面总分的倍数便在1倍基础上加1;若本轮开始前两张大王均已打出,则本轮只在出现至少一张上述铁证牌时清零牌面总分,没有铁证牌则正常计分。本轮内才打出的第二张大王不影响本轮效果,只从下一轮开始生效。',
+ setup: DEFAULT_RULE_SETUP
+ }),
+ Object.freeze({
+ id: RuleIds.WAITING_RABBIT,
+ name: '守株待兔',
+ content: '埋底完成后,每名玩家暗中指定一张有花色的牌面,不能指定王或当前级牌,但可以指定5、10、K。当其他玩家打出该牌面时,本轮座次最先指定它且尚未成功交换的玩家,可以用手里一张非分牌与其中一张目标牌交换;目标牌进入其手牌,非分牌原位顶替上桌。每张实体分牌只在全局首次上桌时计分,被换回手牌后再次打出只保留牌面大小,不再计分。',
+ setup: DEFAULT_RULE_SETUP
+ }),
+ Object.freeze({
+ id: RuleIds.BURN_THE_BOATS,
+ name: '破釜沉舟',
+ content: '本局不能投降,不能重开。',
+ setup: DEFAULT_RULE_SETUP
+ }),
+ Object.freeze({
+ id: RuleIds.HIDDEN_DRAGON_IN_ABYSS,
+ name: '潜龙在渊',
+ content: '埋底完成后,每名玩家公开声明自己手牌中数量最多的一个点数(本局级牌除外);若最多点数唯一则由系统自动声明,并列时由玩家自行选择。玩家的手牌数首次不大于12时,若其此前及本次均未打出过所声明的点数,则所属阵营获得10分。',
+ setup: DEFAULT_RULE_SETUP
+ }),
+ Object.freeze({
+ id: RuleIds.ADMINISTRATIVE_REVIEW,
+ name: '行政审查',
+ content: '本局有12张底牌,四家各持24张牌开始出牌。开始出牌前,庄家下家公开指定一种副花色,庄家上家公开指定一种点数;底牌继续封存,庄家不可查看。庄家或其队友累计打出过指定副花色和指定点数后,庄家才收到并查看12张底牌,立即埋12张后继续当前牌局。',
+ setup: Object.freeze({ bottomCardsCount: 12, attackerStartingScore: 0 })
+ }),
+ Object.freeze({
+ id: RuleIds.POLITICAL_REVIEW,
+ name: '政治审查',
+ content: '每名玩家每局限一次。在该玩家尚未发动前,其队友每次提交出牌后都暂停牌局并询问该玩家是否令队友收回。选择收回才消耗次数;收回只传递反对信号,不产生任何禁出限制,队友可以立即原样重新出牌。选择放行不消耗次数。',
+ setup: DEFAULT_RULE_SETUP
+ }),
+ Object.freeze({
+ id: RuleIds.NO_ONE_SURVIVES,
+ name: '无人生还',
+ content: '每轮一号位的出牌正常明置;二、三、四号位的出牌在本人视角始终明置,对其他玩家自动暗置且只公开张数,本轮牌面总分也不提前显示。四家出完后统一公开暗牌与本轮总分,再按正常规则结算胜负和下轮牌权。',
+ setup: DEFAULT_RULE_SETUP
+ }),
+ Object.freeze({
+ id: RuleIds.LURE_TIGER_FROM_MOUNTAIN,
+ name: '调虎离山',
+ content: '每方阵营每局限发动一次。每名玩家都可在出牌阶段随时预备,系统在目标轮开始、首张牌打出前按本轮座次依次询问预备者;同队首名确认并选定目标的玩家抢占本方次数,其队友不能再发动。发动者指定一名本轮非一号位玩家,使其本轮无法参与甩牌询问,且本轮打出的牌不计大小与分数。',
+ setup: DEFAULT_RULE_SETUP,
+ activeSkill: Object.freeze({
+ id: ActiveSkillIds.LURE_TIGER_FROM_MOUNTAIN,
+ name: '调虎离山',
+ usageLimit: 1,
+ timing: 'anytime_prepare_round_start_confirm',
+ effect: 'silence_non_leader_for_round'
+ })
+ }),
+ Object.freeze({
+ id: RuleIds.DEFENSE_AS_OFFENSE,
+ name: '以守为攻',
+ content: '每轮结束后,按“力争上游”的完整牌力顺序比较四家出牌;本轮一号位每小于一名其他玩家,其下一轮全部手牌牌面便提升一级,并在玩家名旁标记对应的“+X”。加成只持续下一轮,实体牌原有分值不变;副牌A的+1、+2、+3依次为B、C、D,大王的+1、+2、+3依次为郡王、亲王、白王(皇),并分别在D与白王处封顶。',
+ setup: DEFAULT_RULE_SETUP
+ }),
+ Object.freeze({
+ id: RuleIds.ANTINOMY,
+ name: '二律背反',
+ content: '庄家埋完底、正式开始出牌前,四名玩家各自选择一张普通花色牌面(花色+点数),收齐后同时亮出。仅被一名玩家指定的牌面,其两张实体牌本局视为不同牌,不能组成对子或拖拉机;同一牌面被多人指定时不执行拆对效果。每轮结束后,本轮中自己所指定牌面曾被任何玩家打出的声明者重新选择,收齐后同时更新;完成前不能开始下一轮出牌。',
+ setup: DEFAULT_RULE_SETUP
+ }),
+ Object.freeze({
+ id: RuleIds.CHANGE_RICE_TO_MULBERRY,
+ name: '改稻为桑',
+ content: '庄家埋底后、正式出牌前,两名闲家分别选择自己手牌中⌊分牌数÷2⌋张分牌;原本为副牌的变为同花色A,原本为主牌的变为大王。被改造的实体牌永久失去分值,只按新牌面参与牌型、跟牌和大小结算。',
+ setup: DEFAULT_RULE_SETUP
+ }),
+ Object.freeze({
+ id: RuleIds.DESTROY_DYKE_FLOOD_FIELDS,
+ name: '毁堤淹田',
+ content: '庄家每局限发动一次。闲家赢得一轮后、该轮计分前,庄家可以令本轮所有分数作废并记录作废分数。接下来的三轮为灾期,期间照常计分;若闲家在灾期内累计获得不少于20分,或游戏在灾期结束前结束,则视为事发,闲家取回作废分数并额外获得20分,随后结束灾期。若牌局恰于灾期第三轮结束,且闲家拿底,也视为事发。若三轮结束时仍未事发,记录的分数永久作废。',
+ setup: DEFAULT_RULE_SETUP
+ }),
+ Object.freeze({
+ id: RuleIds.RECORD_ON_FILE,
+ name: '记录在案',
+ content: '任一轮只要出现至少一张传统分数牌(5、10或K)、当前级牌或王牌,下一轮公开显示记牌器。记牌器仅在该轮生效;该轮若再次出现上述任一种牌,下一轮重新独立生效一轮,否则下一轮关闭。记牌器按实体花色与点数统计本局至今已经打出的牌,并标明两副牌中同一牌面已出现的张数;记牌器本身不改变牌力、得分或出牌。',
+ setup: DEFAULT_RULE_SETUP
+ }),
+ Object.freeze({
+ id: RuleIds.WEIGHING_THOUSAND_JIN,
+ name: '上称千斤',
+ content: '每轮结束时,按“力争上游”的完整牌力顺序比较庄家与两名闲家的本轮出牌(完全相同时后出者更小)。若庄家的牌大于至少一名闲家,本轮每张分牌各减5分,最低减至0分;否则本轮每张分牌的分值加倍。',
+ setup: DEFAULT_RULE_SETUP
+ }),
+ Object.freeze({
+ id: RuleIds.KING_OVER_WHITE,
+ name: '王上加白',
+ content: '开局洗牌后,从两副牌的四张大小王中随机选择一张,永久变为白王(皇);其余三张王不变。白王按全局最大王牌参与跟牌、牌型与大小比较。',
+ setup: DEFAULT_RULE_SETUP
+ }),
+ Object.freeze({
+ id: RuleIds.FEAR_OF_BREAKING_VASE,
+ name: '投鼠忌器',
+ content: '每轮结束时,若赢家一方出现以下任一“误伤队友”情形,则该方失去10分:①首家最终最大,而其队友本轮打出了至少两对或至少两张王;②第二家是本轮唯一完成毙牌的玩家,且若不计第二家的出牌,其队友本可在其余三家中最大。闲家方失分时闲家总分−10;庄家方失分时闲家总分+10。',
+ setup: DEFAULT_RULE_SETUP
+ }),
+ Object.freeze({
+ id: RuleIds.EIGHT_KINGS_COUNCIL,
+ name: '八王议政',
+ content: '本局牌堆额外加入两张郡王和两张亲王。郡王、亲王均为主牌,依次大于大王;同牌面的两张可以作为一对王亮成无主。',
+ setup: DEFAULT_RULE_SETUP
+ }),
+ Object.freeze({
+ id: RuleIds.NINE_PRINCES_SUCCESSION,
+ name: '九子夺嫡',
+ content: '每轮结束时,若本轮赢家收下了对方阵营打出的至少一张分牌,且仍有可以提升的手牌,其可以选择一张手牌沿当前完整牌力序列永久提升一级,也可以放弃。若此次提升使该牌成为白王(皇),该玩家所在阵营立即获得10分,随后本局不再触发九子夺嫡。',
+ setup: DEFAULT_RULE_SETUP
+ })
+]);
+
+const RULES_BY_ID = new Map(RULE_DEFINITIONS.map(rule => [rule.id, rule]));
+
+function copyPublicRule(rule) {
+ if (!rule) return null;
+ return {
+ id: rule.id,
+ name: rule.name,
+ content: rule.content,
+ ...(rule.activeSkill ? { activeSkill: { ...rule.activeSkill } } : {}),
+ ...(Array.isArray(rule.rules)
+ ? { rules: rule.rules.map(copyPublicRule).filter(Boolean) }
+ : {})
+ };
+}
+
+export function getImplementedRules() {
+ return RULE_DEFINITIONS.map(copyPublicRule);
+}
+
+export function getRuleById(id) {
+ return copyPublicRule(RULES_BY_ID.get(id));
+}
+
+export function getRuleSetup(rule) {
+ if (Array.isArray(rule?.rules) && rule.rules.length > 0) {
+ const setups = rule.rules.map(childRule => getRuleSetup(childRule));
+ const resolveSetupValue = (key, defaultValue, label) => {
+ const nonDefaultValues = [
+ ...new Set(setups.map(setup => setup[key]).filter(value => value !== defaultValue))
+ ];
+ if (nonDefaultValues.length > 1) {
+ throw new Error(`所选两条规则的${label}冲突,请刷新候选或改选其他组合`);
+ }
+ return nonDefaultValues[0] ?? defaultValue;
+ };
+ return {
+ bottomCardsCount: resolveSetupValue(
+ 'bottomCardsCount',
+ DEFAULT_RULE_SETUP.bottomCardsCount,
+ '底牌数量'
+ ),
+ attackerStartingScore: resolveSetupValue(
+ 'attackerStartingScore',
+ DEFAULT_RULE_SETUP.attackerStartingScore,
+ '闲家初始分数'
+ )
+ };
+ }
+ const definition = RULES_BY_ID.get(rule?.id);
+ return { ...(definition?.setup || DEFAULT_RULE_SETUP) };
+}
+
+/** 从全部已实现规则中无重复地随机提供指定数量的候选。 */
+export function createRuleOptions(
+ random = Math.random,
+ { count = 2, excludeIds = [] } = {}
+) {
+ const excluded = new Set(excludeIds);
+ const options = getImplementedRules().filter(rule => !excluded.has(rule.id));
+ for (let index = options.length - 1; index > 0; index--) {
+ const sample = Number(random());
+ const boundedSample = Number.isFinite(sample)
+ ? Math.min(0.999999999, Math.max(0, sample))
+ : 0;
+ const swapIndex = Math.floor(boundedSample * (index + 1));
+ [options[index], options[swapIndex]] = [options[swapIndex], options[index]];
+ }
+ return options.slice(0, Math.max(0, count));
+}
+
+/** 只接受服务端本局实际提供的候选,忽略客户端传入的名称和描述。 */
+export function resolveOfferedRule(selection, offeredRules) {
+ const id = typeof selection === 'string' ? selection : selection?.id;
+ if (!id || !Array.isArray(offeredRules)) return null;
+ const offered = offeredRules.find(rule => rule.id === id);
+ return offered ? copyPublicRule(RULES_BY_ID.get(offered.id)) : null;
+}
+
+export function ruleIncludesId(rule, ruleId) {
+ if (!ruleId || !rule) return false;
+ if (rule.id === ruleId) return true;
+ return Array.isArray(rule.rules)
+ && rule.rules.some(childRule => ruleIncludesId(childRule, ruleId));
+}
+
+export function createDoubleHappinessRule(selections, offeredRules) {
+ const selectionIds = Array.isArray(selections)
+ ? selections.map(selection => (
+ typeof selection === 'string' ? selection : selection?.id
+ ))
+ : [];
+ if (selectionIds.length !== 2 || new Set(selectionIds).size !== 2) {
+ throw new Error('双喜临门必须选择两条不同的规则');
+ }
+ const rules = selectionIds.map(id => resolveOfferedRule(id, offeredRules));
+ if (rules.some(rule => !rule || rule.id === RuleIds.DOUBLE_HAPPINESS)) {
+ throw new Error('只能从当前三条候选中选择两条规则');
+ }
+
+ const activeSkills = rules
+ .map(rule => RULES_BY_ID.get(rule.id)?.activeSkill)
+ .filter(Boolean);
+ if (activeSkills.length > 1) {
+ throw new Error('所选两条规则都含主动技能,请房主刷新候选或改选其他组合');
+ }
+ const exchangeOffsets = rules
+ .map(rule => RULES_BY_ID.get(rule.id)?.openingCardExchangeOffset)
+ .filter(Number.isInteger);
+ if (exchangeOffsets.length > 1) {
+ throw new Error('所选两条规则都要求开局换牌,请房主刷新候选或改选其他组合');
+ }
+
+ const compositeRule = {
+ id: RuleIds.DOUBLE_HAPPINESS,
+ name: '双喜临门',
+ content: rules.map(rule => `${rule.name}:${rule.content}`).join(';'),
+ rules,
+ ...(activeSkills[0] ? { activeSkill: { ...activeSkills[0] } } : {})
+ };
+ // 这里同时承担两条规则可机械合并的初始配置校验。
+ getRuleSetup(compositeRule);
+ return copyPublicRule(compositeRule);
+}
+
+export function isReverseRankOrderRule(rule) {
+ return ruleIncludesId(rule, RuleIds.REVERSE_RANK_ORDER);
+}
+
+export function isEnduringRule(rule) {
+ return ruleIncludesId(rule, RuleIds.ENDURING);
+}
+
+export function isAveragePoolingRule(rule) {
+ return ruleIncludesId(rule, RuleIds.AVERAGE_POOLING);
+}
+
+export function isDreamKillingRule(rule) {
+ return ruleIncludesId(rule, RuleIds.DREAM_KILLING);
+}
+
+export function isJointHarmonyRule(rule) {
+ return ruleIncludesId(rule, RuleIds.JOINT_HARMONY);
+}
+
+export function isDivineWeaponRule(rule) {
+ return ruleIncludesId(rule, RuleIds.DIVINE_WEAPON);
+}
+
+export function isMagicTrickRule(rule) {
+ return ruleIncludesId(rule, RuleIds.MAGIC_TRICK);
+}
+
+export function isAbruptStopRule(rule) {
+ return ruleIncludesId(rule, RuleIds.ABRUPT_STOP);
+}
+
+export function isClusterAnalysisRule(rule) {
+ return ruleIncludesId(rule, RuleIds.CLUSTER_ANALYSIS);
+}
+
+export function isForbiddenMagicRule(rule) {
+ return ruleIncludesId(rule, RuleIds.FORBIDDEN_MAGIC);
+}
+
+export function isMeticulousAccountingRule(rule) {
+ return ruleIncludesId(rule, RuleIds.METICULOUS_ACCOUNTING);
+}
+
+export function isLostInFogRule(rule) {
+ return ruleIncludesId(rule, RuleIds.LOST_IN_FOG);
+}
+
+export function isBirdsGoneBowHiddenRule(rule) {
+ return ruleIncludesId(rule, RuleIds.BIRDS_GONE_BOW_HIDDEN);
+}
+
+export function isOddEvenScoringRule(rule) {
+ return ruleIncludesId(rule, RuleIds.ODD_EVEN_SCORING);
+}
+
+export function isSecondBattlefieldRule(rule) {
+ return ruleIncludesId(rule, RuleIds.SECOND_BATTLEFIELD);
+}
+
+export function isOneCountryTwoSystemsRule(rule) {
+ return ruleIncludesId(rule, RuleIds.ONE_COUNTRY_TWO_SYSTEMS);
+}
+
+export function isWoodenOxFlowingHorseRule(rule) {
+ return ruleIncludesId(rule, RuleIds.WOODEN_OX_FLOWING_HORSE);
+}
+
+export function isStrengthCompensationRule(rule) {
+ return ruleIncludesId(rule, RuleIds.STRENGTH_COMPENSATION);
+}
+
+export function isUnarmedRule(rule) {
+ return ruleIncludesId(rule, RuleIds.UNARMED);
+}
+
+export function isMutualSupportRule(rule) {
+ return ruleIncludesId(rule, RuleIds.MUTUAL_SUPPORT);
+}
+
+export function isCandleToDawnRule(rule) {
+ return ruleIncludesId(rule, RuleIds.CANDLE_TO_DAWN);
+}
+
+export function isCulturalRevolutionRule(rule) {
+ return ruleIncludesId(rule, RuleIds.CULTURAL_REVOLUTION);
+}
+
+export function isThreeTigersRule(rule) {
+ return ruleIncludesId(rule, RuleIds.THREE_TIGERS);
+}
+
+export function isInviteIntoUrnRule(rule) {
+ return ruleIncludesId(rule, RuleIds.INVITE_INTO_URN);
+}
+
+export function isOldHorseStillHasStrengthRule(rule) {
+ return ruleIncludesId(rule, RuleIds.OLD_HORSE_STILL_HAS_STRENGTH);
+}
+
+export function isTrumpWinsRule(rule) {
+ return ruleIncludesId(rule, RuleIds.TRUMP_WINS);
+}
+
+export function isOpenlyRevealedRule(rule) {
+ return ruleIncludesId(rule, RuleIds.OPENLY_REVEALED);
+}
+
+export function isStrawBoatBorrowingArrowsRule(rule) {
+ return ruleIncludesId(rule, RuleIds.STRAW_BOAT_BORROWING_ARROWS);
+}
+
+export function isBushGateRule(rule) {
+ return ruleIncludesId(rule, RuleIds.BUSH_GATE);
+}
+
+export function isTeammateCheerRule(rule) {
+ return ruleIncludesId(rule, RuleIds.TEAMMATE_CHEER);
+}
+
+export function isOutwardHarmonyInnerDivisionRule(rule) {
+ return ruleIncludesId(rule, RuleIds.OUTWARD_HARMONY_INNER_DIVISION);
+}
+
+export function isAmbiguousRule(rule) {
+ return ruleIncludesId(rule, RuleIds.AMBIGUOUS);
+}
+
+export function isTwoGhostsKnockDoorRule(rule) {
+ return ruleIncludesId(rule, RuleIds.TWO_GHOSTS_KNOCK_DOOR);
+}
+
+export function isPeopleCommuneRule(rule) {
+ return ruleIncludesId(rule, RuleIds.PEOPLE_COMMUNE);
+}
+
+export function isRemoveFirewoodRule(rule) {
+ return ruleIncludesId(rule, RuleIds.REMOVE_FIREWOOD_FROM_UNDER_CAULDRON);
+}
+
+export function isMainstayRule(rule) {
+ return ruleIncludesId(rule, RuleIds.MAINSTAY);
+}
+
+export function isHappyTwinsRule(rule) {
+ return ruleIncludesId(rule, RuleIds.HAPPY_TWINS);
+}
+
+export function isEncircleThreeMissingOneRule(rule) {
+ return ruleIncludesId(rule, RuleIds.ENCIRCLE_THREE_MISSING_ONE);
+}
+
+export function isThreeSixNineGradesRule(rule) {
+ return ruleIncludesId(rule, RuleIds.THREE_SIX_NINE_GRADES);
+}
+
+export function isIronEvidenceRule(rule) {
+ return ruleIncludesId(rule, RuleIds.IRON_EVIDENCE);
+}
+
+export function isWaitingRabbitRule(rule) {
+ return ruleIncludesId(rule, RuleIds.WAITING_RABBIT);
+}
+
+export function isBurnTheBoatsRule(rule) {
+ return ruleIncludesId(rule, RuleIds.BURN_THE_BOATS);
+}
+
+export function isHiddenDragonInAbyssRule(rule) {
+ return ruleIncludesId(rule, RuleIds.HIDDEN_DRAGON_IN_ABYSS);
+}
+
+export function isAdministrativeReviewRule(rule) {
+ return ruleIncludesId(rule, RuleIds.ADMINISTRATIVE_REVIEW);
+}
+
+export function isPoliticalReviewRule(rule) {
+ return ruleIncludesId(rule, RuleIds.POLITICAL_REVIEW);
+}
+
+export function isNoOneSurvivesRule(rule) {
+ return ruleIncludesId(rule, RuleIds.NO_ONE_SURVIVES);
+}
+
+export function isLureTigerFromMountainRule(rule) {
+ return ruleIncludesId(rule, RuleIds.LURE_TIGER_FROM_MOUNTAIN);
+}
+
+export function isDefenseAsOffenseRule(rule) {
+ return ruleIncludesId(rule, RuleIds.DEFENSE_AS_OFFENSE);
+}
+
+export function isAntinomyRule(rule) {
+ return ruleIncludesId(rule, RuleIds.ANTINOMY);
+}
+
+export function isChangeRiceToMulberryRule(rule) {
+ return ruleIncludesId(rule, RuleIds.CHANGE_RICE_TO_MULBERRY);
+}
+
+export function isDestroyDykeFloodFieldsRule(rule) {
+ return ruleIncludesId(rule, RuleIds.DESTROY_DYKE_FLOOD_FIELDS);
+}
+
+export function isRecordOnFileRule(rule) {
+ return ruleIncludesId(rule, RuleIds.RECORD_ON_FILE);
+}
+
+export function isWeighingThousandJinRule(rule) {
+ return ruleIncludesId(rule, RuleIds.WEIGHING_THOUSAND_JIN);
+}
+
+export function isKingOverWhiteRule(rule) {
+ return ruleIncludesId(rule, RuleIds.KING_OVER_WHITE);
+}
+
+export function isFearOfBreakingVaseRule(rule) {
+ return ruleIncludesId(rule, RuleIds.FEAR_OF_BREAKING_VASE);
+}
+
+export function isEightKingsCouncilRule(rule) {
+ return ruleIncludesId(rule, RuleIds.EIGHT_KINGS_COUNCIL);
+}
+
+export function isNinePrincesSuccessionRule(rule) {
+ return ruleIncludesId(rule, RuleIds.NINE_PRINCES_SUCCESSION);
+}
+
+export function getOddEvenRoundMultiplier(rule, roundNumber) {
+ if (!isOddEvenScoringRule(rule)) return 1;
+ return Number(roundNumber) % 2 === 0 ? 2 : 0;
+}
+
+/**
+ * 返回开局换牌目标相对当前玩家的座位偏移;非换牌规则返回 null。
+ * 默认座次索引递增方向是出牌方向,因此 +1 为下家、-1 为上家。
+ */
+export function getOpeningCardExchangeOffset(rule) {
+ const rules = Array.isArray(rule?.rules) ? rule.rules : [rule];
+ const offsets = rules
+ .map(candidate => RULES_BY_ID.get(candidate?.id)?.openingCardExchangeOffset)
+ .filter(Number.isInteger);
+ return offsets.length === 1 ? offsets[0] : null;
+}
+
+export function isOpeningCardExchangeRule(rule) {
+ return getOpeningCardExchangeOffset(rule) !== null;
+}
+
+export function isPerfectStrategyRule(rule) {
+ return ruleIncludesId(rule, RuleIds.PERFECT_STRATEGY);
+}
+
+export function isIcebergTipRule(rule) {
+ return ruleIncludesId(rule, RuleIds.ICEBERG_TIP);
+}
+
+export function isMutualVisibilityRule(rule) {
+ return ruleIncludesId(rule, RuleIds.MUTUAL_VISIBILITY);
+}
+
+export function isDoubleHappinessRule(rule) {
+ return ruleIncludesId(rule, RuleIds.DOUBLE_HAPPINESS);
+}
+
+export function isOpenAndHonestRule(rule) {
+ return ruleIncludesId(rule, RuleIds.OPEN_AND_HONEST);
+}
+
+export function isTenSidedAmbushRule(rule) {
+ return ruleIncludesId(rule, RuleIds.TEN_SIDED_AMBUSH);
+}
+
+export function isIrresistibleForceRule(rule) {
+ return ruleIncludesId(rule, RuleIds.IRRESISTIBLE_FORCE);
+}
+
+export function isSingleStepDebugRule(rule) {
+ return ruleIncludesId(rule, RuleIds.SINGLE_STEP_DEBUG);
+}
+
+export function isReformAndOpeningUpRule(rule) {
+ return ruleIncludesId(rule, RuleIds.REFORM_AND_OPENING_UP);
+}
+
+export function isSubstituteSacrificeRule(rule) {
+ return ruleIncludesId(rule, RuleIds.SUBSTITUTE_SACRIFICE);
+}
+
+export function getActiveSkillForRule(rule) {
+ const rules = Array.isArray(rule?.rules) ? rule.rules : [rule];
+ const activeSkills = rules
+ .map(candidate => RULES_BY_ID.get(candidate?.id)?.activeSkill)
+ .filter(Boolean);
+ const activeSkill = activeSkills.length === 1 ? activeSkills[0] : null;
+ return activeSkill ? { ...activeSkill } : null;
+}
+
+export function isSixSixGreatSuccessRule(rule) {
+ return ruleIncludesId(rule, RuleIds.SIX_SIX_GREAT_SUCCESS);
+}
+
+export function isTaiChiFourSymbolsRule(rule) {
+ return ruleIncludesId(rule, RuleIds.TAI_CHI_FOUR_SYMBOLS);
+}
+
+export function isOneHorseLeadsRule(rule) {
+ return ruleIncludesId(rule, RuleIds.ONE_HORSE_LEADS);
+}
+
+export function isHeavyFogRule(rule) {
+ return ruleIncludesId(rule, RuleIds.HEAVY_FOG);
+}
+
+export function isConcealedPassageRule(rule) {
+ return ruleIncludesId(rule, RuleIds.CONCEALED_PASSAGE);
+}
+
+export function isFatalBeautyRule(rule) {
+ return ruleIncludesId(rule, RuleIds.FATAL_BEAUTY);
+}
+
+export function isStealingBeamsRule(rule) {
+ return ruleIncludesId(rule, RuleIds.STEALING_BEAMS);
+}
+
+export function isCosmicShiftRule(rule) {
+ return ruleIncludesId(rule, RuleIds.COSMIC_SHIFT);
+}
+
+export function isLastStandRule(rule) {
+ return ruleIncludesId(rule, RuleIds.LAST_STAND);
+}
+
+export function isLateMoverAdvantageRule(rule) {
+ return ruleIncludesId(rule, RuleIds.LATE_MOVER_ADVANTAGE);
+}
+
+export function isGoWithTheFlowRule(rule) {
+ return ruleIncludesId(rule, RuleIds.GO_WITH_THE_FLOW);
+}
+
+export function isFrequentFluctuationRule(rule) {
+ return ruleIncludesId(rule, RuleIds.FREQUENT_FLUCTUATION);
+}
+
+export function isMinorDisturbanceRule(rule) {
+ return ruleIncludesId(rule, RuleIds.MINOR_DISTURBANCE);
+}
+
+export function isLingeringDiscardRule(rule) {
+ return ruleIncludesId(rule, RuleIds.LINGERING_DISCARD);
+}
+
+export function isTimeReversalRule(rule) {
+ return ruleIncludesId(rule, RuleIds.TIME_REVERSAL);
+}
+
+export function isRouteSwingRule(rule) {
+ return ruleIncludesId(rule, RuleIds.ROUTE_SWING);
+}
+
+export function isBeltAndRoadRule(rule) {
+ return ruleIncludesId(rule, RuleIds.BELT_AND_ROAD);
+}
+
+export function isDayNightRotationRule(rule) {
+ return ruleIncludesId(rule, RuleIds.DAY_NIGHT_ROTATION);
+}
+
+export function isRespectEldersAndChildrenRule(rule) {
+ return ruleIncludesId(rule, RuleIds.RESPECT_ELDERS_AND_CHILDREN);
+}
+
+export function isStriveUpstreamRule(rule) {
+ return ruleIncludesId(rule, RuleIds.STRIVE_UPSTREAM);
+}
+
+export function isAfterglowRule(rule) {
+ return ruleIncludesId(rule, RuleIds.AFTERGLOW);
+}
+
+export function isRitesCollapseRule(rule) {
+ return ruleIncludesId(rule, RuleIds.RITES_COLLAPSE);
+}
+
+export function isRecommendTalentRule(rule) {
+ return ruleIncludesId(rule, RuleIds.RECOMMEND_TALENT);
+}
+
+export function isAccidentInsuranceRule(rule) {
+ return ruleIncludesId(rule, RuleIds.ACCIDENT_INSURANCE);
+}
+
+export function isThreePowersRule(rule) {
+ return ruleIncludesId(rule, RuleIds.THREE_POWERS);
+}
+
+export function isGentlemanPromiseRule(rule) {
+ return ruleIncludesId(rule, RuleIds.GENTLEMAN_PROMISE);
+}
+
+export function isRepeatedExhaustionRule(rule) {
+ return ruleIncludesId(rule, RuleIds.REPEATED_EXHAUSTION);
+}
+
+export function isFocusFigureRule(rule) {
+ return ruleIncludesId(rule, RuleIds.FOCUS_FIGURE);
+}
+
+export function isCooldownTimeRule(rule) {
+ return ruleIncludesId(rule, RuleIds.COOLDOWN_TIME);
+}
+
+export function isTimeCoolingRule(rule) {
+ return ruleIncludesId(rule, RuleIds.TIME_COOLING);
+}
+
+export function isPlannedEconomyRule(rule) {
+ return ruleIncludesId(rule, RuleIds.PLANNED_ECONOMY);
+}
+
+const DAY_NIGHT_RANKS = Object.freeze([
+ 'A', '2', '3', '4', '5', '6', '7', '8', '9', '10', 'J', 'Q', 'K'
+]);
+
+/** 第1轮从2开始,第13轮轮到A。 */
+export function getDayNightRotatingRank(roundNumber) {
+ if (!Number.isInteger(roundNumber) || roundNumber < 1) return null;
+ return DAY_NIGHT_RANKS[roundNumber % DAY_NIGHT_RANKS.length];
+}
+
+/** 轮转到级牌时不提升任何牌,因此当轮仍由A作为普通牌最大点数。 */
+export function getDayNightHighestRank(roundNumber, trumpRank) {
+ const rotatingRank = getDayNightRotatingRank(roundNumber);
+ if (!rotatingRank) return null;
+ return String(rotatingRank) === String(trumpRank) ? 'A' : rotatingRank;
+}
diff --git a/tractor-game-simulator/server/src/services/BotService.js b/tractor-game-simulator/server/src/services/BotService.js
index aeaf25e..439a341 100644
--- a/tractor-game-simulator/server/src/services/BotService.js
+++ b/tractor-game-simulator/server/src/services/BotService.js
@@ -1,8 +1,24 @@
import { spawn } from 'child_process';
+import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
import logger from '../utils/logger.js';
import { BotTypes } from '../utils/constants.js';
+import {
+ getCardStrength,
+ getEffectiveSuit,
+ isTrumpCard,
+ validateFollowingPlay,
+ validateLeadingPlay
+} from '../utils/cardPatternUtils.js';
+import {
+ isOneCountryTwoSystemsRule,
+ isAfterglowRule,
+ isRitesCollapseRule,
+ isSingleStepDebugRule
+} from '../rules/ruleRegistry.js';
+import { getRulePlayableCards } from '../utils/cardCooldownUtils.js';
+import { mapOneCountryCards } from '../utils/oneCountryTwoSystemsUtils.js';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
@@ -11,6 +27,15 @@ const __dirname = path.dirname(__filename);
* 卡牌格式转换工具
*/
class CardConverter {
+ static supportedRanks = new Set([
+ '2', '3', '4', '5', '6', '7', '8', '9', '10',
+ 'J', 'Q', 'K', 'A', 'small_joker', 'big_joker'
+ ]);
+
+ static isSupportedCard(card) {
+ return Boolean(card && this.supportedRanks.has(card.rank));
+ }
+
/**
* 将项目卡牌格式转换为bot需要的格式
* 项目格式: {suit: 'hearts', rank: 'A', id: 'hearts-A-0'}
@@ -112,7 +137,7 @@ export class BotService {
logger.info('使用简化版Bot(不依赖任何Python包)');
} else if (selectedBotType === BotTypes.WHO_DESIGNED) {
// 使用WhoDesigned bot
- this.botScriptPath = path.resolve(__dirname, '../../../../WhoDesigned/__main__.py');
+ this.botScriptPath = path.resolve(__dirname, '../../../../simple-bot/who_designed_adapter.py');
logger.info('使用WhoDesigned Bot');
} else {
// 默认使用简化版bot
@@ -131,10 +156,33 @@ export class BotService {
* @param {Object} room - 房间对象(用于获取所有玩家信息)
* @returns {Promise
} - bot返回的卡牌ID数组
*/
- async getBotAction(gameState, playerCards, playerIndex, room) {
+ async getBotAction(gameState, playerCards, playerIndex, room, externalPlayableCards = []) {
try {
+ const physicalDecisionCards = getRulePlayableCards({
+ gameState,
+ playerId: room?.players?.[playerIndex]?.id,
+ playerCards,
+ requiredCount: gameState.leadingPattern?.length || 1,
+ isLeading: !gameState.leadingPattern
+ });
+ const decisionCards = [...physicalDecisionCards, ...externalPlayableCards];
+ const visibleCards = [
+ ...decisionCards,
+ ...(gameState.currentRoundPlays || []).flatMap(play => play.cards || []),
+ ...(gameState.playHistory || []).flatMap(play => play.cards || [])
+ ];
+ if (visibleCards.some(card => !this.converter.isSupportedCard(card))) {
+ logger.info('当前牌局含外部 Bot 不认识的扩展牌面,改用内置合法出牌策略');
+ return this.getFallbackAction(
+ gameState,
+ playerCards,
+ room?.players?.[playerIndex]?.id,
+ playerIndex,
+ externalPlayableCards
+ );
+ }
// 构建bot需要的输入格式
- const botInput = this._buildBotInput(gameState, playerCards, playerIndex, room);
+ const botInput = this._buildBotInput(gameState, decisionCards, playerIndex, room);
logger.info(`Bot输入数据: ${JSON.stringify(botInput)}`);
@@ -144,7 +192,45 @@ export class BotService {
logger.info(`Bot响应数据: ${JSON.stringify(botResponse)}`);
// 解析bot返回的卡牌,转换为项目格式的卡牌ID
- return this._parseBotResponse(botResponse, playerCards);
+ const action = this._parseBotResponse(botResponse, decisionCards);
+ if (isRitesCollapseRule(gameState.selectedRule) && !gameState.leadingPattern) {
+ const actionIds = new Set(action);
+ const selectedCards = decisionCards.filter(card => actionIds.has(card.id));
+ if (
+ selectedCards.some(card => card.rank === 'A')
+ && decisionCards.some(card => card.rank !== 'A')
+ ) {
+ logger.info('礼崩乐坏规则中 Bot 尝试首发A,改为合法的非A单张首发');
+ return this.getFallbackAction(
+ gameState,
+ playerCards,
+ room?.players?.[playerIndex]?.id,
+ playerIndex,
+ externalPlayableCards
+ );
+ }
+ }
+ if (isSingleStepDebugRule(gameState.selectedRule) && !gameState.leadingPattern) {
+ const actionIds = new Set(action);
+ const selectedCards = playerCards.filter(card => actionIds.has(card.id));
+ const validation = validateLeadingPlay(
+ selectedCards,
+ gameState.trumpSuit,
+ gameState.trumpRank,
+ { ...gameState.selectedRule, currentRound: gameState.currentRound }
+ );
+ if (!validation.valid) {
+ logger.info('单步调试规则下 Bot 尝试甩牌,改为合法的单张首发');
+ return this.getFallbackAction(
+ gameState,
+ playerCards,
+ room?.players?.[playerIndex]?.id,
+ playerIndex,
+ externalPlayableCards
+ );
+ }
+ }
+ return action;
} catch (error) {
logger.error('Bot决策失败:', error);
@@ -168,14 +254,29 @@ export class BotService {
// 构建已出牌记录(played)
const played = this._buildPlayed(gameState, room);
+ const emptySuits = this._buildEmptySuits(gameState, room);
return {
id: playerIndex,
deck,
history,
major,
- played
+ played,
+ emptySuits,
+ level: gameState.trumpRank,
+ trumpSuit: this._toBotSuit(gameState.trumpSuit)
+ };
+ }
+
+ _toBotSuit(suit) {
+ const suitMap = {
+ hearts: 'h',
+ diamonds: 'd',
+ clubs: 'c',
+ spades: 's',
+ no_trump: 'n'
};
+ return suitMap[suit] || 'n';
}
/**
@@ -183,22 +284,16 @@ export class BotService {
*/
_buildHistory(gameState, room) {
// 对于自由出牌模式,返回最近的几次出牌记录
- if (!gameState.playHistory || gameState.playHistory.length === 0) {
+ if (!gameState.currentRoundPlays || gameState.currentRoundPlays.length === 0) {
return [];
}
// 获取最近的出牌记录(最多10条)
- const recentHistory = [];
- const maxRecords = 10;
- const startIndex = Math.max(0, gameState.playHistory.length - maxRecords);
-
- for (let i = startIndex; i < gameState.playHistory.length; i++) {
- const record = gameState.playHistory[i];
- const cards = this.converter.cardsArrayToBotFormat(record.cards);
- recentHistory.push(cards);
- }
-
- return recentHistory;
+ const plays = gameState.currentRoundPlays.map(play => ({
+ playerIndex: play.playerIndex,
+ cards: this.converter.cardsArrayToBotFormat(play.cards)
+ }));
+ return this.botType === BotTypes.SIMPLE ? plays.map(play => play.cards) : plays;
}
/**
@@ -242,6 +337,48 @@ export class BotService {
return played;
}
+ /**
+ * 复原 WhoDesigned 构造器原本会从完整墩历史推断的“缺门”信息。
+ * playHistory 按实际出牌顺序写入;普通四人牌局中每四条是一整墩。
+ */
+ _buildEmptySuits(gameState, room) {
+ const emptySuits = [[], [], [], []];
+ const records = gameState.playHistory || [];
+ const playerCount = room?.players?.length || 4;
+ if (playerCount !== 4) return emptySuits;
+
+ for (let offset = 0; offset + playerCount <= records.length; offset += playerCount) {
+ const trick = records.slice(offset, offset + playerCount);
+ const leadCard = trick[0]?.cards?.[0];
+ if (!leadCard) continue;
+
+ const leadingSuit = getEffectiveSuit(
+ leadCard,
+ gameState.trumpSuit,
+ gameState.trumpRank
+ );
+ const botSuit = leadingSuit === 'trump'
+ ? this._toBotSuit(gameState.trumpSuit)
+ : this._toBotSuit(leadingSuit);
+
+ trick.slice(1).forEach(record => {
+ const playerIndex = Number.isInteger(record.playerIndex)
+ ? record.playerIndex
+ : room.getPlayerIndex(record.playerId);
+ if (playerIndex < 0 || playerIndex >= 4 || !Array.isArray(record.cards)) return;
+
+ const exhaustedLedSuit = record.cards.some(card => (
+ getEffectiveSuit(card, gameState.trumpSuit, gameState.trumpRank) !== leadingSuit
+ ));
+ if (exhaustedLedSuit && !emptySuits[playerIndex].includes(botSuit)) {
+ emptySuits[playerIndex].push(botSuit);
+ }
+ });
+ }
+
+ return emptySuits;
+ }
+
/**
* 调用Python bot脚本
*/
@@ -250,10 +387,22 @@ export class BotService {
logger.info(`调用Bot脚本: ${this.botScriptPath}`);
logger.info(`Bot输入: ${JSON.stringify(input)}`);
- const pythonProcess = spawn('python3', [this.botScriptPath]);
+ const pythonCommand = process.env.PYTHON_BIN || (process.platform === 'win32' ? 'python' : 'python3');
+ const pythonProcess = spawn(pythonCommand, [this.botScriptPath], {
+ env: { ...process.env, PYTHONIOENCODING: 'utf-8' }
+ });
let output = '';
let errorOutput = '';
+ let settled = false;
+ let timeout;
+
+ const finish = (callback, value) => {
+ if (settled) return;
+ settled = true;
+ if (timeout) clearTimeout(timeout);
+ callback(value);
+ };
// 发送输入数据
pythonProcess.stdin.write(JSON.stringify(input));
@@ -279,7 +428,7 @@ export class BotService {
if (code !== 0) {
const errorMsg = `Bot进程退出,代码: ${code}, 错误: ${errorOutput}`;
logger.error(errorMsg);
- reject(new Error(errorMsg));
+ finish(reject, new Error(errorMsg));
return;
}
@@ -287,23 +436,227 @@ export class BotService {
logger.info(`Bot原始输出: ${output}`);
const result = JSON.parse(output);
logger.info(`Bot解析后结果: ${JSON.stringify(result)}`);
- resolve(result);
+ finish(resolve, result);
} catch (error) {
const errorMsg = `解析bot输出失败: ${error.message}, 输出: ${output}`;
logger.error(errorMsg);
- reject(new Error(errorMsg));
+ finish(reject, new Error(errorMsg));
}
});
+ pythonProcess.on('error', (error) => {
+ finish(reject, new Error(`无法启动 ${pythonCommand}: ${error.message}`));
+ });
+
// 超时处理
- setTimeout(() => {
+ timeout = setTimeout(() => {
logger.error('Bot响应超时,强制结束进程');
pythonProcess.kill();
- reject(new Error('Bot响应超时'));
+ finish(reject, new Error('Bot响应超时'));
}, 30000); // 30秒超时
});
}
+ /** Generate a deterministic legal play when an external bot fails. */
+ getFallbackAction(
+ gameState,
+ playerCards,
+ playerId = null,
+ playerIndex = null,
+ externalPlayableCards = []
+ ) {
+ const leadingPattern = gameState.leadingPattern;
+ const trumpSuit = gameState.trumpSuit;
+ const trumpRank = gameState.trumpRank;
+ const activeRule = gameState.selectedRule
+ ? {
+ ...gameState.selectedRule,
+ currentRound: gameState.currentRound,
+ antinomySplitFaceKeys: Array.from(
+ gameState.antinomyDeclarationsByPlayerId?.values?.() || []
+ )
+ .filter(declaration => declaration?.effective)
+ .map(declaration => declaration.faceKey)
+ }
+ : null;
+ const playableCards = getRulePlayableCards({
+ gameState,
+ playerId,
+ playerCards,
+ requiredCount: leadingPattern?.length || 1,
+ isLeading: !leadingPattern
+ });
+ const effectivePlayableCards = isOneCountryTwoSystemsRule(gameState.selectedRule)
+ ? mapOneCountryCards(playableCards, playerIndex, gameState.oneCountryResolved)
+ : playableCards;
+ const effectiveExternalCards = isOneCountryTwoSystemsRule(gameState.selectedRule)
+ ? mapOneCountryCards(externalPlayableCards, playerIndex, gameState.oneCountryResolved)
+ : externalPlayableCards;
+ const sorted = [...effectivePlayableCards, ...effectiveExternalCards].sort((a, b) =>
+ getCardStrength(a, trumpSuit, trumpRank, activeRule) -
+ getCardStrength(b, trumpSuit, trumpRank, activeRule)
+ );
+ const afterglowActive = Boolean(
+ isAfterglowRule(gameState.selectedRule)
+ && gameState.afterglowActivePlayerIds?.has(playerId)
+ );
+ const afterglowTrumps = afterglowActive
+ ? sorted.filter(card => isTrumpCard(card, trumpSuit, trumpRank))
+ : [];
+
+ if (afterglowTrumps.length > 0) {
+ if (!leadingPattern) return [afterglowTrumps[0].id];
+ const required = leadingPattern.length;
+ return afterglowTrumps.slice(0, required).map(card => card.id);
+ }
+
+ if (!leadingPattern) {
+ const legalLeadingCards = isRitesCollapseRule(gameState.selectedRule)
+ ? sorted.filter(card => card.rank !== 'A')
+ : sorted;
+ const fallbackCards = legalLeadingCards.length > 0 ? legalLeadingCards : sorted;
+ return fallbackCards.length > 0 ? [fallbackCards[0].id] : [];
+ }
+
+ const required = leadingPattern.length;
+
+ // 木牛流马中的牌可选,但不能被当成实体手牌来推导跟牌义务。
+ if (effectiveExternalCards.length > 0) {
+ let checked = 0;
+ const limit = 50000;
+ const chosen = [];
+ const search = start => {
+ if (checked >= limit) return null;
+ if (chosen.length === required) {
+ checked += 1;
+ const candidate = chosen.map(index => sorted[index]);
+ return validateFollowingPlay(
+ candidate,
+ effectivePlayableCards,
+ leadingPattern,
+ trumpSuit,
+ trumpRank,
+ activeRule
+ ).valid ? candidate : null;
+ }
+ for (let index = start; index <= sorted.length - (required - chosen.length); index += 1) {
+ chosen.push(index);
+ const result = search(index + 1);
+ chosen.pop();
+ if (result) return result;
+ }
+ return null;
+ };
+ return (search(0) || sorted.slice(0, required)).map(card => card.id);
+ }
+
+ if (leadingPattern.type === 'tai_chi_four_symbols') {
+ let checked = 0;
+ const limit = 50000;
+ const chosen = [];
+ const searchAllCards = (start) => {
+ if (checked >= limit) return null;
+ if (chosen.length === required) {
+ checked++;
+ const candidate = chosen.map(index => sorted[index]);
+ return validateFollowingPlay(
+ candidate,
+ effectivePlayableCards,
+ leadingPattern,
+ trumpSuit,
+ trumpRank,
+ activeRule
+ ).valid ? candidate : null;
+ }
+
+ for (let index = start; index <= sorted.length - (required - chosen.length); index++) {
+ chosen.push(index);
+ const result = searchAllCards(index + 1);
+ chosen.pop();
+ if (result) return result;
+ }
+ return null;
+ };
+
+ return (searchAllCards(0) || sorted.slice(0, required)).map(card => card.id);
+ }
+
+ const sameSuit = sorted.filter(card =>
+ getEffectiveSuit(card, trumpSuit, trumpRank) === leadingPattern.suit
+ );
+ const otherSuit = sorted.filter(card =>
+ getEffectiveSuit(card, trumpSuit, trumpRank) !== leadingPattern.suit
+ );
+
+ if (sameSuit.length <= required) {
+ return [...sameSuit, ...otherSuit.slice(0, required - sameSuit.length)].map(card => card.id);
+ }
+
+ const direct = sameSuit.slice(0, required);
+ if (validateFollowingPlay(
+ direct,
+ effectivePlayableCards,
+ leadingPattern,
+ trumpSuit,
+ trumpRank,
+ activeRule
+ ).valid) {
+ return direct.map(card => card.id);
+ }
+
+ let checked = 0;
+ const limit = 50000;
+ const chosen = [];
+ const search = (start) => {
+ if (checked >= limit) return null;
+ if (chosen.length === required) {
+ checked++;
+ const candidate = chosen.map(index => sameSuit[index]);
+ return validateFollowingPlay(
+ candidate,
+ effectivePlayableCards,
+ leadingPattern,
+ trumpSuit,
+ trumpRank,
+ activeRule
+ ).valid ? candidate : null;
+ }
+
+ for (let index = start; index <= sameSuit.length - (required - chosen.length); index++) {
+ chosen.push(index);
+ const result = search(index + 1);
+ chosen.pop();
+ if (result) return result;
+ }
+ return null;
+ };
+
+ return (search(0) || direct).map(card => card.id);
+ }
+
+ /** “梦中杀人”按首家张数盲抽手牌,不检查花色、牌型或跟牌义务。 */
+ getRandomAction(gameState, playerCards, random = Math.random) {
+ if (playerCards.length === 0) return [];
+ const required = Math.min(
+ gameState.leadingPattern?.length || 1,
+ playerCards.length
+ );
+ const shuffled = [...playerCards];
+ const randomIndex = upperBound => {
+ const sample = Number(random());
+ const bounded = Number.isFinite(sample)
+ ? Math.min(0.999999999, Math.max(0, sample))
+ : 0;
+ return Math.floor(bounded * upperBound);
+ };
+
+ for (let index = 0; index < required; index += 1) {
+ const swapIndex = index + randomIndex(shuffled.length - index);
+ [shuffled[index], shuffled[swapIndex]] = [shuffled[swapIndex], shuffled[index]];
+ }
+ return shuffled.slice(0, required).map(card => card.id);
+ }
+
/**
* 解析bot响应,转换为项目卡牌ID
*/
@@ -340,9 +693,9 @@ export class BotService {
*/
async checkBotAvailability() {
try {
- const mainFilePath = path.join(this.botScriptPath, '__main__.py');
- // 可以在这里添加文件存在性检查
- return true;
+ return fs.existsSync(this.botScriptPath) &&
+ (this.botType !== BotTypes.WHO_DESIGNED ||
+ fs.existsSync(path.resolve(__dirname, '../../../../WhoDesigned/mvGen.py')));
} catch (error) {
logger.error('Bot不可用:', error);
return false;
diff --git a/tractor-game-simulator/server/src/services/DeckService.js b/tractor-game-simulator/server/src/services/DeckService.js
index 74860e2..96eeb39 100644
--- a/tractor-game-simulator/server/src/services/DeckService.js
+++ b/tractor-game-simulator/server/src/services/DeckService.js
@@ -32,6 +32,19 @@ export class DeckService {
return deck; // 总共108张
}
+ /**
+ * “八王议政”:在标准两副牌中额外加入两张郡王和两张亲王。
+ * 两种扩展王各自沿用 0/1 的副本编号,保证实体牌 ID 唯一且稳定。
+ */
+ static addEightKingsCouncilCards(deck) {
+ const expandedDeck = [...deck];
+ for (let copyIndex = 0; copyIndex < 2; copyIndex++) {
+ expandedDeck.push(new Card(Suits.JOKER, Ranks.COUNTY_PRINCE_JOKER, copyIndex));
+ expandedDeck.push(new Card(Suits.JOKER, Ranks.PRINCE_JOKER, copyIndex));
+ }
+ return expandedDeck;
+ }
+
/**
* Fisher-Yates 洗牌算法
*/
@@ -44,6 +57,49 @@ export class DeckService {
return shuffled;
}
+ static transformSpadesToHearts(deck) {
+ return deck.map(card => {
+ if (card.suit === Suits.SPADES) {
+ card.originalSuit = Suits.SPADES;
+ card.suit = Suits.HEARTS;
+ card.value = card.calculateValue();
+ }
+ return card;
+ });
+ }
+
+ /**
+ * “王上加白”:从当前牌堆的四张普通王中随机选择一张,永久改为白王。
+ * 保留实体牌 ID,避免同一张牌在发牌、换牌和出牌记录中的身份发生变化。
+ */
+ static transformRandomJokerToWhite(deck, random = Math.random) {
+ const ordinaryJokers = deck.filter(card => (
+ card.suit === Suits.JOKER
+ && [Ranks.SMALL_JOKER, Ranks.BIG_JOKER].includes(card.rank)
+ ));
+ if (ordinaryJokers.length === 0) return deck;
+
+ const sample = Number(random());
+ const boundedSample = Number.isFinite(sample)
+ ? Math.min(0.999999999, Math.max(0, sample))
+ : 0;
+ const selectedJoker = ordinaryJokers[
+ Math.floor(boundedSample * ordinaryJokers.length)
+ ];
+ selectedJoker.rank = Ranks.WHITE_JOKER;
+ selectedJoker.value = selectedJoker.calculateValue();
+ return deck;
+ }
+
+ static prepareUnarmedDeck(deck) {
+ return deck
+ .filter(card => card.suit !== Suits.JOKER)
+ .map(card => {
+ card.isUnarmed = true;
+ return card;
+ });
+ }
+
/**
* 准备发牌:分离底牌和剩余牌堆
*/
diff --git a/tractor-game-simulator/server/src/services/DrawingPhaseManager.js b/tractor-game-simulator/server/src/services/DrawingPhaseManager.js
index 60c60d8..379ea1a 100644
--- a/tractor-game-simulator/server/src/services/DrawingPhaseManager.js
+++ b/tractor-game-simulator/server/src/services/DrawingPhaseManager.js
@@ -1,11 +1,45 @@
import { GamePhases } from '../utils/constants.js';
import { DeckService } from './DeckService.js';
+import {
+ isAdministrativeReviewRule,
+ isEightKingsCouncilRule,
+ isFatalBeautyRule,
+ isHeavyFogRule,
+ isKingOverWhiteRule,
+ isLastStandRule,
+ isOneCountryTwoSystemsRule,
+ isOpenlyRevealedRule,
+ isPeopleCommuneRule,
+ isPlannedEconomyRule,
+ isThreeSixNineGradesRule,
+ isUnarmedRule
+} from '../rules/ruleRegistry.js';
+import {
+ getOneCountryPublicState,
+ resolveOneCountryTwoSystems
+} from '../utils/oneCountryTwoSystemsUtils.js';
import logger from '../utils/logger.js';
+const MISTY_FOG_CARD_COUNT = 8;
+const PLANNED_ECONOMY_RESERVE_COUNT = 20;
+
export class DrawingPhaseManager {
- constructor(room, io) {
+ constructor(
+ room,
+ io,
+ onDealerAssigned = null,
+ onDrawingComplete = null,
+ onDealerSelected = null,
+ random = Math.random,
+ onCardDealt = null
+ ) {
this.room = room;
this.io = io;
+ this.onDealerAssigned = onDealerAssigned;
+ this.onDrawingComplete = onDrawingComplete;
+ this.onDealerSelected = onDealerSelected;
+ this.random = random;
+ this.onCardDealt = onCardDealt;
this.timer = null;
this.dealerTimer = null; // 指定庄家的定时器
}
@@ -14,20 +48,55 @@ export class DrawingPhaseManager {
* 开始摸牌阶段
*/
start() {
- const { bottomCardsCount, dealInterval } = this.room.config;
+ const { dealInterval } = this.room.config;
+ const { bottomCardsCount } = this.room.gameState;
const playerCount = this.room.players.length;
logger.info(`房间 ${this.room.id} 开始摸牌阶段`);
// 创建并洗牌
- const deck = DeckService.shuffle(DeckService.createDeck());
+ let deck = DeckService.createDeck();
+ if (isEightKingsCouncilRule(this.room.gameState.selectedRule)) {
+ deck = DeckService.addEightKingsCouncilCards(deck);
+ }
+ deck = DeckService.shuffle(deck);
+ if (isKingOverWhiteRule(this.room.gameState.selectedRule)) {
+ deck = DeckService.transformRandomJokerToWhite(deck, this.random);
+ }
+ const isUnarmed = isUnarmedRule(this.room.gameState.selectedRule);
+ if (isUnarmed) {
+ deck = DeckService.prepareUnarmedDeck(deck);
+ }
+ if (isFatalBeautyRule(this.room.gameState.selectedRule)) {
+ deck = DeckService.transformSpadesToHearts(deck);
+ }
+
+ // 迷雾牌在洗牌后立即从牌堆顶暗中移除,不属于底牌,也不交给任何玩家。
+ const mistyFogCardCount = isHeavyFogRule(this.room.gameState.selectedRule)
+ ? MISTY_FOG_CARD_COUNT
+ : 0;
+ this.room.gameState.mistyFogCards = deck.slice(0, mistyFogCardCount);
+ const playableDeck = deck.slice(mistyFogCardCount);
// 分离底牌和发牌堆
- const { bottomCards, remainingDeck } = DeckService.prepareDeal(deck, bottomCardsCount);
+ const { bottomCards, remainingDeck: postBottomDeck } = DeckService.prepareDeal(
+ playableDeck,
+ bottomCardsCount
+ );
+ const plannedEconomyReserveCount = isPlannedEconomyRule(this.room.gameState.selectedRule)
+ ? PLANNED_ECONOMY_RESERVE_COUNT
+ : 0;
+ const reserveStartIndex = Math.max(0, postBottomDeck.length - plannedEconomyReserveCount);
+ const remainingDeck = postBottomDeck.slice(0, reserveStartIndex);
+ this.room.gameState.plannedEconomyReserveCards = postBottomDeck.slice(reserveStartIndex);
+ this.room.gameState.plannedEconomyDrawRounds = 0;
this.room.gameState.bottomCards = bottomCards;
this.room.gameState.deck = remainingDeck;
this.room.gameState.phase = GamePhases.DRAWING;
+ this.room.gameState.postDrawStage = 'dealing';
+ this.room.gameState.isTrumpDeclarationLocked = false;
+ this.room.gameState.pendingDealerPlayerId = null;
this.room.gameState.drawingIndex = 0;
this.room.gameState.startTime = new Date();
@@ -39,7 +108,12 @@ export class DrawingPhaseManager {
this.io.to(this.room.id).emit('drawing_started', {
totalCards: remainingDeck.length,
bottomCardsCount,
- dealInterval
+ removedCardsCount: mistyFogCardCount + (isUnarmed ? 4 : 0),
+ reservedCardsCount: this.room.gameState.plannedEconomyReserveCards.length,
+ dealInterval,
+ ...(isOpenlyRevealedRule(this.room.gameState.selectedRule) ? {
+ publicBottomCards: bottomCards.map(card => card.toJSON())
+ } : {})
});
// 定时发牌
@@ -93,11 +167,16 @@ export class DrawingPhaseManager {
totalCards: player.cards.length
});
+ if (this.onCardDealt) {
+ this.onCardDealt(player, card);
+ }
+
// 广播发牌进度(不包含具体牌)
this.io.to(this.room.id).emit('deal_progress', {
playerIndex,
playerId: player.id,
playerName: player.name,
+ cardsCount: player.cards.length,
current: drawingIndex + 1,
total: deck.length
});
@@ -111,21 +190,31 @@ export class DrawingPhaseManager {
finish() {
this.stop();
- logger.info(`房间 ${this.room.id} 发牌完成,10秒后自动指定庄家`);
+ logger.info(`房间 ${this.room.id} 发牌完成,进入亮主/反主确认阶段`);
// 广播发牌完成
this.io.to(this.room.id).emit('drawing_complete', {
- message: '发牌完成,10秒后自动指定庄家'
+ message: '发牌完成,亮主/反主结束后锁定庄家'
});
- // 开始庄家倒计时
- this.startDealerCountdown();
+ if (this.onDrawingComplete) {
+ this.onDrawingComplete();
+ } else {
+ // 兼容独立使用摸牌管理器的场景。
+ this.startDealerCountdown();
+ }
}
/**
* 开始/重置庄家倒计时(10秒)
*/
startDealerCountdown() {
+ if (this.room.gameState.isTrumpDeclarationLocked || this.room.gameState.cardExchange) {
+ logger.warn(`房间 ${this.room.id} 亮主阶段已锁定,忽略庄家倒计时请求`);
+ return false;
+ }
+
+ this.room.gameState.postDrawStage = 'trump_window';
// 清除之前的定时器
if (this.dealerTimer) {
clearTimeout(this.dealerTimer);
@@ -143,6 +232,7 @@ export class DrawingPhaseManager {
this.dealerTimer = setTimeout(() => {
this.assignDealer();
}, 10000);
+ return true;
}
/**
@@ -158,6 +248,23 @@ export class DrawingPhaseManager {
this.stopDealerTimer();
const { currentTrumpDeclaration, dealerPlayerIndex } = this.room.gameState;
+ if (
+ !currentTrumpDeclaration
+ && isLastStandRule(this.room.gameState.selectedRule)
+ ) {
+ const suits = ['hearts', 'diamonds', 'clubs', 'spades'];
+ const sample = Number(this.random());
+ const bounded = Number.isFinite(sample)
+ ? Math.min(0.999999999, Math.max(0, sample))
+ : Math.random();
+ this.room.gameState.trumpSuit = suits[Math.floor(bounded * suits.length)];
+ this.io.to(this.room.id).emit('trump_updated', {
+ trumpSuit: this.room.gameState.trumpSuit,
+ trumpRank: this.room.gameState.trumpRank,
+ systemSelected: true
+ });
+ logger.info(`房间 ${this.room.id} 绝处逢生:无人亮主,系统随机选择 ${this.room.gameState.trumpSuit} 为主花色`);
+ }
let dealer = null;
// 规则1:如果不是第一局,直接使用上一局计算的庄家索引
@@ -192,9 +299,8 @@ export class DrawingPhaseManager {
// 广播倒计时结束
this.io.to(this.room.id).emit('dealer_countdown_end');
-
- // 设置庄家
- this.room.gameState.buryingPlayerId = dealer.id;
+ this.room.gameState.isTrumpDeclarationLocked = true;
+ this.room.gameState.pendingDealerPlayerId = dealer.id;
// 如果是第一局(dealerPlayerIndex为null),同时设置dealerPlayerIndex
// 这样前端可以统一使用dealerPlayerIndex来判断庄家
@@ -204,6 +310,97 @@ export class DrawingPhaseManager {
logger.info(`房间 ${this.room.id} 第一局设置庄家索引: ${dealerIndex}`);
}
+ if (isOneCountryTwoSystemsRule(this.room.gameState.selectedRule)) {
+ const resolution = resolveOneCountryTwoSystems(
+ this.room.gameState.oneCountryDeclarationsByTeam,
+ this.room.gameState.dealerPlayerIndex
+ );
+ this.room.gameState.oneCountryResolved = resolution;
+ this.room.gameState.trumpSuit = resolution?.canonicalTrumpSuit || 'no_trump';
+ this.io.to(this.room.id).emit('trump_updated', {
+ trumpSuit: this.room.gameState.trumpSuit,
+ trumpRank: this.room.gameState.trumpRank,
+ oneCountryTwoSystems: getOneCountryPublicState(this.room.gameState)
+ });
+ logger.info(
+ `房间 ${this.room.id} 一国两制锁定:` +
+ `${resolution?.isNoTrump
+ ? '双方无主'
+ : resolution?.hasDistinctTeamSuits
+ ? `庄家方 ${resolution.dealerSuit},闲家方 ${resolution.attackerSuit}`
+ : `双方 ${resolution?.canonicalTrumpSuit}`}`
+ );
+ }
+
+ if (isThreeSixNineGradesRule(this.room.gameState.selectedRule)) {
+ const isJokerNoTrump = this.room.gameState.currentTrumpDeclaration?.suit === 'joker';
+ if (isJokerNoTrump) {
+ this.room.gameState.currentInferiorDeclaration = null;
+ this.room.gameState.inferiorSuit = null;
+ } else {
+ this.room.gameState.inferiorSuit =
+ this.room.gameState.currentInferiorDeclaration?.suit || null;
+ }
+ this.io.to(this.room.id).emit('three_six_nine_updated', {
+ locked: true,
+ trumpSuit: this.room.gameState.trumpSuit,
+ trumpRank: this.room.gameState.trumpRank,
+ inferiorSuit: this.room.gameState.inferiorSuit,
+ currentTrumpDeclaration: this.room.gameState.currentTrumpDeclaration
+ ? {
+ ...this.room.gameState.currentTrumpDeclaration,
+ cards: this.room.gameState.currentTrumpDeclaration.cards.map(card => card.toJSON())
+ }
+ : null,
+ currentInferiorDeclaration: this.room.gameState.currentInferiorDeclaration
+ ? {
+ ...this.room.gameState.currentInferiorDeclaration,
+ cards: this.room.gameState.currentInferiorDeclaration.cards.map(card => card.toJSON())
+ }
+ : null,
+ claimedSuits: Object.fromEntries(this.room.gameState.threeSixNineClaimedSuits)
+ });
+ logger.info(
+ `房间 ${this.room.id} 三六九等锁定:` +
+ `${this.room.gameState.trumpSuit && this.room.gameState.trumpSuit !== 'no_trump'
+ ? `${this.room.gameState.trumpSuit} 主`
+ : '无普通花色主'},` +
+ `${this.room.gameState.inferiorSuit ? `${this.room.gameState.inferiorSuit} 劣` : '无劣花色'}`
+ );
+ }
+
+ this.io.to(this.room.id).emit('dealer_selected', {
+ playerId: dealer.id,
+ playerName: dealer.name
+ });
+
+ // 特殊规则可在庄家锁定后、收底牌前插入异步流程(例如四家换牌)。
+ if (this.onDealerSelected && this.onDealerSelected(dealer) === true) {
+ this.io.to(this.room.id).emit('room_updated', {
+ room: this.room.toJSON()
+ });
+ return dealer;
+ }
+
+ return this.completeDealerAssignment(dealer);
+ }
+
+ /** 庄家身份已锁定后,真正发放底牌并进入埋底阶段。 */
+ completeDealerAssignment(dealerOrId = null) {
+ const dealer = typeof dealerOrId === 'string'
+ ? this.room.findPlayerById(dealerOrId)
+ : dealerOrId || this.room.findPlayerById(this.room.gameState.pendingDealerPlayerId);
+ if (!dealer) {
+ throw new Error('尚未锁定庄家');
+ }
+ if (this.room.gameState.buryingPlayerId === dealer.id && this.room.gameState.phase !== GamePhases.DRAWING) {
+ return dealer;
+ }
+
+ this.room.gameState.pendingDealerPlayerId = null;
+ this.room.gameState.buryingPlayerId = dealer.id;
+ this.room.gameState.postDrawStage = null;
+
// 广播庄家信息
this.io.to(this.room.id).emit('burying_player_set', {
playerId: dealer.id,
@@ -212,6 +409,41 @@ export class DrawingPhaseManager {
// 给庄家发底牌
const bottomCards = this.room.gameState.bottomCards;
+ if (isPeopleCommuneRule(this.room.gameState.selectedRule)) {
+ if (
+ this.room.players.length !== 4
+ || bottomCards.length !== 0
+ || this.room.players.some(player => player.cards.length !== 27)
+ ) {
+ throw new Error('人民公社需要四名玩家各摸满27张牌');
+ }
+
+ const dealerIndex = this.room.getPlayerIndex(dealer.id);
+ this.room.gameState.peopleCommuneBuryingOrder = Array.from(
+ { length: this.room.players.length },
+ (_, offset) => this.room.players[(dealerIndex + offset) % this.room.players.length].id
+ );
+ this.room.gameState.peopleCommuneCurrentBuryingPlayerId = dealer.id;
+ this.room.gameState.peopleCommuneBuriedCardsByPlayerId.clear();
+
+ logger.info(`房间 ${this.room.id} 人民公社:四家均已摸满27张,开始各埋两张`);
+ this.room.gameState.phase = GamePhases.BURYING;
+ this.io.to(this.room.id).emit('room_updated', { room: this.room.toJSON() });
+ if (this.onDealerAssigned) this.onDealerAssigned(dealer);
+ return dealer;
+ }
+
+ if (isAdministrativeReviewRule(this.room.gameState.selectedRule)) {
+ // 行政审查在满足公开条件前,底牌始终只留在服务端,不能提前加入庄家手牌或私发牌面。
+ this.room.gameState.phase = GamePhases.BURYING;
+ logger.info(
+ `房间 ${this.room.id} 行政审查:${bottomCards.length}张底牌继续封存,四家先以初始手牌出牌`
+ );
+ this.io.to(this.room.id).emit('room_updated', { room: this.room.toJSON() });
+ if (this.onDealerAssigned) this.onDealerAssigned(dealer);
+ return dealer;
+ }
+
bottomCards.forEach(card => dealer.addCard(card));
// 自动排序
@@ -232,6 +464,11 @@ export class DrawingPhaseManager {
this.io.to(this.room.id).emit('room_updated', {
room: this.room.toJSON()
});
+
+ if (this.onDealerAssigned) {
+ this.onDealerAssigned(dealer);
+ }
+ return dealer;
}
/**
diff --git a/tractor-game-simulator/server/src/services/GameEngine.js b/tractor-game-simulator/server/src/services/GameEngine.js
index 5636b9d..5a96b17 100644
--- a/tractor-game-simulator/server/src/services/GameEngine.js
+++ b/tractor-game-simulator/server/src/services/GameEngine.js
@@ -1,4 +1,4 @@
-import { GamePhases, PlayModes } from '../utils/constants.js';
+import { GamePhases, PlayModes, Ranks, Suits, TurnOrders, levelToRank } from '../utils/constants.js';
import { DrawingPhaseManager } from './DrawingPhaseManager.js';
import { RoundManager } from './RoundManager.js';
import { DeckService } from './DeckService.js';
@@ -6,12 +6,22 @@ import { Card } from '../models/Card.js';
import logger from '../utils/logger.js';
import {
detectPattern,
+ findSmallestPlayForRespectElders,
+ rankRoundPlaysByRespectOrder,
validateLeadingPlay,
validateFollowingPlay,
compareCards,
+ demoteForbiddenMagicHand,
parseThrowCombination,
+ resolveClusterAnalysisPlay,
+ resolveEnduringComparison,
+ resolveForbiddenMagicPlay,
+ resolveJokerSubstitutionPlay,
+ getSuitlessPatternProfile,
validateThrow,
- PatternTypes
+ PatternTypes,
+ getEffectiveSuit,
+ getAntinomyFaceKey
} from '../utils/cardPatternUtils.js';
import {
calculateRoundPoints,
@@ -24,93 +34,10117 @@ import {
upgradeLevel,
getNextDealerIndex
} from '../utils/scoringUtils.js';
-import { levelToRank } from '../utils/constants.js';
+import { getCardPoints, getMeticulousAccountingCardPoints } from '../utils/scoringUtils.js';
+import { getCardStrength, isTrumpCard } from '../utils/cardPatternUtils.js';
+import { comparePokerScores, evaluateBestPokerHand } from '../utils/pokerUtils.js';
+import {
+ getCardCooldownType,
+ getCardCooldownValue,
+ getRuleDisabledCards,
+ getRulePlayableCards,
+ recordBirdsGoneBowHiddenPointCards
+} from '../utils/cardCooldownUtils.js';
+import { mapOneCountryCards } from '../utils/oneCountryTwoSystemsUtils.js';
+import { shiftStrengthCompensationCardFace } from '../utils/strengthCompensationUtils.js';
+import { transformThreeTigersCards } from '../utils/threeTigersUtils.js';
+import { validateDeclaration } from '../utils/trumpUtils.js';
+import {
+ chooseWhoDesignedCardsToBury,
+ chooseWhoDesignedTrumpDeclaration,
+ shouldUseWhoDesignedStrategy
+} from './whoDesignedStrategy.js';
+import {
+ calculateIronEvidenceRoundScoring,
+ getIronEvidenceRoundMode,
+ isIronEvidenceBigJoker
+} from '../utils/ironEvidenceUtils.js';
+import {
+ ActiveSkillIds,
+ createDoubleHappinessRule,
+ createRuleOptions,
+ getActiveSkillForRule,
+ getOpeningCardExchangeOffset,
+ getOddEvenRoundMultiplier,
+ getRuleById,
+ getRuleSetup,
+ isAdministrativeReviewRule,
+ isAntinomyRule,
+ isChangeRiceToMulberryRule,
+ isIcebergTipRule,
+ isCosmicShiftRule,
+ isFatalBeautyRule,
+ isFrequentFluctuationRule,
+ isGoWithTheFlowRule,
+ isInviteIntoUrnRule,
+ isIrresistibleForceRule,
+ isHeavyFogRule,
+ isLingeringDiscardRule,
+ isLureTigerFromMountainRule,
+ isMutualVisibilityRule,
+ isMinorDisturbanceRule,
+ isBurnTheBoatsRule,
+ isNoOneSurvivesRule,
+ isOneHorseLeadsRule,
+ isLastStandRule,
+ isOpenAndHonestRule,
+ isOpeningCardExchangeRule,
+ isPerfectStrategyRule,
+ isReformAndOpeningUpRule,
+ isRitesCollapseRule,
+ isAccidentInsuranceRule,
+ isRespectEldersAndChildrenRule,
+ isRouteSwingRule,
+ isDayNightRotationRule,
+ isDefenseAsOffenseRule,
+ isDestroyDykeFloodFieldsRule,
+ isDoubleHappinessRule,
+ isTenSidedAmbushRule,
+ isThreePowersRule,
+ isGentlemanPromiseRule,
+ isFocusFigureRule,
+ isRepeatedExhaustionRule,
+ isEnduringRule,
+ isEncircleThreeMissingOneRule,
+ isAveragePoolingRule,
+ isDreamKillingRule,
+ isJointHarmonyRule,
+ isDivineWeaponRule,
+ isMagicTrickRule,
+ isMeticulousAccountingRule,
+ isLostInFogRule,
+ isMainstayRule,
+ isHappyTwinsRule,
+ isHiddenDragonInAbyssRule,
+ isIronEvidenceRule,
+ isWaitingRabbitRule,
+ isCandleToDawnRule,
+ isCulturalRevolutionRule,
+ isMutualSupportRule,
+ isOldHorseStillHasStrengthRule,
+ isAbruptStopRule,
+ isForbiddenMagicRule,
+ isPlannedEconomyRule,
+ isOneCountryTwoSystemsRule,
+ isPeopleCommuneRule,
+ isPoliticalReviewRule,
+ isRecordOnFileRule,
+ isRemoveFirewoodRule,
+ isSecondBattlefieldRule,
+ isStrengthCompensationRule,
+ isStrawBoatBorrowingArrowsRule,
+ isStriveUpstreamRule,
+ isBushGateRule,
+ isAmbiguousRule,
+ isAfterglowRule,
+ isOutwardHarmonyInnerDivisionRule,
+ isTeammateCheerRule,
+ isThreeTigersRule,
+ isThreeSixNineGradesRule,
+ isTwoGhostsKnockDoorRule,
+ isTrumpWinsRule,
+ isWoodenOxFlowingHorseRule,
+ isWeighingThousandJinRule,
+ isFearOfBreakingVaseRule,
+ isNinePrincesSuccessionRule,
+ isTimeReversalRule,
+ resolveOfferedRule,
+ RuleIds
+} from '../rules/ruleRegistry.js';
+
+const OPENING_EXCHANGE_CARD_COUNT = 2;
+// 换牌不只是路径提示:接收者还需要时间辨认收到的牌,再看它们落入手牌。
+const OPENING_EXCHANGE_ANIMATION_MS = 2200;
+const MAINSTAY_CARD_COUNT = 5;
+const MAINSTAY_ANIMATION_MS = 1100;
+const SECONDARY_BURY_ANIMATION_MS = 1400;
+const ICEBERG_REVEALED_CARD_COUNT = 2;
+const TEN_SIDED_AMBUSH_CARD_POINTS = 5;
+const WHOLE_HAND_EXCHANGE_ANIMATION_MS = 1300;
+const TIME_REVERSAL_DECISION_DELAY_MS = 2000;
+const PLANNED_ECONOMY_DRAW_ANIMATION_MS = 1500;
+const EQUIVALENT_RECIPROCITY_ANIMATION_MS = 1600;
+const MUTUAL_SUPPORT_ANIMATION_MS = 1100;
+const SECOND_BATTLEFIELD_AWARD = 5;
+const SECOND_BATTLEFIELD_MIN_ACCUMULATED_CARDS = 5;
+const SECOND_BATTLEFIELD_FINAL_HAND_THRESHOLD = 5;
+const OUTWARD_HARMONY_AWARD = 5;
+const FEAR_OF_BREAKING_VASE_PENALTY = 10;
+const WOODEN_OX_MAX_TRANSFERS = 4;
+const INVITE_INTO_URN_SUITS = Object.freeze(['hearts', 'diamonds', 'clubs', 'spades']);
+const INVITE_INTO_URN_RANKS = Object.freeze([
+ '2', '3', '4', '5', '6', '7', '8', '9', '10', 'J', 'Q', 'K', 'A'
+]);
+const ANTINOMY_SUITS = INVITE_INTO_URN_SUITS;
+const ANTINOMY_RANKS = INVITE_INTO_URN_RANKS;
+const CULTURAL_REVOLUTION_SUITS = Object.freeze([
+ Suits.HEARTS,
+ Suits.DIAMONDS,
+ Suits.CLUBS,
+ Suits.SPADES
+]);
+const ENCIRCLE_THREE_MISSING_ONE_SUITS = Object.freeze([
+ Suits.HEARTS,
+ Suits.DIAMONDS,
+ Suits.CLUBS,
+ Suits.SPADES
+]);
+const CULTURAL_REVOLUTION_RANKS = Object.freeze([
+ Ranks.TWO,
+ Ranks.THREE,
+ Ranks.FOUR,
+ Ranks.FIVE,
+ Ranks.SIX,
+ Ranks.SEVEN,
+ Ranks.EIGHT,
+ Ranks.NINE,
+ Ranks.TEN,
+ Ranks.JACK,
+ Ranks.QUEEN,
+ Ranks.KING,
+ Ranks.ACE
+]);
+const THREE_TIGERS_SUITS = Object.freeze([
+ Suits.HEARTS,
+ Suits.DIAMONDS,
+ Suits.CLUBS,
+ Suits.SPADES
+]);
+const DIVINE_WEAPON_SUITS = Object.freeze([
+ Suits.HEARTS,
+ Suits.DIAMONDS,
+ Suits.CLUBS,
+ Suits.SPADES
+]);
+const DIVINE_WEAPON_RANKS = Object.freeze([
+ Ranks.TWO,
+ Ranks.THREE,
+ Ranks.FOUR,
+ Ranks.FIVE,
+ Ranks.SIX,
+ Ranks.SEVEN,
+ Ranks.EIGHT,
+ Ranks.NINE,
+ Ranks.TEN,
+ Ranks.JACK,
+ Ranks.QUEEN,
+ Ranks.KING,
+ Ranks.ACE
+]);
+const TEN_SIDED_AMBUSH_RANKS = Object.freeze([
+ Ranks.TWO,
+ Ranks.THREE,
+ Ranks.FOUR,
+ Ranks.FIVE,
+ Ranks.SIX,
+ Ranks.SEVEN,
+ Ranks.EIGHT,
+ Ranks.NINE,
+ Ranks.TEN,
+ Ranks.JACK,
+ Ranks.QUEEN,
+ Ranks.KING,
+ Ranks.ACE
+]);
+const POINT_RANKS = new Set([Ranks.FIVE, Ranks.TEN, Ranks.KING]);
+const GENTLEMAN_PROMISE_SUITS = Object.freeze([
+ Suits.HEARTS,
+ Suits.DIAMONDS,
+ Suits.CLUBS,
+ Suits.SPADES,
+ 'trump'
+]);
+const HIDDEN_DRAGON_RANKS = TEN_SIDED_AMBUSH_RANKS;
+const THREE_POWERS_SLOT_CONFIG = Object.freeze([
+ // 原描述按2、3、4号位依次重载10、5、K;此处按右上角表格的5、10、K顺序保存。
+ Object.freeze({ sourceRank: Ranks.FIVE, pointValue: 5, selectorOffset: 2, selectorPosition: 3 }),
+ Object.freeze({ sourceRank: Ranks.TEN, pointValue: 10, selectorOffset: 1, selectorPosition: 2 }),
+ Object.freeze({ sourceRank: Ranks.KING, pointValue: 10, selectorOffset: 3, selectorPosition: 4 })
+]);
+
+export class GameEngine {
+ constructor(room, io, random = Math.random) {
+ this.room = room;
+ this.io = io;
+ this.random = random;
+ this.drawingManager = null;
+ this.roundManager = null;
+ this.botActionTimer = null;
+ this.secondaryBuryTimer = null;
+ this.peopleCommuneBuryTimer = null;
+ this.timeReversalDecisionTimer = null;
+ this.timeReversalRoundSnapshot = null;
+ this.onBotTurn = null;
+ this.onTimeReversalWindowClosed = null;
+ this.cardExchangeSelections = new Map();
+ this.icebergSelectionRequests = new Map();
+ this.icebergInitialSelectionActive = false;
+ this.gentlemanPromiseOptionsByPlayerId = new Map();
+ this.hiddenDragonOptionsByPlayerId = new Map();
+ this.antinomyPendingSelections = new Map();
+ this.isFinalizingDestroyDykeRound = false;
+ this.mainstayResumePlayerIndex = null;
+ this.mainstayCompletionMode = null;
+ }
+
+ /**
+ * 为断线重连的单个真人恢复只属于他的私密状态。
+ *
+ * 房间 toJSON 只能携带公开信息;暗选牌面、尚未提交的投票以及政治审查
+ * 放行凭证不能广播给全房间。客户端在牌桌监听器挂载完成后显式请求本列表,
+ * 再复用既有 Socket 事件恢复对应交互。
+ */
+ getPrivateGameStateSyncEvents(playerId) {
+ const player = this.room.findPlayerById(playerId);
+ if (!player || player.isBot) return [];
+
+ const { gameState } = this.room;
+ const events = [];
+ const add = (event, payload) => {
+ if (!event || payload === null || payload === undefined) return;
+ events.push({
+ event,
+ payload: typeof payload === 'object'
+ ? { ...payload, recovered: true }
+ : payload
+ });
+ };
+
+ // 持续明置类规则的可见手牌同样属于“按观看者生成”的私密快照。
+ add('rule_visible_hands_updated', {
+ hands: this.createRuleVisibleHandsFor(player),
+ announcement: null
+ });
+
+ const removeFirewoodDecision = gameState.removeFirewoodCurrentDecision;
+ if (removeFirewoodDecision?.counteredPlayerId === player.id) {
+ add('remove_firewood_decision_required', removeFirewoodDecision);
+ }
+
+ const mainstayAction = gameState.mainstayCurrentAction;
+ if (mainstayAction?.chooserPlayerId === player.id) {
+ add(
+ mainstayAction.stage === 'decision'
+ ? 'mainstay_decision_required'
+ : 'mainstay_cards_required',
+ mainstayAction.stage === 'decision'
+ ? {
+ actionId: mainstayAction.id,
+ trumpCount: mainstayAction.trumpCount || 0
+ }
+ : {
+ actionId: mainstayAction.id,
+ stage: mainstayAction.stage,
+ requiredCards: mainstayAction.requiredCards || MAINSTAY_CARD_COUNT
+ }
+ );
+ }
+
+ const mule = Array.from(gameState.woodenOxMulesByTeam.values())
+ .find(candidate => candidate.holderPlayerId === player.id) || null;
+ add('wooden_ox_private_state', mule ? {
+ teamIndex: mule.teamIndex,
+ holderPlayerId: mule.holderPlayerId,
+ storedCard: mule.storedCard?.toJSON ? mule.storedCard.toJSON() : mule.storedCard,
+ transfersUsed: mule.transfersUsed,
+ maxTransfers: mule.maxTransfers
+ } : null);
+ if (gameState.woodenOxRoundWindow?.pendingPlayerIds?.has(player.id) && mule) {
+ add('wooden_ox_decision_required', {
+ round: gameState.woodenOxRoundWindow.round,
+ teamIndex: mule.teamIndex,
+ holderPlayerId: player.id,
+ hasStoredCard: Boolean(mule.storedCard),
+ storedCard: mule.storedCard?.toJSON ? mule.storedCard.toJSON() : mule.storedCard,
+ transfersUsed: mule.transfersUsed,
+ maxTransfers: mule.maxTransfers,
+ mustTransfer: gameState.woodenOxRoundWindow.requiredTransferPlayerIds.has(player.id)
+ });
+ }
+
+ const politicalPending = gameState.politicalReviewPending;
+ if (politicalPending) {
+ const publicPending = {
+ id: politicalPending.id,
+ round: politicalPending.round,
+ reviewerPlayerId: politicalPending.reviewerPlayerId,
+ reviewerPlayerName: politicalPending.reviewerPlayerName,
+ teammatePlayerId: politicalPending.teammatePlayerId,
+ teammatePlayerName: politicalPending.teammatePlayerName,
+ cards: politicalPending.cards.map(card => ({ ...card }))
+ };
+ if (politicalPending.reviewerPlayerId === player.id) {
+ add('political_review_decision_required', publicPending);
+ }
+ if (politicalPending.requestingPlayerId === player.id) {
+ add('political_review_play_held', {
+ ...publicPending,
+ handCards: player.cards.map(card => card.toJSON ? card.toJSON() : card)
+ });
+ }
+ }
+
+ const politicalApproval = gameState.politicalReviewApproval;
+ if (politicalApproval?.requestingPlayerId === player.id) {
+ add('political_review_play_approved', {
+ id: politicalApproval.id,
+ cardIds: [...politicalApproval.cardIds],
+ controlledPlayerId: politicalApproval.controlledPlayerId,
+ activeSkillId: politicalApproval.activeSkillId || null,
+ ...(politicalApproval.playOptions || {})
+ });
+ }
+
+ if (
+ gameState.timeReversalDecisionState === 'awaiting_response'
+ && gameState.timeReversalReservations.has(player.id)
+ ) {
+ add('time_reversal_decision_required', {
+ round: gameState.timeReversalWindowRound,
+ playerId: player.id,
+ playerName: player.name,
+ players: Array.from(gameState.timeReversalReservations.values(), reservation => ({
+ playerId: reservation.playerId,
+ playerName: reservation.playerName
+ }))
+ });
+ }
+
+ if (gameState.lastStandPendingPlayerIds.has(player.id)) {
+ add('last_stand_decision_required', {
+ playerId: player.id,
+ playerName: player.name,
+ cardsCount: player.cards.length,
+ suit: player.cards[0]?.suit || null
+ });
+ }
+
+ if (gameState.teammateCheerPending?.playerId === player.id) {
+ add('teammate_cheer_decision_required', gameState.teammateCheerPending);
+ }
+ if (gameState.afterglowPending?.playerId === player.id) {
+ add('afterglow_decision_required', gameState.afterglowPending);
+ }
+
+ if (gameState.forbiddenMagicCurrentDecisionPlayerId === player.id) {
+ add('forbidden_magic_decision_required', {
+ round: gameState.forbiddenMagicDecisionRound,
+ playerId: player.id,
+ playerName: player.name,
+ queuedPlayerIds: [...gameState.forbiddenMagicDecisionQueue]
+ });
+ }
+
+ const lureTigerDecision = gameState.lureTigerCurrentDecision;
+ if (lureTigerDecision?.playerId === player.id) {
+ add(
+ lureTigerDecision.stage === 'target'
+ ? 'lure_tiger_target_required'
+ : 'lure_tiger_decision_required',
+ {
+ ...lureTigerDecision,
+ eligibleTargetIds: [...(lureTigerDecision.eligibleTargetIds || [])]
+ }
+ );
+ }
+
+ if (gameState.icebergPendingPlayerIds.has(player.id)) {
+ let request = this.icebergSelectionRequests.get(player.id);
+ if (!request) {
+ const handIds = new Set(player.cards.map(card => card.id));
+ const currentlyRevealedCardIds = [
+ ...(gameState.icebergRevealedCardIdsByPlayer.get(player.id) || [])
+ ].filter(cardId => handIds.has(cardId));
+ const targetCount = Math.min(ICEBERG_REVEALED_CARD_COUNT, player.cards.length);
+ request = {
+ playerId: player.id,
+ reason: this.icebergInitialSelectionActive ? 'initial' : 'replenish',
+ requiredCount: Math.max(0, targetCount - currentlyRevealedCardIds.length),
+ targetCount,
+ currentlyRevealedCardIds
+ };
+ this.icebergSelectionRequests.set(player.id, request);
+ }
+ add('iceberg_reveal_selection_required', {
+ reason: request.reason,
+ requiredCount: request.requiredCount,
+ targetCount: request.targetCount,
+ currentlyRevealedCardIds: [...request.currentlyRevealedCardIds]
+ });
+ }
+
+ if (gameState.tenSidedAmbushSelectorPlayerId === player.id) {
+ if (gameState.isTenSidedAmbushSelectionPending) {
+ add('ten_sided_ambush_selection_required', {
+ eligibleRanks: this.getEligibleTenSidedAmbushRanks(),
+ trumpRank: gameState.trumpRank
+ });
+ } else if (gameState.tenSidedAmbushRank && !gameState.isTenSidedAmbushRevealed) {
+ add('ten_sided_ambush_rank_selected', {
+ rank: gameState.tenSidedAmbushRank,
+ isPrivate: true
+ });
+ }
+ }
+
+ gameState.threePowersSlots
+ .filter(slot => slot.selectorPlayerId === player.id)
+ .forEach(slot => {
+ if (!slot.selectedRank) {
+ add('three_powers_selection_required', {
+ sourceRank: slot.sourceRank,
+ pointValue: slot.pointValue,
+ selectorPosition: slot.selectorPosition,
+ eligibleRanks: this.getEligibleThreePowersRanks(),
+ trumpRank: gameState.trumpRank
+ });
+ } else if (!slot.isRevealed) {
+ add('three_powers_rank_selected', {
+ sourceRank: slot.sourceRank,
+ pointValue: slot.pointValue,
+ rank: slot.selectedRank,
+ isPrivate: true
+ });
+ }
+ });
+
+ if (gameState.waitingRabbitPendingSelectionPlayerIds.has(player.id)) {
+ add('waiting_rabbit_selection_required', this.getWaitingRabbitSelectionOptions());
+ } else {
+ const declaration = gameState.waitingRabbitDeclarationsByPlayerId.get(player.id);
+ if (declaration) {
+ add('waiting_rabbit_target_selected', {
+ ...declaration,
+ isPrivate: true
+ });
+ }
+ }
+ const waitingRabbitDecision = gameState.waitingRabbitDecision;
+ if (waitingRabbitDecision?.chooserPlayerId === player.id) {
+ add('waiting_rabbit_exchange_required', {
+ ...this.getWaitingRabbitPublicDecision(waitingRabbitDecision),
+ eligibleDiscardCardIds: player.cards
+ .filter(card => this.getRuleCardPoints(card) === 0)
+ .map(card => card.id)
+ });
+ }
+
+ if (gameState.gentlemanPromisePendingPlayerIds.has(player.id)) {
+ const options = this.getGentlemanPromiseEligibleSuits(player);
+ this.gentlemanPromiseOptionsByPlayerId.set(player.id, options.eligibleSuits);
+ add('gentleman_promise_selection_required', {
+ eligibleSuits: [...options.eligibleSuits],
+ suitCounts: { ...options.counts },
+ minimumCount: options.minimumCount
+ });
+ }
+
+ if (gameState.hiddenDragonPendingPlayerIds.has(player.id)) {
+ const options = this.getHiddenDragonEligibleRanks(player);
+ this.hiddenDragonOptionsByPlayerId.set(player.id, options.eligibleRanks);
+ add('hidden_dragon_selection_required', {
+ eligibleRanks: [...options.eligibleRanks],
+ rankCounts: { ...options.counts },
+ maximumCount: options.maximumCount,
+ trumpRank: gameState.trumpRank
+ });
+ }
+
+ if (gameState.antinomyPendingPlayerIds.has(player.id)) {
+ add('antinomy_selection_required', {
+ stage: gameState.antinomySelectionStage || 'opening',
+ triggerRound: gameState.antinomyTriggerRound,
+ eligibleSuits: [...ANTINOMY_SUITS],
+ eligibleRanks: [...ANTINOMY_RANKS],
+ currentDeclaration: gameState.antinomyDeclarationsByPlayerId.get(player.id) || null
+ });
+ }
+
+ if (gameState.riceToMulberryPendingPlayerIds.has(player.id)) {
+ const eligibleCardIds = player.cards
+ .filter(card => !card.isRiceToMulberryTransformed && this.getRuleCardPoints(card) > 0)
+ .map(card => card.id);
+ add('rice_to_mulberry_selection_required', {
+ requiredCount: Math.floor(eligibleCardIds.length / 2),
+ eligibleCardIds
+ });
+ }
+
+ if (gameState.destroyDykeDecision?.dealerPlayerId === player.id) {
+ add('destroy_dyke_decision_required', gameState.destroyDykeDecision);
+ }
+
+ if (gameState.surrenderCurrentDecision?.teammatePlayerId === player.id) {
+ add('surrender_decision_required', this.getSurrenderPublicDecision());
+ }
+
+ if (
+ gameState.ninePrincesDecision?.playerId === player.id
+ && !this.hasPendingTimeReversalDecision()
+ ) {
+ add(
+ 'nine_princes_selection_required',
+ this.getNinePrincesPrivateDecision(gameState.ninePrincesDecision)
+ );
+ }
+
+ const administrativeReview = gameState.administrativeReview;
+ if (administrativeReview) {
+ if (
+ administrativeReview.suitSelectorPlayerId === player.id
+ && !administrativeReview.suit
+ ) {
+ add('administrative_review_selection_required', {
+ type: 'suit',
+ eligibleOptions: this.getAdministrativeReviewSuitOptions(),
+ trumpSuit: gameState.trumpSuit,
+ trumpRank: gameState.trumpRank
+ });
+ }
+ if (
+ administrativeReview.rankSelectorPlayerId === player.id
+ && !administrativeReview.rank
+ ) {
+ add('administrative_review_selection_required', {
+ type: 'rank',
+ eligibleOptions: [...HIDDEN_DRAGON_RANKS],
+ trumpSuit: gameState.trumpSuit,
+ trumpRank: gameState.trumpRank
+ });
+ }
+ }
+
+ const focusTeam = this.getFocusFigureTeamForPlayer(player.id);
+ if (focusTeam?.isFinalized) {
+ const focusPlayer = this.room.findPlayerById(focusTeam.finalPlayerId);
+ add('focus_figure_team_finalized', {
+ team: focusTeam.team,
+ attempt: focusTeam.attempt,
+ focusPlayerId: focusPlayer?.id || null,
+ focusPlayerName: focusPlayer?.name || '未知玩家'
+ });
+ } else if (
+ gameState.isFocusFigureVotingStarted
+ && focusTeam
+ && !focusTeam.votes.has(player.id)
+ ) {
+ const nominee = this.room.findPlayerById(focusTeam.nomineePlayerId);
+ add('focus_figure_vote_required', {
+ team: focusTeam.team,
+ attempt: focusTeam.attempt,
+ nomineePlayerId: nominee?.id || null,
+ nomineePlayerName: nominee?.name || '未知玩家'
+ });
+ }
+
+ const challenge = gameState.equivalentReciprocityChallenge;
+ const challengePlayerIds = challenge
+ ? [challenge.initiatorPlayerId, challenge.targetPlayerId]
+ : [];
+ if (
+ challengePlayerIds.includes(player.id)
+ && !challenge.selectedCardsByPlayerId?.has(player.id)
+ ) {
+ const opponentPlayerId = challengePlayerIds.find(id => id !== player.id);
+ const opponent = this.room.findPlayerById(opponentPlayerId);
+ add('equivalent_reciprocity_card_required', {
+ challengeId: challenge.id,
+ opponentPlayerId,
+ opponentPlayerName: opponent?.name || '对手'
+ });
+ }
+
+ const ambiguousDecision = gameState.ambiguousRoundDecision;
+ if (ambiguousDecision?.currentPlayerId === player.id) {
+ const selection = ambiguousDecision.selections.find(item => item.playerId === player.id);
+ if (selection) {
+ add('ambiguous_choice_required', {
+ round: ambiguousDecision.round,
+ playerId: player.id,
+ playerName: player.name,
+ position: selection.position,
+ options: selection.options.map(option => ({
+ index: option.index,
+ cards: option.cards.map(card => card.toJSON ? card.toJSON() : card)
+ }))
+ });
+ }
+ }
+
+ const magicTrickSelection = gameState.magicTrickSelection;
+ if (magicTrickSelection?.playerId === player.id) {
+ add('magic_trick_prepared', {
+ round: magicTrickSelection.round,
+ playerId: player.id,
+ playerName: player.name,
+ targetPlayerIds: [...magicTrickSelection.targetPlayerIds],
+ targetPlayerNames: magicTrickSelection.targetPlayerIds.map(
+ targetId => this.room.findPlayerById(targetId)?.name || '未知玩家'
+ )
+ });
+ }
+
+ return events;
+ }
+
+ getRuleRuntimeContext() {
+ const { selectedRule, currentRound, inferiorSuit } = this.room.gameState;
+ if (!selectedRule) return null;
+ if (isDayNightRotationRule(selectedRule)) {
+ return { ...selectedRule, currentRound };
+ }
+ if (isThreeSixNineGradesRule(selectedRule)) {
+ return { ...selectedRule, inferiorSuit };
+ }
+ if (isAntinomyRule(selectedRule)) {
+ return {
+ ...selectedRule,
+ antinomySplitFaceKeys: this.getAntinomySplitFaceKeys()
+ };
+ }
+ return selectedRule;
+ }
+
+ beginIronEvidenceRound() {
+ const { gameState } = this.room;
+ if (!isIronEvidenceRule(gameState.selectedRule)) {
+ gameState.ironEvidenceRoundMode = null;
+ return null;
+ }
+ gameState.ironEvidenceRoundMode = getIronEvidenceRoundMode(
+ gameState.ironEvidencePlayedBigJokerIds.size
+ );
+ return gameState.ironEvidenceRoundMode;
+ }
+
+ ensureIronEvidenceRoundMode() {
+ if (!isIronEvidenceRule(this.room.gameState.selectedRule)) return null;
+ return this.room.gameState.ironEvidenceRoundMode || this.beginIronEvidenceRound();
+ }
+
+ recordIronEvidenceBigJokers(cards = []) {
+ if (!isIronEvidenceRule(this.room.gameState.selectedRule)) return [];
+ const ids = cards
+ .filter(isIronEvidenceBigJoker)
+ .map(card => card.id);
+ ids.forEach(cardId => this.room.gameState.ironEvidencePlayedBigJokerIds.add(cardId));
+ return ids;
+ }
+
+ /**
+ * 开始游戏 - 进入准备等待阶段
+ */
+ startGame() {
+ logger.info(`房间 ${this.room.id} 开始游戏 - 等待玩家准备`);
+
+ // 重置游戏状态
+ this.room.gameState.reset();
+
+ // 重置所有玩家的准备状态
+ this.room.players.forEach(player => {
+ player.isReady = false;
+ });
+
+ // 标记为等待准备状态(保持在WAITING阶段)
+ this.room.gameState.isWaitingForReady = true;
+
+ // Bot自动准备
+ this.room.players.filter(p => p.isBot).forEach(bot => {
+ bot.isReady = true;
+ });
+
+ // 广播进入准备等待状态
+ this.io.to(this.room.id).emit('game_started', {
+ phase: GamePhases.WAITING,
+ isWaitingForReady: true,
+ config: this.room.config
+ });
+
+ // 第一局随机指定一名玩家二选一;若已有已完成的上一局,则沿用其末轮赢家。
+ this.startRuleSelection();
+ }
+
+ /**
+ * 为即将开始的一局创建服务端可信的二选一候选并确定选择者。
+ */
+ startRuleSelection() {
+ const { gameState, players } = this.room;
+ if (players.length === 0) {
+ throw new Error('没有玩家可以选择规则');
+ }
+
+ const configuredTestRule = this.room.config.testMode
+ ? getRuleById(this.room.config.testRuleId)
+ : null;
+ if (configuredTestRule && !isDoubleHappinessRule(configuredTestRule)) {
+ const ruleSetup = getRuleSetup(configuredTestRule);
+ gameState.selectedRule = configuredTestRule;
+ gameState.ruleOptions = [];
+ gameState.ruleChooserPlayerId = null;
+ gameState.isRuleSelectionPending = false;
+ gameState.ruleSelectionMode = null;
+ gameState.bottomCardsCount = ruleSetup.bottomCardsCount;
+ gameState.attackerScore = ruleSetup.attackerStartingScore;
+ this.initializeFocusFigureCandidates();
+ this.io.to(this.room.id).emit('rule_selected', {
+ playerId: null,
+ playerName: '规则测试模式',
+ rule: configuredTestRule,
+ testMode: true
+ });
+ logger.info(`房间 ${this.room.id} 测试模式直接启用规则: ${configuredTestRule.name}`);
+ return null;
+ }
+ if (configuredTestRule && isDoubleHappinessRule(configuredTestRule)) {
+ const chooser = this.room.findPlayerBySocketId(this.room.hostId)
+ || players.find(player => !player.isBot)
+ || players[0];
+ gameState.selectedRule = null;
+ gameState.ruleOptions = [];
+ gameState.ruleChooserPlayerId = chooser.id;
+ gameState.isRuleSelectionPending = true;
+ gameState.ruleSelectionMode = 'single';
+ this.beginDoubleHappinessSelection(chooser.id);
+ logger.info(
+ `房间 ${this.room.id} 测试模式启用双喜临门,由房主 ${chooser.name} 直接进行三选二`
+ );
+ return chooser;
+ }
+
+ const rememberedIndex = gameState.nextRuleChooserIndex;
+ const chooserIndex = Number.isInteger(rememberedIndex) && players[rememberedIndex]
+ ? rememberedIndex
+ : Math.floor(this.random() * players.length);
+ const chooser = players[chooserIndex];
+
+ gameState.selectedRule = null;
+ const forcedTestRuleId = process.env.NODE_ENV === 'test' &&
+ this.room.name.startsWith('__e2e_rule__:')
+ ? this.room.name.slice('__e2e_rule__:'.length)
+ : null;
+ const forcedTestRule = forcedTestRuleId
+ ? getRuleById(forcedTestRuleId)
+ : null;
+ gameState.ruleOptions = forcedTestRule
+ ? [forcedTestRule, getRuleById(RuleIds.NORMAL_GAME)]
+ : createRuleOptions(this.random);
+ gameState.ruleChooserPlayerId = chooser.id;
+ gameState.isRuleSelectionPending = true;
+ gameState.ruleSelectionMode = 'single';
+
+ this.io.to(this.room.id).emit('rule_selection_started', {
+ chooserPlayerId: chooser.id,
+ chooserPlayerName: chooser.name,
+ selectionMode: gameState.ruleSelectionMode,
+ options: gameState.ruleOptions
+ });
+
+ logger.info(`房间 ${this.room.id} 由 ${chooser.name} 从两条规则中选择本局规则`);
+
+ // Bot选择者不能阻塞流程,随机选择一个服务端候选。
+ if (chooser.isBot) {
+ const optionIndex = Math.floor(this.random() * gameState.ruleOptions.length);
+ this.selectRule(chooser.id, gameState.ruleOptions[optionIndex]);
+ }
+
+ return chooser;
+ }
+
+ /**
+ * 由本局指定玩家确认规则。返回 true 表示选择后已直接进入发牌。
+ */
+ selectRule(playerId, selection) {
+ const { gameState } = this.room;
+ if (!gameState.isRuleSelectionPending) {
+ throw new Error('当前不在规则选择阶段');
+ }
+ if (gameState.ruleChooserPlayerId !== playerId) {
+ throw new Error('你不是本局的规则选择者');
+ }
+
+ if (gameState.ruleSelectionMode === 'double_happiness') {
+ const selectionIds = Array.isArray(selection?.ids)
+ ? selection.ids
+ : Array.isArray(selection?.rules)
+ ? selection.rules
+ : [];
+ const combinedRule = createDoubleHappinessRule(selectionIds, gameState.ruleOptions);
+ return this.finalizeRuleSelection(playerId, combinedRule);
+ }
+
+ const rule = resolveOfferedRule(selection, gameState.ruleOptions);
+ if (!rule) {
+ throw new Error('只能从本局提供的两条规则中选择');
+ }
+ if (isDoubleHappinessRule(rule)) {
+ return this.beginDoubleHappinessSelection(playerId);
+ }
+
+ return this.finalizeRuleSelection(playerId, rule);
+ }
+
+ beginDoubleHappinessSelection(playerId) {
+ const { gameState } = this.room;
+ gameState.selectedRule = null;
+ gameState.ruleOptions = createRuleOptions(this.random, {
+ count: 3,
+ excludeIds: [RuleIds.DOUBLE_HAPPINESS]
+ });
+ gameState.ruleSelectionMode = 'double_happiness';
+ const chooser = this.room.findPlayerById(playerId);
+ this.io.to(this.room.id).emit('double_happiness_selection_started', {
+ chooserPlayerId: playerId,
+ chooserPlayerName: chooser?.name || '未知玩家',
+ options: gameState.ruleOptions
+ });
+ logger.info(
+ `房间 ${this.room.id} 玩家 ${chooser?.name || playerId} 触发双喜临门,改为三选二`
+ );
+
+ if (chooser?.isBot) {
+ let selectedPair = null;
+ for (let first = 0; first < gameState.ruleOptions.length; first += 1) {
+ for (let second = first + 1; second < gameState.ruleOptions.length; second += 1) {
+ const ids = [gameState.ruleOptions[first].id, gameState.ruleOptions[second].id];
+ try {
+ createDoubleHappinessRule(ids, gameState.ruleOptions);
+ selectedPair = ids;
+ break;
+ } catch {
+ // Bot继续尝试当前三条候选中的下一种组合。
+ }
+ }
+ if (selectedPair) break;
+ }
+ if (!selectedPair) {
+ const normalRule = getRuleById(RuleIds.NORMAL_GAME);
+ const partner = gameState.ruleOptions.find(option => option.id !== normalRule.id);
+ gameState.ruleOptions = [
+ partner,
+ normalRule,
+ ...gameState.ruleOptions.filter(option => ![partner?.id, normalRule.id].includes(option.id))
+ ].filter(Boolean).slice(0, 3);
+ selectedPair = [partner.id, normalRule.id];
+ }
+ return this.selectRule(playerId, { ids: selectedPair });
+ }
+ return false;
+ }
+
+ refreshDoubleHappinessOption(requesterSocketId, optionIndex) {
+ const { gameState } = this.room;
+ if (
+ !gameState.isRuleSelectionPending
+ || gameState.ruleSelectionMode !== 'double_happiness'
+ ) {
+ throw new Error('当前不在双喜临门的三选二阶段');
+ }
+ if (this.room.hostId !== requesterSocketId) {
+ throw new Error('只有房主可以刷新双喜临门候选');
+ }
+ if (
+ !Number.isInteger(optionIndex)
+ || optionIndex < 0
+ || optionIndex >= gameState.ruleOptions.length
+ ) {
+ throw new Error('要刷新的候选位置无效');
+ }
+
+ const oldRule = gameState.ruleOptions[optionIndex];
+ const replacement = createRuleOptions(this.random, {
+ count: 1,
+ excludeIds: [
+ RuleIds.DOUBLE_HAPPINESS,
+ ...gameState.ruleOptions.map(rule => rule.id)
+ ]
+ })[0];
+ if (!replacement) throw new Error('没有可用于刷新的其他规则');
+ gameState.ruleOptions = gameState.ruleOptions.map((rule, index) => (
+ index === optionIndex ? replacement : rule
+ ));
+ const host = this.room.findPlayerBySocketId(requesterSocketId);
+ const result = {
+ optionIndex,
+ oldRule,
+ rule: replacement,
+ refreshedByPlayerId: host?.id || null,
+ refreshedByPlayerName: host?.name || '房主'
+ };
+ this.io.to(this.room.id).emit('double_happiness_option_refreshed', result);
+ logger.info(
+ `房间 ${this.room.id} 房主将双喜临门候选 ${oldRule.name} 刷新为 ${replacement.name}`
+ );
+ return result;
+ }
+
+ finalizeRuleSelection(playerId, rule) {
+ const { gameState } = this.room;
+ gameState.selectedRule = rule;
+ gameState.isRuleSelectionPending = false;
+ gameState.ruleSelectionMode = null;
+ const ruleSetup = getRuleSetup(rule);
+ gameState.bottomCardsCount = ruleSetup.bottomCardsCount;
+ gameState.attackerScore = ruleSetup.attackerStartingScore;
+ this.initializeFocusFigureCandidates();
+
+ const player = this.room.findPlayerById(playerId);
+ this.io.to(this.room.id).emit('rule_selected', {
+ playerId,
+ playerName: player?.name || '未知玩家',
+ rule
+ });
+
+ logger.info(`房间 ${this.room.id} 玩家 ${player?.name || playerId} 选择规则: ${rule.name}`);
+
+ if (!gameState.isWaitingForReady) {
+ this.startDrawing();
+ return true;
+ }
+ return false;
+ }
+
+ /**
+ * 玩家准备/取消准备
+ */
+ playerReady(playerId) {
+ if (!this.room.gameState.isWaitingForReady) {
+ throw new Error('当前不在准备等待阶段');
+ }
+
+ const player = this.room.findPlayerById(playerId);
+ if (!player) {
+ throw new Error('玩家不存在');
+ }
+
+ if (player.isBot) {
+ throw new Error('Bot无需准备');
+ }
+
+ // 切换准备状态
+ player.isReady = !player.isReady;
+ logger.info(`房间 ${this.room.id} 玩家 ${player.name} ${player.isReady ? '已准备' : '取消准备'}`);
+
+ // 检查是否所有真人玩家都准备好了
+ const humanPlayers = this.room.players.filter(p => !p.isBot);
+ const allReady = humanPlayers.every(p => p.isReady);
+
+ if (allReady) {
+ logger.info(`房间 ${this.room.id} 所有玩家准备完毕`);
+ // 清除准备等待状态
+ this.room.gameState.isWaitingForReady = false;
+ if (!this.room.gameState.isRuleSelectionPending) {
+ this.startDrawing();
+ }
+ }
+
+ return allReady;
+ }
+
+ /**
+ * 开始发牌
+ */
+ startDrawing() {
+ // 创建并启动摸牌管理器
+ this.drawingManager = new DrawingPhaseManager(
+ this.room,
+ this.io,
+ dealer => this.handleDealerAssigned(dealer),
+ () => this.handleDrawingComplete(),
+ dealer => this.handleDealerSelected(dealer),
+ this.random,
+ (player, card) => this.handleRuleCardDealt(player, card)
+ );
+ this.drawingManager.start();
+
+ // 广播开始发牌
+ this.io.to(this.room.id).emit('start_drawing', {
+ phase: GamePhases.DRAWING
+ });
+ }
+
+ /** 发牌结束后开放最后的亮主/反主窗口;换牌必须等庄家锁定后才开始。 */
+ handleDrawingComplete() {
+ this.room.gameState.postDrawStage = 'trump_window';
+ this.drawingManager?.startDealerCountdown();
+ this.broadcastRoomUpdate();
+ }
+
+ /** “二鬼拍门”:摸牌时一旦达到两王,余下整局持续公开该玩家仍持有的全部王。 */
+ handleRuleCardDealt(player) {
+ const { gameState } = this.room;
+ let ruleResult = null;
+
+ if (
+ isTwoGhostsKnockDoorRule(gameState.selectedRule)
+ && gameState.phase === GamePhases.DRAWING
+ && player
+ ) {
+ const jokers = player.cards.filter(card => card.suit === Suits.JOKER);
+ const wasRevealed = gameState.twoGhostsRevealedPlayerIds.has(player.id);
+ if (wasRevealed || jokers.length >= 2) {
+ gameState.twoGhostsRevealedPlayerIds.add(player.id);
+ this.emitRuleVisibleHands(
+ wasRevealed
+ ? null
+ : `${player.name} 二鬼拍门:明置手中的全部王`
+ );
+ ruleResult = {
+ triggered: !wasRevealed,
+ playerId: player.id,
+ cards: jokers.map(card => card.toJSON())
+ };
+ }
+ }
+
+ this.tryWhoDesignedBotTrumpDeclaration(player);
+ return ruleResult;
+ }
+
+ /**
+ * WhoDesigned 原程序会在每次摸牌后调用 call_Snatch。旧适配器只接了
+ * 出牌阶段,导致 Bot 永远不会主动亮主或反主。
+ */
+ tryWhoDesignedBotTrumpDeclaration(player) {
+ const { gameState } = this.room;
+ if (
+ !player?.isBot
+ || !shouldUseWhoDesignedStrategy(this.room)
+ || gameState.phase !== GamePhases.DRAWING
+ || gameState.isTrumpDeclarationLocked
+ || gameState.cardExchange
+ || !gameState.trumpRank
+ ) return null;
+
+ // 这两条 DLC 使用多份独立声明状态;WhoDesigned 原版只支持普通单主牌局。
+ if (
+ isOneCountryTwoSystemsRule(gameState.selectedRule)
+ || isThreeSixNineGradesRule(gameState.selectedRule)
+ ) return null;
+
+ const decision = chooseWhoDesignedTrumpDeclaration({
+ cards: player.cards,
+ trumpRank: gameState.trumpRank,
+ currentTrumpDeclaration: gameState.currentTrumpDeclaration,
+ playerId: player.id
+ });
+ if (!decision) return null;
+
+ const previousDeclaration = gameState.currentTrumpDeclaration;
+ const validation = validateDeclaration(
+ player.cards,
+ decision.suit,
+ decision.count,
+ gameState.trumpRank,
+ previousDeclaration,
+ player.id
+ );
+ if (!validation.valid) return null;
+
+ const declaration = {
+ playerId: player.id,
+ playerName: player.name,
+ suit: decision.suit,
+ count: decision.count,
+ declarationType: validation.declarationType,
+ strength: validation.strength,
+ jokerType: validation.jokerType,
+ declarationRole: 'trump',
+ isCounter: previousDeclaration !== null,
+ cards: validation.cards
+ };
+
+ if (
+ previousDeclaration
+ && previousDeclaration.playerId !== player.id
+ && isRemoveFirewoodRule(gameState.selectedRule)
+ ) {
+ gameState.removeFirewoodCounterPairs.push({
+ sequence: gameState.removeFirewoodCounterPairs.length + 1,
+ counteredPlayerId: previousDeclaration.playerId,
+ counteredPlayerName: previousDeclaration.playerName,
+ counteringPlayerId: player.id,
+ counteringPlayerName: player.name
+ });
+ }
+
+ gameState.currentTrumpDeclaration = declaration;
+ gameState.trumpSuit = decision.suit;
+ this.io.to(this.room.id).emit('trump_declared', {
+ playerId: player.id,
+ playerName: player.name,
+ suit: decision.suit,
+ count: decision.count,
+ declarationType: validation.declarationType,
+ strength: validation.strength,
+ isCounter: declaration.isCounter,
+ declarationRole: 'trump',
+ teamIndex: null,
+ oneCountryTwoSystems: false,
+ cards: validation.cards.map(card => card.toJSON())
+ });
+ this.io.to(this.room.id).emit('trump_updated', {
+ trumpSuit: gameState.trumpSuit,
+ trumpRank: gameState.trumpRank,
+ oneCountryTwoSystems: null,
+ inferiorSuit: null
+ });
+ logger.info(
+ `WhoDesigned Bot ${player.name} ${declaration.isCounter ? '反主' : '亮主'}: `
+ + `${decision.count === 2 ? '一对' : '单张'} ${decision.suit}`
+ );
+ return declaration;
+ }
+
+ /** 庄家锁定后、收底牌前,为开局换牌规则插入四家同时换牌。 */
+ handleDealerSelected(dealer = null) {
+ if (this.startRemoveFirewoodExchange(dealer)) return true;
+ this.applyFatalBeautyTrumpBonus();
+ if (isHappyTwinsRule(this.room.gameState.selectedRule)) {
+ this.applyHappyTwinsPositionSwap(dealer);
+ }
+ if (!isOpeningCardExchangeRule(this.room.gameState.selectedRule)) return false;
+ this.startOpeningCardExchange();
+ return true;
+ }
+
+ /**
+ * “釜底抽薪”:庄家已按最终反主结果锁定,但尚未收底牌。
+ * 每次反主形成一对“被反主者—反主者”,亮主结束后从最后一对向前询问。
+ */
+ startRemoveFirewoodExchange(dealer = null) {
+ const { gameState } = this.room;
+ if (!isRemoveFirewoodRule(gameState.selectedRule)) return false;
+ if (gameState.removeFirewoodCounterPairs.length === 0) return false;
+ if (gameState.removeFirewoodCurrentDecision || gameState.removeFirewoodExchangeQueue.length > 0) {
+ return true;
+ }
+
+ gameState.removeFirewoodExchangeQueue = [...gameState.removeFirewoodCounterPairs]
+ .reverse()
+ .map(pair => ({ ...pair }));
+ gameState.removeFirewoodExchangeResults = [];
+ gameState.postDrawStage = 'remove_firewood_exchange';
+ if (dealer?.id) gameState.pendingDealerPlayerId = dealer.id;
+
+ this.io.to(this.room.id).emit('remove_firewood_exchange_started', {
+ totalCount: gameState.removeFirewoodExchangeQueue.length,
+ message: '反主结算完成,开始由后向前询问釜底抽薪'
+ });
+ this.advanceRemoveFirewoodExchange();
+ return true;
+ }
+
+ advanceRemoveFirewoodExchange() {
+ const { gameState } = this.room;
+ if (!isRemoveFirewoodRule(gameState.selectedRule)) return null;
+ if (gameState.removeFirewoodCurrentDecision) {
+ return gameState.removeFirewoodCurrentDecision;
+ }
+
+ const nextPair = gameState.removeFirewoodExchangeQueue.shift() || null;
+ if (!nextPair) {
+ gameState.postDrawStage = null;
+ this.io.to(this.room.id).emit('remove_firewood_exchange_completed', {
+ totalCount: gameState.removeFirewoodExchangeResults.length,
+ exchangedCount: gameState.removeFirewoodExchangeResults.filter(result => result.accepted).length
+ });
+ this.broadcastRoomUpdate();
+ if (gameState.pendingDealerPlayerId) {
+ this.drawingManager?.completeDealerAssignment(gameState.pendingDealerPlayerId);
+ }
+ return null;
+ }
+
+ const decision = {
+ ...nextPair,
+ remainingCount: gameState.removeFirewoodExchangeQueue.length
+ };
+ gameState.removeFirewoodCurrentDecision = decision;
+ this.io.to(this.room.id).emit('remove_firewood_decision_pending', decision);
+ this.broadcastRoomUpdate();
+
+ const counteredPlayer = this.room.findPlayerById(decision.counteredPlayerId);
+ if (!counteredPlayer) {
+ throw new Error('被反主的玩家不存在');
+ }
+ if (counteredPlayer.isBot) {
+ // Bot同样拥有选择权;在不知道对方手牌的前提下随机决定,绝不强制交换。
+ return this.respondRemoveFirewood(counteredPlayer.id, this.random() < 0.5);
+ }
+ if (counteredPlayer.socketId) {
+ this.io.to(counteredPlayer.socketId).emit('remove_firewood_decision_required', decision);
+ }
+ return decision;
+ }
+
+ respondRemoveFirewood(playerId, accept = false) {
+ const { gameState } = this.room;
+ if (!isRemoveFirewoodRule(gameState.selectedRule)) {
+ throw new Error('当前规则不是釜底抽薪');
+ }
+ const decision = gameState.removeFirewoodCurrentDecision;
+ if (!decision) throw new Error('当前没有待处理的釜底抽薪');
+ if (decision.counteredPlayerId !== playerId) {
+ throw new Error('只有本次被反主的玩家可以决定是否交换');
+ }
+
+ const counteredPlayer = this.room.findPlayerById(decision.counteredPlayerId);
+ const counteringPlayer = this.room.findPlayerById(decision.counteringPlayerId);
+ if (!counteredPlayer || !counteringPlayer) throw new Error('交换玩家不存在');
+
+ const accepted = accept === true;
+ let exchange = null;
+ if (accepted) {
+ const counteredHand = [...counteredPlayer.cards];
+ const counteringHand = [...counteringPlayer.cards];
+ counteredPlayer.cards = DeckService.autoSortCards(counteringHand);
+ counteringPlayer.cards = DeckService.autoSortCards(counteredHand);
+ counteredPlayer.shownCards.clear();
+ counteringPlayer.shownCards.clear();
+
+ exchange = {
+ triggerKey: `remove_firewood_${decision.sequence}`,
+ ruleName: '釜底抽薪',
+ exchangeKind: 'pair',
+ actorPlayerId: counteredPlayer.id,
+ actorPlayerName: counteredPlayer.name,
+ targetPlayerId: counteringPlayer.id,
+ targetPlayerName: counteringPlayer.name,
+ transfers: [
+ {
+ fromPlayerId: counteredPlayer.id,
+ fromPlayerName: counteredPlayer.name,
+ toPlayerId: counteringPlayer.id,
+ toPlayerName: counteringPlayer.name,
+ cardsCount: counteredHand.length
+ },
+ {
+ fromPlayerId: counteringPlayer.id,
+ fromPlayerName: counteringPlayer.name,
+ toPlayerId: counteredPlayer.id,
+ toPlayerName: counteredPlayer.name,
+ cardsCount: counteringHand.length
+ }
+ ],
+ animationDuration: WHOLE_HAND_EXCHANGE_ANIMATION_MS
+ };
+ this.io.to(this.room.id).emit('whole_hand_exchange_resolved', exchange);
+ [counteredPlayer, counteringPlayer].forEach(player => {
+ const other = player.id === counteredPlayer.id ? counteringPlayer : counteredPlayer;
+ if (!player.socketId) return;
+ this.io.to(player.socketId).emit('whole_hand_exchange_hand_updated', {
+ ...exchange,
+ fromPlayerId: other.id,
+ fromPlayerName: other.name,
+ cards: player.cards.map(card => card.toJSON())
+ });
+ });
+ }
+
+ const result = {
+ ...decision,
+ accepted,
+ exchange
+ };
+ gameState.removeFirewoodExchangeResults.push({
+ sequence: decision.sequence,
+ counteredPlayerId: decision.counteredPlayerId,
+ counteredPlayerName: decision.counteredPlayerName,
+ counteringPlayerId: decision.counteringPlayerId,
+ counteringPlayerName: decision.counteringPlayerName,
+ accepted
+ });
+ gameState.removeFirewoodCurrentDecision = null;
+ this.io.to(this.room.id).emit('remove_firewood_decision_resolved', {
+ sequence: decision.sequence,
+ counteredPlayerId: decision.counteredPlayerId,
+ counteredPlayerName: decision.counteredPlayerName,
+ counteringPlayerId: decision.counteringPlayerId,
+ counteringPlayerName: decision.counteringPlayerName,
+ accepted,
+ remainingCount: gameState.removeFirewoodExchangeQueue.length
+ });
+ logger.info(
+ `房间 ${this.room.id} 釜底抽薪:${counteredPlayer.name}` +
+ `${accepted ? `与 ${counteringPlayer.name} 交换全部手牌` : '选择不交换'}`
+ );
+
+ this.advanceRemoveFirewoodExchange();
+ return result;
+ }
+
+ applyFatalBeautyTrumpBonus() {
+ const { gameState } = this.room;
+ if (
+ !isFatalBeautyRule(gameState.selectedRule)
+ || gameState.fatalBeautyTrumpBonusApplied
+ || gameState.trumpSuit !== 'hearts'
+ ) {
+ return false;
+ }
+ gameState.attackerScore += 20;
+ gameState.fatalBeautyTrumpBonusApplied = true;
+ this.io.to(this.room.id).emit('fatal_beauty_trump_bonus', {
+ bonus: 20,
+ attackerScore: gameState.attackerScore
+ });
+ logger.info(`房间 ${this.room.id} 红颜祸水:红桃为主,闲家开局分调整为 ${gameState.attackerScore}`);
+ return true;
+ }
+
+ /**
+ * 欢乐成双只改变座次,不改变开局时的组队关系。
+ * 规则生效期间必须按换位前的玩家顺序取队伍,不能直接用当前数组下标奇偶。
+ */
+ getPlayerTeamIndex(playerId) {
+ if (!playerId) return null;
+ const happyTwins = this.room.gameState.happyTwins;
+ if (
+ isHappyTwinsRule(this.room.gameState.selectedRule)
+ && Array.isArray(happyTwins?.originalOrderPlayerIds)
+ ) {
+ const originalIndex = happyTwins.originalOrderPlayerIds.indexOf(playerId);
+ if (originalIndex >= 0) return originalIndex % 2;
+ }
+ const currentIndex = this.room.getPlayerIndex(playerId);
+ return currentIndex >= 0 ? currentIndex % 2 : null;
+ }
+
+ isAttackerPlayerIndex(playerIndex, dealerIndex) {
+ const player = this.room.findPlayerByIndex(playerIndex);
+ const dealer = this.room.findPlayerByIndex(dealerIndex);
+ const playerTeamIndex = this.getPlayerTeamIndex(player?.id);
+ const dealerTeamIndex = this.getPlayerTeamIndex(dealer?.id);
+ if (playerTeamIndex !== null && dealerTeamIndex !== null) {
+ return playerTeamIndex !== dealerTeamIndex;
+ }
+ return isAttacker(playerIndex, dealerIndex, this.room.players.length);
+ }
+
+ getFixedTeammate(playerId) {
+ const happyTwins = this.room.gameState.happyTwins;
+ if (
+ isHappyTwinsRule(this.room.gameState.selectedRule)
+ && Array.isArray(happyTwins?.originalOrderPlayerIds)
+ && happyTwins.originalOrderPlayerIds.length === 4
+ ) {
+ const originalIndex = happyTwins.originalOrderPlayerIds.indexOf(playerId);
+ if (originalIndex >= 0) {
+ const teammateId = happyTwins.originalOrderPlayerIds[(originalIndex + 2) % 4];
+ return this.room.findPlayerById(teammateId);
+ }
+ }
+ const playerIndex = this.room.getPlayerIndex(playerId);
+ if (playerIndex < 0 || this.room.players.length !== 4) return null;
+ return this.room.findPlayerByIndex((playerIndex + 2) % 4);
+ }
+
+ requestSurrender(playerId) {
+ const { gameState } = this.room;
+ if (![GamePhases.DRAWING, GamePhases.BURYING, GamePhases.PLAYING].includes(gameState.phase)) {
+ throw new Error('当前牌局不能发起投降');
+ }
+ if (isBurnTheBoatsRule(gameState.selectedRule)) {
+ throw new Error('破釜沉舟规则禁止投降');
+ }
+ if (gameState.surrenderCurrentDecision || gameState.surrenderDecisionQueue.length > 0) {
+ throw new Error('本轮投降表决已经开始,请等待处理完成');
+ }
+ const player = this.room.findPlayerById(playerId);
+ if (!player) throw new Error('玩家不存在');
+ if (gameState.surrenderRequests.has(player.id)) {
+ throw new Error('你已经发起投降,请等待本轮结束');
+ }
+
+ const request = {
+ initiatorPlayerId: player.id,
+ initiatorPlayerName: player.name,
+ requestedRound: gameState.currentRound > 0 ? gameState.currentRound : null,
+ requestedAt: new Date().toISOString()
+ };
+ gameState.surrenderRequests.set(player.id, request);
+ logger.info(
+ `房间 ${this.room.id} 玩家 ${player.name} 发起投降,等待本轮完整结束后询问队友`
+ );
+ return { ...request };
+ }
+
+ hasPendingSurrenderDecision() {
+ const { gameState } = this.room;
+ return Boolean(
+ gameState.surrenderCurrentDecision
+ || gameState.surrenderDecisionQueue.length > 0
+ );
+ }
+
+ getSurrenderPublicDecision(decision = this.room.gameState.surrenderCurrentDecision) {
+ return decision ? { ...decision } : null;
+ }
+
+ isSurrenderReviewReady() {
+ const { gameState } = this.room;
+ if (
+ gameState.phase !== GamePhases.PLAYING
+ || gameState.surrenderRequests.size === 0
+ || gameState.currentRound <= 1
+ || gameState.currentRoundPlays.length > 0
+ || gameState.playersPlayedThisRound.size > 0
+ || !Number.isInteger(gameState.lastRoundWinnerIndex)
+ ) return false;
+
+ return !(
+ this.hasPendingTimeReversalDecision()
+ || this.hasPendingMutualSupportAction()
+ || this.hasPendingStrawBoatBorrowingArrowsDecision()
+ || this.hasPendingTeammateCheerDecision()
+ || this.hasPendingAfterglowDecision()
+ || this.hasPendingNinePrincesDecision()
+ || this.hasPendingAmbiguousChoice()
+ || this.hasPendingDestroyDykeDecision()
+ || this.hasPendingWaitingRabbitDecision()
+ || this.hasPendingPoliticalReviewDecision()
+ || this.hasPendingForbiddenMagicDecision()
+ || this.hasPendingLureTigerDecision()
+ || this.hasPendingWoodenOxDecision()
+ || this.hasPendingEquivalentReciprocityChallenge()
+ || this.hasPendingIcebergSelection()
+ || this.hasPendingTenSidedAmbushSelection()
+ || this.hasPendingThreePowersSelection()
+ || this.hasPendingGentlemanPromiseSelection()
+ || this.hasPendingHiddenDragonSelection()
+ || this.hasPendingAntinomySelection()
+ || this.hasPendingRiceToMulberrySelection()
+ || this.hasPendingAdministrativeReviewSelection()
+ || this.hasPendingFocusFigureVote()
+ || this.hasPendingMainstayAction()
+ || this.hasPendingLastStandDecision()
+ || gameState.cardExchange?.stage === 'round'
+ );
+ }
+
+ prepareSurrenderReview({
+ completedRound = this.room.gameState.currentRound - 1,
+ finishGameAfterReview = false
+ } = {}) {
+ const { gameState } = this.room;
+ if (finishGameAfterReview && gameState.surrenderRequests.size > 0) {
+ gameState.surrenderFinishGameAfterReview = true;
+ }
+ if (this.hasPendingSurrenderDecision()) {
+ return this.getSurrenderPublicDecision();
+ }
+ if (!this.isSurrenderReviewReady()) return null;
+
+ const dealer = this.room.findPlayerById(gameState.buryingPlayerId);
+ const dealerIndex = this.room.getPlayerIndex(dealer?.id);
+ if (!dealer || dealerIndex < 0) return null;
+
+ const orderByPlayerId = new Map();
+ for (let offset = 0; offset < this.room.players.length; offset += 1) {
+ const orderedPlayer = this.room.findPlayerByIndex(
+ (dealerIndex + offset) % this.room.players.length
+ );
+ if (orderedPlayer) orderByPlayerId.set(orderedPlayer.id, offset);
+ }
+
+ const decisions = Array.from(gameState.surrenderRequests.values())
+ .sort((left, right) => (
+ (orderByPlayerId.get(left.initiatorPlayerId) ?? Number.MAX_SAFE_INTEGER)
+ - (orderByPlayerId.get(right.initiatorPlayerId) ?? Number.MAX_SAFE_INTEGER)
+ ))
+ .map(request => {
+ const initiator = this.room.findPlayerById(request.initiatorPlayerId);
+ const teammate = this.getFixedTeammate(request.initiatorPlayerId);
+ if (!initiator || !teammate) return null;
+ const initiatorTeamIndex = this.getPlayerTeamIndex(initiator.id);
+ const dealerTeamIndex = this.getPlayerTeamIndex(dealer.id);
+ return {
+ id: `surrender-${completedRound}-${initiator.id}`,
+ completedRound,
+ initiatorPlayerId: initiator.id,
+ initiatorPlayerName: initiator.name,
+ teammatePlayerId: teammate.id,
+ teammatePlayerName: teammate.name,
+ surrenderingSide: initiatorTeamIndex === dealerTeamIndex ? 'dealer' : 'attacker',
+ attackerScore: gameState.attackerScore
+ };
+ })
+ .filter(Boolean);
+
+ gameState.surrenderRequests.clear();
+ gameState.surrenderFinishGameAfterReview = Boolean(
+ gameState.surrenderFinishGameAfterReview || finishGameAfterReview
+ );
+ const decisionCount = decisions.length;
+ gameState.surrenderDecisionQueue = decisions;
+ gameState.surrenderCurrentDecision = gameState.surrenderDecisionQueue.shift() || null;
+ if (!gameState.surrenderCurrentDecision) return null;
+
+ logger.info(
+ `房间 ${this.room.id} 第${completedRound}轮结束,开始按庄家起顺序处理` +
+ `${decisionCount}个投降申请`
+ );
+ return this.getSurrenderPublicDecision();
+ }
+
+ getDealerVictoryLevelUp(attackerScore) {
+ if (attackerScore === 0) return 3;
+ if (attackerScore < 40) return 2;
+ return 1;
+ }
+
+ finishGameBySurrender(decision) {
+ const { gameState } = this.room;
+ const scoreBeforeSurrender = gameState.attackerScore;
+ const dealerPlayer = this.room.findPlayerById(gameState.buryingPlayerId)
+ || this.room.findPlayerByIndex(gameState.dealerPlayerIndex);
+ const dealerTeamIndex = this.getPlayerTeamIndex(dealerPlayer?.id);
+ // 只有投降被队友正式同意、牌局结束后才公开四家的剩余手牌。
+ // 在这里先于升级/换庄流程取快照,确保欢乐成双等临时换位规则下的身份仍然准确。
+ const revealedHands = this.room.players.map((player, seatIndex) => ({
+ playerId: player.id,
+ playerName: player.name,
+ seatIndex,
+ isDealer: player.id === dealerPlayer?.id,
+ side: this.getPlayerTeamIndex(player.id) === dealerTeamIndex ? 'dealer' : 'attacker',
+ cards: player.cards.map(card => card.toJSON ? card.toJSON() : card)
+ }));
+ let forcedUpgrade;
+
+ if (decision.surrenderingSide === 'dealer') {
+ gameState.attackerScore = scoreBeforeSurrender + 80;
+ forcedUpgrade = calculateLevelUpgrade(gameState.attackerScore);
+ } else {
+ forcedUpgrade = {
+ attackerWon: false,
+ dealerLevelUp: decision.completedRound <= 2
+ ? 1
+ : this.getDealerVictoryLevelUp(scoreBeforeSurrender),
+ attackerLevelUp: 0
+ };
+ }
+
+ const surrenderResult = {
+ accepted: true,
+ completedRound: decision.completedRound,
+ initiatorPlayerId: decision.initiatorPlayerId,
+ initiatorPlayerName: decision.initiatorPlayerName,
+ teammatePlayerId: decision.teammatePlayerId,
+ teammatePlayerName: decision.teammatePlayerName,
+ surrenderingSide: decision.surrenderingSide,
+ winningSide: decision.surrenderingSide === 'dealer' ? 'attacker' : 'dealer',
+ scoreBeforeSurrender,
+ scoreAdjustment: gameState.attackerScore - scoreBeforeSurrender,
+ finalAttackerScore: gameState.attackerScore,
+ earlyAttackerSurrender: decision.surrenderingSide === 'attacker'
+ && decision.completedRound <= 2,
+ revealedHands
+ };
+
+ gameState.phase = GamePhases.REVEALING;
+ gameState.endTime = new Date();
+ gameState.surrenderCurrentDecision = null;
+ gameState.surrenderDecisionQueue = [];
+ gameState.surrenderRequests.clear();
+ gameState.surrenderFinishGameAfterReview = false;
+ gameState.surrenderLastResult = surrenderResult;
+ gameState.bottomScoreResult = {
+ resultText: decision.surrenderingSide === 'dealer'
+ ? `${decision.initiatorPlayerName}一方投降,闲家方获胜`
+ : `${decision.initiatorPlayerName}一方投降,庄家方获胜`,
+ collectedPointCards: gameState.collectedPointCards.map(
+ card => card.toJSON ? card.toJSON() : card
+ ),
+ bottomCards: gameState.bottomCards.map(card => card.toJSON()),
+ bottomPoints: 0,
+ bottomMultiplier: 0,
+ bottomScoreGained: 0,
+ totalScore: gameState.attackerScore,
+ currentGameTrumpSuit: gameState.trumpSuit,
+ currentGameTrumpRank: gameState.trumpRank,
+ surrender: surrenderResult
+ };
+ gameState.upgradeResult = this.calculateUpgrade(forcedUpgrade);
+ this.room.players.forEach(player => {
+ player.isReadyForNext = player.isBot;
+ });
+ gameState.nextRuleChooserIndex = gameState.lastRoundWinnerIndex;
+
+ logger.info(
+ `房间 ${this.room.id} 投降结算:${gameState.bottomScoreResult.resultText},` +
+ `闲家结算分 ${gameState.attackerScore}`
+ );
+ return surrenderResult;
+ }
+
+ respondSurrender(playerId, accept) {
+ const { gameState } = this.room;
+ const decision = gameState.surrenderCurrentDecision;
+ if (!decision || decision.teammatePlayerId !== playerId) {
+ throw new Error('当前没有等待你的投降决定');
+ }
+ const responder = this.room.findPlayerById(playerId);
+ if (!responder) throw new Error('玩家不存在');
+
+ gameState.surrenderCurrentDecision = null;
+ if (accept) {
+ const surrender = this.finishGameBySurrender(decision);
+ return {
+ accepted: true,
+ gameFinished: true,
+ surrender,
+ decision,
+ nextDecision: null
+ };
+ }
+
+ const rejection = {
+ accepted: false,
+ completedRound: decision.completedRound,
+ initiatorPlayerId: decision.initiatorPlayerId,
+ initiatorPlayerName: decision.initiatorPlayerName,
+ teammatePlayerId: responder.id,
+ teammatePlayerName: responder.name
+ };
+ gameState.surrenderLastResult = rejection;
+ gameState.surrenderCurrentDecision = gameState.surrenderDecisionQueue.shift() || null;
+ if (gameState.surrenderCurrentDecision) {
+ return {
+ accepted: false,
+ gameFinished: false,
+ rejection,
+ decision,
+ nextDecision: this.getSurrenderPublicDecision()
+ };
+ }
+
+ const shouldFinishGame = gameState.surrenderFinishGameAfterReview
+ || this.room.players.every(player => this.getPlayableCardCount(player) === 0);
+ gameState.surrenderFinishGameAfterReview = false;
+ if (shouldFinishGame) this.finishGame();
+ if (!shouldFinishGame && isForbiddenMagicRule(gameState.selectedRule)) {
+ this.enqueueForbiddenMagicRoundDecisions();
+ }
+ if (!shouldFinishGame && isLureTigerFromMountainRule(gameState.selectedRule)) {
+ this.enqueueLureTigerRoundDecisions();
+ }
+ return {
+ accepted: false,
+ gameFinished: shouldFinishGame,
+ rejection,
+ decision,
+ nextDecision: null
+ };
+ }
+
+ /** “欢乐成双”:庄家锁定后、收底牌前,与其固定上家(索引-1)交换座位。 */
+ applyHappyTwinsPositionSwap(dealer = null) {
+ const { gameState, players } = this.room;
+ if (!isHappyTwinsRule(gameState.selectedRule)) return null;
+ if (players.length !== 4) throw new Error('欢乐成双仅支持四人局');
+ if (gameState.happyTwins?.active && !gameState.happyTwins.restored) {
+ return gameState.happyTwins;
+ }
+
+ const dealerPlayer = dealer || this.room.findPlayerById(gameState.pendingDealerPlayerId);
+ if (!dealerPlayer) throw new Error('欢乐成双需要先锁定庄家');
+ const dealerIndex = this.room.getPlayerIndex(dealerPlayer.id);
+ if (dealerIndex < 0) throw new Error('欢乐成双找不到庄家座位');
+ const upstreamIndex = (dealerIndex - 1 + players.length) % players.length;
+ const upstreamPlayer = players[upstreamIndex];
+ const originalOrderPlayerIds = players.map(player => player.id);
+ const playerIdAtIndex = index => (
+ Number.isInteger(index) && players[index] ? players[index].id : null
+ );
+ // 换位前先把所有“按数组索引保存”的行动状态还原成玩家 ID。
+ // 否则索引会继续指向原物理位置,换位后就可能让庄家原位置上的上家先出牌。
+ const indexedPlayerIds = {
+ currentPlayerId: playerIdAtIndex(gameState.currentPlayerIndex),
+ roundStartPlayerId: playerIdAtIndex(gameState.roundStartPlayerIndex),
+ currentWinnerPlayerId: playerIdAtIndex(gameState.currentWinnerIndex),
+ lastRoundWinnerPlayerId: playerIdAtIndex(gameState.lastRoundWinnerIndex),
+ nextRuleChooserPlayerId: playerIdAtIndex(gameState.nextRuleChooserIndex)
+ };
+
+ [players[dealerIndex], players[upstreamIndex]] = [
+ players[upstreamIndex],
+ players[dealerIndex]
+ ];
+ players.forEach((player, index) => {
+ player.position = index;
+ });
+
+ const swappedDealerIndex = this.room.getPlayerIndex(dealerPlayer.id);
+ gameState.dealerPlayerIndex = swappedDealerIndex;
+ const remapIndex = playerId => (
+ playerId ? this.room.getPlayerIndex(playerId) : null
+ );
+ if (indexedPlayerIds.currentPlayerId) {
+ gameState.currentPlayerIndex = remapIndex(indexedPlayerIds.currentPlayerId);
+ }
+ if (indexedPlayerIds.roundStartPlayerId) {
+ gameState.roundStartPlayerIndex = remapIndex(indexedPlayerIds.roundStartPlayerId);
+ }
+ if (indexedPlayerIds.currentWinnerPlayerId) {
+ gameState.currentWinnerIndex = remapIndex(indexedPlayerIds.currentWinnerPlayerId);
+ }
+ if (indexedPlayerIds.lastRoundWinnerPlayerId) {
+ gameState.lastRoundWinnerIndex = remapIndex(indexedPlayerIds.lastRoundWinnerPlayerId);
+ }
+ if (indexedPlayerIds.nextRuleChooserPlayerId) {
+ gameState.nextRuleChooserIndex = remapIndex(indexedPlayerIds.nextRuleChooserPlayerId);
+ }
+ gameState.happyTwins = {
+ active: true,
+ restored: false,
+ dealerPlayerId: dealerPlayer.id,
+ dealerPlayerName: dealerPlayer.name,
+ upstreamPlayerId: upstreamPlayer.id,
+ upstreamPlayerName: upstreamPlayer.name,
+ originalDealerIndex: dealerIndex,
+ originalUpstreamIndex: upstreamIndex,
+ swappedDealerIndex,
+ swappedUpstreamIndex: this.room.getPlayerIndex(upstreamPlayer.id),
+ originalOrderPlayerIds,
+ swappedOrderPlayerIds: players.map(player => player.id),
+ nextDealerPlayerId: null,
+ nextDealerIndex: null
+ };
+
+ this.io.to(this.room.id).emit('happy_twins_positions_swapped', {
+ dealerPlayerId: dealerPlayer.id,
+ dealerPlayerName: dealerPlayer.name,
+ upstreamPlayerId: upstreamPlayer.id,
+ upstreamPlayerName: upstreamPlayer.name,
+ dealerIndex: swappedDealerIndex,
+ upstreamIndex: gameState.happyTwins.swappedUpstreamIndex
+ });
+ this.broadcastRoomUpdate();
+ logger.info(
+ `房间 ${this.room.id} 欢乐成双:庄家 ${dealerPlayer.name} 与上家 ${upstreamPlayer.name} 交换位置`
+ );
+ return gameState.happyTwins;
+ }
+
+ /**
+ * 终局先在临时座次中确定下一庄具体玩家,再恢复原顺序并按玩家ID重算索引。
+ * 同时把仍需跨局使用的赢家索引一并重映射,避免下局规则选择者错位。
+ */
+ restoreHappyTwinsPositions(nextDealerPlayerId = null) {
+ const { gameState } = this.room;
+ const state = gameState.happyTwins;
+ if (!isHappyTwinsRule(gameState.selectedRule) || !state || state.restored) return null;
+
+ const previousOrder = [...this.room.players];
+ const playerById = new Map(previousOrder.map(player => [player.id, player]));
+ const restoredPlayers = state.originalOrderPlayerIds.map(playerId => playerById.get(playerId));
+ if (restoredPlayers.some(player => !player)) {
+ throw new Error('欢乐成双无法恢复原座次:玩家顺序不完整');
+ }
+
+ const playerIdAtIndex = index => (
+ Number.isInteger(index) && previousOrder[index] ? previousOrder[index].id : null
+ );
+ const lastRoundWinnerPlayerId = playerIdAtIndex(gameState.lastRoundWinnerIndex);
+ const currentPlayerId = playerIdAtIndex(gameState.currentPlayerIndex);
+ const roundStartPlayerId = playerIdAtIndex(gameState.roundStartPlayerIndex);
+ const currentWinnerPlayerId = playerIdAtIndex(gameState.currentWinnerIndex);
+ const nextRuleChooserPlayerId = playerIdAtIndex(gameState.nextRuleChooserIndex);
+
+ this.room.players = restoredPlayers;
+ this.room.players.forEach((player, index) => {
+ player.position = index;
+ });
+ const restoredIndexOf = playerId => (
+ playerId ? this.room.getPlayerIndex(playerId) : null
+ );
+
+ if (lastRoundWinnerPlayerId) {
+ gameState.lastRoundWinnerIndex = restoredIndexOf(lastRoundWinnerPlayerId);
+ }
+ if (currentPlayerId) gameState.currentPlayerIndex = restoredIndexOf(currentPlayerId);
+ if (roundStartPlayerId) gameState.roundStartPlayerIndex = restoredIndexOf(roundStartPlayerId);
+ if (currentWinnerPlayerId) gameState.currentWinnerIndex = restoredIndexOf(currentWinnerPlayerId);
+ if (nextRuleChooserPlayerId) {
+ gameState.nextRuleChooserIndex = restoredIndexOf(nextRuleChooserPlayerId);
+ }
+ gameState.playHistory = gameState.playHistory.map(entry => ({
+ ...entry,
+ playerIndex: restoredIndexOf(entry.playerId)
+ }));
+
+ const resolvedNextDealerPlayerId = nextDealerPlayerId || state.dealerPlayerId;
+ const restoredNextDealerIndex = restoredIndexOf(resolvedNextDealerPlayerId);
+ if (!Number.isInteger(restoredNextDealerIndex) || restoredNextDealerIndex < 0) {
+ throw new Error('欢乐成双无法在原座次中找到下一局庄家');
+ }
+ gameState.dealerPlayerIndex = restoredNextDealerIndex;
+ state.active = false;
+ state.restored = true;
+ state.nextDealerPlayerId = resolvedNextDealerPlayerId;
+ state.nextDealerIndex = restoredNextDealerIndex;
+ state.restoredOrderPlayerIds = this.room.players.map(player => player.id);
+
+ const nextDealer = this.room.findPlayerById(resolvedNextDealerPlayerId);
+ this.io.to(this.room.id).emit('happy_twins_positions_restored', {
+ dealerPlayerId: state.dealerPlayerId,
+ dealerPlayerName: state.dealerPlayerName,
+ upstreamPlayerId: state.upstreamPlayerId,
+ upstreamPlayerName: state.upstreamPlayerName,
+ nextDealerPlayerId: resolvedNextDealerPlayerId,
+ nextDealerPlayerName: nextDealer?.name || '未知玩家',
+ nextDealerIndex: restoredNextDealerIndex
+ });
+ logger.info(
+ `房间 ${this.room.id} 欢乐成双:恢复原座次,下一局由 ${nextDealer?.name || '未知玩家'} 上庄`
+ );
+ return {
+ nextDealerPlayerId: resolvedNextDealerPlayerId,
+ nextDealerIndex: restoredNextDealerIndex,
+ lastRoundWinnerPlayerId,
+ restoredOrderPlayerIds: [...state.restoredOrderPlayerIds]
+ };
+ }
+
+ /**
+ * “中流砥柱”必须按一至四号位串行处理。队友收到牌后,等轮到自己时会用
+ * 已变化的实时手牌重新计算主牌数,因此同队两人可以先后发动并把主牌交回去。
+ */
+ startMainstay({ completionMode = 'opening' } = {}) {
+ const { gameState, players } = this.room;
+ if (!isMainstayRule(gameState.selectedRule)) return false;
+ if (gameState.phase !== GamePhases.PLAYING) {
+ throw new Error('只有庄家完成埋底后才能开始中流砥柱');
+ }
+ if (players.length !== 4) throw new Error('中流砥柱仅支持四人局');
+
+ gameState.isTrumpDeclarationLocked = true;
+ gameState.mainstayPlayerQueue = [];
+ gameState.mainstayCurrentAction = null;
+ gameState.mainstayResults = [];
+
+ if (!gameState.trumpSuit || gameState.trumpSuit === Suits.NO_TRUMP) {
+ this.io.to(this.room.id).emit('mainstay_completed', {
+ skipped: true,
+ reason: 'no_trump',
+ message: '无主局不执行中流砥柱'
+ });
+ return false;
+ }
+
+ gameState.postDrawStage = 'mainstay';
+ this.mainstayResumePlayerIndex = Number.isInteger(gameState.currentPlayerIndex)
+ ? gameState.currentPlayerIndex
+ : gameState.roundStartPlayerIndex;
+ this.mainstayCompletionMode = completionMode;
+ // 中流砥柱完成前没有任何玩家拥有出牌权;原行动位在流程结束后恢复。
+ gameState.currentPlayerIndex = null;
+ // 开局号位以庄家为一号位,沿玩家数组(牌桌逆时针)依次为二、三、四号位。
+ const firstPositionIndex = Number.isInteger(gameState.dealerPlayerIndex)
+ ? gameState.dealerPlayerIndex
+ : 0;
+ gameState.mainstayPlayerQueue = Array.from(
+ { length: players.length },
+ (_, offset) => players[(firstPositionIndex + offset) % players.length].id
+ );
+ this.io.to(this.room.id).emit('mainstay_started', {
+ ruleId: gameState.selectedRule.id,
+ ruleName: gameState.selectedRule.name,
+ requiredCards: MAINSTAY_CARD_COUNT,
+ playerOrder: [...gameState.mainstayPlayerQueue]
+ });
+ this.broadcastRoomUpdate();
+ this.advanceMainstay();
+ return true;
+ }
+
+ getMainstayTrumpCards(player) {
+ const { trumpSuit, trumpRank } = this.room.gameState;
+ return (player?.cards || []).filter(card => isTrumpCard(card, trumpSuit, trumpRank));
+ }
+
+ getMainstayTeammate(playerId) {
+ const playerIndex = this.room.getPlayerIndex(playerId);
+ if (playerIndex < 0 || this.room.players.length !== 4) return null;
+ return this.room.findPlayerByIndex((playerIndex + 2) % this.room.players.length);
+ }
+
+ emitMainstayActionRequired(action) {
+ if (!action) return;
+ const chooser = this.room.findPlayerById(action.chooserPlayerId);
+ const actor = this.room.findPlayerById(action.actorPlayerId);
+ const teammate = this.room.findPlayerById(action.teammatePlayerId);
+ const privatePayload = {
+ ...action,
+ actorPlayerName: actor?.name || '未知玩家',
+ teammatePlayerName: teammate?.name || '未知玩家'
+ };
+ const { trumpCount: _privateTrumpCount, ...publicPayload } = privatePayload;
+ this.io.to(this.room.id).emit('mainstay_action_pending', publicPayload);
+ if (!chooser?.socketId || chooser.isBot) return;
+ this.io.to(chooser.socketId).emit(
+ action.stage === 'decision' ? 'mainstay_decision_required' : 'mainstay_cards_required',
+ privatePayload
+ );
+ }
+
+ advanceMainstay() {
+ const { gameState } = this.room;
+ if (!isMainstayRule(gameState.selectedRule)) return null;
+ if (gameState.mainstayCurrentAction) return gameState.mainstayCurrentAction;
+
+ while (gameState.mainstayPlayerQueue.length > 0) {
+ const actorPlayerId = gameState.mainstayPlayerQueue.shift();
+ const actor = this.room.findPlayerById(actorPlayerId);
+ const teammate = this.getMainstayTeammate(actorPlayerId);
+ if (!actor || !teammate) throw new Error('中流砥柱的玩家或队友不存在');
+
+ const trumpCount = this.getMainstayTrumpCards(actor).length;
+ const firstPositionIndex = Number.isInteger(gameState.dealerPlayerIndex)
+ ? gameState.dealerPlayerIndex
+ : 0;
+ const actorIndex = this.room.getPlayerIndex(actor.id);
+ const position = ((actorIndex - firstPositionIndex + this.room.players.length)
+ % this.room.players.length) + 1;
+ if (trumpCount > MAINSTAY_CARD_COUNT || actor.cards.length < MAINSTAY_CARD_COUNT) {
+ const skipped = {
+ playerId: actor.id,
+ playerName: actor.name,
+ position,
+ trumpCount,
+ eligible: false,
+ accepted: false,
+ reason: trumpCount > MAINSTAY_CARD_COUNT ? 'too_many_trumps' : 'not_enough_cards'
+ };
+ gameState.mainstayResults.push(skipped);
+ const { trumpCount: _privateTrumpCount, ...publicSkipped } = skipped;
+ this.io.to(this.room.id).emit('mainstay_player_skipped', publicSkipped);
+ continue;
+ }
+
+ const action = {
+ id: `mainstay-${position}-${actor.id}-${Date.now()}`,
+ stage: 'decision',
+ position,
+ actorPlayerId: actor.id,
+ teammatePlayerId: teammate.id,
+ chooserPlayerId: actor.id,
+ trumpCount,
+ requiredCards: MAINSTAY_CARD_COUNT,
+ remainingCount: gameState.mainstayPlayerQueue.length
+ };
+ gameState.mainstayCurrentAction = action;
+ this.emitMainstayActionRequired(action);
+ this.broadcastRoomUpdate();
+
+ if (actor.isBot) return this.respondMainstay(actor.id, true);
+ return action;
+ }
+
+ gameState.mainstayCurrentAction = null;
+ gameState.postDrawStage = null;
+ const resumePlayerIndex = this.mainstayResumePlayerIndex;
+ const completionMode = this.mainstayCompletionMode;
+ this.mainstayResumePlayerIndex = null;
+ this.mainstayCompletionMode = null;
+ if (Number.isInteger(resumePlayerIndex)) {
+ gameState.currentPlayerIndex = resumePlayerIndex;
+ }
+ const results = gameState.mainstayResults.map(result => Object.fromEntries(
+ Object.entries(result).filter(([key]) => key !== 'trumpCount')
+ ));
+ this.io.to(this.room.id).emit('mainstay_completed', {
+ skipped: false,
+ results,
+ activatedCount: results.filter(result => result.accepted).length
+ });
+ this.broadcastRoomUpdate();
+ if (completionMode === 'opening') {
+ this.continueOpeningAfterBury();
+ } else if (this.onBotTurn) {
+ this.onBotTurn();
+ }
+ return null;
+ }
+
+ hasPendingMainstayAction() {
+ const { gameState } = this.room;
+ return gameState.postDrawStage === 'mainstay'
+ || Boolean(gameState.mainstayCurrentAction)
+ || gameState.mainstayPlayerQueue.length > 0;
+ }
+
+ respondMainstay(playerId, accept = false) {
+ const { gameState } = this.room;
+ if (!isMainstayRule(gameState.selectedRule)) throw new Error('当前规则不是中流砥柱');
+ const action = gameState.mainstayCurrentAction;
+ if (!action || action.stage !== 'decision') throw new Error('当前没有待决定的中流砥柱');
+ if (action.chooserPlayerId !== playerId) throw new Error('当前不需要你决定中流砥柱');
+
+ const actor = this.room.findPlayerById(action.actorPlayerId);
+ if (!actor) throw new Error('中流砥柱玩家不存在');
+ const accepted = accept === true;
+ this.io.to(this.room.id).emit('mainstay_decision_resolved', {
+ actionId: action.id,
+ playerId: actor.id,
+ playerName: actor.name,
+ accepted
+ });
+
+ if (!accepted) {
+ gameState.mainstayResults.push({
+ playerId: actor.id,
+ playerName: actor.name,
+ position: action.position,
+ trumpCount: action.trumpCount,
+ eligible: true,
+ accepted: false
+ });
+ gameState.mainstayCurrentAction = null;
+ this.broadcastRoomUpdate();
+ this.advanceMainstay();
+ return { resolved: true, accepted: false, actionId: action.id };
+ }
+
+ gameState.mainstayCurrentAction = {
+ ...action,
+ stage: 'give',
+ chooserPlayerId: action.actorPlayerId
+ };
+ this.emitMainstayActionRequired(gameState.mainstayCurrentAction);
+ this.broadcastRoomUpdate();
+ if (actor.isBot) {
+ return this.submitMainstayCards(
+ actor.id,
+ action.id,
+ this.selectMainstayCardsForBot(actor.id)
+ );
+ }
+ return {
+ resolved: false,
+ accepted: true,
+ actionId: action.id,
+ action: { ...gameState.mainstayCurrentAction }
+ };
+ }
+
+ selectMainstayCardsForBot(playerId) {
+ const { gameState } = this.room;
+ const action = gameState.mainstayCurrentAction;
+ const player = this.room.findPlayerById(playerId);
+ if (!action || !player?.isBot || action.chooserPlayerId !== playerId) return [];
+
+ const sorted = [...player.cards].sort((left, right) => (
+ getCardStrength(left, gameState.trumpSuit, gameState.trumpRank, gameState.selectedRule)
+ - getCardStrength(right, gameState.trumpSuit, gameState.trumpRank, gameState.selectedRule)
+ ));
+ if (action.stage === 'return') {
+ return sorted.slice(0, MAINSTAY_CARD_COUNT).map(card => card.id);
+ }
+ if (action.stage !== 'give') return [];
+
+ const trumpCards = this.getMainstayTrumpCards(player);
+ const trumpIds = new Set(trumpCards.map(card => card.id));
+ return [
+ ...trumpCards,
+ ...sorted.filter(card => !trumpIds.has(card.id))
+ ].slice(0, MAINSTAY_CARD_COUNT).map(card => card.id);
+ }
+
+ submitMainstayCards(playerId, actionId, cardIds = []) {
+ const { gameState } = this.room;
+ if (!isMainstayRule(gameState.selectedRule)) throw new Error('当前规则不是中流砥柱');
+ const action = gameState.mainstayCurrentAction;
+ if (!action || action.id !== actionId || !['give', 'return'].includes(action.stage)) {
+ throw new Error('这次中流砥柱交牌已经结束或不存在');
+ }
+ if (action.chooserPlayerId !== playerId) throw new Error('当前不需要你选择中流砥柱的牌');
+ if (!Array.isArray(cardIds) || cardIds.length !== MAINSTAY_CARD_COUNT) {
+ throw new Error(`必须选择${MAINSTAY_CARD_COUNT}张牌`);
+ }
+
+ const uniqueCardIds = [...new Set(cardIds)];
+ if (uniqueCardIds.length !== MAINSTAY_CARD_COUNT) throw new Error('不能重复选择同一张牌');
+ const fromPlayer = this.room.findPlayerById(playerId);
+ const toPlayerId = action.stage === 'give'
+ ? action.teammatePlayerId
+ : action.actorPlayerId;
+ const toPlayer = this.room.findPlayerById(toPlayerId);
+ if (!fromPlayer || !toPlayer) throw new Error('中流砥柱交牌玩家不存在');
+ const cards = uniqueCardIds.map(cardId => fromPlayer.cards.find(card => card.id === cardId));
+ if (cards.some(card => !card)) throw new Error('选择的牌已不在手中');
+
+ if (action.stage === 'give') {
+ const selectedIds = new Set(uniqueCardIds);
+ const missingTrump = this.getMainstayTrumpCards(fromPlayer)
+ .some(card => !selectedIds.has(card.id));
+ if (missingTrump) throw new Error('交出的5张牌必须包括当前全部主牌');
+ }
+
+ fromPlayer.removeCards(uniqueCardIds);
+ cards.forEach(card => toPlayer.addCard(card));
+ fromPlayer.cards = DeckService.autoSortCards(fromPlayer.cards);
+ toPlayer.cards = DeckService.autoSortCards(toPlayer.cards);
+
+ const completedStage = action.stage;
+ const transfer = {
+ actionId: action.id,
+ stage: completedStage,
+ fromPlayerId: fromPlayer.id,
+ fromPlayerName: fromPlayer.name,
+ toPlayerId: toPlayer.id,
+ toPlayerName: toPlayer.name,
+ cardsCount: MAINSTAY_CARD_COUNT,
+ animationDuration: MAINSTAY_ANIMATION_MS
+ };
+ this.io.to(this.room.id).emit('mainstay_transfer_resolved', transfer);
+ [fromPlayer, toPlayer].forEach(player => {
+ if (!player.socketId || player.isBot) return;
+ this.io.to(player.socketId).emit('mainstay_hand_updated', {
+ actionId: action.id,
+ stage: completedStage,
+ cards: player.cards.map(card => card.toJSON()),
+ animationDuration: MAINSTAY_ANIMATION_MS
+ });
+ });
+
+ if (completedStage === 'give') {
+ gameState.mainstayCurrentAction = {
+ ...action,
+ stage: 'return',
+ chooserPlayerId: action.teammatePlayerId
+ };
+ this.emitMainstayActionRequired(gameState.mainstayCurrentAction);
+ this.broadcastRoomUpdate();
+ const teammate = this.room.findPlayerById(action.teammatePlayerId);
+ if (teammate?.isBot) {
+ return this.submitMainstayCards(
+ teammate.id,
+ action.id,
+ this.selectMainstayCardsForBot(teammate.id)
+ );
+ }
+ return {
+ resolved: false,
+ actionId: action.id,
+ stage: completedStage,
+ transfer,
+ action: { ...gameState.mainstayCurrentAction }
+ };
+ }
+
+ const actor = this.room.findPlayerById(action.actorPlayerId);
+ gameState.mainstayResults.push({
+ playerId: action.actorPlayerId,
+ playerName: actor?.name || '未知玩家',
+ position: action.position,
+ trumpCount: action.trumpCount,
+ eligible: true,
+ accepted: true
+ });
+ gameState.mainstayCurrentAction = null;
+ this.io.to(this.room.id).emit('mainstay_pair_completed', {
+ actionId: action.id,
+ actorPlayerId: action.actorPlayerId,
+ actorPlayerName: actor?.name || '未知玩家',
+ teammatePlayerId: action.teammatePlayerId,
+ teammatePlayerName: toPlayer.id === action.actorPlayerId
+ ? fromPlayer.name
+ : toPlayer.name
+ });
+ this.broadcastRoomUpdate();
+ this.advanceMainstay();
+ return { resolved: true, actionId: action.id, stage: completedStage, transfer };
+ }
+
+ startOpeningCardExchange() {
+ const { gameState, players } = this.room;
+ if (gameState.phase !== GamePhases.DRAWING) {
+ throw new Error('只有摸牌结束后才能开始换牌');
+ }
+ if (players.length !== 4) {
+ throw new Error('开局换牌规则仅支持四人局');
+ }
+
+ const offset = getOpeningCardExchangeOffset(gameState.selectedRule);
+ if (!Number.isInteger(offset)) {
+ return false;
+ }
+
+ const targetByPlayerId = {};
+ players.forEach((player, index) => {
+ const targetIndex = (index + offset + players.length) % players.length;
+ targetByPlayerId[player.id] = players[targetIndex].id;
+ });
+
+ this.cardExchangeSelections.clear();
+ gameState.isTrumpDeclarationLocked = true;
+ gameState.postDrawStage = 'card_exchange';
+ gameState.cardExchange = {
+ stage: 'opening',
+ operation: 'exchange',
+ triggerRound: null,
+ ruleId: gameState.selectedRule.id,
+ ruleName: gameState.selectedRule.name,
+ requiredCards: OPENING_EXCHANGE_CARD_COUNT,
+ targetByPlayerId,
+ submittedPlayerIds: new Set()
+ };
+
+ const transfers = this.createPublicCardExchangeTransfers();
+ this.io.to(this.room.id).emit('card_exchange_started', {
+ ruleId: gameState.selectedRule.id,
+ ruleName: gameState.selectedRule.name,
+ requiredCards: OPENING_EXCHANGE_CARD_COUNT,
+ transfers
+ });
+ this.broadcastRoomUpdate();
+
+ logger.info(`房间 ${this.room.id} 开始执行规则 ${gameState.selectedRule.name} 的开局换牌`);
+
+ // Bot不能阻塞收齐选择;沿用当前手牌排序,自动交出最前面的两张。
+ for (const bot of players.filter(player => player.isBot)) {
+ if (!gameState.cardExchange) break;
+ this.submitOpeningCardExchange(
+ bot.id,
+ bot.cards.slice(0, OPENING_EXCHANGE_CARD_COUNT).map(card => card.id)
+ );
+ }
+
+ return true;
+ }
+
+ createPublicCardExchangeTransfers() {
+ const exchange = this.room.gameState.cardExchange;
+ if (!exchange) return [];
+ return this.room.players.map(player => {
+ const target = this.room.findPlayerById(exchange.targetByPlayerId[player.id]);
+ const isDiscard = exchange.operation === 'discard';
+ return {
+ fromPlayerId: player.id,
+ fromPlayerName: player.name,
+ toPlayerId: target?.id || null,
+ toPlayerName: isDiscard ? '弃牌区' : (target?.name || '未知玩家'),
+ cardsCount: exchange.requiredCards
+ };
+ });
+ }
+
+ submitOpeningCardExchange(playerId, cardIds) {
+ const exchange = this.room.gameState.cardExchange;
+ const isOpeningExchange = exchange?.stage === 'opening'
+ && this.room.gameState.phase === GamePhases.DRAWING;
+ const isRoundExchange = exchange?.stage === 'round'
+ && this.room.gameState.phase === GamePhases.PLAYING;
+ if (!exchange || (!isOpeningExchange && !isRoundExchange)) {
+ throw new Error('当前不在换牌阶段');
+ }
+
+ const player = this.room.findPlayerById(playerId);
+ if (!player) throw new Error('玩家不存在');
+ if (exchange.submittedPlayerIds.has(playerId)) {
+ throw new Error('你已经确认过换牌');
+ }
+ if (!Array.isArray(cardIds) || cardIds.length !== exchange.requiredCards) {
+ throw new Error(`必须选择${exchange.requiredCards}张牌`);
+ }
+
+ const uniqueIds = new Set(cardIds);
+ if (uniqueIds.size !== exchange.requiredCards) {
+ throw new Error('不能重复选择同一张牌');
+ }
+ const cards = cardIds.map(id => player.cards.find(card => card.id === id));
+ if (cards.some(card => !card)) {
+ throw new Error('选择的牌不在手中');
+ }
+
+ this.cardExchangeSelections.set(playerId, cards);
+ exchange.submittedPlayerIds.add(playerId);
+
+ this.io.to(this.room.id).emit('card_exchange_submitted', {
+ playerId,
+ playerName: player.name,
+ operation: exchange.operation || 'exchange',
+ ruleName: exchange.ruleName,
+ submittedCount: exchange.submittedPlayerIds.size,
+ totalCount: this.room.players.length
+ });
+
+ if (exchange.submittedPlayerIds.size === this.room.players.length) {
+ return this.resolveOpeningCardExchange();
+ }
+
+ this.broadcastRoomUpdate();
+ return { resolved: false };
+ }
+
+ resolveOpeningCardExchange() {
+ const exchange = this.room.gameState.cardExchange;
+ if (!exchange || this.cardExchangeSelections.size !== this.room.players.length) {
+ throw new Error('尚未收齐所有玩家的换牌选择');
+ }
+
+ const transfers = this.createPublicCardExchangeTransfers();
+ const operation = exchange.operation || 'exchange';
+ const privateResults = new Map(this.room.players.map(player => [player.id, {
+ sentCardIds: [],
+ receivedCards: [],
+ fromPlayerId: null,
+ fromPlayerName: null,
+ toPlayerId: exchange.targetByPlayerId[player.id]
+ }]));
+
+ // 先同时移除四家的选择,再统一加入目标手牌,避免顺序影响后续校验。
+ for (const player of this.room.players) {
+ const selected = this.cardExchangeSelections.get(player.id);
+ const selectedIds = selected.map(card => card.id);
+ player.removeCards(selectedIds);
+ selectedIds.forEach(id => player.shownCards.delete(id));
+ privateResults.get(player.id).sentCardIds = selectedIds;
+ }
+
+ if (operation === 'discard') {
+ for (const player of this.room.players) {
+ const playerIndex = this.room.getPlayerIndex(player.id);
+ const selected = this.cardExchangeSelections.get(player.id);
+ selected.forEach(card => {
+ this.room.gameState.lingeringDiscardedCards.push({
+ playerId: player.id,
+ playerIndex,
+ card
+ });
+ });
+ }
+ } else {
+ for (const sender of this.room.players) {
+ const recipient = this.room.findPlayerById(exchange.targetByPlayerId[sender.id]);
+ const selected = this.cardExchangeSelections.get(sender.id);
+ selected.forEach(card => recipient.addCard(card));
+ const result = privateResults.get(recipient.id);
+ result.receivedCards = selected.map(card => card.toJSON());
+ result.fromPlayerId = sender.id;
+ result.fromPlayerName = sender.name;
+ }
+ }
+
+ this.room.players.forEach(player => {
+ player.cards = DeckService.autoSortCards(player.cards);
+ });
+
+ const ruleName = exchange.ruleName;
+ const stage = exchange.stage;
+ const triggerRound = exchange.triggerRound;
+ this.room.gameState.cardExchange = null;
+ this.cardExchangeSelections.clear();
+
+ // 先广播不含牌面的路径动画,再分别发送各自实际收发的牌。
+ this.io.to(this.room.id).emit('card_exchange_resolved', {
+ ruleName,
+ operation,
+ transfers,
+ animationDuration: OPENING_EXCHANGE_ANIMATION_MS
+ });
+ for (const player of this.room.players) {
+ if (player.isBot) continue;
+ this.io.to(player.socketId).emit('card_exchange_hand_updated', {
+ ...privateResults.get(player.id),
+ ruleName,
+ operation,
+ animationDuration: OPENING_EXCHANGE_ANIMATION_MS
+ });
+ }
+
+ let gameFinished = false;
+ if (
+ stage === 'round'
+ && operation === 'discard'
+ && this.room.players.every(player => player.cards.length === 0)
+ ) {
+ this.finishGame();
+ gameFinished = true;
+ }
+
+ this.broadcastRoomUpdate();
+ if (stage === 'opening' && this.room.gameState.pendingDealerPlayerId) {
+ this.drawingManager?.completeDealerAssignment(this.room.gameState.pendingDealerPlayerId);
+ }
+ logger.info(
+ `房间 ${this.room.id} 已完成 ${ruleName} 的${stage === 'round' ? `第${triggerRound}轮轮末` : '开局'}` +
+ `${operation === 'discard' ? '暗弃' : '换牌'}`
+ );
+ return { resolved: true, stage, triggerRound, operation, transfers, gameFinished };
+ }
+
+ broadcastRoomUpdate() {
+ this.io.to(this.room.id).emit('room_updated', {
+ room: this.room.toJSON()
+ });
+ }
+
+ initializeWoodenOx() {
+ const { gameState, players } = this.room;
+ if (!isWoodenOxFlowingHorseRule(gameState.selectedRule)) return null;
+ if (players.length !== 4) {
+ throw new Error('木牛流马仅支持四人局');
+ }
+ if (gameState.woodenOxMulesByTeam.size > 0) {
+ return gameState.woodenOxMulesByTeam;
+ }
+
+ // 一号位是庄家,沿牌桌逆时针依次计为2、3、4号位。
+ // 玩家数组按牌桌逆时针排列;不能把房间数组下标1、2误当成固定的2、3号位。
+ const dealerIndex = this.room.getPlayerIndex(gameState.buryingPlayerId);
+ const firstPositionIndex = dealerIndex >= 0
+ ? dealerIndex
+ : gameState.roundStartPlayerIndex;
+ if (!Number.isInteger(firstPositionIndex)) {
+ throw new Error('木牛流马需要先确定庄家座位');
+ }
+
+ [1, 2].map(offset => (
+ (firstPositionIndex + offset) % players.length
+ )).forEach(playerIndex => {
+ const holder = players[playerIndex];
+ const teamIndex = playerIndex % 2;
+ gameState.woodenOxMulesByTeam.set(teamIndex, {
+ teamIndex,
+ initialHolderPlayerId: holder.id,
+ holderPlayerId: holder.id,
+ storedCard: null,
+ transfersUsed: 0,
+ maxTransfers: WOODEN_OX_MAX_TRANSFERS
+ });
+ });
+ return gameState.woodenOxMulesByTeam;
+ }
+
+ getWoodenOxMuleHeldBy(playerId) {
+ if (!isWoodenOxFlowingHorseRule(this.room.gameState.selectedRule)) return null;
+ return Array.from(this.room.gameState.woodenOxMulesByTeam.values())
+ .find(mule => mule.holderPlayerId === playerId) || null;
+ }
+
+ getWoodenOxStoredCardForPlayer(playerId) {
+ return this.getWoodenOxMuleHeldBy(playerId)?.storedCard || null;
+ }
+
+ getPlayableCardsForPlayer(playerOrId) {
+ const player = typeof playerOrId === 'string'
+ ? this.room.findPlayerById(playerOrId)
+ : playerOrId;
+ if (!player) return [];
+ const storedCard = this.getWoodenOxStoredCardForPlayer(player.id);
+ return storedCard ? [...player.cards, storedCard] : [...player.cards];
+ }
+
+ getPlayableCardCount(playerOrId) {
+ return this.getPlayableCardsForPlayer(playerOrId).length;
+ }
+
+ isWoodenOxTransferRequired(mule) {
+ if (!mule) return false;
+ const holderIndex = this.room.getPlayerIndex(mule.holderPlayerId);
+ if (holderIndex < 0) return false;
+ const teammate = this.room.players.find((candidate, index) => (
+ candidate.id !== mule.holderPlayerId && index % 2 === holderIndex % 2
+ ));
+ return Boolean(
+ teammate
+ && this.getPlayableCardCount(teammate) === 0
+ && this.getPlayableCardCount(mule.holderPlayerId) > 0
+ );
+ }
+
+ emitWoodenOxPrivateState(playerId) {
+ const player = this.room.findPlayerById(playerId);
+ if (!player?.socketId) return;
+ const mule = this.getWoodenOxMuleHeldBy(playerId);
+ this.io.to(player.socketId).emit('wooden_ox_private_state', mule ? {
+ teamIndex: mule.teamIndex,
+ holderPlayerId: mule.holderPlayerId,
+ storedCard: mule.storedCard?.toJSON ? mule.storedCard.toJSON() : mule.storedCard,
+ transfersUsed: mule.transfersUsed,
+ maxTransfers: mule.maxTransfers
+ } : null);
+ }
+
+ hasPendingWoodenOxDecision() {
+ return Boolean(this.room.gameState.woodenOxRoundWindow?.pendingPlayerIds?.size);
+ }
+
+ assertWoodenOxRoundWindowComplete() {
+ if (this.hasPendingWoodenOxDecision()) {
+ throw new Error('请先完成本轮的木牛流马操作');
+ }
+ }
+
+ openWoodenOxRoundWindow() {
+ const { gameState } = this.room;
+ if (!isWoodenOxFlowingHorseRule(gameState.selectedRule)) return null;
+ if (gameState.phase !== GamePhases.PLAYING || gameState.currentRoundPlays.length > 0) {
+ return null;
+ }
+ this.initializeWoodenOx();
+
+ const pendingPlayerIds = new Set();
+ const requiredTransferPlayerIds = new Set();
+ const botPlayerIds = [];
+ for (const mule of gameState.woodenOxMulesByTeam.values()) {
+ const holder = this.room.findPlayerById(mule.holderPlayerId);
+ if (!holder || mule.transfersUsed >= mule.maxTransfers) {
+ if (holder) this.emitWoodenOxPrivateState(holder.id);
+ continue;
+ }
+ pendingPlayerIds.add(holder.id);
+ if (this.isWoodenOxTransferRequired(mule)) {
+ requiredTransferPlayerIds.add(holder.id);
+ }
+ this.emitWoodenOxPrivateState(holder.id);
+ if (holder.isBot) {
+ botPlayerIds.push(holder.id);
+ continue;
+ }
+ if (holder.socketId) {
+ this.io.to(holder.socketId).emit('wooden_ox_decision_required', {
+ round: gameState.currentRound,
+ teamIndex: mule.teamIndex,
+ holderPlayerId: holder.id,
+ hasStoredCard: Boolean(mule.storedCard),
+ storedCard: mule.storedCard?.toJSON ? mule.storedCard.toJSON() : mule.storedCard,
+ transfersUsed: mule.transfersUsed,
+ maxTransfers: mule.maxTransfers,
+ mustTransfer: requiredTransferPlayerIds.has(holder.id)
+ });
+ }
+ }
+ gameState.woodenOxRoundWindow = {
+ round: gameState.currentRound,
+ pendingPlayerIds,
+ requiredTransferPlayerIds
+ };
+ this.io.to(this.room.id).emit('wooden_ox_round_started', {
+ round: gameState.currentRound,
+ pendingPlayerIds: Array.from(pendingPlayerIds)
+ });
+ for (const botPlayerId of botPlayerIds) {
+ if (!pendingPlayerIds.has(botPlayerId)) continue;
+ const mule = this.getWoodenOxMuleHeldBy(botPlayerId);
+ const bot = this.room.findPlayerById(botPlayerId);
+ if (requiredTransferPlayerIds.has(botPlayerId)) {
+ if (mule?.storedCard) {
+ this.manageWoodenOx(botPlayerId, 'pass');
+ } else if (bot?.cards?.length > 0) {
+ this.manageWoodenOx(botPlayerId, 'load_and_pass', bot.cards[0].id);
+ } else {
+ throw new Error('Bot无法完成必须的木牛流马传递');
+ }
+ } else {
+ this.manageWoodenOx(botPlayerId, 'skip');
+ }
+ }
+ return gameState.woodenOxRoundWindow;
+ }
+
+ manageWoodenOx(playerId, action, cardId = null) {
+ const { gameState } = this.room;
+ if (!isWoodenOxFlowingHorseRule(gameState.selectedRule)) {
+ throw new Error('本局没有启用木牛流马');
+ }
+ if (gameState.phase !== GamePhases.PLAYING || gameState.currentRoundPlays.length > 0) {
+ throw new Error('木牛流马只能在每轮首张牌打出前操作');
+ }
+ const window = gameState.woodenOxRoundWindow;
+ if (!window || window.round !== gameState.currentRound || !window.pendingPlayerIds.has(playerId)) {
+ throw new Error('你本轮没有待处理的木牛流马');
+ }
+ const player = this.room.findPlayerById(playerId);
+ const mule = this.getWoodenOxMuleHeldBy(playerId);
+ if (!player || !mule) throw new Error('你当前没有持有木牛流马');
+
+ if (action === 'skip') {
+ if (window.requiredTransferPlayerIds?.has(playerId)) {
+ throw new Error('队友已无牌可出,本轮必须把木牛流马交给队友以恢复牌数');
+ }
+ window.pendingPlayerIds.delete(playerId);
+ } else {
+ if (!['pass', 'load_and_pass'].includes(action)) {
+ throw new Error('未知的木牛流马操作');
+ }
+ if (mule.transfersUsed >= mule.maxTransfers) {
+ throw new Error('本队木牛流马已完成两次往返');
+ }
+ if (action === 'pass' && !mule.storedCard) {
+ throw new Error('木牛流马中没有可交给队友的牌');
+ }
+ let returnedCard = null;
+ if (action === 'load_and_pass') {
+ const selectedCard = player.cards.find(card => card.id === cardId);
+ if (!selectedCard) throw new Error('请选择一张自己的手牌放入木牛流马');
+ returnedCard = mule.storedCard;
+ player.removeCards([selectedCard.id]);
+ if (returnedCard) player.addCard(returnedCard);
+ player.cards = DeckService.autoSortCards(player.cards);
+ mule.storedCard = selectedCard;
+ }
+
+ const playerIndex = this.room.getPlayerIndex(playerId);
+ const teammate = this.room.players.find((candidate, index) => (
+ candidate.id !== playerId && index % 2 === playerIndex % 2
+ ));
+ if (!teammate) throw new Error('找不到可接收木牛流马的队友');
+ mule.holderPlayerId = teammate.id;
+ mule.transfersUsed += 1;
+ window.pendingPlayerIds.delete(playerId);
+ window.requiredTransferPlayerIds?.delete(playerId);
+
+ if (player.socketId) {
+ this.io.to(player.socketId).emit('wooden_ox_hand_updated', {
+ cards: player.cards.map(card => card.toJSON ? card.toJSON() : card),
+ returnedCard: returnedCard?.toJSON ? returnedCard.toJSON() : returnedCard
+ });
+ this.io.to(player.socketId).emit('wooden_ox_private_state', null);
+ }
+ this.emitWoodenOxPrivateState(teammate.id);
+ this.io.to(this.room.id).emit('wooden_ox_transferred', {
+ round: gameState.currentRound,
+ teamIndex: mule.teamIndex,
+ fromPlayerId: player.id,
+ fromPlayerName: player.name,
+ toPlayerId: teammate.id,
+ toPlayerName: teammate.name,
+ replacedCard: Boolean(returnedCard),
+ hasStoredCard: Boolean(mule.storedCard),
+ transfersUsed: mule.transfersUsed,
+ maxTransfers: mule.maxTransfers,
+ completedRoundTrips: Math.floor(mule.transfersUsed / 2)
+ });
+ }
+
+ const completed = window.pendingPlayerIds.size === 0;
+ if (completed) {
+ this.io.to(this.room.id).emit('wooden_ox_round_ready', { round: gameState.currentRound });
+ }
+ this.broadcastRoomUpdate();
+ return {
+ completed,
+ round: gameState.currentRound,
+ action,
+ pendingPlayerIds: Array.from(window.pendingPlayerIds)
+ };
+ }
+
+ restoreNinePrincesPermanentFace(card) {
+ if (
+ !card?.isNinePrincesPromoted
+ || !card.ninePrincesPermanentSuit
+ || !card.ninePrincesPermanentRank
+ ) {
+ return card;
+ }
+ card.suit = card.ninePrincesPermanentSuit;
+ card.rank = card.ninePrincesPermanentRank;
+ card.value = card.calculateValue();
+ return card;
+ }
+
+ restoreStrengthCompensationCards() {
+ for (const player of this.room.players) {
+ for (const card of player.cards) {
+ if (!card.isStrengthCompensated) continue;
+ if (card.originalRank) card.rank = card.originalRank;
+ if (card.originalSuit) card.suit = card.originalSuit;
+ card.originalRank = null;
+ card.originalSuit = null;
+ card.isStrengthCompensated = false;
+ card.strengthCompensationDelta = 0;
+ this.restoreNinePrincesPermanentFace(card);
+ card.value = card.calculateValue();
+ }
+ }
+ }
+
+ emitStrengthCompensationHands(status) {
+ for (const player of this.room.players) {
+ if (!player.socketId || player.isBot) continue;
+ const modifier = player.id === status.plusPlayerId
+ ? 1
+ : player.id === status.minusPlayerId
+ ? -1
+ : 0;
+ this.io.to(player.socketId).emit('strength_compensation_hand_updated', {
+ round: status.round,
+ modifier,
+ cards: player.cards.map(card => card.toJSON ? card.toJSON() : card)
+ });
+ }
+ this.io.to(this.room.id).emit('strength_compensation_rotated', status);
+ }
+
+ applyStrengthCompensationForRound({ emit = true } = {}) {
+ const { gameState } = this.room;
+ if (!isStrengthCompensationRule(gameState.selectedRule)) return null;
+ if (!Number.isInteger(gameState.currentRound) || gameState.currentRound < 1) return null;
+
+ this.restoreStrengthCompensationCards();
+ const playerCount = this.room.players.length;
+ const dealerIndex = this.room.getPlayerIndex(gameState.buryingPlayerId);
+ if (playerCount !== 4 || dealerIndex < 0) {
+ throw new Error('取长补短需要四名玩家及已确定的庄家');
+ }
+
+ const plusSeatNumber = gameState.currentRound % playerCount;
+ const minusSeatNumber = (gameState.currentRound + 2) % playerCount;
+ const plusPlayer = this.room.players[(dealerIndex + plusSeatNumber) % playerCount];
+ const minusPlayer = this.room.players[(dealerIndex + minusSeatNumber) % playerCount];
+
+ const transformHand = (player, delta) => {
+ for (const card of player.cards) {
+ card.originalRank = card.rank;
+ card.originalSuit = card.suit;
+ const shiftedFace = shiftStrengthCompensationCardFace(
+ card,
+ gameState.trumpSuit,
+ gameState.trumpRank,
+ delta
+ );
+ card.rank = shiftedFace.rank;
+ card.suit = shiftedFace.suit;
+ card.isStrengthCompensated = true;
+ card.strengthCompensationDelta = delta;
+ card.value = card.calculateValue();
+ }
+ };
+ transformHand(plusPlayer, 1);
+ transformHand(minusPlayer, -1);
+
+ const status = {
+ round: gameState.currentRound,
+ dealerPlayerId: gameState.buryingPlayerId,
+ plusSeatNumber,
+ plusPlayerId: plusPlayer.id,
+ plusPlayerName: plusPlayer.name,
+ minusSeatNumber,
+ minusPlayerId: minusPlayer.id,
+ minusPlayerName: minusPlayer.name
+ };
+ gameState.strengthCompensation = status;
+ if (emit) this.emitStrengthCompensationHands(status);
+ return status;
+ }
+
+ restoreDefenseAsOffenseCards() {
+ for (const player of this.room.players) {
+ for (const card of player.cards) {
+ if (!card.isDefenseAsOffenseBoosted) continue;
+ if (card.originalRank) card.rank = card.originalRank;
+ if (card.originalSuit) card.suit = card.originalSuit;
+ card.originalRank = null;
+ card.originalSuit = null;
+ card.isDefenseAsOffenseBoosted = false;
+ card.defenseAsOffenseDelta = 0;
+ this.restoreNinePrincesPermanentFace(card);
+ card.value = card.calculateValue();
+ }
+ }
+ }
+
+ getDefenseAsOffenseStatusForNextRound(completedRound) {
+ const { gameState } = this.room;
+ if (!isDefenseAsOffenseRule(gameState.selectedRule)) return null;
+ if (gameState.currentRoundPlays.length !== this.room.players.length) return null;
+
+ const comparisonPlays = gameState.currentRoundPlays.map(play => ({
+ ...play,
+ cards: play.comparisonCards || play.cards,
+ pattern: play.comparisonPattern || play.pattern
+ }));
+ const leadingPlay = comparisonPlays[0];
+ const rankedPlays = rankRoundPlaysByRespectOrder(
+ comparisonPlays,
+ gameState.trumpSuit,
+ gameState.trumpRank,
+ gameState.selectedRule
+ );
+ // 完全相同的牌力按实际出牌顺序稳定排列;首家天然排在同牌力玩家之前,
+ // 因而它在全序中的下标正好等于严格大于它的玩家人数。
+ const delta = rankedPlays.findIndex(play => play.playerId === leadingPlay.playerId);
+ const player = this.room.findPlayerById(leadingPlay.playerId);
+ if (!player || delta < 0) return null;
+ return {
+ triggerRound: completedRound,
+ round: completedRound + 1,
+ playerIndex: leadingPlay.playerIndex,
+ playerId: leadingPlay.playerId,
+ playerName: player.name,
+ delta
+ };
+ }
+
+ getWeighingThousandJinRoundScoring() {
+ const { gameState, players } = this.room;
+ if (
+ !isWeighingThousandJinRule(gameState.selectedRule)
+ || gameState.currentRoundPlays.length !== players.length
+ ) {
+ return null;
+ }
+
+ const dealerIndex = this.room.getPlayerIndex(gameState.buryingPlayerId);
+ if (dealerIndex < 0) return null;
+
+ const comparisonPlays = gameState.currentRoundPlays.map(play => ({
+ ...play,
+ cards: play.comparisonCards || play.cards,
+ pattern: play.comparisonPattern || play.pattern
+ }));
+ const rankedPlays = rankRoundPlaysByRespectOrder(
+ comparisonPlays,
+ gameState.trumpSuit,
+ gameState.trumpRank,
+ gameState.selectedRule
+ );
+ const dealerPlay = comparisonPlays.find(play => play.playerIndex === dealerIndex);
+ const dealerRankIndex = rankedPlays.findIndex(play => play.playerIndex === dealerIndex);
+ if (!dealerPlay || dealerRankIndex < 0) return null;
+
+ const attackerComparisons = rankedPlays
+ .map((play, rankIndex) => ({ play, rankIndex }))
+ .filter(({ play }) => this.isAttackerPlayerIndex(play.playerIndex, dealerIndex))
+ .map(({ play, rankIndex }) => {
+ const attacker = this.room.findPlayerByIndex(play.playerIndex);
+ return {
+ playerIndex: play.playerIndex,
+ playerId: play.playerId,
+ playerName: attacker?.name || '未知玩家',
+ rank: rankIndex + 1,
+ dealerOutranks: dealerRankIndex < rankIndex
+ };
+ });
+ const outrankedAttackerCount = attackerComparisons.filter(
+ comparison => comparison.dealerOutranks
+ ).length;
+ const dealer = this.room.findPlayerByIndex(dealerIndex);
+
+ return {
+ mode: outrankedAttackerCount > 0 ? 'subtract_five' : 'double',
+ dealerPlayerIndex: dealerIndex,
+ dealerPlayerId: dealer?.id || dealerPlay.playerId,
+ dealerPlayerName: dealer?.name || '庄家',
+ dealerRank: dealerRankIndex + 1,
+ outrankedAttackerCount,
+ attackerComparisons
+ };
+ }
+
+ adjustWeighingThousandJinCardPoints(points, scoring) {
+ const normalizedPoints = Number(points) || 0;
+ if (!scoring || normalizedPoints <= 0) return normalizedPoints;
+ return scoring.mode === 'subtract_five'
+ ? Math.max(0, normalizedPoints - 5)
+ : normalizedPoints * 2;
+ }
+
+ emitDefenseAsOffenseHands(transition) {
+ for (const player of this.room.players) {
+ if (!player.socketId || player.isBot) continue;
+ this.io.to(player.socketId).emit('defense_as_offense_hand_updated', {
+ round: transition?.round ?? this.room.gameState.currentRound,
+ delta: transition?.playerId === player.id ? transition.delta : 0,
+ cards: player.cards.map(card => card.toJSON ? card.toJSON() : card)
+ });
+ }
+ }
+
+ applyDefenseAsOffenseForRound(nextStatus, { emit = true } = {}) {
+ const { gameState } = this.room;
+ if (!isDefenseAsOffenseRule(gameState.selectedRule)) return null;
+
+ gameState.defenseAsOffenseLastRound = gameState.defenseAsOffense
+ ? { ...gameState.defenseAsOffense }
+ : null;
+ this.restoreDefenseAsOffenseCards();
+ const delta = Math.max(0, Math.min(3, Math.trunc(Number(nextStatus?.delta) || 0)));
+ const player = nextStatus?.playerId
+ ? this.room.findPlayerById(nextStatus.playerId)
+ : null;
+ const activeStatus = player && player.cards.length > 0 && delta > 0
+ ? { ...nextStatus, delta }
+ : null;
+
+ if (activeStatus) {
+ for (const card of player.cards) {
+ card.originalRank = card.rank;
+ card.originalSuit = card.suit;
+ const shiftedFace = shiftStrengthCompensationCardFace(
+ card,
+ gameState.trumpSuit,
+ gameState.trumpRank,
+ delta
+ );
+ card.rank = shiftedFace.rank;
+ card.suit = shiftedFace.suit;
+ card.isDefenseAsOffenseBoosted = true;
+ card.defenseAsOffenseDelta = delta;
+ card.value = card.calculateValue();
+ }
+ }
+
+ gameState.defenseAsOffense = activeStatus;
+ const transition = nextStatus
+ ? { ...nextStatus, delta, active: Boolean(activeStatus) }
+ : null;
+ if (emit) this.emitDefenseAsOffenseHands(transition);
+ return transition;
+ }
+
+ hasUsedActiveSkill(playerId, activeSkillId) {
+ return this.room.gameState.activeSkillUsesByPlayerId
+ .get(playerId)
+ ?.has(activeSkillId) || false;
+ }
+
+ recordActiveSkillUse(playerId, activeSkillId) {
+ const usedSkills = this.room.gameState.activeSkillUsesByPlayerId.get(playerId) || new Set();
+ usedSkills.add(activeSkillId);
+ this.room.gameState.activeSkillUsesByPlayerId.set(playerId, usedSkills);
+ }
+
+ restoreActiveSkillUse(playerId, activeSkillId) {
+ const usedSkills = this.room.gameState.activeSkillUsesByPlayerId.get(playerId);
+ if (!usedSkills) return;
+ usedSkills.delete(activeSkillId);
+ if (usedSkills.size === 0) {
+ this.room.gameState.activeSkillUsesByPlayerId.delete(playerId);
+ }
+ }
+
+ hasPendingPoliticalReviewDecision() {
+ return Boolean(this.room.gameState.politicalReviewPending);
+ }
+
+ getPoliticalReviewTeammate(playerId) {
+ const playerIndex = this.room.getPlayerIndex(playerId);
+ if (playerIndex < 0 || this.room.players.length !== 4) return null;
+ return this.room.findPlayerByIndex((playerIndex + 2) % this.room.players.length);
+ }
+
+ validatePoliticalReviewCandidate(player, cardIds) {
+ const { gameState } = this.room;
+ if (gameState.phase !== GamePhases.PLAYING || !this.roundManager) {
+ throw new Error('当前不是出牌阶段');
+ }
+ const playerIndex = this.room.getPlayerIndex(player.id);
+ if (!this.roundManager.canPlayerPlay(playerIndex)) {
+ throw new Error('现在还没有轮到该玩家出牌');
+ }
+ if (!Array.isArray(cardIds) || cardIds.length === 0) {
+ throw new Error('请选择要打出的牌');
+ }
+ const uniqueIds = new Set(cardIds);
+ const cards = player.cards.filter(card => uniqueIds.has(card.id));
+ if (cards.length !== cardIds.length || uniqueIds.size !== cardIds.length) {
+ throw new Error('选择的牌不在手中');
+ }
+
+ const { trumpSuit, trumpRank } = gameState;
+ const isLeading = gameState.currentRoundPlays.length === 0;
+ const validation = isLeading
+ ? validateLeadingPlay(cards, trumpSuit, trumpRank, this.getRuleRuntimeContext())
+ : validateFollowingPlay(
+ cards,
+ player.cards,
+ gameState.leadingPattern,
+ trumpSuit,
+ trumpRank,
+ this.getRuleRuntimeContext()
+ );
+ if (!validation.valid) throw new Error(validation.message);
+ return cards;
+ }
+
+ preparePoliticalReviewForPlay(
+ requestingPlayerId,
+ cardIds,
+ controlledPlayerId,
+ activeSkillId,
+ playOptions
+ ) {
+ const { gameState } = this.room;
+ if (!isPoliticalReviewRule(gameState.selectedRule)) return null;
+ if (this.room.players.length !== 4) throw new Error('政治审查仅支持四人局');
+ if (gameState.politicalReviewPending) throw new Error('请先完成当前政治审查');
+
+ const turnPlayerId = controlledPlayerId || requestingPlayerId;
+ const approval = gameState.politicalReviewApproval;
+ const approvalId = playOptions?.politicalReviewApprovalId || null;
+ if (approvalId) {
+ if (
+ !approval
+ || approval.id !== approvalId
+ || approval.requestingPlayerId !== requestingPlayerId
+ || approval.teammatePlayerId !== turnPlayerId
+ || approval.controlledPlayerId !== controlledPlayerId
+ || [...approval.cardIds].sort().join('|') !== [...cardIds].sort().join('|')
+ ) {
+ throw new Error('政治审查放行凭证无效,请重新出牌');
+ }
+ return { approved: true, approvalId, cardIds: [...approval.cardIds] };
+ }
+ if (approval) {
+ const approvedRequester = this.room.findPlayerById(approval.requestingPlayerId);
+ if (
+ approvedRequester?.isBot
+ && approval.requestingPlayerId === requestingPlayerId
+ && approval.teammatePlayerId === turnPlayerId
+ ) {
+ return {
+ approved: true,
+ approvalId: approval.id,
+ cardIds: [...approval.cardIds]
+ };
+ }
+ throw new Error('请先提交队友已经放行的那手牌');
+ }
+ if (activeSkillId) throw new Error('政治审查规则下没有随出牌发动的主动技能');
+
+ const requester = this.room.findPlayerById(requestingPlayerId);
+ const teammate = this.room.findPlayerById(turnPlayerId);
+ if (!requester || !teammate) throw new Error('玩家不存在');
+ if (controlledPlayerId && controlledPlayerId !== requestingPlayerId) {
+ throw new Error('政治审查规则下不能代替其他玩家出牌');
+ }
+ const reviewer = this.getPoliticalReviewTeammate(teammate.id);
+ if (!reviewer) throw new Error('找不到队友');
+ if (this.hasUsedActiveSkill(reviewer.id, RuleIds.POLITICAL_REVIEW)) return null;
+
+ const cards = this.validatePoliticalReviewCandidate(teammate, cardIds);
+ const decisionId = `political-review-${gameState.currentRound}-${Date.now()}-${Math.floor(this.random() * 1e6)}`;
+ const storedPlayOptions = {
+ ...playOptions,
+ politicalReviewApprovalId: null,
+ jokerSubstitutions: [...(playOptions?.jokerSubstitutions || [])],
+ clusterAnalysisSubstitutions: [...(playOptions?.clusterAnalysisSubstitutions || [])],
+ forbiddenMagicSubstitutions: [...(playOptions?.forbiddenMagicSubstitutions || [])],
+ ambiguousAlternativeCardIds: [...(playOptions?.ambiguousAlternativeCardIds || [])]
+ };
+ const publicCards = cards.map(card => card.toJSON ? card.toJSON() : card);
+ const pending = {
+ id: decisionId,
+ round: gameState.currentRound,
+ reviewerPlayerId: reviewer.id,
+ reviewerPlayerName: reviewer.name,
+ teammatePlayerId: teammate.id,
+ teammatePlayerName: teammate.name,
+ requestingPlayerId,
+ controlledPlayerId,
+ activeSkillId,
+ cardIds: [...cardIds],
+ playOptions: storedPlayOptions,
+ cards: publicCards
+ };
+ gameState.politicalReviewPending = pending;
+
+ const publicPending = {
+ id: decisionId,
+ round: pending.round,
+ reviewerPlayerId: reviewer.id,
+ reviewerPlayerName: reviewer.name,
+ teammatePlayerId: teammate.id,
+ teammatePlayerName: teammate.name,
+ cards: publicCards
+ };
+ this.io.to(this.room.id).emit('political_review_play_pending', publicPending);
+ if (requester.socketId) {
+ this.io.to(requester.socketId).emit('political_review_play_held', {
+ ...publicPending,
+ handCards: teammate.cards.map(card => card.toJSON ? card.toJSON() : card)
+ });
+ }
+ if (!reviewer.isBot && reviewer.socketId) {
+ this.io.to(reviewer.socketId).emit('political_review_decision_required', publicPending);
+ }
+ this.broadcastRoomUpdate();
+
+ if (reviewer.isBot) {
+ setTimeout(() => {
+ try {
+ if (gameState.politicalReviewPending?.id !== decisionId) return;
+ this.respondPoliticalReview(reviewer.id, this.random() < 0.25);
+ if (this.onBotTurn) this.onBotTurn();
+ } catch (error) {
+ logger.error(`政治审查Bot ${reviewer.name} 决策失败:`, error);
+ }
+ }, 500);
+ }
+ return publicPending;
+ }
+
+ respondPoliticalReview(playerId, returnPlay) {
+ const { gameState } = this.room;
+ const pending = gameState.politicalReviewPending;
+ if (!isPoliticalReviewRule(gameState.selectedRule) || !pending) {
+ throw new Error('当前没有等待处理的政治审查');
+ }
+ if (pending.reviewerPlayerId !== playerId) {
+ throw new Error('只有出牌者的队友可以作出政治审查决定');
+ }
+ if (this.hasUsedActiveSkill(playerId, RuleIds.POLITICAL_REVIEW)) {
+ throw new Error('你本局已经发动过政治审查');
+ }
+
+ const reviewer = this.room.findPlayerById(playerId);
+ const shouldReturn = returnPlay === true;
+ gameState.politicalReviewPending = null;
+ if (shouldReturn) {
+ this.recordActiveSkillUse(playerId, RuleIds.POLITICAL_REVIEW);
+ gameState.politicalReviewApproval = null;
+ } else {
+ gameState.politicalReviewApproval = {
+ id: pending.id,
+ requestingPlayerId: pending.requestingPlayerId,
+ controlledPlayerId: pending.controlledPlayerId,
+ teammatePlayerId: pending.teammatePlayerId,
+ cardIds: [...pending.cardIds],
+ activeSkillId: pending.activeSkillId,
+ playOptions: {
+ ...pending.playOptions,
+ jokerSubstitutions: [...(pending.playOptions?.jokerSubstitutions || [])],
+ clusterAnalysisSubstitutions: [
+ ...(pending.playOptions?.clusterAnalysisSubstitutions || [])
+ ],
+ forbiddenMagicSubstitutions: [
+ ...(pending.playOptions?.forbiddenMagicSubstitutions || [])
+ ],
+ ambiguousAlternativeCardIds: [
+ ...(pending.playOptions?.ambiguousAlternativeCardIds || [])
+ ]
+ }
+ };
+ }
+
+ const result = {
+ id: pending.id,
+ round: pending.round,
+ reviewerPlayerId: reviewer.id,
+ reviewerPlayerName: reviewer.name,
+ teammatePlayerId: pending.teammatePlayerId,
+ teammatePlayerName: pending.teammatePlayerName,
+ cards: pending.cards.map(card => ({ ...card })),
+ returned: shouldReturn,
+ message: shouldReturn
+ ? `${reviewer.name} 发动政治审查,令 ${pending.teammatePlayerName} 收回本次出牌;原样重出仍然合法`
+ : `${reviewer.name} 放行 ${pending.teammatePlayerName} 的本次出牌`
+ };
+ gameState.politicalReviewLastResult = result;
+ if (shouldReturn) {
+ this.io.to(this.room.id).emit('active_skill_activated', {
+ id: RuleIds.POLITICAL_REVIEW,
+ name: '政治审查',
+ playerId: reviewer.id,
+ playerName: reviewer.name
+ });
+ }
+ this.io.to(this.room.id).emit('political_review_resolved', result);
+
+ if (!shouldReturn) {
+ const requester = this.room.findPlayerById(pending.requestingPlayerId);
+ if (requester?.socketId && !requester.isBot) {
+ this.io.to(requester.socketId).emit('political_review_play_approved', {
+ id: pending.id,
+ cardIds: [...pending.cardIds],
+ controlledPlayerId: pending.controlledPlayerId,
+ activeSkillId: pending.activeSkillId,
+ ...pending.playOptions
+ });
+ }
+ }
+ this.broadcastRoomUpdate();
+ logger.info(`房间 ${this.room.id} ${result.message}`);
+ return result;
+ }
+
+ activateCulturalRevolution(playerId, declarationType, value) {
+ const { gameState } = this.room;
+ if (gameState.phase !== GamePhases.PLAYING || gameState.playMode !== PlayModes.ORDERED) {
+ throw new Error('当前不是有序出牌阶段');
+ }
+ if (!isCulturalRevolutionRule(gameState.selectedRule)) {
+ throw new Error('本局没有文化革命技能');
+ }
+ const activeSkill = getActiveSkillForRule(gameState.selectedRule);
+ if (activeSkill?.id !== ActiveSkillIds.CULTURAL_REVOLUTION) {
+ throw new Error('本局没有文化革命技能');
+ }
+ if (this.hasUsedActiveSkill(playerId, activeSkill.id)) {
+ throw new Error('文化革命每名玩家每局只能发动一次');
+ }
+
+ const player = this.room.findPlayerById(playerId);
+ if (!player) throw new Error('玩家不存在');
+ const playerIndex = this.room.getPlayerIndex(playerId);
+ if (playerIndex !== gameState.currentPlayerIndex) {
+ throw new Error('现在还没有轮到你出牌');
+ }
+ if (
+ gameState.currentRoundPlays.length !== 0
+ || gameState.playersPlayedThisRound.size !== 0
+ || playerIndex !== gameState.roundStartPlayerIndex
+ ) {
+ throw new Error('文化革命只能在作为本轮一号位出牌前发动');
+ }
+ if (!Number.isInteger(gameState.currentRound) || gameState.currentRound < 1) {
+ throw new Error('首轮尚未开始');
+ }
+ if (!['suit', 'rank'].includes(declarationType)) {
+ throw new Error('请先选择革花色或革点数');
+ }
+ const allowedValues = declarationType === 'suit'
+ ? CULTURAL_REVOLUTION_SUITS
+ : CULTURAL_REVOLUTION_RANKS;
+ if (!allowedValues.includes(value)) {
+ throw new Error(declarationType === 'suit' ? '请选择一种标准花色' : '请选择2至A的一种点数');
+ }
+
+ // 首次发动时保存本局原主;覆盖旧声明时始终从原主重新计算,绝不把两个声明叠加。
+ if (gameState.culturalRevolutionBaseTrumpRank === null) {
+ gameState.culturalRevolutionBaseTrumpSuit = gameState.trumpSuit;
+ gameState.culturalRevolutionBaseTrumpRank = gameState.trumpRank;
+ }
+ gameState.trumpSuit = declarationType === 'suit'
+ ? value
+ : gameState.culturalRevolutionBaseTrumpSuit;
+ gameState.trumpRank = declarationType === 'rank'
+ ? value
+ : gameState.culturalRevolutionBaseTrumpRank;
+
+ const activation = {
+ playerId: player.id,
+ playerName: player.name,
+ declarationType,
+ value,
+ activatedRound: gameState.currentRound,
+ expiresAfterRound: gameState.currentRound + 1,
+ effectiveTrumpSuit: gameState.trumpSuit,
+ effectiveTrumpRank: gameState.trumpRank
+ };
+ gameState.culturalRevolution = activation;
+ this.recordActiveSkillUse(player.id, activeSkill.id);
+ logger.info(
+ `房间 ${this.room.id} ${player.name} 发动文化革命:` +
+ `${declarationType === 'suit' ? '革花色' : '革点数'} ${value},` +
+ `持续至第${activation.expiresAfterRound}轮结束`
+ );
+ return {
+ ...activation,
+ activeSkillId: activeSkill.id,
+ activeSkillName: activeSkill.name,
+ baseTrumpSuit: gameState.culturalRevolutionBaseTrumpSuit,
+ baseTrumpRank: gameState.culturalRevolutionBaseTrumpRank
+ };
+ }
+
+ activateInviteIntoUrn(playerId, targetPlayerId, suit, rank) {
+ const { gameState } = this.room;
+ if (gameState.phase !== GamePhases.PLAYING || gameState.playMode !== PlayModes.ORDERED) {
+ throw new Error('当前不是有序出牌阶段');
+ }
+ if (!isInviteIntoUrnRule(gameState.selectedRule)) {
+ throw new Error('本局没有请君入瓮技能');
+ }
+ const activeSkill = getActiveSkillForRule(gameState.selectedRule);
+ if (activeSkill?.id !== ActiveSkillIds.INVITE_INTO_URN) {
+ throw new Error('本局没有请君入瓮技能');
+ }
+ if (this.hasUsedActiveSkill(playerId, activeSkill.id)) {
+ throw new Error('请君入瓮每名玩家每局只能发动一次');
+ }
+
+ const player = this.room.findPlayerById(playerId);
+ const target = this.room.findPlayerById(targetPlayerId);
+ if (!player || !target) throw new Error('指定玩家不存在');
+ if (player.id === target.id) throw new Error('请君入瓮不能指定自己');
+ const playerIndex = this.room.getPlayerIndex(playerId);
+ if (playerIndex !== gameState.currentPlayerIndex) {
+ throw new Error('现在还没有轮到你出牌');
+ }
+ if (
+ gameState.currentRoundPlays.length !== 0
+ || gameState.playersPlayedThisRound.size !== 0
+ || playerIndex !== gameState.roundStartPlayerIndex
+ ) {
+ throw new Error('请君入瓮只能在作为本轮一号位出牌前发动');
+ }
+
+ const isJoker = suit === Suits.JOKER;
+ const validFace = isJoker
+ ? [Ranks.SMALL_JOKER, Ranks.BIG_JOKER, Ranks.WHITE_JOKER].includes(rank)
+ : INVITE_INTO_URN_SUITS.includes(suit) && INVITE_INTO_URN_RANKS.includes(rank);
+ if (!validFace) throw new Error('请选择一种有效的实体牌面');
+
+ const declaration = {
+ round: gameState.currentRound,
+ sourcePlayerId: player.id,
+ sourcePlayerName: player.name,
+ targetPlayerId: target.id,
+ targetPlayerName: target.name,
+ suit,
+ rank
+ };
+ gameState.inviteIntoUrnDeclarations.push(declaration);
+ this.recordActiveSkillUse(player.id, activeSkill.id);
+ logger.info(
+ `房间 ${this.room.id} ${player.name} 发动请君入瓮:` +
+ `指定 ${target.name} 的 ${suit} ${rank}`
+ );
+ return {
+ ...declaration,
+ activeSkillId: activeSkill.id,
+ activeSkillName: activeSkill.name
+ };
+ }
+
+ selectCulturalRevolutionForBot(playerId) {
+ const { gameState } = this.room;
+ const player = this.room.findPlayerById(playerId);
+ const playerIndex = this.room.getPlayerIndex(playerId);
+ if (
+ !player
+ || !isCulturalRevolutionRule(gameState.selectedRule)
+ || this.hasUsedActiveSkill(playerId, ActiveSkillIds.CULTURAL_REVOLUTION)
+ || playerIndex !== gameState.currentPlayerIndex
+ || playerIndex !== gameState.roundStartPlayerIndex
+ || gameState.currentRoundPlays.length !== 0
+ || gameState.playersPlayedThisRound.size !== 0
+ ) {
+ return null;
+ }
+
+ // 优先把手中的同花色对子(尤其10/K)提升为级牌;否则选择持有最多的非原主花色。
+ let bestRank = null;
+ let bestPairCount = 1;
+ const preferredRanks = [Ranks.KING, Ranks.TEN, ...CULTURAL_REVOLUTION_RANKS];
+ for (const rank of preferredRanks) {
+ let sameSuitMax = 0;
+ for (const suit of CULTURAL_REVOLUTION_SUITS) {
+ sameSuitMax = Math.max(
+ sameSuitMax,
+ player.cards.filter(card => card.suit === suit && card.rank === rank).length
+ );
+ }
+ if (sameSuitMax > bestPairCount) {
+ bestPairCount = sameSuitMax;
+ bestRank = rank;
+ }
+ }
+ if (bestRank) return { declarationType: 'rank', value: bestRank };
+
+ const baseTrumpSuit = gameState.culturalRevolutionBaseTrumpRank === null
+ ? gameState.trumpSuit
+ : gameState.culturalRevolutionBaseTrumpSuit;
+ const rankedSuits = CULTURAL_REVOLUTION_SUITS
+ .map(suit => ({
+ suit,
+ count: player.cards.filter(card => card.suit === suit).length
+ }))
+ .sort((left, right) => right.count - left.count);
+ const selectedSuit = rankedSuits.find(entry => entry.suit !== baseTrumpSuit)
+ || rankedSuits[0];
+ return selectedSuit ? { declarationType: 'suit', value: selectedSuit.suit } : null;
+ }
+
+ expireCulturalRevolutionAtRoundEnd(completedRound) {
+ const { gameState } = this.room;
+ const active = gameState.culturalRevolution;
+ if (!active || completedRound < active.expiresAfterRound) return null;
+
+ const transition = {
+ ...active,
+ completedRound,
+ restoredTrumpSuit: gameState.culturalRevolutionBaseTrumpSuit,
+ restoredTrumpRank: gameState.culturalRevolutionBaseTrumpRank
+ };
+ gameState.trumpSuit = gameState.culturalRevolutionBaseTrumpSuit;
+ gameState.trumpRank = gameState.culturalRevolutionBaseTrumpRank;
+ gameState.culturalRevolution = null;
+ logger.info(`房间 ${this.room.id} 文化革命于第${completedRound}轮结束,恢复本局原主`);
+ return transition;
+ }
+
+ createDivineWeaponDeck() {
+ const deck = [];
+ for (const suit of DIVINE_WEAPON_SUITS) {
+ for (const rank of DIVINE_WEAPON_RANKS) {
+ const card = new Card(suit, rank, 0);
+ card.id = `divine-${suit}-${rank}`;
+ deck.push(card);
+ }
+ }
+ for (let index = deck.length - 1; index > 0; index--) {
+ const sample = Number(this.random());
+ const boundedSample = Number.isFinite(sample)
+ ? Math.min(0.999999999, Math.max(0, sample))
+ : 0;
+ const swapIndex = Math.floor(boundedSample * (index + 1));
+ [deck[index], deck[swapIndex]] = [deck[swapIndex], deck[index]];
+ }
+ return deck;
+ }
+
+ refreshDivineWeaponCards() {
+ const { gameState } = this.room;
+ if (!isDivineWeaponRule(gameState.selectedRule)) return null;
+ if (gameState.divineWeaponReserveCards.length < 2) {
+ gameState.divineWeaponReserveCards = this.createDivineWeaponDeck();
+ }
+ const previousCards = [...gameState.divineWeaponCards];
+ gameState.divineWeaponCards = gameState.divineWeaponReserveCards.splice(0, 2);
+ gameState.divineWeaponGeneration += 1;
+ gameState.divineWeaponUsedThisRound = false;
+ gameState.divineWeaponUsedByPlayerId = null;
+ gameState.divineWeaponUsedCardId = null;
+ return {
+ generation: gameState.divineWeaponGeneration,
+ previousCards: previousCards.map(card => card.toJSON()),
+ cards: gameState.divineWeaponCards.map(card => card.toJSON())
+ };
+ }
+
+ initializeDivineWeaponCards() {
+ const { gameState } = this.room;
+ if (!isDivineWeaponRule(gameState.selectedRule)) return null;
+ if (gameState.divineWeaponCards.length === 2) return null;
+ gameState.divineWeaponReserveCards = this.createDivineWeaponDeck();
+ return this.refreshDivineWeaponCards();
+ }
+
+ initializeSecondBattlefield() {
+ const { gameState } = this.room;
+ if (!isSecondBattlefieldRule(gameState.selectedRule)) return null;
+ const accumulatedCards = gameState.secondBattlefieldAccumulatedCardsByPlayerId;
+ for (const player of this.room.players) {
+ if (!accumulatedCards.has(player.id)) accumulatedCards.set(player.id, []);
+ }
+ return {
+ initialized: true,
+ accumulatedCountsByPlayerId: Object.fromEntries(
+ this.room.players.map(player => [player.id, accumulatedCards.get(player.id).length])
+ )
+ };
+ }
+
+ applySecondBattlefieldAtRoundEnd() {
+ const { gameState } = this.room;
+ if (!isSecondBattlefieldRule(gameState.selectedRule)) return null;
+ this.initializeSecondBattlefield();
+
+ gameState.currentRoundPlays.forEach(play => {
+ const accumulatedCards = gameState.secondBattlefieldAccumulatedCardsByPlayerId.get(play.playerId) || [];
+ accumulatedCards.push(...(play.originalCards || play.cards || []));
+ gameState.secondBattlefieldAccumulatedCardsByPlayerId.set(play.playerId, accumulatedCards);
+ });
+
+ const accumulatedCountsByPlayerId = Object.fromEntries(
+ this.room.players.map(player => [
+ player.id,
+ (gameState.secondBattlefieldAccumulatedCardsByPlayerId.get(player.id) || []).length
+ ])
+ );
+ const allPlayersFinished = this.room.players.every(player => player.cards.length === 0);
+ const everyoneHasEnoughCards = this.room.players.every(
+ player => accumulatedCountsByPlayerId[player.id] >= SECOND_BATTLEFIELD_MIN_ACCUMULATED_CARDS
+ );
+ const allPlayersBelowFinalThreshold = this.room.players.every(
+ player => player.cards.length < SECOND_BATTLEFIELD_FINAL_HAND_THRESHOLD
+ );
+ // 只有本场已经攒够开牌门槛时才判断是否锁入最终场;正好剩5张仍照常开牌。
+ if (!allPlayersFinished && everyoneHasEnoughCards && allPlayersBelowFinalThreshold) {
+ gameState.secondBattlefieldFinalStage = true;
+ }
+ if (
+ !allPlayersFinished
+ && (gameState.secondBattlefieldFinalStage || !everyoneHasEnoughCards)
+ ) {
+ return {
+ triggered: false,
+ isFinalStage: gameState.secondBattlefieldFinalStage,
+ accumulatedCountsByPlayerId
+ };
+ }
+
+ const playerResults = this.room.players.map((player, playerIndex) => {
+ const accumulatedCards = gameState.secondBattlefieldAccumulatedCardsByPlayerId.get(player.id) || [];
+ const bestHand = evaluateBestPokerHand(accumulatedCards);
+ return {
+ playerId: player.id,
+ playerName: player.name,
+ playerIndex,
+ accumulatedCardCount: accumulatedCards.length,
+ accumulatedCards: accumulatedCards.map(card => card.toJSON ? card.toJSON() : card),
+ category: bestHand.category,
+ categoryName: bestHand.categoryName,
+ score: [...bestHand.score],
+ bestFive: bestHand.cards.map(card => card.toJSON ? card.toJSON() : card)
+ };
+ });
+ const bestScore = playerResults.reduce(
+ (currentBest, playerResult) => (
+ !currentBest || comparePokerScores(playerResult.score, currentBest) > 0
+ ? playerResult.score
+ : currentBest
+ ),
+ null
+ );
+ const winners = playerResults.filter(
+ playerResult => comparePokerScores(playerResult.score, bestScore) === 0
+ );
+ const dealerIndex = this.room.getPlayerIndex(gameState.buryingPlayerId);
+ const winningSides = new Set(
+ winners.map(winner => (
+ this.isAttackerPlayerIndex(winner.playerIndex, dealerIndex)
+ ? 'attacker'
+ : 'dealer'
+ ))
+ );
+ const scoreDelta = winningSides.size !== 1
+ ? 0
+ : winningSides.has('attacker')
+ ? SECOND_BATTLEFIELD_AWARD
+ : -SECOND_BATTLEFIELD_AWARD;
+ gameState.attackerScore += scoreDelta;
+ gameState.secondBattlefieldShowdownCount += 1;
+
+ gameState.secondBattlefieldAccumulatedCardsByPlayerId = new Map(
+ this.room.players.map(player => [player.id, []])
+ );
+ const result = {
+ triggered: true,
+ triggerRound: gameState.currentRound,
+ showdownNumber: gameState.secondBattlefieldShowdownCount,
+ isFinal: allPlayersFinished,
+ players: playerResults,
+ winnerPlayerIds: winners.map(winner => winner.playerId),
+ winnerPlayerNames: winners.map(winner => winner.playerName),
+ winningCategoryName: winners[0]?.categoryName || '高牌',
+ winningSides: [...winningSides],
+ award: SECOND_BATTLEFIELD_AWARD,
+ scoreDelta,
+ attackerScore: gameState.attackerScore
+ };
+ gameState.secondBattlefieldLastResult = result;
+ logger.info(
+ `房间 ${this.room.id} 第二战场第${result.showdownNumber}场:` +
+ `${result.winnerPlayerNames.join('、')}以${result.winningCategoryName}获胜,` +
+ `闲家分数变化${scoreDelta > 0 ? '+' : ''}${scoreDelta}`
+ );
+ return result;
+ }
+
+ refreshUsedDivineWeaponCards() {
+ const { gameState } = this.room;
+ if (
+ !isDivineWeaponRule(gameState.selectedRule)
+ || !gameState.divineWeaponUsedThisRound
+ ) {
+ return null;
+ }
+ // 本轮只要有人发动过,两张神兵牌便在轮末一起作废并重新抽取。
+ return this.refreshDivineWeaponCards();
+ }
+
+ resolveDivineWeaponPlay(player, selectedCards, availableHandCards, playOptions = {}) {
+ const { gameState } = this.room;
+ if (gameState.divineWeaponUsedThisRound) {
+ throw new Error('本轮已有玩家发动神兵天降');
+ }
+ const targetCard = gameState.divineWeaponCards.find(
+ card => card.id === playOptions.divineWeaponCardId
+ );
+ if (!targetCard) throw new Error('请先选择本轮仍然有效的神兵牌');
+ const sourceCard = selectedCards.find(
+ card => card.id === playOptions.divineWeaponSourceCardId
+ );
+ if (!sourceCard) throw new Error('请选中一张手牌作为神兵转化牌');
+ if (sourceCard.suit !== targetCard.suit && sourceCard.rank !== targetCard.rank) {
+ throw new Error('转化牌必须与所选神兵牌花色或点数相同');
+ }
+
+ const transformCard = card => {
+ if (card.id !== sourceCard.id) return card;
+ const transformed = new Card(targetCard.suit, targetCard.rank, 0);
+ transformed.id = sourceCard.id;
+ transformed.originalSuit = sourceCard.suit;
+ transformed.originalRank = sourceCard.rank;
+ transformed.isLastStandTrump = false;
+ transformed.isDivineWeaponTransformed = true;
+ transformed.divineWeaponCardId = targetCard.id;
+ return transformed;
+ };
+ return {
+ effectiveCards: selectedCards.map(transformCard),
+ // 是否缺门、是否必须跟对子/拖拉机,必须按发动前的真实手牌判断。
+ // 否则把最后一张首花色副牌变成级牌后会被误判为缺门并允许毙牌。
+ effectiveHandCards: availableHandCards,
+ sourceCard,
+ targetCard,
+ publicInfo: {
+ sourceCardId: sourceCard.id,
+ sourceSuit: sourceCard.suit,
+ sourceRank: sourceCard.rank,
+ targetCard: targetCard.toJSON()
+ }
+ };
+ }
+
+ hasPendingTimeReversalDecision() {
+ // 轮中“预备”只占用技能名额,不暂停出牌;第四家出完进入 holding 后才加锁。
+ return Boolean(this.room.gameState.timeReversalDecisionState);
+ }
+
+ assertTimeReversalDecisionComplete() {
+ if (!this.hasPendingTimeReversalDecision()) return;
+ throw new Error('本轮正在等待时间倒流决定');
+ }
+
+ captureTimeReversalRoundSnapshot() {
+ const { gameState, players } = this.room;
+ if (!isTimeReversalRule(gameState.selectedRule) || gameState.currentRound < 1) {
+ return null;
+ }
+ if (this.timeReversalRoundSnapshot?.currentRound === gameState.currentRound) {
+ return this.timeReversalRoundSnapshot;
+ }
+
+ this.timeReversalRoundSnapshot = {
+ phase: gameState.phase,
+ currentRound: gameState.currentRound,
+ currentPlayerIndex: gameState.currentPlayerIndex,
+ roundStartPlayerIndex: gameState.roundStartPlayerIndex,
+ currentWinnerIndex: gameState.currentWinnerIndex,
+ playersPlayedThisRound: new Set(gameState.playersPlayedThisRound),
+ currentRoundPlays: [...gameState.currentRoundPlays],
+ leadingPattern: gameState.leadingPattern,
+ playHistory: [...gameState.playHistory],
+ attackerScore: gameState.attackerScore,
+ collectedPointCards: [...gameState.collectedPointCards],
+ lastRoundLeadingPattern: gameState.lastRoundLeadingPattern,
+ lastRoundWinnerIndex: gameState.lastRoundWinnerIndex,
+ tenSidedAmbushAttackerNetCardCount: gameState.tenSidedAmbushAttackerNetCardCount,
+ activeSkillUsesByPlayerId: new Map(
+ [...gameState.activeSkillUsesByPlayerId.entries()].map(([playerId, skillIds]) => [
+ playerId,
+ new Set(skillIds)
+ ])
+ ),
+ divineWeaponReserveCards: [...gameState.divineWeaponReserveCards],
+ divineWeaponCards: [...gameState.divineWeaponCards],
+ divineWeaponGeneration: gameState.divineWeaponGeneration,
+ divineWeaponUsedThisRound: gameState.divineWeaponUsedThisRound,
+ divineWeaponUsedByPlayerId: gameState.divineWeaponUsedByPlayerId,
+ divineWeaponUsedCardId: gameState.divineWeaponUsedCardId,
+ ninePrincesDecision: gameState.ninePrincesDecision
+ ? {
+ ...gameState.ninePrincesDecision,
+ eligibleCardIds: [...gameState.ninePrincesDecision.eligibleCardIds]
+ }
+ : null,
+ ninePrincesResolved: gameState.ninePrincesResolved,
+ ninePrincesLastResult: gameState.ninePrincesLastResult
+ ? { ...gameState.ninePrincesLastResult }
+ : null,
+ timeReversalLockedRounds: new Set(gameState.timeReversalLockedRounds),
+ trumpAction: gameState.trumpAction || null,
+ players: players.map(player => ({
+ playerId: player.id,
+ cards: [...player.cards],
+ shownCards: new Set(player.shownCards)
+ }))
+ };
+ return this.timeReversalRoundSnapshot;
+ }
+
+ restoreTimeReversalRoundSnapshot() {
+ const snapshot = this.timeReversalRoundSnapshot;
+ if (!snapshot) throw new Error('找不到可回溯的本轮状态');
+ const { gameState } = this.room;
+
+ for (const playerSnapshot of snapshot.players) {
+ const player = this.room.findPlayerById(playerSnapshot.playerId);
+ if (!player) continue;
+ player.cards = DeckService.autoSortCards([...playerSnapshot.cards]);
+ player.shownCards = new Set(playerSnapshot.shownCards);
+ }
+
+ gameState.phase = snapshot.phase;
+ gameState.currentRound = snapshot.currentRound;
+ gameState.currentPlayerIndex = snapshot.currentPlayerIndex;
+ gameState.roundStartPlayerIndex = snapshot.roundStartPlayerIndex;
+ gameState.currentWinnerIndex = snapshot.currentWinnerIndex;
+ gameState.playersPlayedThisRound = new Set(snapshot.playersPlayedThisRound);
+ gameState.currentRoundPlays = [...snapshot.currentRoundPlays];
+ gameState.leadingPattern = snapshot.leadingPattern;
+ gameState.playHistory = [...snapshot.playHistory];
+ gameState.attackerScore = snapshot.attackerScore;
+ gameState.collectedPointCards = [...snapshot.collectedPointCards];
+ gameState.lastRoundLeadingPattern = snapshot.lastRoundLeadingPattern;
+ gameState.lastRoundWinnerIndex = snapshot.lastRoundWinnerIndex;
+ gameState.tenSidedAmbushAttackerNetCardCount = snapshot.tenSidedAmbushAttackerNetCardCount;
+ gameState.activeSkillUsesByPlayerId = new Map(
+ [...snapshot.activeSkillUsesByPlayerId.entries()].map(([playerId, skillIds]) => [
+ playerId,
+ new Set(skillIds)
+ ])
+ );
+ gameState.divineWeaponReserveCards = [...snapshot.divineWeaponReserveCards];
+ gameState.divineWeaponCards = [...snapshot.divineWeaponCards];
+ gameState.divineWeaponGeneration = snapshot.divineWeaponGeneration;
+ gameState.divineWeaponUsedThisRound = snapshot.divineWeaponUsedThisRound;
+ gameState.divineWeaponUsedByPlayerId = snapshot.divineWeaponUsedByPlayerId;
+ gameState.divineWeaponUsedCardId = snapshot.divineWeaponUsedCardId;
+ gameState.ninePrincesDecision = snapshot.ninePrincesDecision
+ ? {
+ ...snapshot.ninePrincesDecision,
+ eligibleCardIds: [...snapshot.ninePrincesDecision.eligibleCardIds]
+ }
+ : null;
+ gameState.ninePrincesResolved = snapshot.ninePrincesResolved;
+ gameState.ninePrincesLastResult = snapshot.ninePrincesLastResult
+ ? { ...snapshot.ninePrincesLastResult }
+ : null;
+ gameState.timeReversalLockedRounds = new Set(snapshot.timeReversalLockedRounds);
+ gameState.trumpAction = snapshot.trumpAction;
+ gameState.timeReversalReservations.clear();
+ gameState.timeReversalDecisionState = null;
+ gameState.timeReversalWindowRound = null;
+ return snapshot;
+ }
+
+ activateTimeReversal(playerId) {
+ const { gameState } = this.room;
+ if (gameState.phase !== GamePhases.PLAYING || !this.roundManager || gameState.currentRound < 1) {
+ throw new Error('进入出牌阶段后才能预备时间倒流');
+ }
+ const activeSkill = getActiveSkillForRule(gameState.selectedRule);
+ if (activeSkill?.id !== ActiveSkillIds.TIME_REVERSAL) {
+ throw new Error('本局没有时间倒流技能');
+ }
+ const player = this.room.findPlayerById(playerId);
+ if (!player) throw new Error('玩家不存在');
+ if (this.hasUsedActiveSkill(player.id, activeSkill.id)) {
+ throw new Error('时间倒流每名玩家每局只能发动一次');
+ }
+ if (gameState.timeReversalDecisionState === 'awaiting_response') {
+ throw new Error('本轮时间倒流预备窗口已经结束');
+ }
+ const targetRound = gameState.timeReversalDecisionState === 'holding'
+ ? gameState.timeReversalWindowRound
+ : gameState.currentRound;
+ if (!Number.isInteger(targetRound) || targetRound < 1) {
+ throw new Error('当前没有可以预备时间倒流的轮次');
+ }
+ if (gameState.timeReversalLockedRounds.has(targetRound)) {
+ throw new Error('本轮已经发动过时间倒流');
+ }
+ if (gameState.timeReversalReservations.has(player.id)) {
+ throw new Error('你已经预备了本轮时间倒流');
+ }
+
+ // 轮中首次预备前保存本轮初态;轮末两秒窗口沿用第四家出牌前已建立的快照。
+ if (gameState.timeReversalDecisionState !== 'holding') {
+ this.captureTimeReversalRoundSnapshot();
+ }
+ const reservation = {
+ playerId: player.id,
+ playerName: player.name,
+ round: targetRound
+ };
+ gameState.timeReversalReservations.set(player.id, reservation);
+ const result = {
+ ...reservation,
+ activeSkillId: activeSkill.id,
+ activeSkillName: activeSkill.name
+ };
+ logger.info(`房间 ${this.room.id} 玩家 ${player.name} 预备第${targetRound}轮时间倒流`);
+ return result;
+ }
+
+ scheduleTimeReversalDecision() {
+ const { gameState } = this.room;
+ if (gameState.timeReversalDecisionState !== 'holding') {
+ return false;
+ }
+ if (this.timeReversalDecisionTimer) clearTimeout(this.timeReversalDecisionTimer);
+ this.timeReversalDecisionTimer = setTimeout(() => {
+ this.timeReversalDecisionTimer = null;
+ this.promptTimeReversalDecision();
+ }, TIME_REVERSAL_DECISION_DELAY_MS);
+ return true;
+ }
+
+ promptTimeReversalDecision() {
+ const { gameState } = this.room;
+ if (gameState.timeReversalDecisionState !== 'holding') return null;
+ const reservations = Array.from(gameState.timeReversalReservations.values());
+ if (reservations.length === 0) {
+ const result = this.closeTimeReversalWindow('no_reservations');
+ this.onTimeReversalWindowClosed?.(result);
+ return result;
+ }
+ gameState.timeReversalDecisionState = 'awaiting_response';
+ const payload = {
+ round: gameState.timeReversalWindowRound,
+ players: reservations.map(({ playerId, playerName }) => ({ playerId, playerName }))
+ };
+ for (const reservation of reservations) {
+ const player = this.room.findPlayerById(reservation.playerId);
+ if (!player?.socketId) continue;
+ this.io.to(player.socketId).emit('time_reversal_decision_required', {
+ ...payload,
+ playerId: player.id,
+ playerName: player.name
+ });
+ }
+ this.io.to(this.room.id).emit('time_reversal_decision_pending', payload);
+ this.broadcastRoomUpdate();
+ return payload;
+ }
+
+ respondTimeReversal(playerId, accept) {
+ const { gameState } = this.room;
+ const reservation = gameState.timeReversalReservations.get(playerId);
+ if (
+ !reservation
+ || gameState.timeReversalDecisionState !== 'awaiting_response'
+ ) {
+ throw new Error('当前没有等待你的时间倒流决定');
+ }
+ const player = this.room.findPlayerById(playerId);
+ if (!player) throw new Error('玩家不存在');
+ if (this.timeReversalDecisionTimer) {
+ clearTimeout(this.timeReversalDecisionTimer);
+ this.timeReversalDecisionTimer = null;
+ }
+
+ if (!accept) {
+ gameState.timeReversalReservations.delete(player.id);
+ logger.info(`房间 ${this.room.id} 玩家 ${player.name} 放弃第${reservation.round}轮时间倒流`);
+ if (gameState.timeReversalReservations.size === 0) {
+ return {
+ ...this.closeTimeReversalWindow('all_declined'),
+ playerId: player.id,
+ playerName: player.name
+ };
+ }
+ return {
+ accepted: false,
+ resolved: false,
+ playerId: player.id,
+ playerName: player.name,
+ round: reservation.round,
+ pendingPlayerIds: Array.from(gameState.timeReversalReservations.keys()),
+ gameFinished: false
+ };
+ }
+
+ const snapshot = this.restoreTimeReversalRoundSnapshot();
+ this.recordActiveSkillUse(player.id, ActiveSkillIds.TIME_REVERSAL);
+ // 回放的仍是同一轮,锁住该轮,避免四名玩家在同一轮连锁倒流。
+ gameState.timeReversalLockedRounds.add(snapshot.currentRound);
+ this.timeReversalRoundSnapshot = null;
+ logger.info(`房间 ${this.room.id} 玩家 ${player.name} 发动时间倒流,回到第${snapshot.currentRound}轮开始前`);
+ return {
+ accepted: true,
+ resolved: true,
+ playerId: player.id,
+ playerName: player.name,
+ round: snapshot.currentRound,
+ firstPlayerIndex: snapshot.roundStartPlayerIndex,
+ firstPlayerId: this.room.findPlayerByIndex(snapshot.roundStartPlayerIndex)?.id || null,
+ gameFinished: false,
+ hands: this.room.players.map(candidate => ({
+ playerId: candidate.id,
+ cards: candidate.cards.map(card => card.toJSON())
+ }))
+ };
+ }
+
+ closeTimeReversalWindow(reason) {
+ const { gameState } = this.room;
+ const round = gameState.timeReversalWindowRound;
+ gameState.timeReversalReservations.clear();
+ gameState.timeReversalDecisionState = null;
+ gameState.timeReversalWindowRound = null;
+ this.timeReversalRoundSnapshot = null;
+ const ninePrinces = this.beginNinePrincesDecision();
+ const gameWouldFinish = this.room.players.every(candidate => candidate.cards.length === 0);
+ const surrenderDecision = this.hasPendingNinePrincesDecision()
+ ? null
+ : this.prepareSurrenderReview({
+ completedRound: round,
+ finishGameAfterReview: gameWouldFinish
+ });
+ const gameFinished = gameWouldFinish
+ && !this.hasPendingNinePrincesDecision()
+ && !surrenderDecision
+ && gameState.surrenderRequests.size === 0;
+ if (gameFinished) this.finishGame();
+ return {
+ accepted: false,
+ resolved: true,
+ reason,
+ round,
+ gameFinished,
+ surrenderDecision,
+ ninePrinces
+ };
+ }
+
+ hasPendingLastStandDecision() {
+ return this.room.gameState.lastStandPendingPlayerIds.size > 0;
+ }
+
+ assertLastStandDecisionComplete() {
+ if (!this.hasPendingLastStandDecision()) return;
+ const pendingNames = [...this.room.gameState.lastStandPendingPlayerIds]
+ .map(playerId => this.room.findPlayerById(playerId)?.name)
+ .filter(Boolean);
+ throw new Error(`请等待 ${pendingNames.join('、') || '玩家'} 决定是否发动绝处逢生`);
+ }
+
+ isEligibleForLastStand(player) {
+ const { gameState } = this.room;
+ if (
+ !isLastStandRule(gameState.selectedRule)
+ || gameState.lastStandActivatedPlayerIds.has(player.id)
+ || gameState.lastStandPendingPlayerIds.has(player.id)
+ || player.cards.length < 5
+ ) {
+ return false;
+ }
+ if (player.cards.some(card => isTrumpCard(card, gameState.trumpSuit, gameState.trumpRank))) {
+ return false;
+ }
+ return new Set(player.cards.map(card => card.suit)).size === 1;
+ }
+
+ requestLastStandIfEligible(player) {
+ if (!this.isEligibleForLastStand(player)) return null;
+ const { gameState } = this.room;
+ gameState.lastStandPendingPlayerIds.add(player.id);
+ const payload = {
+ playerId: player.id,
+ playerName: player.name,
+ cardsCount: player.cards.length,
+ suit: player.cards[0]?.suit || null
+ };
+ this.io.to(player.socketId).emit('last_stand_decision_required', payload);
+ this.io.to(this.room.id).emit('last_stand_decision_pending', {
+ playerId: player.id,
+ playerName: player.name
+ });
+ this.broadcastRoomUpdate();
+ if (player.isBot) {
+ return this.respondLastStand(player.id, true);
+ }
+ return payload;
+ }
+
+ respondLastStand(playerId, accept) {
+ const { gameState } = this.room;
+ if (!gameState.lastStandPendingPlayerIds.has(playerId)) {
+ throw new Error('当前没有等待你的绝处逢生决定');
+ }
+ const player = this.room.findPlayerById(playerId);
+ if (!player) throw new Error('玩家不存在');
+
+ gameState.lastStandPendingPlayerIds.delete(playerId);
+ if (accept) {
+ if (!this.isEligibleForLastStand(player)) {
+ // isEligibleForLastStand intentionally excludes pending players.
+ const hasEnoughCards = player.cards.length >= 5;
+ const hasNoTrump = player.cards.every(card =>
+ !isTrumpCard(card, gameState.trumpSuit, gameState.trumpRank)
+ );
+ const isSingleSuit = new Set(player.cards.map(card => card.suit)).size === 1;
+ if (!hasEnoughCards || !hasNoTrump || !isSingleSuit) {
+ throw new Error('你的手牌已不再满足绝处逢生条件');
+ }
+ }
+ player.cards.forEach(card => {
+ card.isLastStandTrump = true;
+ });
+ player.cards = DeckService.autoSortCards(player.cards);
+ gameState.lastStandActivatedPlayerIds.add(player.id);
+ this.io.to(player.socketId).emit('last_stand_hand_updated', {
+ cards: player.cards.map(card => card.toJSON())
+ });
+ this.io.to(this.room.id).emit('last_stand_activated', {
+ playerId: player.id,
+ playerName: player.name,
+ cardsCount: player.cards.length
+ });
+ logger.info(`房间 ${this.room.id} 玩家 ${player.name} 发动绝处逢生,${player.cards.length} 张牌全部视为主牌`);
+ } else {
+ this.io.to(this.room.id).emit('last_stand_declined', {
+ playerId: player.id,
+ playerName: player.name
+ });
+ }
+ this.broadcastRoomUpdate();
+ return { accepted: Boolean(accept), playerId: player.id };
+ }
+
+ hasPendingTeammateCheerDecision() {
+ return Boolean(this.room.gameState.teammateCheerPending);
+ }
+
+ assertTeammateCheerDecisionComplete() {
+ const pending = this.room.gameState.teammateCheerPending;
+ if (!pending) return;
+ throw new Error(`请等待 ${pending.playerName || '玩家'} 决定是否发动队友加油`);
+ }
+
+ isEligibleForTeammateCheer(player) {
+ const { gameState } = this.room;
+ if (
+ !player
+ || !isTeammateCheerRule(gameState.selectedRule)
+ || gameState.phase !== GamePhases.PLAYING
+ || player.cards.length === 0
+ || gameState.teammateCheerPending
+ || gameState.teammateCheerUsedPlayerIds.has(player.id)
+ ) {
+ return false;
+ }
+ return player.cards.every(card => (
+ !isTrumpCard(card, gameState.trumpSuit, gameState.trumpRank)
+ ));
+ }
+
+ requestTeammateCheerIfEligible(player, { triggerRound = null } = {}) {
+ if (!this.isEligibleForTeammateCheer(player)) return null;
+ const playerIndex = this.room.getPlayerIndex(player.id);
+ if (playerIndex < 0 || this.room.players.length !== 4) return null;
+ const teammate = this.room.players[(playerIndex + 2) % this.room.players.length];
+ if (!teammate) return null;
+
+ const pending = {
+ playerId: player.id,
+ playerName: player.name,
+ teammatePlayerId: teammate.id,
+ teammatePlayerName: teammate.name,
+ triggerRound: Number.isInteger(triggerRound)
+ ? triggerRound
+ : this.room.gameState.currentRound
+ };
+ this.room.gameState.teammateCheerPending = pending;
+ return { ...pending };
+ }
+
+ respondTeammateCheer(playerId, accept) {
+ const { gameState } = this.room;
+ const pending = gameState.teammateCheerPending;
+ if (!pending || pending.playerId !== playerId) {
+ throw new Error('当前没有等待你的队友加油决定');
+ }
+ const player = this.room.findPlayerById(playerId);
+ const teammate = this.room.findPlayerById(pending.teammatePlayerId);
+ if (!player || !teammate) throw new Error('玩家或队友不存在');
+
+ const triggeringPlay = [...gameState.playHistory]
+ .reverse()
+ .find(play => play.playerId === player.id) || null;
+ let transformedCards = [];
+ let buffedCardsBefore = [];
+ if (accept) {
+ if (player.cards.length === 0) {
+ throw new Error('手牌已经出完,不能发动队友加油');
+ }
+ const stillHasNoTrump = player.cards.every(card => (
+ !isTrumpCard(card, gameState.trumpSuit, gameState.trumpRank)
+ ));
+ if (!stillHasNoTrump) throw new Error('你的手牌中已经重新出现主牌,不能发动队友加油');
+ if (gameState.teammateCheerUsedPlayerIds.has(player.id)) {
+ throw new Error('你本局已经发动过队友加油');
+ }
+
+ buffedCardsBefore = teammate.cards.map(card => ({
+ id: card.id,
+ suit: card.suit,
+ rank: card.rank,
+ originalSuit: card.originalSuit || null,
+ originalRank: card.originalRank || null,
+ isTeammateCheered: Boolean(card.isTeammateCheered)
+ }));
+ teammate.cards.forEach(card => {
+ if (card.isTeammateCheered) return;
+ card.originalRank = card.originalRank || card.rank;
+ card.originalSuit = card.originalSuit || card.suit;
+ const shiftedFace = shiftStrengthCompensationCardFace(
+ card,
+ gameState.trumpSuit,
+ gameState.trumpRank,
+ 1
+ );
+ card.rank = shiftedFace.rank;
+ card.suit = shiftedFace.suit;
+ card.isTeammateCheered = true;
+ card.value = card.calculateValue();
+ });
+ teammate.cards = DeckService.autoSortCards(teammate.cards);
+ transformedCards = teammate.cards.map(card => card.toJSON ? card.toJSON() : card);
+ gameState.teammateCheerUsedPlayerIds.add(player.id);
+ gameState.teammateCheerBuffedPlayerIds.add(teammate.id);
+ }
+ if (triggeringPlay) {
+ triggeringPlay.teammateCheerResolution = {
+ accepted: Boolean(accept),
+ playerId: player.id,
+ teammatePlayerId: teammate.id,
+ buffedCardsBefore
+ };
+ }
+ gameState.teammateCheerPending = null;
+
+ const result = {
+ ...pending,
+ accepted: Boolean(accept),
+ buffedPlayerId: accept ? teammate.id : null,
+ buffedPlayerName: accept ? teammate.name : null,
+ transformedCards,
+ message: accept
+ ? `${player.name} 发动队友加油,${teammate.name} 获得永久牌面+1 Buff`
+ : `${player.name} 暂不发动队友加油`
+ };
+ const publicResult = { ...result };
+ delete publicResult.transformedCards;
+ gameState.teammateCheerLastResult = publicResult;
+
+ const gameFinished = this.room.players.every(candidate => (
+ this.getPlayableCardCount(candidate) === 0
+ ));
+ if (gameFinished) this.finishGame();
+ logger.info(`房间 ${this.room.id} ${result.message}`);
+ return { ...result, gameFinished };
+ }
+
+ hasPendingAfterglowDecision() {
+ return Boolean(this.room.gameState.afterglowPending);
+ }
+
+ assertAfterglowDecisionComplete() {
+ const pending = this.room.gameState.afterglowPending;
+ if (!pending) return;
+ throw new Error(`请等待 ${pending.playerName || '玩家'} 决定是否发动回光返照`);
+ }
+
+ getAfterglowTrumpCards(player) {
+ const { gameState } = this.room;
+ return (player?.cards || []).filter(card => (
+ isTrumpCard(card, gameState.trumpSuit, gameState.trumpRank)
+ ));
+ }
+
+ isEligibleForAfterglow(player) {
+ const { gameState } = this.room;
+ if (
+ !player
+ || !isAfterglowRule(gameState.selectedRule)
+ || gameState.phase !== GamePhases.PLAYING
+ || gameState.trumpSuit === Suits.NO_TRUMP
+ || player.cards.length === 0
+ || gameState.afterglowPending
+ || gameState.afterglowUsedPlayerIds.has(player.id)
+ || gameState.afterglowActivePlayerIds.has(player.id)
+ ) {
+ return false;
+ }
+ const trumpCount = this.getAfterglowTrumpCards(player).length;
+ return trumpCount >= 1 && trumpCount <= 3;
+ }
+
+ requestAfterglowIfEligible(player, {
+ triggerRound = null,
+ triggerTiming = 'after_play'
+ } = {}) {
+ if (!this.isEligibleForAfterglow(player)) return null;
+ const pending = {
+ playerId: player.id,
+ playerName: player.name,
+ trumpCount: this.getAfterglowTrumpCards(player).length,
+ remainingCount: player.cards.length,
+ triggerTiming,
+ triggerRound: Number.isInteger(triggerRound)
+ ? triggerRound
+ : this.room.gameState.currentRound
+ };
+ this.room.gameState.afterglowPending = pending;
+ return { ...pending };
+ }
+
+ beginOpeningAfterglowDecision(player) {
+ const decision = this.requestAfterglowIfEligible(player, {
+ triggerRound: 1,
+ triggerTiming: 'before_first_play'
+ });
+ if (!decision) return null;
+
+ this.io.to(this.room.id).emit('afterglow_decision_pending', decision);
+ if (!player.isBot) {
+ if (player.socketId) {
+ this.io.to(player.socketId).emit('afterglow_decision_required', decision);
+ }
+ return decision;
+ }
+
+ const result = this.respondAfterglow(player.id, true);
+ const { transformedCards: _transformedCards, ...publicResult } = result;
+ this.io.to(this.room.id).emit('afterglow_activated', publicResult);
+ return result;
+ }
+
+ respondAfterglow(playerId, accept) {
+ const { gameState } = this.room;
+ const pending = gameState.afterglowPending;
+ if (!pending || pending.playerId !== playerId) {
+ throw new Error('当前没有等待你的回光返照决定');
+ }
+ const player = this.room.findPlayerById(playerId);
+ if (!player) throw new Error('玩家不存在');
+
+ const triggeringPlay = [...gameState.playHistory]
+ .reverse()
+ .find(play => play.playerId === player.id) || null;
+ let transformedCards = [];
+ let boostedCardsBefore = [];
+ if (accept) {
+ if (gameState.trumpSuit === Suits.NO_TRUMP) {
+ gameState.afterglowPending = null;
+ throw new Error('无主局不能发动回光返照');
+ }
+ const trumpCount = this.getAfterglowTrumpCards(player).length;
+ if (player.cards.length === 0) {
+ throw new Error('手牌已经出完,不能发动回光返照');
+ }
+ if (trumpCount < 1 || trumpCount > 3) {
+ throw new Error('只有仍持有1至3张主牌时才能发动回光返照');
+ }
+ if (gameState.afterglowUsedPlayerIds.has(player.id)) {
+ throw new Error('你本局已经发动过回光返照');
+ }
+ boostedCardsBefore = this.getAfterglowTrumpCards(player).map(card => ({
+ id: card.id,
+ suit: card.suit,
+ rank: card.rank,
+ originalSuit: card.originalSuit || null,
+ originalRank: card.originalRank || null,
+ isAfterglowBoosted: Boolean(card.isAfterglowBoosted)
+ }));
+ player.cards = DeckService.autoSortCards(this.transformAfterglowCards(player.cards));
+ transformedCards = player.cards.map(card => card.toJSON ? card.toJSON() : card);
+ gameState.afterglowUsedPlayerIds.add(player.id);
+ gameState.afterglowActivePlayerIds.add(player.id);
+ }
+ if (triggeringPlay) {
+ triggeringPlay.afterglowResolution = {
+ accepted: Boolean(accept),
+ playerId: player.id,
+ boostedCardsBefore
+ };
+ }
+ gameState.afterglowPending = null;
+
+ const result = {
+ ...pending,
+ accepted: Boolean(accept),
+ active: Boolean(accept),
+ transformedCards,
+ message: accept
+ ? `${player.name} 发动回光返照:剩余主牌立即+1,只要仍有主牌,每次只能出主牌`
+ : `${player.name} 暂不发动回光返照`
+ };
+ const publicResult = { ...result };
+ delete publicResult.transformedCards;
+ gameState.afterglowLastResult = publicResult;
+ logger.info(`房间 ${this.room.id} ${result.message}`);
+ return result;
+ }
+
+ transformAfterglowCards(cards) {
+ const { trumpSuit, trumpRank } = this.room.gameState;
+ return cards.map(card => {
+ if (!isTrumpCard(card, trumpSuit, trumpRank)) return card;
+ if (card.isAfterglowBoosted) return card;
+ const shiftedFace = shiftStrengthCompensationCardFace(
+ card,
+ trumpSuit,
+ trumpRank,
+ 1
+ );
+ const transformed = new Card(shiftedFace.suit, shiftedFace.rank, 0);
+ Object.assign(transformed, card, shiftedFace, {
+ id: card.id,
+ originalSuit: card.originalSuit || card.suit,
+ originalRank: card.originalRank || card.rank,
+ isAfterglowBoosted: true
+ });
+ transformed.value = transformed.calculateValue();
+ return transformed;
+ });
+ }
+
+ resolveWholeHandExchange({ triggerKey, ruleName, offset, threshold }) {
+ const { gameState, players } = this.room;
+ if (gameState.wholeHandExchangeTriggers.has(triggerKey)) return null;
+ gameState.wholeHandExchangeTriggers.add(triggerKey);
+
+ const snapshots = players.map(player => [...player.cards]);
+ const transfers = players.map((player, index) => {
+ const targetIndex = (index + offset + players.length) % players.length;
+ return {
+ fromPlayerId: player.id,
+ fromPlayerName: player.name,
+ toPlayerId: players[targetIndex].id,
+ toPlayerName: players[targetIndex].name,
+ cardsCount: snapshots[index].length
+ };
+ });
+
+ players.forEach((recipient, recipientIndex) => {
+ const sourceIndex = (recipientIndex - offset + players.length) % players.length;
+ recipient.cards = DeckService.autoSortCards(snapshots[sourceIndex]);
+ recipient.shownCards.clear();
+ });
+
+ const payload = {
+ triggerKey,
+ ruleName,
+ threshold,
+ transfers,
+ animationDuration: WHOLE_HAND_EXCHANGE_ANIMATION_MS
+ };
+ logger.info(
+ `房间 ${this.room.id} ${ruleName}:本轮结束后四家手牌均不多于 ${threshold} 张,完成整手交换`
+ );
+ return payload;
+ }
+
+ applyWholeHandExchangeAtRoundEnd() {
+ const { gameState, players } = this.room;
+ const allHandsAtOrBelow = threshold => players.every(
+ player => player.cards.length <= threshold
+ );
+ if (isCosmicShiftRule(gameState.selectedRule) && allHandsAtOrBelow(12)) {
+ return this.resolveWholeHandExchange({
+ triggerKey: 'cosmic_shift_12',
+ ruleName: '斗转星移',
+ offset: 2,
+ threshold: 12
+ });
+ }
+ if (isGoWithTheFlowRule(gameState.selectedRule)) {
+ if (
+ allHandsAtOrBelow(16)
+ && !gameState.wholeHandExchangeTriggers.has('go_with_the_flow_16')
+ ) {
+ return this.resolveWholeHandExchange({
+ triggerKey: 'go_with_the_flow_16',
+ ruleName: '随波逐流',
+ offset: 1,
+ threshold: 16
+ });
+ }
+ if (allHandsAtOrBelow(9)) {
+ return this.resolveWholeHandExchange({
+ triggerKey: 'go_with_the_flow_9',
+ ruleName: '随波逐流',
+ offset: 1,
+ threshold: 9
+ });
+ }
+ }
+ return null;
+ }
+
+ prepareRoundCardExchangeAtRoundEnd(allRoundCards) {
+ const { gameState, players } = this.room;
+ if (gameState.cardExchange || players.some(player => player.cards.length === 0)) {
+ return null;
+ }
+
+ const hasPointCard = allRoundCards.some(card => getCardPoints(card) > 0);
+ let offset = null;
+ if (isFrequentFluctuationRule(gameState.selectedRule) && hasPointCard) {
+ offset = 1;
+ } else if (isMinorDisturbanceRule(gameState.selectedRule) && !hasPointCard) {
+ offset = 2;
+ }
+ if (!Number.isInteger(offset)) return null;
+
+ const targetByPlayerId = {};
+ players.forEach((player, index) => {
+ targetByPlayerId[player.id] = players[(index + offset) % players.length].id;
+ });
+ this.cardExchangeSelections.clear();
+ gameState.cardExchange = {
+ stage: 'round',
+ operation: 'exchange',
+ triggerRound: gameState.currentRound,
+ ruleId: gameState.selectedRule.id,
+ ruleName: gameState.selectedRule.name,
+ requiredCards: 1,
+ targetByPlayerId,
+ submittedPlayerIds: new Set()
+ };
+
+ logger.info(
+ `房间 ${this.room.id} ${gameState.selectedRule.name}:第${gameState.currentRound}轮` +
+ `${hasPointCard ? '含' : '不含'}分数牌,等待四家各交一张牌`
+ );
+ return {
+ stage: 'round',
+ operation: 'exchange',
+ triggerRound: gameState.currentRound,
+ ruleId: gameState.selectedRule.id,
+ ruleName: gameState.selectedRule.name,
+ requiredCards: 1,
+ transfers: this.createPublicCardExchangeTransfers()
+ };
+ }
+
+ prepareRoundDiscardAtRoundEnd(allRoundCards) {
+ const { gameState, players } = this.room;
+ if (
+ gameState.cardExchange
+ || !isLingeringDiscardRule(gameState.selectedRule)
+ || players.some(player => player.cards.length === 0)
+ || !allRoundCards.some(card => getCardPoints(card) > 0)
+ ) {
+ return null;
+ }
+
+ const targetByPlayerId = Object.fromEntries(players.map(player => [player.id, null]));
+ this.cardExchangeSelections.clear();
+ gameState.cardExchange = {
+ stage: 'round',
+ operation: 'discard',
+ triggerRound: gameState.currentRound,
+ ruleId: gameState.selectedRule.id,
+ ruleName: gameState.selectedRule.name,
+ requiredCards: 1,
+ targetByPlayerId,
+ submittedPlayerIds: new Set()
+ };
+
+ logger.info(
+ `房间 ${this.room.id} 弃掷逦迤:第${gameState.currentRound}轮含分数牌,等待四家各自暗弃一张牌`
+ );
+ return {
+ stage: 'round',
+ operation: 'discard',
+ triggerRound: gameState.currentRound,
+ ruleId: gameState.selectedRule.id,
+ ruleName: gameState.selectedRule.name,
+ requiredCards: 1,
+ transfers: this.createPublicCardExchangeTransfers()
+ };
+ }
+
+ submitAutomaticRoundCardExchanges() {
+ let result = { resolved: false, stage: 'round' };
+ for (const bot of this.room.players.filter(player => player.isBot)) {
+ const exchange = this.room.gameState.cardExchange;
+ if (!exchange || exchange.stage !== 'round') break;
+ if (exchange.submittedPlayerIds.has(bot.id)) continue;
+ const selectedCard = bot.cards[0];
+ if (!selectedCard) continue;
+ result = this.submitOpeningCardExchange(bot.id, [selectedCard.id]);
+ }
+ return result;
+ }
+
+ activateLateMoverAdvantage(playerId) {
+ const { gameState } = this.room;
+ if (gameState.phase !== GamePhases.PLAYING || gameState.playMode !== PlayModes.ORDERED) {
+ throw new Error('当前不是有序出牌阶段');
+ }
+
+ const activeSkill = getActiveSkillForRule(gameState.selectedRule);
+ if (activeSkill?.id !== ActiveSkillIds.LATE_MOVER_ADVANTAGE) {
+ throw new Error('本局没有后发制人技能');
+ }
+ if (this.hasUsedActiveSkill(playerId, activeSkill.id)) {
+ throw new Error('后发制人每名玩家每局只能发动一次');
+ }
+
+ const player = this.room.findPlayerById(playerId);
+ if (!player) throw new Error('玩家不存在');
+ const playerIndex = this.room.players.findIndex(candidate => candidate.id === player.id);
+ if (playerIndex !== gameState.currentPlayerIndex) {
+ throw new Error('现在还没有轮到你出牌');
+ }
+
+ const playedPlayerIndexes = [...gameState.playersPlayedThisRound];
+ const secondPlayerIndex = playedPlayerIndexes.at(-1);
+ const expectedThirdPlayerIndex = Number.isInteger(secondPlayerIndex)
+ ? this.roundManager.getPlayerIndexAtOffset(secondPlayerIndex, 1)
+ : null;
+ if (
+ playedPlayerIndexes.length !== 2
+ || expectedThirdPlayerIndex !== playerIndex
+ || gameState.playersPlayedThisRound.has(playerIndex)
+ ) {
+ throw new Error('后发制人只能由本轮三号位在出牌前发动');
+ }
+
+ const nextPlayerIndex = this.roundManager.getPlayerIndexAtOffset(playerIndex, 1);
+ if (gameState.playersPlayedThisRound.has(nextPlayerIndex)) {
+ throw new Error('下家已经出过牌,无法发动后发制人');
+ }
+ const nextPlayer = this.room.findPlayerByIndex(nextPlayerIndex);
+ if (!nextPlayer) throw new Error('找不到下家');
+
+ this.recordActiveSkillUse(player.id, activeSkill.id);
+ gameState.currentPlayerIndex = nextPlayerIndex;
+ const result = {
+ playerId: player.id,
+ playerName: player.name,
+ nextPlayerId: nextPlayer.id,
+ nextPlayerName: nextPlayer.name,
+ currentPlayerIndex: nextPlayerIndex,
+ activeSkillId: activeSkill.id,
+ activeSkillName: activeSkill.name,
+ message: `${player.name} 发动后发制人,改由 ${nextPlayer.name} 先出牌`
+ };
+ logger.info(`房间 ${this.room.id} ${result.message}`);
+ return result;
+ }
+
+ activateRecommendTalent(playerId) {
+ const { gameState } = this.room;
+ if (gameState.phase !== GamePhases.PLAYING || gameState.playMode !== PlayModes.ORDERED) {
+ throw new Error('当前不是有序出牌阶段');
+ }
+
+ const activeSkill = getActiveSkillForRule(gameState.selectedRule);
+ if (activeSkill?.id !== ActiveSkillIds.RECOMMEND_TALENT) {
+ throw new Error('本局没有举贤任能技能');
+ }
+ if (this.hasUsedActiveSkill(playerId, activeSkill.id)) {
+ throw new Error('举贤任能每名玩家每局只能发动一次');
+ }
+
+ const player = this.room.findPlayerById(playerId);
+ if (!player) throw new Error('玩家不存在');
+ const playerIndex = this.room.players.findIndex(candidate => candidate.id === player.id);
+ if (playerIndex !== gameState.currentPlayerIndex) {
+ throw new Error('现在还没有轮到你出牌');
+ }
+ if (
+ gameState.currentRoundPlays.length !== 0
+ || gameState.playersPlayedThisRound.size !== 0
+ || playerIndex !== gameState.roundStartPlayerIndex
+ ) {
+ throw new Error('举贤任能只能由本轮一号位在出牌前发动');
+ }
+
+ const nextPlayerIndex = this.roundManager.getPlayerIndexAtOffset(playerIndex, 1);
+ const nextPlayer = this.room.findPlayerByIndex(nextPlayerIndex);
+ if (!nextPlayer) throw new Error('找不到下家');
+
+ this.recordActiveSkillUse(player.id, activeSkill.id);
+ gameState.currentPlayerIndex = nextPlayerIndex;
+ const result = {
+ playerId: player.id,
+ playerName: player.name,
+ nextPlayerId: nextPlayer.id,
+ nextPlayerName: nextPlayer.name,
+ currentPlayerIndex: nextPlayerIndex,
+ activeSkillId: activeSkill.id,
+ activeSkillName: activeSkill.name,
+ message: `${player.name} 发动举贤任能,改由 ${nextPlayer.name} 先出牌,自己改为本轮最后出牌`
+ };
+ logger.info(`房间 ${this.room.id} ${result.message}`);
+ return result;
+ }
+
+ activateBushGate(playerId) {
+ const { gameState } = this.room;
+ if (gameState.phase !== GamePhases.PLAYING || gameState.playMode !== PlayModes.ORDERED) {
+ throw new Error('当前不是有序出牌阶段');
+ }
+ if (!this.roundManager) throw new Error('本轮尚未开始');
+
+ const activeSkill = getActiveSkillForRule(gameState.selectedRule);
+ if (
+ !isBushGateRule(gameState.selectedRule)
+ || activeSkill?.id !== ActiveSkillIds.BUSH_GATE
+ ) {
+ throw new Error('本局没有布什戈门技能');
+ }
+ if (this.hasUsedActiveSkill(playerId, activeSkill.id)) {
+ throw new Error('布什戈门每名玩家每局只能发动一次');
+ }
+ if (gameState.bushGateRestriction) {
+ throw new Error('一号位尚未完成布什戈门要求的重新首发');
+ }
+
+ const player = this.room.findPlayerById(playerId);
+ if (!player) throw new Error('玩家不存在');
+ const playerIndex = this.room.getPlayerIndex(player.id);
+ const leadingPlay = gameState.currentRoundPlays[0] || null;
+ const leaderIndex = leadingPlay?.playerIndex;
+ const expectedSecondIndex = Number.isInteger(leaderIndex)
+ ? this.roundManager.getPlayerIndexAtOffset(leaderIndex, 1)
+ : null;
+ if (
+ gameState.currentRoundPlays.length !== 1
+ || gameState.playersPlayedThisRound.size !== 1
+ || leaderIndex !== gameState.roundStartPlayerIndex
+ || playerIndex !== gameState.currentPlayerIndex
+ || playerIndex !== expectedSecondIndex
+ || gameState.playersPlayedThisRound.has(playerIndex)
+ ) {
+ throw new Error('布什戈门只能由本轮二号位在一号位出牌后、自己出牌前发动');
+ }
+
+ const leader = this.room.findPlayerById(leadingPlay.playerId);
+ if (!leader) throw new Error('找不到本轮一号位');
+ // 收回之前仍留在一号位手中的牌,就是这次重发唯一允许使用的牌源。
+ if (leader.cards.length === 0) {
+ throw new Error('一号位已经没有其他手牌,无法重新首发');
+ }
+
+ const historyIndex = gameState.playHistory.findLastIndex(play => (
+ play.playerId === leader.id
+ && play.playerIndex === leaderIndex
+ ));
+ if (historyIndex < 0) throw new Error('找不到一号位本轮的首发记录');
+ const hasLaterHistory = gameState.playHistory
+ .slice(historyIndex + 1)
+ .some(play => play.playerId !== leader.id);
+ if (hasLaterHistory) throw new Error('二号位已经出牌,无法发动布什戈门');
+
+ const returnedPhysicalCards = [...(leadingPlay.originalCards || leadingPlay.cards || [])];
+ if (returnedPhysicalCards.length === 0) throw new Error('一号位没有可收回的牌');
+ returnedPhysicalCards.forEach(card => leader.addCard(card));
+ leader.cards = DeckService.autoSortCards(leader.cards);
+
+ gameState.currentRoundPlays = [];
+ gameState.leadingPattern = null;
+ gameState.currentWinnerIndex = null;
+ gameState.playersPlayedThisRound.delete(leaderIndex);
+ gameState.currentPlayerIndex = leaderIndex;
+ gameState.playHistory.splice(historyIndex, 1);
+
+ const returnedCards = returnedPhysicalCards.map(card => (
+ card.toJSON ? card.toJSON() : { ...card }
+ ));
+ const restriction = {
+ round: gameState.currentRound,
+ activatorPlayerId: player.id,
+ activatorPlayerName: player.name,
+ leaderPlayerId: leader.id,
+ leaderPlayerName: leader.name,
+ forbiddenCardIds: returnedPhysicalCards.map(card => card.id),
+ returnedCards
+ };
+ gameState.bushGateRestriction = restriction;
+ const result = {
+ ...restriction,
+ currentPlayerIndex: leaderIndex,
+ remainingCount: leader.cards.length,
+ replayCompleted: false,
+ activeSkillId: activeSkill.id,
+ activeSkillName: activeSkill.name,
+ message: `${player.name} 发动布什戈门,${leader.name} 收回首发并须改用其他牌重新合法首发`
+ };
+ gameState.bushGateLastResult = result;
+ this.recordActiveSkillUse(player.id, activeSkill.id);
+ this.updateRuleHandVisibilityAfterCardChange(leader);
+ logger.info(`房间 ${this.room.id} ${result.message}`);
+ return result;
+ }
+
+ hasPendingForbiddenMagicDecision() {
+ const { gameState } = this.room;
+ return Boolean(
+ gameState.forbiddenMagicCurrentDecisionPlayerId
+ || gameState.forbiddenMagicDecisionQueue.length > 0
+ );
+ }
+
+ assertForbiddenMagicDecisionComplete() {
+ if (!this.hasPendingForbiddenMagicDecision()) return;
+ const player = this.room.findPlayerById(
+ this.room.gameState.forbiddenMagicCurrentDecisionPlayerId
+ );
+ throw new Error(`请等待 ${player?.name || '玩家'} 确认是否发动禁术秘法`);
+ }
+
+ enqueueForbiddenMagicRoundDecisions() {
+ const { gameState } = this.room;
+ if (
+ !isForbiddenMagicRule(gameState.selectedRule)
+ || gameState.phase !== GamePhases.PLAYING
+ || gameState.currentRound < 1
+ ) return null;
+
+ const alreadyQueued = new Set([
+ gameState.forbiddenMagicCurrentDecisionPlayerId,
+ ...gameState.forbiddenMagicDecisionQueue
+ ].filter(Boolean));
+ const candidates = Array.from(gameState.forbiddenMagicReservations.values())
+ .filter(reservation => (
+ reservation.targetRound <= gameState.currentRound
+ && !alreadyQueued.has(reservation.playerId)
+ && !gameState.forbiddenMagicActivePlayerIds.has(reservation.playerId)
+ && !this.hasUsedActiveSkill(reservation.playerId, ActiveSkillIds.FORBIDDEN_MAGIC)
+ ))
+ .sort((left, right) => (
+ this.room.getPlayerIndex(left.playerId) - this.room.getPlayerIndex(right.playerId)
+ ));
+
+ if (candidates.length > 0) {
+ gameState.forbiddenMagicDecisionRound = gameState.currentRound;
+ gameState.forbiddenMagicDecisionQueue.push(
+ ...candidates.map(reservation => reservation.playerId)
+ );
+ }
+ if (!gameState.forbiddenMagicCurrentDecisionPlayerId) {
+ return this.promptNextForbiddenMagicDecision();
+ }
+ this.broadcastRoomUpdate();
+ return {
+ pending: true,
+ round: gameState.forbiddenMagicDecisionRound,
+ playerId: gameState.forbiddenMagicCurrentDecisionPlayerId,
+ queuedPlayerIds: [...gameState.forbiddenMagicDecisionQueue]
+ };
+ }
+
+ promptNextForbiddenMagicDecision() {
+ const { gameState } = this.room;
+ let player = null;
+ while (gameState.forbiddenMagicDecisionQueue.length > 0 && !player) {
+ const playerId = gameState.forbiddenMagicDecisionQueue.shift();
+ const reservation = gameState.forbiddenMagicReservations.get(playerId);
+ if (
+ reservation
+ && reservation.targetRound <= gameState.currentRound
+ && !gameState.forbiddenMagicActivePlayerIds.has(playerId)
+ && !this.hasUsedActiveSkill(playerId, ActiveSkillIds.FORBIDDEN_MAGIC)
+ ) {
+ player = this.room.findPlayerById(playerId);
+ }
+ }
+
+ if (!player) {
+ const round = gameState.forbiddenMagicDecisionRound;
+ gameState.forbiddenMagicCurrentDecisionPlayerId = null;
+ gameState.forbiddenMagicDecisionRound = null;
+ if (round !== null) {
+ this.io.to(this.room.id).emit('forbidden_magic_decisions_completed', { round });
+ }
+ this.broadcastRoomUpdate();
+ return { pending: false, resolved: true, round };
+ }
+
+ gameState.forbiddenMagicCurrentDecisionPlayerId = player.id;
+ const payload = {
+ round: gameState.forbiddenMagicDecisionRound,
+ playerId: player.id,
+ playerName: player.name,
+ queuedPlayerIds: [...gameState.forbiddenMagicDecisionQueue]
+ };
+ if (player.socketId) {
+ this.io.to(player.socketId).emit('forbidden_magic_decision_required', payload);
+ }
+ this.io.to(this.room.id).emit('forbidden_magic_decision_pending', {
+ round: payload.round,
+ playerId: player.id,
+ playerName: player.name
+ });
+ this.broadcastRoomUpdate();
+ return { ...payload, pending: true, resolved: false };
+ }
+
+ activateForbiddenMagic(playerId) {
+ const { gameState } = this.room;
+ if (gameState.phase !== GamePhases.PLAYING || !this.roundManager || gameState.currentRound < 1) {
+ throw new Error('进入出牌阶段后才能预备禁术秘法');
+ }
+ const activeSkill = getActiveSkillForRule(gameState.selectedRule);
+ if (activeSkill?.id !== ActiveSkillIds.FORBIDDEN_MAGIC) {
+ throw new Error('本局没有禁术秘法技能');
+ }
+ const player = this.room.findPlayerById(playerId);
+ if (!player) throw new Error('玩家不存在');
+ if (
+ this.hasUsedActiveSkill(player.id, activeSkill.id)
+ || gameState.forbiddenMagicActivePlayerIds.has(player.id)
+ ) {
+ throw new Error('禁术秘法每名玩家每局只能发动一次');
+ }
+ if (gameState.forbiddenMagicReservations.has(player.id)) {
+ throw new Error('你已经预备了禁术秘法,请等待轮首确认');
+ }
+
+ const isFreshRound = gameState.currentRoundPlays.length === 0
+ && gameState.playersPlayedThisRound.size === 0;
+ const targetRound = isFreshRound ? gameState.currentRound : gameState.currentRound + 1;
+ const reservation = {
+ playerId: player.id,
+ playerName: player.name,
+ targetRound
+ };
+ gameState.forbiddenMagicReservations.set(player.id, reservation);
+ const decision = targetRound === gameState.currentRound
+ ? this.enqueueForbiddenMagicRoundDecisions()
+ : null;
+ logger.info(`房间 ${this.room.id} 玩家 ${player.name} 预备在第${targetRound}轮确认禁术秘法`);
+ return {
+ id: activeSkill.id,
+ name: activeSkill.name,
+ ...reservation,
+ decisionPending: Boolean(decision?.pending),
+ message: `${player.name} 已预备禁术秘法,将在第${targetRound}轮开始时确认`
+ };
+ }
+
+ respondForbiddenMagic(playerId, accept) {
+ const { gameState } = this.room;
+ if (gameState.forbiddenMagicCurrentDecisionPlayerId !== playerId) {
+ throw new Error('当前没有轮到你确认禁术秘法');
+ }
+ const reservation = gameState.forbiddenMagicReservations.get(playerId);
+ const player = this.room.findPlayerById(playerId);
+ if (!reservation || !player) throw new Error('禁术秘法预备状态已失效');
+
+ gameState.forbiddenMagicCurrentDecisionPlayerId = null;
+ gameState.forbiddenMagicReservations.delete(playerId);
+ if (accept) {
+ this.recordActiveSkillUse(player.id, ActiveSkillIds.FORBIDDEN_MAGIC);
+ gameState.forbiddenMagicActivePlayerIds.add(player.id);
+ logger.info(`房间 ${this.room.id} 玩家 ${player.name} 确认发动禁术秘法,本局永久生效`);
+ } else {
+ logger.info(`房间 ${this.room.id} 玩家 ${player.name} 暂不发动禁术秘法,保留以后预备机会`);
+ }
+
+ const nextDecision = this.promptNextForbiddenMagicDecision();
+ return {
+ accepted: Boolean(accept),
+ resolved: !nextDecision.pending,
+ playerId: player.id,
+ playerName: player.name,
+ round: reservation.targetRound,
+ activeSkillId: ActiveSkillIds.FORBIDDEN_MAGIC,
+ activeSkillName: '禁术秘法',
+ nextPlayerId: nextDecision.playerId || null,
+ pendingPlayerIds: [
+ gameState.forbiddenMagicCurrentDecisionPlayerId,
+ ...gameState.forbiddenMagicDecisionQueue
+ ].filter(Boolean)
+ };
+ }
+
+ hasPendingLureTigerDecision() {
+ const { gameState } = this.room;
+ return Boolean(
+ gameState.lureTigerCurrentDecision
+ || gameState.lureTigerDecisionQueue.length > 0
+ );
+ }
+
+ assertLureTigerDecisionComplete() {
+ const decision = this.room.gameState.lureTigerCurrentDecision;
+ if (!decision && this.room.gameState.lureTigerDecisionQueue.length === 0) return;
+ const player = this.room.findPlayerById(decision?.playerId);
+ throw new Error(`请等待 ${player?.name || '玩家'} 完成调虎离山决定`);
+ }
+
+ isLureTigerSilenced(playerId) {
+ const { gameState } = this.room;
+ return Boolean(
+ isLureTigerFromMountainRule(gameState.selectedRule)
+ && gameState.lureTigerSilencedRound === gameState.currentRound
+ && gameState.lureTigerSilencedPlayerIds.has(playerId)
+ );
+ }
+
+ getLureTigerEligibleTargetIds() {
+ const leader = this.room.findPlayerByIndex(this.room.gameState.roundStartPlayerIndex);
+ return this.room.players
+ .filter(player => player.id !== leader?.id)
+ .map(player => player.id);
+ }
+
+ getLureTigerRoundOrder() {
+ const { gameState } = this.room;
+ if (!Number.isInteger(gameState.roundStartPlayerIndex)) {
+ return this.room.players.map(player => player.id);
+ }
+ const indexes = [gameState.roundStartPlayerIndex];
+ for (let offset = 1; offset < this.room.players.length; offset += 1) {
+ indexes.push(this.roundManager
+ ? this.roundManager.getPlayerIndexAtOffset(gameState.roundStartPlayerIndex, offset)
+ : (gameState.roundStartPlayerIndex + offset) % this.room.players.length);
+ }
+ return indexes.map(index => this.room.findPlayerByIndex(index)?.id).filter(Boolean);
+ }
+
+ clearLureTigerSilenceForRound(round = this.room.gameState.currentRound) {
+ const { gameState } = this.room;
+ gameState.lureTigerSilencedPlayerIds.clear();
+ gameState.lureTigerSilencedRound = round;
+ gameState.lureTigerRoundActivations = [];
+ }
+
+ enqueueLureTigerRoundDecisions() {
+ const { gameState } = this.room;
+ if (
+ !isLureTigerFromMountainRule(gameState.selectedRule)
+ || gameState.phase !== GamePhases.PLAYING
+ || gameState.currentRound < 1
+ ) return null;
+
+ if (gameState.lureTigerSilencedRound !== gameState.currentRound) {
+ this.clearLureTigerSilenceForRound(gameState.currentRound);
+ }
+ const alreadyQueued = new Set([
+ gameState.lureTigerCurrentDecision?.playerId,
+ ...gameState.lureTigerDecisionQueue
+ ].filter(Boolean));
+ const roundOrder = this.getLureTigerRoundOrder();
+ const orderByPlayerId = new Map(roundOrder.map((playerId, index) => [playerId, index]));
+ const candidates = Array.from(gameState.lureTigerReservations.values())
+ .filter(reservation => {
+ const teamIndex = this.getPlayerTeamIndex(reservation.playerId);
+ return reservation.targetRound <= gameState.currentRound
+ && !alreadyQueued.has(reservation.playerId)
+ && teamIndex !== null
+ && !gameState.lureTigerUsedTeamIndexes.has(teamIndex);
+ })
+ .sort((left, right) => (
+ (orderByPlayerId.get(left.playerId) ?? Number.MAX_SAFE_INTEGER)
+ - (orderByPlayerId.get(right.playerId) ?? Number.MAX_SAFE_INTEGER)
+ ));
+
+ gameState.lureTigerDecisionQueue.push(
+ ...candidates.map(reservation => reservation.playerId)
+ );
+ gameState.lureTigerDecisionQueue.sort((leftPlayerId, rightPlayerId) => (
+ (orderByPlayerId.get(leftPlayerId) ?? Number.MAX_SAFE_INTEGER)
+ - (orderByPlayerId.get(rightPlayerId) ?? Number.MAX_SAFE_INTEGER)
+ ));
+ if (!gameState.lureTigerCurrentDecision && gameState.lureTigerDecisionQueue.length > 0) {
+ return this.promptNextLureTigerDecision(false);
+ }
+ if (gameState.lureTigerCurrentDecision) {
+ this.broadcastRoomUpdate();
+ return {
+ pending: true,
+ ...gameState.lureTigerCurrentDecision,
+ queuedPlayerIds: [...gameState.lureTigerDecisionQueue]
+ };
+ }
+ this.broadcastRoomUpdate();
+ return null;
+ }
+
+ promptNextLureTigerDecision(emitCompletion = true) {
+ const { gameState } = this.room;
+ let player = null;
+ let reservation = null;
+ while (gameState.lureTigerDecisionQueue.length > 0 && !player) {
+ const playerId = gameState.lureTigerDecisionQueue.shift();
+ const candidateReservation = gameState.lureTigerReservations.get(playerId);
+ const teamIndex = this.getPlayerTeamIndex(playerId);
+ if (
+ candidateReservation
+ && candidateReservation.targetRound <= gameState.currentRound
+ && teamIndex !== null
+ && !gameState.lureTigerUsedTeamIndexes.has(teamIndex)
+ ) {
+ player = this.room.findPlayerById(playerId);
+ reservation = candidateReservation;
+ } else {
+ gameState.lureTigerReservations.delete(playerId);
+ }
+ }
+
+ if (!player || !reservation) {
+ gameState.lureTigerCurrentDecision = null;
+ if (emitCompletion) {
+ this.io.to(this.room.id).emit('lure_tiger_decisions_completed', {
+ round: gameState.currentRound,
+ silencedPlayerIds: Array.from(gameState.lureTigerSilencedPlayerIds)
+ });
+ }
+ this.broadcastRoomUpdate();
+ return { pending: false, resolved: true, round: gameState.currentRound };
+ }
+
+ const teamIndex = this.getPlayerTeamIndex(player.id);
+ const decision = {
+ round: gameState.currentRound,
+ playerId: player.id,
+ playerName: player.name,
+ teamIndex,
+ stage: 'confirm',
+ eligibleTargetIds: this.getLureTigerEligibleTargetIds()
+ };
+ gameState.lureTigerCurrentDecision = decision;
+ if (player.socketId) {
+ this.io.to(player.socketId).emit('lure_tiger_decision_required', decision);
+ }
+ this.io.to(this.room.id).emit('lure_tiger_decision_pending', {
+ round: decision.round,
+ playerId: player.id,
+ playerName: player.name,
+ stage: decision.stage
+ });
+ this.broadcastRoomUpdate();
+ return {
+ ...decision,
+ queuedPlayerIds: [...gameState.lureTigerDecisionQueue],
+ pending: true,
+ resolved: false
+ };
+ }
+
+ activateLureTiger(playerId) {
+ const { gameState } = this.room;
+ if (gameState.phase !== GamePhases.PLAYING || !this.roundManager || gameState.currentRound < 1) {
+ throw new Error('进入出牌阶段后才能预备调虎离山');
+ }
+ const activeSkill = getActiveSkillForRule(gameState.selectedRule);
+ if (activeSkill?.id !== ActiveSkillIds.LURE_TIGER_FROM_MOUNTAIN) {
+ throw new Error('本局没有调虎离山技能');
+ }
+ const player = this.room.findPlayerById(playerId);
+ if (!player) throw new Error('玩家不存在');
+ const teamIndex = this.getPlayerTeamIndex(player.id);
+ if (teamIndex === null) throw new Error('无法确定玩家阵营');
+ if (gameState.lureTigerUsedTeamIndexes.has(teamIndex)) {
+ throw new Error('你方阵营本局已经发动过调虎离山');
+ }
+ if (gameState.lureTigerReservations.has(player.id)) {
+ throw new Error('你已经预备了调虎离山,请等待轮首确认');
+ }
+
+ const isFreshRound = gameState.currentRoundPlays.length === 0
+ && gameState.playersPlayedThisRound.size === 0;
+ const targetRound = isFreshRound ? gameState.currentRound : gameState.currentRound + 1;
+ const reservation = {
+ playerId: player.id,
+ playerName: player.name,
+ teamIndex,
+ targetRound
+ };
+ gameState.lureTigerReservations.set(player.id, reservation);
+ const decision = targetRound === gameState.currentRound
+ ? this.enqueueLureTigerRoundDecisions()
+ : null;
+ logger.info(`房间 ${this.room.id} 玩家 ${player.name} 预备在第${targetRound}轮确认调虎离山`);
+ return {
+ id: activeSkill.id,
+ name: activeSkill.name,
+ ...reservation,
+ decisionPending: Boolean(decision?.pending),
+ message: `${player.name} 已预备调虎离山,将在第${targetRound}轮开始时确认`
+ };
+ }
+
+ respondLureTiger(playerId, accept) {
+ const { gameState } = this.room;
+ const decision = gameState.lureTigerCurrentDecision;
+ if (decision?.playerId !== playerId || decision.stage !== 'confirm') {
+ throw new Error('当前没有轮到你确认调虎离山');
+ }
+ const reservation = gameState.lureTigerReservations.get(playerId);
+ const player = this.room.findPlayerById(playerId);
+ if (!reservation || !player) throw new Error('调虎离山预备状态已失效');
+
+ if (!accept) {
+ gameState.lureTigerCurrentDecision = null;
+ gameState.lureTigerReservations.delete(playerId);
+ const nextDecision = this.promptNextLureTigerDecision(true);
+ logger.info(`房间 ${this.room.id} 玩家 ${player.name} 暂不发动调虎离山`);
+ return {
+ accepted: false,
+ needsTarget: false,
+ resolved: !nextDecision.pending,
+ playerId: player.id,
+ playerName: player.name,
+ round: decision.round,
+ activeSkillId: ActiveSkillIds.LURE_TIGER_FROM_MOUNTAIN,
+ activeSkillName: '调虎离山',
+ nextPlayerId: nextDecision.playerId || null
+ };
+ }
+
+ gameState.lureTigerCurrentDecision = {
+ ...decision,
+ stage: 'target',
+ eligibleTargetIds: this.getLureTigerEligibleTargetIds()
+ };
+ const targetPayload = { ...gameState.lureTigerCurrentDecision };
+ if (player.socketId) {
+ this.io.to(player.socketId).emit('lure_tiger_target_required', targetPayload);
+ }
+ this.io.to(this.room.id).emit('lure_tiger_decision_pending', {
+ round: decision.round,
+ playerId: player.id,
+ playerName: player.name,
+ stage: 'target'
+ });
+ this.broadcastRoomUpdate();
+ return {
+ accepted: true,
+ needsTarget: true,
+ resolved: false,
+ ...targetPayload,
+ activeSkillId: ActiveSkillIds.LURE_TIGER_FROM_MOUNTAIN,
+ activeSkillName: '调虎离山'
+ };
+ }
+
+ selectLureTigerTarget(playerId, targetPlayerId) {
+ const { gameState } = this.room;
+ const decision = gameState.lureTigerCurrentDecision;
+ if (decision?.playerId !== playerId || decision.stage !== 'target') {
+ throw new Error('当前没有轮到你选择调虎离山目标');
+ }
+ if (!decision.eligibleTargetIds.includes(targetPlayerId)) {
+ throw new Error('调虎离山只能指定本轮非一号位玩家');
+ }
+ const player = this.room.findPlayerById(playerId);
+ const target = this.room.findPlayerById(targetPlayerId);
+ if (!player || !target) throw new Error('玩家不存在');
+ const teamIndex = this.getPlayerTeamIndex(player.id);
+ if (teamIndex === null || gameState.lureTigerUsedTeamIndexes.has(teamIndex)) {
+ throw new Error('你方阵营本局已经发动过调虎离山');
+ }
+
+ gameState.lureTigerUsedTeamIndexes.add(teamIndex);
+ gameState.lureTigerSilencedRound = gameState.currentRound;
+ gameState.lureTigerSilencedPlayerIds.add(target.id);
+ const activation = {
+ round: gameState.currentRound,
+ teamIndex,
+ playerId: player.id,
+ playerName: player.name,
+ targetPlayerId: target.id,
+ targetPlayerName: target.name
+ };
+ gameState.lureTigerRoundActivations.push(activation);
+ this.recordActiveSkillUse(player.id, ActiveSkillIds.LURE_TIGER_FROM_MOUNTAIN);
+ gameState.lureTigerCurrentDecision = null;
+
+ for (const [reservedPlayerId] of gameState.lureTigerReservations) {
+ if (this.getPlayerTeamIndex(reservedPlayerId) === teamIndex) {
+ gameState.lureTigerReservations.delete(reservedPlayerId);
+ }
+ }
+ gameState.lureTigerDecisionQueue = gameState.lureTigerDecisionQueue.filter(
+ queuedPlayerId => this.getPlayerTeamIndex(queuedPlayerId) !== teamIndex
+ );
+ const nextDecision = this.promptNextLureTigerDecision(true);
+ logger.info(`房间 ${this.room.id} ${player.name} 发动调虎离山,沉默 ${target.name}`);
+ return {
+ ...activation,
+ accepted: true,
+ resolved: !nextDecision.pending,
+ nextPlayerId: nextDecision.playerId || null,
+ activeSkillId: ActiveSkillIds.LURE_TIGER_FROM_MOUNTAIN,
+ activeSkillName: '调虎离山',
+ silencedPlayerIds: Array.from(gameState.lureTigerSilencedPlayerIds)
+ };
+ }
+
+ prepareMagicTrick(playerId, targetPlayerIds) {
+ const { gameState } = this.room;
+ if (gameState.phase !== GamePhases.PLAYING || gameState.playMode !== PlayModes.ORDERED) {
+ throw new Error('当前不是有序出牌阶段');
+ }
+ const activeSkill = getActiveSkillForRule(gameState.selectedRule);
+ if (activeSkill?.id !== ActiveSkillIds.MAGIC_TRICK) {
+ throw new Error('本局没有魔术戏法技能');
+ }
+ if (this.hasUsedActiveSkill(playerId, activeSkill.id)) {
+ throw new Error('魔术戏法每名玩家每局只能发动一次');
+ }
+ if (gameState.magicTrickSelection) {
+ throw new Error('本轮已经暗中准备了魔术戏法');
+ }
+
+ const player = this.room.findPlayerById(playerId);
+ const playerIndex = this.room.getPlayerIndex(playerId);
+ if (!player) throw new Error('玩家不存在');
+ if (playerIndex !== gameState.currentPlayerIndex) throw new Error('现在还没有轮到你出牌');
+ if (
+ gameState.currentRoundPlays.length !== 0
+ || gameState.playersPlayedThisRound.size !== 0
+ || playerIndex !== gameState.roundStartPlayerIndex
+ ) {
+ throw new Error('魔术戏法只能由本轮一号位在出牌前发动');
+ }
+
+ const targets = Array.isArray(targetPlayerIds) ? [...new Set(targetPlayerIds)] : [];
+ if (targets.length !== 2) throw new Error('必须选择两名不同的玩家');
+ if (targets.includes(player.id)) throw new Error('魔术戏法不能选择自己');
+ const targetPlayers = targets.map(targetId => this.room.findPlayerById(targetId));
+ if (targetPlayers.some(target => !target)) throw new Error('所选玩家不存在');
+
+ gameState.magicTrickSelection = {
+ round: gameState.currentRound,
+ playerId: player.id,
+ targetPlayerIds: targets
+ };
+ logger.info(`房间 ${this.room.id} ${player.name} 暗中准备魔术戏法`);
+ return {
+ round: gameState.currentRound,
+ playerId: player.id,
+ playerName: player.name,
+ targetPlayerIds: targets,
+ targetPlayerNames: targetPlayers.map(target => target.name),
+ activeSkillId: activeSkill.id,
+ activeSkillName: activeSkill.name
+ };
+ }
+
+ hasPendingEquivalentReciprocityChallenge() {
+ return Boolean(this.room.gameState.equivalentReciprocityChallenge);
+ }
+
+ startEquivalentReciprocity(initiatorPlayerId, targetPlayerId) {
+ const { gameState } = this.room;
+ if (gameState.phase !== GamePhases.PLAYING || gameState.playMode !== PlayModes.ORDERED) {
+ throw new Error('当前不是有序出牌阶段');
+ }
+
+ const activeSkill = getActiveSkillForRule(gameState.selectedRule);
+ if (activeSkill?.id !== ActiveSkillIds.EQUIVALENT_RECIPROCITY) {
+ throw new Error('本局没有等价互惠技能');
+ }
+ if (this.hasUsedActiveSkill(initiatorPlayerId, activeSkill.id)) {
+ throw new Error('等价互惠每名玩家每局只能发动一次');
+ }
+ if (gameState.equivalentReciprocityChallenge) {
+ throw new Error('当前已有一组玩家正在拼点');
+ }
+
+ const initiator = this.room.findPlayerById(initiatorPlayerId);
+ const target = this.room.findPlayerById(targetPlayerId);
+ if (!initiator || !target) throw new Error('玩家不存在');
+ if (initiator.id === target.id) throw new Error('不能与自己拼点');
+ if (initiator.cards.length === 0 || target.cards.length === 0) {
+ throw new Error('双方都必须至少有一张手牌才能拼点');
+ }
+
+ const initiatorIndex = this.room.getPlayerIndex(initiator.id);
+ if (initiatorIndex !== gameState.currentPlayerIndex) {
+ throw new Error('现在还没有轮到你出牌');
+ }
+ if (
+ gameState.currentRoundPlays.length !== 0
+ || gameState.playersPlayedThisRound.size !== 0
+ || initiatorIndex !== gameState.roundStartPlayerIndex
+ ) {
+ throw new Error('等价互惠只能由本轮一号位在出牌前发动');
+ }
+
+ const challenge = {
+ id: `equivalent-${gameState.currentRound}-${initiator.id}-${Date.now()}`,
+ initiatorPlayerId: initiator.id,
+ targetPlayerId: target.id,
+ selectedCardsByPlayerId: new Map()
+ };
+ gameState.equivalentReciprocityChallenge = challenge;
+ this.recordActiveSkillUse(initiator.id, activeSkill.id);
+
+ logger.info(`房间 ${this.room.id} ${initiator.name} 发动等价互惠,邀请 ${target.name} 拼点`);
+ return {
+ challengeId: challenge.id,
+ initiatorPlayerId: initiator.id,
+ initiatorPlayerName: initiator.name,
+ targetPlayerId: target.id,
+ targetPlayerName: target.name,
+ participantPlayerIds: [initiator.id, target.id],
+ activeSkillId: activeSkill.id,
+ activeSkillName: activeSkill.name
+ };
+ }
+
+ selectEquivalentReciprocityCardForBot(playerId) {
+ const player = this.room.findPlayerById(playerId);
+ if (!player?.isBot || player.cards.length === 0) return null;
+ const { trumpSuit, trumpRank, selectedRule } = this.room.gameState;
+ return [...player.cards].sort((left, right) => (
+ getCardStrength(right, trumpSuit, trumpRank, selectedRule)
+ - getCardStrength(left, trumpSuit, trumpRank, selectedRule)
+ ))[0];
+ }
+
+ submitEquivalentReciprocityCard(playerId, challengeId, cardId) {
+ const { gameState } = this.room;
+ const challenge = gameState.equivalentReciprocityChallenge;
+ if (!challenge || challenge.id !== challengeId) {
+ throw new Error('这次拼点已经结束或不存在');
+ }
+ if (![challenge.initiatorPlayerId, challenge.targetPlayerId].includes(playerId)) {
+ throw new Error('你不是本次拼点参与者');
+ }
+ if (challenge.selectedCardsByPlayerId.has(playerId)) {
+ throw new Error('你已经提交过拼点牌');
+ }
+
+ const player = this.room.findPlayerById(playerId);
+ const selectedCard = player?.cards.find(card => card.id === cardId);
+ if (!selectedCard) throw new Error('选择的拼点牌已不在手中');
+ challenge.selectedCardsByPlayerId.set(playerId, selectedCard);
+
+ const selectionResult = {
+ resolved: false,
+ challengeId: challenge.id,
+ playerId: player.id,
+ playerName: player.name,
+ selectedPlayerIds: Array.from(challenge.selectedCardsByPlayerId.keys())
+ };
+ if (challenge.selectedCardsByPlayerId.size < 2) {
+ return selectionResult;
+ }
+
+ const initiator = this.room.findPlayerById(challenge.initiatorPlayerId);
+ const target = this.room.findPlayerById(challenge.targetPlayerId);
+ const initiatorCard = challenge.selectedCardsByPlayerId.get(initiator.id);
+ const targetCard = challenge.selectedCardsByPlayerId.get(target.id);
+ const { trumpSuit, trumpRank, selectedRule } = gameState;
+ const initiatorStrength = getCardStrength(initiatorCard, trumpSuit, trumpRank, selectedRule);
+ const targetStrength = getCardStrength(targetCard, trumpSuit, trumpRank, selectedRule);
+ const isTie = initiatorStrength === targetStrength;
+ const winner = isTie ? null : (initiatorStrength > targetStrength ? initiator : target);
+ const loser = isTie ? null : (winner.id === initiator.id ? target : initiator);
+
+ let attackerScoreDelta = 0;
+ if (loser) {
+ const loserIndex = this.room.getPlayerIndex(loser.id);
+ attackerScoreDelta = this.isAttackerPlayerIndex(
+ loserIndex,
+ gameState.dealerPlayerIndex
+ ) ? -5 : 5;
+ gameState.attackerScore += attackerScoreDelta;
+ }
+
+ // 先同时移除双方拼点牌,再交叉加入对方手牌,保证交换不受处理顺序影响。
+ initiator.removeCards([initiatorCard.id]);
+ target.removeCards([targetCard.id]);
+ initiator.addCard(targetCard);
+ target.addCard(initiatorCard);
+ initiator.cards = DeckService.autoSortCards(initiator.cards);
+ target.cards = DeckService.autoSortCards(target.cards);
+ gameState.equivalentReciprocityChallenge = null;
+
+ const result = {
+ ...selectionResult,
+ resolved: true,
+ initiatorPlayerId: initiator.id,
+ initiatorPlayerName: initiator.name,
+ targetPlayerId: target.id,
+ targetPlayerName: target.name,
+ cards: [
+ { playerId: initiator.id, playerName: initiator.name, card: initiatorCard.toJSON() },
+ { playerId: target.id, playerName: target.name, card: targetCard.toJSON() }
+ ],
+ isTie,
+ winnerPlayerId: winner?.id || null,
+ winnerPlayerName: winner?.name || null,
+ loserPlayerId: loser?.id || null,
+ loserPlayerName: loser?.name || null,
+ attackerScoreDelta,
+ attackerScore: gameState.attackerScore,
+ animationDuration: EQUIVALENT_RECIPROCITY_ANIMATION_MS,
+ hands: [initiator, target].map(handOwner => ({
+ playerId: handOwner.id,
+ cards: handOwner.cards.map(card => card.toJSON())
+ }))
+ };
+ logger.info(
+ `房间 ${this.room.id} 等价互惠拼点完成:${initiator.name} ${initiatorCard.rank} vs `
+ + `${target.name} ${targetCard.rank},${isTie ? '平局' : `${loser.name}一方失去5分`}`
+ );
+ return result;
+ }
+
+ hasPendingMutualSupportAction() {
+ return Boolean(this.room.gameState.mutualSupportPendingAction);
+ }
+
+ getMutualSupportTeammate(playerId) {
+ const playerIndex = this.room.getPlayerIndex(playerId);
+ if (playerIndex < 0 || this.room.players.length !== 4) return null;
+ return this.room.findPlayerByIndex((playerIndex + 2) % this.room.players.length);
+ }
+
+ getMutualSupportOwedCount(playerId, round = this.room.gameState.currentRound) {
+ return this.room.gameState.mutualSupportRoundTransfers
+ .filter(transfer => transfer.round === round && transfer.receiverPlayerId === playerId)
+ .reduce((total, transfer) => total + transfer.count, 0);
+ }
+
+ getMutualSupportTransferCapacity(playerId) {
+ const { gameState } = this.room;
+ const player = this.room.findPlayerById(playerId);
+ if (!player) return 0;
+ const playerIndex = this.room.getPlayerIndex(playerId);
+ const stillNeedsToPlay = !gameState.playersPlayedThisRound.has(playerIndex);
+ const requiredForPlay = stillNeedsToPlay
+ ? (gameState.leadingPattern?.length || 1)
+ : 0;
+ const owedAtRoundEnd = this.getMutualSupportOwedCount(playerId);
+ return Math.max(0, player.cards.length - requiredForPlay - owedAtRoundEnd);
+ }
+
+ createMutualSupportTransfer(fromPlayer, toPlayer, cardIds, { round, stage }) {
+ const uniqueCardIds = Array.isArray(cardIds) ? [...new Set(cardIds)] : [];
+ if (uniqueCardIds.length !== (cardIds?.length || 0)) {
+ throw new Error('不能重复选择同一张牌');
+ }
+ const cards = uniqueCardIds.map(cardId => (
+ fromPlayer.cards.find(card => card.id === cardId)
+ ));
+ if (cards.some(card => !card)) throw new Error('选择的牌已不在手中');
+
+ fromPlayer.removeCards(uniqueCardIds);
+ cards.forEach(card => toPlayer.addCard(card));
+ fromPlayer.cards = DeckService.autoSortCards(fromPlayer.cards);
+ toPlayer.cards = DeckService.autoSortCards(toPlayer.cards);
+
+ return {
+ round,
+ stage,
+ fromPlayerId: fromPlayer.id,
+ fromPlayerName: fromPlayer.name,
+ toPlayerId: toPlayer.id,
+ toPlayerName: toPlayer.name,
+ cardsCount: cards.length,
+ animationDuration: MUTUAL_SUPPORT_ANIMATION_MS,
+ hands: [fromPlayer, toPlayer].map(player => ({
+ playerId: player.id,
+ cards: player.cards.map(card => card.toJSON())
+ }))
+ };
+ }
+
+ recordMutualSupportRoundTransfer({ round, giverPlayerId, receiverPlayerId, count }) {
+ if (count <= 0) return null;
+ const transfer = {
+ id: `mutual-transfer-${round}-${giverPlayerId}-${receiverPlayerId}-${Date.now()}-`
+ + `${this.room.gameState.mutualSupportRoundTransfers.length}`,
+ round,
+ giverPlayerId,
+ receiverPlayerId,
+ count
+ };
+ this.room.gameState.mutualSupportRoundTransfers.push(transfer);
+ return transfer;
+ }
+
+ activateMutualSupport(playerId, direction, cardIds = []) {
+ const { gameState } = this.room;
+ if (gameState.phase !== GamePhases.PLAYING || gameState.playMode !== PlayModes.ORDERED) {
+ throw new Error('当前不是有序出牌阶段');
+ }
+ if (!isMutualSupportRule(gameState.selectedRule)) {
+ throw new Error('本局没有同舟共济技能');
+ }
+ const activeSkill = getActiveSkillForRule(gameState.selectedRule);
+ if (activeSkill?.id !== ActiveSkillIds.MUTUAL_SUPPORT) {
+ throw new Error('本局没有同舟共济技能');
+ }
+ if (this.hasUsedActiveSkill(playerId, activeSkill.id)) {
+ throw new Error('同舟共济每名玩家每局只能发动一次');
+ }
+ if (this.hasPendingStrawBoatBorrowingArrowsDecision()) {
+ throw new Error('请先完成草船借箭');
+ }
+ if (this.hasPendingMutualSupportAction()) {
+ throw new Error('请先完成当前的同舟共济交牌');
+ }
+
+ const player = this.room.findPlayerById(playerId);
+ const teammate = this.getMutualSupportTeammate(playerId);
+ if (!player || !teammate) throw new Error('玩家或队友不存在');
+ const playerIndex = this.room.getPlayerIndex(playerId);
+ if (playerIndex !== gameState.currentPlayerIndex) {
+ throw new Error('现在还没有轮到你出牌');
+ }
+ if (gameState.playersPlayedThisRound.has(playerIndex)) {
+ throw new Error('本轮已经出过牌,不能再发动同舟共济');
+ }
+ if (!['request', 'give'].includes(direction)) {
+ throw new Error('请选择向队友要牌或给队友牌');
+ }
+
+ const actionId = `mutual-${gameState.currentRound}-${player.id}-${Date.now()}`;
+
+ if (direction === 'request') {
+ const maxCards = Math.min(2, this.getMutualSupportTransferCapacity(teammate.id));
+ this.recordActiveSkillUse(player.id, activeSkill.id);
+ gameState.mutualSupportPendingAction = {
+ id: actionId,
+ stage: 'request',
+ round: gameState.currentRound,
+ initiatorPlayerId: player.id,
+ chooserPlayerId: teammate.id,
+ otherPlayerId: player.id,
+ fromPlayerId: teammate.id,
+ toPlayerId: player.id,
+ minCards: 0,
+ maxCards
+ };
+ logger.info(`房间 ${this.room.id} ${player.name} 发动同舟共济,向队友 ${teammate.name} 请求0至${maxCards}张牌`);
+ return {
+ resolved: false,
+ pending: true,
+ actionId,
+ round: gameState.currentRound,
+ direction,
+ initiatorPlayerId: player.id,
+ initiatorPlayerName: player.name,
+ teammatePlayerId: teammate.id,
+ teammatePlayerName: teammate.name,
+ chooserPlayerId: teammate.id,
+ minCards: 0,
+ maxCards,
+ activeSkillId: activeSkill.id,
+ activeSkillName: activeSkill.name
+ };
+ }
+
+ const uniqueCardIds = Array.isArray(cardIds) ? [...new Set(cardIds)] : [];
+ if (uniqueCardIds.length < 1 || uniqueCardIds.length > 2) {
+ throw new Error('请选择1至2张牌交给队友');
+ }
+ const capacity = Math.min(2, this.getMutualSupportTransferCapacity(player.id));
+ if (uniqueCardIds.length > capacity) {
+ throw new Error('必须保留本轮出牌和轮末返还所需的手牌');
+ }
+ let transfer;
+ try {
+ transfer = this.createMutualSupportTransfer(player, teammate, uniqueCardIds, {
+ round: gameState.currentRound,
+ stage: 'initial'
+ });
+ } catch (error) {
+ throw error;
+ }
+ this.recordActiveSkillUse(player.id, activeSkill.id);
+ this.recordMutualSupportRoundTransfer({
+ round: gameState.currentRound,
+ giverPlayerId: player.id,
+ receiverPlayerId: teammate.id,
+ count: transfer.cardsCount
+ });
+ logger.info(`房间 ${this.room.id} ${player.name} 发动同舟共济,交给 ${teammate.name} ${transfer.cardsCount}张牌`);
+ return {
+ resolved: true,
+ pending: false,
+ actionId,
+ round: gameState.currentRound,
+ direction,
+ initiatorPlayerId: player.id,
+ initiatorPlayerName: player.name,
+ teammatePlayerId: teammate.id,
+ teammatePlayerName: teammate.name,
+ activeSkillId: activeSkill.id,
+ activeSkillName: activeSkill.name,
+ transfer
+ };
+ }
+
+ beginNextMutualSupportReturn() {
+ const { gameState } = this.room;
+ const due = gameState.mutualSupportReturnQueue.shift();
+ if (!due) {
+ gameState.mutualSupportPendingAction = null;
+ return null;
+ }
+ gameState.mutualSupportPendingAction = {
+ id: `mutual-return-${due.id}`,
+ stage: 'return',
+ round: due.round,
+ chooserPlayerId: due.receiverPlayerId,
+ otherPlayerId: due.giverPlayerId,
+ fromPlayerId: due.receiverPlayerId,
+ toPlayerId: due.giverPlayerId,
+ minCards: due.count,
+ maxCards: due.count,
+ requiredCards: due.count,
+ transferId: due.id
+ };
+ return gameState.mutualSupportPendingAction;
+ }
+
+ prepareMutualSupportReturnsAtRoundEnd(completedRound) {
+ const { gameState } = this.room;
+ if (!isMutualSupportRule(gameState.selectedRule)) return null;
+ const dueTransfers = gameState.mutualSupportRoundTransfers.filter(
+ transfer => transfer.round === completedRound
+ );
+ gameState.mutualSupportRoundTransfers = gameState.mutualSupportRoundTransfers.filter(
+ transfer => transfer.round !== completedRound
+ );
+ if (dueTransfers.length === 0) return null;
+ gameState.mutualSupportReturnQueue.push(...dueTransfers);
+ return this.beginNextMutualSupportReturn();
+ }
+
+ selectMutualSupportCardsForBot(playerId) {
+ const { gameState } = this.room;
+ const action = gameState.mutualSupportPendingAction;
+ const player = this.room.findPlayerById(playerId);
+ if (!action || !player?.isBot || action.chooserPlayerId !== playerId) return [];
+ const count = action.stage === 'return'
+ ? action.requiredCards
+ : Math.min(action.maxCards, 2);
+ const { trumpSuit, trumpRank, selectedRule } = gameState;
+ return [...player.cards]
+ .sort((left, right) => (
+ getCardStrength(left, trumpSuit, trumpRank, selectedRule)
+ - getCardStrength(right, trumpSuit, trumpRank, selectedRule)
+ ))
+ .slice(0, count)
+ .map(card => card.id);
+ }
+
+ submitMutualSupportCards(playerId, actionId, cardIds = []) {
+ const { gameState } = this.room;
+ const action = gameState.mutualSupportPendingAction;
+ if (!action || action.id !== actionId) {
+ throw new Error('这次同舟共济交牌已经结束或不存在');
+ }
+ if (action.chooserPlayerId !== playerId) {
+ throw new Error('当前不需要你选择同舟共济的牌');
+ }
+ const uniqueCardIds = Array.isArray(cardIds) ? [...new Set(cardIds)] : [];
+ if (uniqueCardIds.length !== (cardIds?.length || 0)) {
+ throw new Error('不能重复选择同一张牌');
+ }
+ if (uniqueCardIds.length < action.minCards || uniqueCardIds.length > action.maxCards) {
+ throw new Error(
+ action.minCards === action.maxCards
+ ? `必须选择${action.minCards}张牌`
+ : `请选择${action.minCards}至${action.maxCards}张牌`
+ );
+ }
+ if (
+ action.stage === 'request'
+ && uniqueCardIds.length > this.getMutualSupportTransferCapacity(playerId)
+ ) {
+ throw new Error('必须保留本轮出牌和轮末返还所需的手牌');
+ }
+
+ const fromPlayer = this.room.findPlayerById(action.fromPlayerId);
+ const toPlayer = this.room.findPlayerById(action.toPlayerId);
+ if (!fromPlayer || !toPlayer) throw new Error('交牌玩家不存在');
+ const transfer = this.createMutualSupportTransfer(fromPlayer, toPlayer, uniqueCardIds, {
+ round: action.round,
+ stage: action.stage
+ });
+
+ if (action.stage === 'request' && transfer.cardsCount > 0) {
+ this.recordMutualSupportRoundTransfer({
+ round: action.round,
+ giverPlayerId: fromPlayer.id,
+ receiverPlayerId: toPlayer.id,
+ count: transfer.cardsCount
+ });
+ }
+
+ const completedStage = action.stage;
+ gameState.mutualSupportPendingAction = null;
+ const nextAction = completedStage === 'return'
+ ? this.beginNextMutualSupportReturn()
+ : null;
+ const gameFinished = completedStage === 'return'
+ && !nextAction
+ && this.room.players.every(player => this.getPlayableCardCount(player) === 0);
+ if (gameFinished) this.finishGame();
+
+ logger.info(
+ `房间 ${this.room.id} 同舟共济${completedStage === 'return' ? '轮末返还' : '请求交牌'}完成:`
+ + `${fromPlayer.name} 交给 ${toPlayer.name} ${transfer.cardsCount}张牌`
+ );
+ return {
+ resolved: true,
+ actionId,
+ round: action.round,
+ stage: completedStage,
+ chooserPlayerId: playerId,
+ transfer,
+ nextAction,
+ allReturnsCompleted: completedStage === 'return' && !nextAction,
+ gameFinished
+ };
+ }
+
+ validateActiveSkillPlay(player, activeSkillId, cards, isLeading) {
+ const activeSkill = getActiveSkillForRule(this.room.gameState.selectedRule);
+ if (!activeSkill || activeSkill.id !== activeSkillId) {
+ throw new Error('本局没有这个主动技能');
+ }
+ if (![
+ ActiveSkillIds.SUBSTITUTE_SACRIFICE,
+ ActiveSkillIds.CONCEALED_PASSAGE,
+ ActiveSkillIds.STEALING_BEAMS,
+ ActiveSkillIds.BELT_AND_ROAD,
+ ActiveSkillIds.DIVINE_WEAPON,
+ ActiveSkillIds.CLUSTER_ANALYSIS,
+ ActiveSkillIds.ILLUSION_AND_REALITY,
+ ActiveSkillIds.AMBIGUOUS
+ ].includes(activeSkill.id)) {
+ throw new Error('暂不支持这个主动技能');
+ }
+ const priorAmbiguousPlay = activeSkill.id === ActiveSkillIds.AMBIGUOUS
+ && this.room.gameState.currentRoundPlays.some(
+ play => play.activeSkillId === ActiveSkillIds.AMBIGUOUS
+ );
+ if (
+ activeSkill.usageLimit !== null
+ && this.hasUsedActiveSkill(player.id, activeSkill.id)
+ && !priorAmbiguousPlay
+ ) {
+ throw new Error(`${activeSkill.name}每名玩家每局只能发动一次`);
+ }
+ if (activeSkill.timing === 'following_play' && isLeading) {
+ throw new Error(`${activeSkill.name}只能在跟牌时发动`);
+ }
+ if (activeSkill.timing === 'leading_play' && !isLeading) {
+ throw new Error(`${activeSkill.name}只能在首发时发动`);
+ }
+ if (
+ activeSkill.timing === 'middle_positions_play'
+ && ![1, 2].includes(this.room.gameState.currentRoundPlays.length)
+ ) {
+ throw new Error(`${activeSkill.name}只能由本轮二号位或三号位发动`);
+ }
+ if (!isLeading) {
+ const requiredCount = this.room.gameState.leadingPattern?.length || 0;
+ if (cards.length !== requiredCount) {
+ throw new Error(`发动${activeSkill.name}仍须出${requiredCount}张牌`);
+ }
+ }
+ return activeSkill;
+ }
+
+ selectBotCardsToBury(player, requiredCount = this.room.gameState.bottomCardsCount) {
+ const { trumpSuit, trumpRank, selectedRule, bottomCardsCount } = this.room.gameState;
+ if (shouldUseWhoDesignedStrategy(this.room)) {
+ return chooseWhoDesignedCardsToBury(
+ player.cards,
+ requiredCount,
+ trumpSuit,
+ trumpRank
+ );
+ }
+
+ return [...player.cards]
+ .sort((a, b) => {
+ const aTrump = isTrumpCard(a, trumpSuit, trumpRank) ? 1 : 0;
+ const bTrump = isTrumpCard(b, trumpSuit, trumpRank) ? 1 : 0;
+ if (aTrump !== bTrump) return aTrump - bTrump;
+
+ const pointDiff = getCardPoints(a) - getCardPoints(b);
+ if (pointDiff !== 0) return pointDiff;
+
+ return getCardStrength(a, trumpSuit, trumpRank, selectedRule) -
+ getCardStrength(b, trumpSuit, trumpRank, selectedRule);
+ })
+ .slice(0, requiredCount);
+ }
+
+ announceBuryResult(actor, result) {
+ this.io.to(this.room.id).emit('cards_buried', {
+ playerId: actor.id,
+ playerName: actor.name,
+ skipped: result.skipped,
+ isSecondary: result.isSecondary,
+ completed: result.completed,
+ peopleCommune: Boolean(result.peopleCommune),
+ submittedCount: result.submittedCount ?? null,
+ totalCount: result.totalCount ?? null
+ });
+
+ if (!result.completed) {
+ const secondaryPlayer = result.secondaryBuryingPlayer;
+ this.io.to(this.room.id).emit('secondary_burying_started', {
+ ruleName: '改革开放',
+ dealerPlayerId: result.dealer.id,
+ dealerPlayerName: result.dealer.name,
+ secondaryPlayerId: secondaryPlayer.id,
+ secondaryPlayerName: secondaryPlayer.name,
+ cardsCount: result.transferredCards.length,
+ animationDuration: SECONDARY_BURY_ANIMATION_MS
+ });
+ if (!secondaryPlayer.isBot) {
+ this.io.to(secondaryPlayer.socketId).emit('secondary_bottom_cards_received', {
+ bottomCards: result.transferredCards.map(card => card.toJSON()),
+ totalCards: secondaryPlayer.cards.length
+ });
+ }
+ this.broadcastRoomUpdate();
+
+ if (secondaryPlayer.isBot) {
+ if (this.secondaryBuryTimer) clearTimeout(this.secondaryBuryTimer);
+ this.secondaryBuryTimer = setTimeout(() => {
+ try {
+ const cardsToBury = this.selectBotCardsToBury(secondaryPlayer);
+ this.buryCards(secondaryPlayer.id, cardsToBury.map(card => card.id));
+ } catch (error) {
+ logger.error(`Bot庄家队友 ${secondaryPlayer.name} 自动再埋底失败:`, error);
+ } finally {
+ this.secondaryBuryTimer = null;
+ }
+ }, 500);
+ }
+ return;
+ }
+
+ const firstPlayer = result.firstPlayer;
+ const willStartMainstay = isMainstayRule(this.room.gameState.selectedRule)
+ && Boolean(this.room.gameState.trumpSuit)
+ && this.room.gameState.trumpSuit !== Suits.NO_TRUMP;
+ this.io.to(this.room.id).emit('first_player_set', {
+ playerId: firstPlayer.id,
+ playerName: firstPlayer.name,
+ currentPlayerIndex: this.room.gameState.currentPlayerIndex
+ });
+ this.io.to(this.room.id).emit('phase_changed', {
+ phase: GamePhases.PLAYING,
+ message: willStartMainstay
+ ? `${actor.name}完成${result.isSecondary ? '再' : ''}埋底,开始中流砥柱`
+ : result.isSecondary
+ ? `${actor.name} 完成再埋底,${firstPlayer.name} 先出牌`
+ : result.peopleCommune
+ ? `人民公社四家埋底完成,${firstPlayer.name} 先出牌`
+ : result.skipped
+ ? `本局没有底牌,${firstPlayer.name} 先出牌`
+ : `埋底完成,${firstPlayer.name} 先出牌`
+ });
+ this.broadcastRoomUpdate();
+ if (this.startMainstay({ completionMode: 'opening' })) return;
+ this.continueOpeningAfterBury(firstPlayer);
+ }
+
+ continueOpeningAfterBury(firstPlayer = null) {
+ const openingPlayer = firstPlayer
+ || this.room.findPlayerByIndex(this.room.gameState.currentPlayerIndex)
+ || this.room.findPlayerById(this.room.gameState.firstPlayerId);
+ // 埋底本身也是一次手牌变化。绝处逢生的首轮检查必须放在埋底完成后,
+ // 此时主牌已锁定且不会让庄家在埋底前提前获得额外信息。
+ this.room.players.forEach(player => this.requestLastStandIfEligible(player));
+ this.beginOpeningAfterglowDecision(openingPlayer);
+ if (
+ !this.hasPendingLastStandDecision() &&
+ !this.hasPendingAfterglowDecision() &&
+ !this.hasPendingFocusFigureVote() &&
+ !this.hasPendingCandleSelection() &&
+ !this.hasPendingWoodenOxDecision() &&
+ !this.hasPendingRiceToMulberrySelection() &&
+ this.onBotTurn
+ ) this.onBotTurn();
+ }
+
+ /**
+ * Bot成为庄家时自动完成埋底,并进入正常出牌流程。
+ */
+ handleDealerAssigned(dealer) {
+ if (!dealer) return;
+
+ // 已触发二鬼拍门的庄家收到底牌后,新加入手中的王也属于明置手牌。
+ if (isTwoGhostsKnockDoorRule(this.room.gameState.selectedRule)) {
+ this.updateRuleHandVisibilityAfterCardChange(dealer);
+ }
+
+ if (isAdministrativeReviewRule(this.room.gameState.selectedRule)) {
+ this.startAdministrativeReview(dealer);
+ return;
+ }
+
+ if (isPeopleCommuneRule(this.room.gameState.selectedRule)) {
+ const currentPlayer = this.room.findPlayerById(
+ this.room.gameState.peopleCommuneCurrentBuryingPlayerId
+ );
+ this.schedulePeopleCommuneBotBury(currentPlayer);
+ return;
+ }
+
+ const requiredCount = this.room.gameState.bottomCardsCount;
+ if (requiredCount === 0) {
+ this.buryCards(dealer.id, []);
+ return;
+ }
+
+ if (!dealer.isBot) return;
+
+ if (this.botActionTimer) clearTimeout(this.botActionTimer);
+ this.botActionTimer = setTimeout(() => {
+ try {
+ const cardsToBury = this.selectBotCardsToBury(dealer);
+ this.buryCards(dealer.id, cardsToBury.map(card => card.id));
+ } catch (error) {
+ logger.error(`Bot庄家 ${dealer.name} 自动埋底失败:`, error);
+ } finally {
+ this.botActionTimer = null;
+ }
+ }, 500);
+ }
+
+ /** “算无遗策”:埋底结束、正式开始出牌时公开庄家对家的手牌。 */
+ activatePerfectStrategy(dealer) {
+ if (!isPerfectStrategyRule(this.room.gameState.selectedRule)) return null;
+
+ const dealerIndex = this.room.getPlayerIndex(dealer.id);
+ if (dealerIndex < 0 || this.room.players.length !== 4) {
+ throw new Error('算无遗策仅支持四人局');
+ }
+
+ const openHandPlayer = this.room.findPlayerByIndex((dealerIndex + 2) % 4);
+ this.room.gameState.openHandPlayerId = openHandPlayer.id;
+ this.room.gameState.openHandControllerPlayerId = dealer.id;
+
+ this.io.to(this.room.id).emit('open_hand_revealed', {
+ playerId: openHandPlayer.id,
+ playerName: openHandPlayer.name,
+ controllerPlayerId: dealer.id,
+ controllerPlayerName: dealer.name,
+ cards: openHandPlayer.cards.map(card => card.toJSON())
+ });
+ this.broadcastRoomUpdate();
+ logger.info(`房间 ${this.room.id} 算无遗策:${openHandPlayer.name} 明手,由 ${dealer.name} 代打`);
+ return openHandPlayer;
+ }
+
+ /** 正式进入出牌阶段后启动本局的其他手牌可见性规则。 */
+ activateRuleHandVisibility() {
+ const { gameState, players } = this.room;
+ gameState.icebergRevealedCardIdsByPlayer.clear();
+ gameState.icebergPendingPlayerIds.clear();
+ this.icebergSelectionRequests.clear();
+ this.icebergInitialSelectionActive = false;
+ gameState.areAllHandsRevealed = false;
+
+ if (isTwoGhostsKnockDoorRule(gameState.selectedRule)) {
+ this.emitRuleVisibleHands();
+ return true;
+ }
+
+ if (isIcebergTipRule(gameState.selectedRule)) {
+ this.icebergInitialSelectionActive = true;
+ players.forEach(player => this.prepareIcebergRevealSelection(player, 'initial'));
+ if (!this.hasPendingIcebergSelection()) {
+ this.icebergInitialSelectionActive = false;
+ this.emitRuleVisibleHands('冰山一角:每名玩家已自行选择两张明牌');
+ }
+ return true;
+ }
+ if (isMutualVisibilityRule(gameState.selectedRule)) {
+ this.emitRuleVisibleHands('互通有无:你现在可以查看队友手牌');
+ return true;
+ }
+ return false;
+ }
+
+ hasPendingIcebergSelection() {
+ return this.icebergSelectionRequests.size > 0;
+ }
+
+ assertIcebergSelectionComplete() {
+ if (!this.hasPendingIcebergSelection()) return;
+ const pendingNames = [...this.icebergSelectionRequests.keys()]
+ .map(playerId => this.room.findPlayerById(playerId)?.name)
+ .filter(Boolean);
+ throw new Error(`请等待 ${pendingNames.join('、') || '玩家'} 选择明牌`);
+ }
+
+ prepareIcebergRevealSelection(player, reason = 'replenish') {
+ const { gameState } = this.room;
+ const revealMap = gameState.icebergRevealedCardIdsByPlayer;
+ const handIds = new Set(player.cards.map(card => card.id));
+ const revealedIds = new Set(
+ [...(revealMap.get(player.id) || [])].filter(cardId => handIds.has(cardId))
+ );
+ const targetCount = Math.min(ICEBERG_REVEALED_CARD_COUNT, player.cards.length);
+ const requiredCount = Math.max(0, targetCount - revealedIds.size);
+ const candidates = player.cards.filter(card => !revealedIds.has(card.id));
+
+ revealMap.set(player.id, revealedIds);
+ this.icebergSelectionRequests.delete(player.id);
+ gameState.icebergPendingPlayerIds.delete(player.id);
+
+ if (requiredCount === 0) {
+ return { pending: false, requiredCount: 0 };
+ }
+
+ // Bot代表自己自动选择;若所有候选都必须明置,也无需让真人做无意义确认。
+ if (player.isBot || candidates.length <= requiredCount) {
+ candidates.slice(0, requiredCount).forEach(card => revealedIds.add(card.id));
+ revealMap.set(player.id, revealedIds);
+ return { pending: false, requiredCount, automatic: true };
+ }
+
+ const request = {
+ playerId: player.id,
+ reason,
+ requiredCount,
+ targetCount,
+ currentlyRevealedCardIds: [...revealedIds]
+ };
+ this.icebergSelectionRequests.set(player.id, request);
+ gameState.icebergPendingPlayerIds.add(player.id);
+ this.io.to(player.socketId).emit('iceberg_reveal_selection_required', {
+ reason,
+ requiredCount,
+ targetCount,
+ currentlyRevealedCardIds: request.currentlyRevealedCardIds
+ });
+ return { pending: true, requiredCount };
+ }
+
+ submitIcebergRevealSelection(playerId, cardIds) {
+ const { gameState } = this.room;
+ if (
+ gameState.phase !== GamePhases.PLAYING
+ || !isIcebergTipRule(gameState.selectedRule)
+ ) {
+ throw new Error('当前不需要选择冰山明牌');
+ }
+
+ const request = this.icebergSelectionRequests.get(playerId);
+ if (!request) throw new Error('当前不需要你选择明牌');
+
+ const player = this.room.findPlayerById(playerId);
+ if (!player) throw new Error('玩家不存在');
+ if (!Array.isArray(cardIds) || cardIds.length !== request.requiredCount) {
+ throw new Error(`必须选择${request.requiredCount}张牌明置`);
+ }
+
+ const uniqueIds = new Set(cardIds);
+ if (uniqueIds.size !== request.requiredCount) {
+ throw new Error('不能重复选择同一张牌');
+ }
+ const revealedIds = new Set(
+ [...(gameState.icebergRevealedCardIdsByPlayer.get(playerId) || [])]
+ .filter(id => player.cards.some(card => card.id === id))
+ );
+ if (cardIds.some(id => revealedIds.has(id))) {
+ throw new Error('请选择尚未明置的手牌');
+ }
+ const selectedCards = cardIds.map(id => player.cards.find(card => card.id === id));
+ if (selectedCards.some(card => !card)) {
+ throw new Error('选择的牌不在手中');
+ }
+
+ cardIds.forEach(id => revealedIds.add(id));
+ if (revealedIds.size !== request.targetCount) {
+ throw new Error(`明牌总数必须保持为${request.targetCount}张`);
+ }
+
+ gameState.icebergRevealedCardIdsByPlayer.set(playerId, revealedIds);
+ this.icebergSelectionRequests.delete(playerId);
+ gameState.icebergPendingPlayerIds.delete(playerId);
+ this.io.to(player.socketId).emit('iceberg_reveal_selection_confirmed', {
+ reason: request.reason,
+ revealedCardIds: [...revealedIds]
+ });
+
+ const resolved = !this.hasPendingIcebergSelection();
+ if (resolved) {
+ const announcement = this.icebergInitialSelectionActive
+ ? '冰山一角:所有玩家已自行选择两张明牌'
+ : `${player.name} 已补足明牌`;
+ this.icebergInitialSelectionActive = false;
+ this.emitRuleVisibleHands(announcement);
+ }
+ this.broadcastRoomUpdate();
+ return { resolved, revealedCardIds: [...revealedIds] };
+ }
+
+ createRuleVisibleHandsFor(viewer) {
+ const { gameState, players } = this.room;
+ if (isTwoGhostsKnockDoorRule(gameState.selectedRule)) {
+ return players
+ .filter(player => gameState.twoGhostsRevealedPlayerIds.has(player.id))
+ .map(player => ({
+ playerId: player.id,
+ playerName: player.name,
+ kind: 'jokers',
+ label: '二鬼拍门',
+ cards: player.cards
+ .filter(card => card.suit === Suits.JOKER)
+ .map(card => card.toJSON())
+ }))
+ .filter(hand => hand.cards.length > 0);
+ }
+
+ if (isIcebergTipRule(gameState.selectedRule)) {
+ return players.map(player => {
+ const revealedIds = gameState.icebergRevealedCardIdsByPlayer.get(player.id) || new Set();
+ return {
+ playerId: player.id,
+ playerName: player.name,
+ kind: 'partial',
+ label: '冰山',
+ cards: player.cards.filter(card => revealedIds.has(card.id)).map(card => card.toJSON())
+ };
+ });
+ }
+
+ if (isMutualVisibilityRule(gameState.selectedRule)) {
+ const viewerIndex = this.room.getPlayerIndex(viewer.id);
+ const teammate = players[(viewerIndex + 2) % players.length];
+ return teammate ? [{
+ playerId: teammate.id,
+ playerName: teammate.name,
+ kind: 'teammate',
+ label: '队友手牌',
+ cards: teammate.cards.map(card => card.toJSON())
+ }] : [];
+ }
+
+ if (isOpenAndHonestRule(gameState.selectedRule) && gameState.areAllHandsRevealed) {
+ return players.map(player => ({
+ playerId: player.id,
+ playerName: player.name,
+ kind: 'public',
+ label: '全员明牌',
+ cards: player.cards.map(card => card.toJSON())
+ }));
+ }
+ return [];
+ }
+
+ emitRuleVisibleHands(announcement = null) {
+ const { gameState } = this.room;
+ const isTwoGhostsVisiblePhase = isTwoGhostsKnockDoorRule(gameState.selectedRule)
+ && [GamePhases.DRAWING, GamePhases.BURYING, GamePhases.PLAYING].includes(gameState.phase);
+ if (gameState.phase !== GamePhases.PLAYING && !isTwoGhostsVisiblePhase) return;
+ for (const viewer of this.room.players) {
+ if (!viewer.socketId) continue;
+ this.io.to(viewer.socketId).emit('rule_visible_hands_updated', {
+ ruleId: this.room.gameState.selectedRule?.id || null,
+ hands: this.createRuleVisibleHandsFor(viewer),
+ announcement
+ });
+ }
+ }
+
+ updateRuleHandVisibilityAfterCardChange(player, { roundEnded = false } = {}) {
+ const { gameState } = this.room;
+ let announcement = null;
+ if (isIcebergTipRule(gameState.selectedRule)) {
+ const selection = this.prepareIcebergRevealSelection(player, 'replenish');
+ if (selection.pending) {
+ announcement = `${player.name} 需要补选 ${selection.requiredCount} 张明牌`;
+ }
+ } else if (
+ isOpenAndHonestRule(gameState.selectedRule)
+ && !gameState.areAllHandsRevealed
+ && roundEnded
+ && this.room.players.every(currentPlayer => currentPlayer.cards.length <= 5)
+ ) {
+ gameState.areAllHandsRevealed = true;
+ announcement = '为人坦荡:本轮结束,所有玩家同时明置剩余手牌';
+ }
+
+ if (
+ isIcebergTipRule(gameState.selectedRule)
+ || isMutualVisibilityRule(gameState.selectedRule)
+ || isTwoGhostsKnockDoorRule(gameState.selectedRule)
+ || gameState.areAllHandsRevealed
+ ) {
+ this.emitRuleVisibleHands(announcement);
+ }
+ }
+
+ getEligibleTenSidedAmbushRanks() {
+ const trumpRank = this.room.gameState.trumpRank;
+ return TEN_SIDED_AMBUSH_RANKS.filter(rank =>
+ rank !== trumpRank && !POINT_RANKS.has(rank)
+ );
+ }
+
+ hasPendingTenSidedAmbushSelection() {
+ return Boolean(this.room.gameState.isTenSidedAmbushSelectionPending);
+ }
+
+ assertTenSidedAmbushSelectionComplete() {
+ if (!this.hasPendingTenSidedAmbushSelection()) return;
+ const selector = this.room.findPlayerById(
+ this.room.gameState.tenSidedAmbushSelectorPlayerId
+ );
+ throw new Error(`请等待 ${selector?.name || '庄家队友'} 指定十面埋伏点数`);
+ }
+
+ /** 庄家埋底后,由其队友暗中指定本局的反向五分点数。 */
+ activateTenSidedAmbush(dealer) {
+ const { gameState, players } = this.room;
+ if (!isTenSidedAmbushRule(gameState.selectedRule)) return null;
+
+ const dealerIndex = this.room.getPlayerIndex(dealer.id);
+ if (dealerIndex < 0 || players.length !== 4) {
+ throw new Error('十面埋伏仅支持四人局');
+ }
+
+ const selector = this.room.findPlayerByIndex((dealerIndex + 2) % players.length);
+ const eligibleRanks = this.getEligibleTenSidedAmbushRanks();
+ if (!selector || eligibleRanks.length === 0) {
+ throw new Error('没有可指定的十面埋伏点数');
+ }
+
+ gameState.tenSidedAmbushSelectorPlayerId = selector.id;
+ gameState.tenSidedAmbushRank = null;
+ gameState.isTenSidedAmbushSelectionPending = true;
+ gameState.isTenSidedAmbushRevealed = false;
+ gameState.tenSidedAmbushAttackerNetCardCount = 0;
+
+ this.io.to(this.room.id).emit('ten_sided_ambush_selection_started', {
+ selectorPlayerId: selector.id,
+ selectorPlayerName: selector.name
+ });
+
+ if (selector.isBot) {
+ const sample = Number(this.random());
+ const boundedSample = Number.isFinite(sample)
+ ? Math.min(0.999999999, Math.max(0, sample))
+ : 0;
+ const selectedRank = eligibleRanks[Math.floor(boundedSample * eligibleRanks.length)];
+ return this.selectTenSidedAmbushRank(selector.id, selectedRank);
+ }
+
+ this.io.to(selector.socketId).emit('ten_sided_ambush_selection_required', {
+ eligibleRanks,
+ trumpRank: gameState.trumpRank
+ });
+ this.broadcastRoomUpdate();
+ logger.info(`房间 ${this.room.id} 十面埋伏:等待 ${selector.name} 暗选点数`);
+ return { pending: true, selectorPlayerId: selector.id };
+ }
+
+ selectTenSidedAmbushRank(playerId, rank) {
+ const { gameState } = this.room;
+ if (!isTenSidedAmbushRule(gameState.selectedRule)) {
+ throw new Error('本局没有启用十面埋伏');
+ }
+ if (!gameState.isTenSidedAmbushSelectionPending) {
+ throw new Error('十面埋伏点数已经确定');
+ }
+ if (gameState.tenSidedAmbushSelectorPlayerId !== playerId) {
+ throw new Error('只有庄家队友可以指定十面埋伏点数');
+ }
+
+ const eligibleRanks = this.getEligibleTenSidedAmbushRanks();
+ if (!eligibleRanks.includes(rank)) {
+ throw new Error('不能选择级牌、分牌或王作为十面埋伏点数');
+ }
+
+ const selector = this.room.findPlayerById(playerId);
+ gameState.tenSidedAmbushRank = rank;
+ gameState.isTenSidedAmbushSelectionPending = false;
+
+ if (selector?.socketId) {
+ this.io.to(selector.socketId).emit('ten_sided_ambush_rank_selected', {
+ rank,
+ isPrivate: true
+ });
+ }
+ this.io.to(this.room.id).emit('ten_sided_ambush_rank_locked', {
+ selectorPlayerId: selector?.id || playerId,
+ selectorPlayerName: selector?.name || '庄家队友'
+ });
+ this.broadcastRoomUpdate();
+ logger.info(`房间 ${this.room.id} 十面埋伏:${selector?.name || playerId} 已暗选点数`);
+ return { pending: false, rank };
+ }
+
+ revealTenSidedAmbushIfNeeded(cards, player, source = 'play') {
+ const { gameState } = this.room;
+ const rank = gameState.tenSidedAmbushRank;
+ if (
+ !isTenSidedAmbushRule(gameState.selectedRule)
+ || gameState.isTenSidedAmbushRevealed
+ || !rank
+ || !(cards || []).some(card => card.rank === rank)
+ ) {
+ return null;
+ }
+
+ gameState.isTenSidedAmbushRevealed = true;
+ logger.info(`房间 ${this.room.id} 十面埋伏点数 ${rank} 首次出现并公开`);
+ return {
+ rank,
+ source,
+ playerId: player?.id || null,
+ playerName: player?.name || null
+ };
+ }
+
+ countTenSidedAmbushCards(cards) {
+ if (!isTenSidedAmbushRule(this.room.gameState.selectedRule)) return 0;
+ const rank = this.room.gameState.tenSidedAmbushRank;
+ if (!rank) return 0;
+ return (cards || []).filter(card => card.rank === rank).length;
+ }
+
+ getWaitingRabbitSelectionOptions() {
+ return {
+ eligibleSuits: [...CULTURAL_REVOLUTION_SUITS],
+ eligibleRanks: TEN_SIDED_AMBUSH_RANKS.filter(
+ rank => rank !== this.room.gameState.trumpRank
+ ),
+ trumpRank: this.room.gameState.trumpRank
+ };
+ }
+
+ hasPendingWaitingRabbitSelection() {
+ return this.room.gameState.waitingRabbitPendingSelectionPlayerIds.size > 0;
+ }
+
+ hasPendingWaitingRabbitDecision() {
+ return Boolean(this.room.gameState.waitingRabbitDecision);
+ }
+
+ assertWaitingRabbitReady() {
+ if (this.hasPendingWaitingRabbitSelection()) {
+ throw new Error('请等待所有玩家完成守株待兔目标牌暗选');
+ }
+ if (this.hasPendingWaitingRabbitDecision()) {
+ throw new Error('请先完成守株待兔换牌决定');
+ }
+ }
+
+ recordWaitingRabbitBehavior(type, details = {}) {
+ const { gameState } = this.room;
+ if (!isWaitingRabbitRule(gameState.selectedRule)) return null;
+ const sequence = gameState.waitingRabbitBehaviorRecords.length + 1;
+ const record = {
+ id: `waiting-rabbit-record-${sequence}`,
+ sequence,
+ type,
+ round: gameState.currentRound,
+ ...details
+ };
+ gameState.waitingRabbitBehaviorRecords.push(record);
+ return record;
+ }
+
+ /** 埋底后四家分别暗选一个“花色 + 点数”牌面;5、10、K可以成为目标。 */
+ activateWaitingRabbit() {
+ const { gameState, players } = this.room;
+ if (!isWaitingRabbitRule(gameState.selectedRule)) return null;
+
+ gameState.waitingRabbitDeclarationsByPlayerId.clear();
+ gameState.waitingRabbitUsedPlayerIds.clear();
+ gameState.waitingRabbitSeenPointCardIds.clear();
+ gameState.waitingRabbitDecision = null;
+ gameState.waitingRabbitLastResult = null;
+ gameState.waitingRabbitBehaviorRecords = [];
+ gameState.waitingRabbitPendingSelectionPlayerIds = new Set(
+ players.map(player => player.id)
+ );
+
+ const options = this.getWaitingRabbitSelectionOptions();
+ if (options.eligibleRanks.length === 0) {
+ throw new Error('守株待兔没有可指定的目标牌点数');
+ }
+
+ this.io.to(this.room.id).emit('waiting_rabbit_selection_started', {
+ playerIds: players.map(player => player.id)
+ });
+
+ for (const player of players) {
+ if (player.isBot) {
+ const firstSample = Math.min(0.999999999, Math.max(0, Number(this.random()) || 0));
+ const secondSample = Math.min(0.999999999, Math.max(0, Number(this.random()) || 0));
+ const suit = options.eligibleSuits[
+ Math.floor(firstSample * options.eligibleSuits.length)
+ ];
+ const rank = options.eligibleRanks[
+ Math.floor(secondSample * options.eligibleRanks.length)
+ ];
+ this.selectWaitingRabbitTarget(player.id, suit, rank);
+ } else if (player.socketId) {
+ this.io.to(player.socketId).emit('waiting_rabbit_selection_required', options);
+ }
+ }
+
+ this.broadcastRoomUpdate();
+ return {
+ pending: this.hasPendingWaitingRabbitSelection(),
+ pendingPlayerIds: Array.from(gameState.waitingRabbitPendingSelectionPlayerIds)
+ };
+ }
+
+ selectWaitingRabbitTarget(playerId, suit, rank) {
+ const { gameState } = this.room;
+ if (!isWaitingRabbitRule(gameState.selectedRule)) {
+ throw new Error('本局没有启用守株待兔');
+ }
+ if (!gameState.waitingRabbitPendingSelectionPlayerIds.has(playerId)) {
+ throw new Error('你已经指定过守株待兔目标牌');
+ }
+
+ const options = this.getWaitingRabbitSelectionOptions();
+ if (!options.eligibleSuits.includes(suit) || !options.eligibleRanks.includes(rank)) {
+ throw new Error('守株待兔不能指定王或当前级牌');
+ }
+
+ const player = this.room.findPlayerById(playerId);
+ if (!player) throw new Error('玩家不存在');
+ const declaration = { suit, rank };
+ gameState.waitingRabbitDeclarationsByPlayerId.set(playerId, declaration);
+ gameState.waitingRabbitPendingSelectionPlayerIds.delete(playerId);
+ this.recordWaitingRabbitBehavior('target_locked', {
+ playerId: player.id,
+ playerName: player.name
+ });
+
+ if (player.socketId) {
+ this.io.to(player.socketId).emit('waiting_rabbit_target_selected', {
+ ...declaration,
+ isPrivate: true
+ });
+ }
+ this.io.to(this.room.id).emit('waiting_rabbit_target_locked', {
+ playerId: player.id,
+ playerName: player.name,
+ remainingCount: gameState.waitingRabbitPendingSelectionPlayerIds.size
+ });
+ if (!this.hasPendingWaitingRabbitSelection()) {
+ this.io.to(this.room.id).emit('waiting_rabbit_selection_completed');
+ }
+ this.broadcastRoomUpdate();
+ return {
+ pending: this.hasPendingWaitingRabbitSelection(),
+ target: declaration
+ };
+ }
+
+ getWaitingRabbitPublicDecision(decision = this.room.gameState.waitingRabbitDecision) {
+ if (!decision) return null;
+ return {
+ id: decision.id,
+ round: decision.round,
+ chooserPlayerId: decision.chooserPlayerId,
+ chooserPlayerName: decision.chooserPlayerName,
+ sourcePlayerId: decision.sourcePlayerId,
+ sourcePlayerName: decision.sourcePlayerName,
+ targetCard: decision.targetCard.toJSON
+ ? decision.targetCard.toJSON()
+ : decision.targetCard
+ };
+ }
+
+ findWaitingRabbitRoundEndTrigger() {
+ const { gameState, players } = this.room;
+ if (
+ !isWaitingRabbitRule(gameState.selectedRule)
+ || gameState.waitingRabbitDecision
+ || this.hasPendingWaitingRabbitSelection()
+ || !this.roundManager
+ || gameState.currentRoundPlays.length !== players.length
+ ) {
+ return null;
+ }
+
+ const startIndex = gameState.roundStartPlayerIndex;
+ for (let offset = 0; offset < players.length; offset += 1) {
+ const playerIndex = this.roundManager.getPlayerIndexAtOffset(startIndex, offset);
+ const chooser = this.room.findPlayerByIndex(playerIndex);
+ if (
+ !chooser
+ || gameState.waitingRabbitUsedPlayerIds.has(chooser.id)
+ ) {
+ continue;
+ }
+ const declaration = gameState.waitingRabbitDeclarationsByPlayerId.get(chooser.id);
+ if (!declaration) continue;
+ const sourcePlay = gameState.currentRoundPlays.find(play => (
+ play.playerId !== chooser.id
+ && (play.originalCards || play.cards).some(card => (
+ card.suit === declaration.suit && card.rank === declaration.rank
+ ))
+ ));
+ if (!sourcePlay) continue;
+ const targetCard = (sourcePlay.originalCards || sourcePlay.cards).find(card => (
+ card.suit === declaration.suit && card.rank === declaration.rank
+ ));
+
+ // 轮末按本轮行动座次锁定第一位命中者;该玩家无非分牌时不顺延给后位。
+ const discardCards = chooser.cards.filter(card => getCardPoints(card) === 0);
+ if (discardCards.length === 0) return null;
+ return { chooser, sourcePlay, targetCard, discardCards };
+ }
+ return null;
+ }
+
+ prepareWaitingRabbitDecisionAtRoundEnd() {
+ const trigger = this.findWaitingRabbitRoundEndTrigger();
+ if (!trigger) return null;
+ const sourcePlayer = this.room.findPlayerById(trigger.sourcePlay.playerId);
+ if (!sourcePlayer) return null;
+ const sourcePlayHistoryIndex = this.room.gameState.playHistory.findLastIndex(play => (
+ play.playerId === sourcePlayer.id
+ && (play.cards || []).some(card => card.id === trigger.targetCard.id)
+ ));
+
+ const decision = {
+ id: `waiting-rabbit-${this.room.gameState.currentRound}-${sourcePlayer.id}-${Date.now()}`,
+ round: this.room.gameState.currentRound,
+ chooserPlayerId: trigger.chooser.id,
+ chooserPlayerName: trigger.chooser.name,
+ sourcePlayerId: sourcePlayer.id,
+ sourcePlayerName: sourcePlayer.name,
+ targetCard: trigger.targetCard,
+ sourcePlayHistoryIndex,
+ sourceTableCards: [...(trigger.sourcePlay.originalCards || trigger.sourcePlay.cards)]
+ };
+ this.room.gameState.waitingRabbitDecision = decision;
+ this.recordWaitingRabbitBehavior('target_triggered', {
+ round: decision.round,
+ playerId: decision.chooserPlayerId,
+ playerName: decision.chooserPlayerName,
+ sourcePlayerId: decision.sourcePlayerId,
+ sourcePlayerName: decision.sourcePlayerName,
+ targetCard: decision.targetCard
+ });
+ return decision;
+ }
+
+ recordWaitingRabbitPointAppearances(cards) {
+ if (!isWaitingRabbitRule(this.room.gameState.selectedRule)) return null;
+ const seenIds = this.room.gameState.waitingRabbitSeenPointCardIds;
+ return (cards || [])
+ .filter(card => getCardPoints(card) > 0)
+ .map(card => {
+ const firstAppearance = !seenIds.has(card.id);
+ if (firstAppearance) seenIds.add(card.id);
+ return {
+ card,
+ cardId: card.id,
+ firstAppearance,
+ points: firstAppearance ? getCardPoints(card) : 0
+ };
+ });
+ }
+
+ resolveWaitingRabbitDecision(playerId, { accept, discardCardId = null } = {}) {
+ const { gameState } = this.room;
+ const decision = gameState.waitingRabbitDecision;
+ if (!decision) throw new Error('当前没有待处理的守株待兔换牌');
+ if (decision.chooserPlayerId !== playerId) {
+ throw new Error('只有本次守株待兔的优先玩家可以决定是否换牌');
+ }
+
+ const chooser = this.room.findPlayerById(playerId);
+ if (!chooser) throw new Error('玩家不存在');
+ const accepted = Boolean(accept);
+ let discardCard = null;
+ let publicExchange = null;
+ if (accepted) {
+ discardCard = chooser.cards.find(card => card.id === discardCardId);
+ if (!discardCard || getCardPoints(discardCard) !== 0) {
+ throw new Error('守株待兔只能选择一张手里的非分牌交换');
+ }
+ if (!(decision.sourceTableCards || []).some(card => card.id === decision.targetCard.id)) {
+ throw new Error('守株待兔目标牌已经不在刚结束的牌桌上');
+ }
+
+ chooser.removeCards([discardCard.id]);
+ chooser.addCard(decision.targetCard);
+ chooser.cards = DeckService.autoSortCards(chooser.cards);
+ const tableCards = (decision.sourceTableCards || []).map(card => (
+ card.id === decision.targetCard.id ? discardCard : card
+ ));
+ publicExchange = {
+ accepted: true,
+ chooserPlayerId: chooser.id,
+ chooserPlayerName: chooser.name,
+ sourcePlayerId: decision.sourcePlayerId,
+ sourcePlayerName: decision.sourcePlayerName,
+ targetCard: decision.targetCard.toJSON
+ ? decision.targetCard.toJSON()
+ : decision.targetCard,
+ discardedCard: discardCard.toJSON ? discardCard.toJSON() : discardCard,
+ tableCards: tableCards.map(card => card.toJSON ? card.toJSON() : card)
+ };
+ const sourceHistory = gameState.playHistory[decision.sourcePlayHistoryIndex];
+ if (sourceHistory) {
+ sourceHistory.waitingRabbitExchange = publicExchange;
+ sourceHistory.tableCards = publicExchange.tableCards;
+ }
+ gameState.waitingRabbitUsedPlayerIds.add(chooser.id);
+ this.updateRuleHandVisibilityAfterCardChange(chooser);
+ }
+
+ const resolution = {
+ ...this.getWaitingRabbitPublicDecision(decision),
+ accepted,
+ discardedCard: publicExchange?.discardedCard || null,
+ tableCards: publicExchange?.tableCards || null
+ };
+ gameState.waitingRabbitDecision = null;
+ gameState.waitingRabbitLastResult = resolution;
+ this.recordWaitingRabbitBehavior(accepted ? 'target_exchanged' : 'exchange_declined', {
+ round: decision.round,
+ playerId: decision.chooserPlayerId,
+ playerName: decision.chooserPlayerName,
+ sourcePlayerId: decision.sourcePlayerId,
+ sourcePlayerName: decision.sourcePlayerName,
+ targetCard: decision.targetCard,
+ discardedCard: discardCard
+ });
+ const roundResult = {
+ waitingRabbitResolution: resolution,
+ waitingRabbitExchange: publicExchange,
+ waitingRabbitChooserHand: accepted
+ ? chooser.cards.map(card => card.toJSON ? card.toJSON() : card)
+ : null,
+ gameFinished: false
+ };
+ return { resolution, roundResult };
+ }
+
+ getEligibleThreePowersRanks() {
+ const trumpRank = this.room.gameState.trumpRank;
+ return TEN_SIDED_AMBUSH_RANKS.filter(rank => rank !== trumpRank);
+ }
+
+ hasPendingThreePowersSelection() {
+ return this.room.gameState.threePowersPendingPlayerIds.size > 0;
+ }
+
+ assertThreePowersSelectionComplete() {
+ if (!this.hasPendingThreePowersSelection()) return;
+ throw new Error('请等待2、3、4号位完成三权分立点数选择');
+ }
+
+ /** 埋底后由本局初始2、3、4号位分别重载10、5、K分牌。 */
+ activateThreePowers() {
+ const { gameState, players } = this.room;
+ if (!isThreePowersRule(gameState.selectedRule)) return null;
+ if (players.length !== 4 || !this.roundManager || !Number.isInteger(gameState.roundStartPlayerIndex)) {
+ throw new Error('三权分立仅支持已确定首发顺序的四人局');
+ }
+
+ const eligibleRanks = this.getEligibleThreePowersRanks();
+ if (eligibleRanks.length === 0) throw new Error('没有可用于重载分牌的点数');
+
+ gameState.threePowersSlots = THREE_POWERS_SLOT_CONFIG.map(config => {
+ const selectorIndex = this.roundManager.getPlayerIndexAtOffset(
+ gameState.roundStartPlayerIndex,
+ config.selectorOffset
+ );
+ const selector = this.room.findPlayerByIndex(selectorIndex);
+ if (!selector) throw new Error(`找不到本局${config.selectorPosition}号位玩家`);
+ return {
+ ...config,
+ selectorPlayerId: selector.id,
+ selectedRank: null,
+ isRevealed: false
+ };
+ });
+ gameState.threePowersPendingPlayerIds = new Set(
+ gameState.threePowersSlots.map(slot => slot.selectorPlayerId)
+ );
+
+ this.io.to(this.room.id).emit('three_powers_selection_started', {
+ slots: gameState.threePowersSlots.map(slot => ({
+ sourceRank: slot.sourceRank,
+ pointValue: slot.pointValue,
+ selectorPosition: slot.selectorPosition,
+ selectorPlayerId: slot.selectorPlayerId,
+ selectorPlayerName: this.room.findPlayerById(slot.selectorPlayerId)?.name || '未知玩家'
+ }))
+ });
+
+ for (const slot of gameState.threePowersSlots) {
+ const selector = this.room.findPlayerById(slot.selectorPlayerId);
+ if (selector?.isBot) {
+ const sample = Number(this.random());
+ const boundedSample = Number.isFinite(sample)
+ ? Math.min(0.999999999, Math.max(0, sample))
+ : 0;
+ const rank = eligibleRanks[Math.floor(boundedSample * eligibleRanks.length)];
+ this.selectThreePowersRank(selector.id, slot.sourceRank, rank);
+ } else if (selector?.socketId) {
+ this.io.to(selector.socketId).emit('three_powers_selection_required', {
+ sourceRank: slot.sourceRank,
+ pointValue: slot.pointValue,
+ selectorPosition: slot.selectorPosition,
+ eligibleRanks,
+ trumpRank: gameState.trumpRank
+ });
+ }
+ }
+
+ this.broadcastRoomUpdate();
+ logger.info(`房间 ${this.room.id} 三权分立:等待2、3、4号位暗选重载点数`);
+ return {
+ pending: this.hasPendingThreePowersSelection(),
+ pendingPlayerIds: Array.from(gameState.threePowersPendingPlayerIds)
+ };
+ }
+
+ selectThreePowersRank(playerId, sourceRank, rank) {
+ const { gameState } = this.room;
+ if (!isThreePowersRule(gameState.selectedRule)) {
+ throw new Error('本局没有启用三权分立');
+ }
+ const slot = gameState.threePowersSlots.find(candidate => candidate.sourceRank === sourceRank);
+ if (!slot) throw new Error('不存在这个分牌重载槽');
+ if (slot.selectorPlayerId !== playerId) throw new Error('你不能设置这个分牌重载槽');
+ if (slot.selectedRank) throw new Error(`${sourceRank}分牌重载点数已经确定`);
+ if (!this.getEligibleThreePowersRanks().includes(rank)) {
+ throw new Error('三权分立不能选择级牌或王牌');
+ }
+
+ slot.selectedRank = rank;
+ gameState.threePowersPendingPlayerIds.delete(playerId);
+ const selector = this.room.findPlayerById(playerId);
+ if (selector?.socketId) {
+ this.io.to(selector.socketId).emit('three_powers_rank_selected', {
+ sourceRank,
+ pointValue: slot.pointValue,
+ rank,
+ isPrivate: true
+ });
+ }
+ this.io.to(this.room.id).emit('three_powers_rank_locked', {
+ sourceRank,
+ selectorPlayerId: playerId,
+ selectorPlayerName: selector?.name || '未知玩家',
+ remainingCount: gameState.threePowersPendingPlayerIds.size
+ });
+ this.broadcastRoomUpdate();
+ logger.info(
+ `房间 ${this.room.id} 三权分立:${selector?.name || playerId} 已完成${sourceRank}分牌重载暗选`
+ );
+ return {
+ pending: this.hasPendingThreePowersSelection(),
+ sourceRank,
+ pointValue: slot.pointValue,
+ rank
+ };
+ }
+
+ revealThreePowersIfNeeded(cards, player, source = 'play') {
+ const { gameState } = this.room;
+ if (!isThreePowersRule(gameState.selectedRule)) return null;
+ const playedRanks = new Set((cards || []).map(card => card.rank));
+ const revealedSlots = gameState.threePowersSlots.filter(slot => (
+ !slot.isRevealed && slot.selectedRank && playedRanks.has(slot.selectedRank)
+ ));
+ if (revealedSlots.length === 0) return null;
+
+ revealedSlots.forEach(slot => { slot.isRevealed = true; });
+ const result = {
+ slots: revealedSlots.map(slot => ({
+ sourceRank: slot.sourceRank,
+ pointValue: slot.pointValue,
+ rank: slot.selectedRank
+ })),
+ source,
+ playerId: player?.id || null,
+ playerName: player?.name || null
+ };
+ logger.info(
+ `房间 ${this.room.id} 三权分立揭晓:` +
+ result.slots.map(slot => `${slot.sourceRank}→${slot.rank}`).join(',')
+ );
+ return result;
+ }
+
+ getRuleCardPoints(card) {
+ if (isMeticulousAccountingRule(this.room.gameState.selectedRule)) {
+ return getMeticulousAccountingCardPoints(card);
+ }
+ if (!isThreePowersRule(this.room.gameState.selectedRule)) return getCardPoints(card);
+ return this.room.gameState.threePowersSlots.reduce(
+ (total, slot) => total + (slot.selectedRank === card?.rank ? slot.pointValue : 0),
+ 0
+ );
+ }
+
+ getCandleCardColor(card) {
+ if ([Suits.HEARTS, Suits.DIAMONDS].includes(card?.suit)) return 'red';
+ if ([Suits.CLUBS, Suits.SPADES].includes(card?.suit)) return 'black';
+ if (card?.rank === Ranks.SMALL_JOKER) return 'black';
+ if (card?.rank === Ranks.BIG_JOKER) return 'red';
+ return null;
+ }
+
+ getCandleRoundCardPoints(card, isLit = this.room.gameState.candleLit) {
+ const basePoints = getCardPoints(card);
+ if (basePoints <= 0 || typeof isLit !== 'boolean') return basePoints;
+ const color = this.getCandleCardColor(card);
+ if (!color) return basePoints;
+ const favoredColor = isLit ? 'red' : 'black';
+ return Math.max(0, basePoints + (color === favoredColor ? 5 : -5));
+ }
+
+ hasPendingCandleSelection() {
+ const { gameState } = this.room;
+ return isCandleToDawnRule(gameState.selectedRule) && gameState.candleSelectionPending;
+ }
+
+ assertCandleSelectionComplete() {
+ if (this.hasPendingCandleSelection()) {
+ throw new Error('请等待庄家队友选择烛的初始状态');
+ }
+ }
+
+ initializeCandleToDawn(dealer) {
+ const { gameState, players } = this.room;
+ if (!isCandleToDawnRule(gameState.selectedRule)) return null;
+ if (!dealer || players.length !== 4) throw new Error('烛尽天明仅支持四人局');
+
+ const dealerIndex = this.room.getPlayerIndex(dealer.id);
+ const selector = this.room.findPlayerByIndex((dealerIndex + 2) % players.length);
+ gameState.candleSelectorPlayerId = selector.id;
+ gameState.candleSelectionPending = true;
+ gameState.candleLit = null;
+ gameState.candleLastTransition = null;
+
+ if (selector.isBot) {
+ return this.selectInitialCandleState(selector.id, Number(this.random()) >= 0.5, {
+ source: 'bot'
+ });
+ }
+
+ this.io.to(selector.socketId).emit('candle_initial_choice_required', {
+ selectorPlayerId: selector.id,
+ selectorPlayerName: selector.name
+ });
+ return {
+ pending: true,
+ selectorPlayerId: selector.id,
+ selectorPlayerName: selector.name
+ };
+ }
+
+ selectInitialCandleState(playerId, isLit, { source = 'player' } = {}) {
+ const { gameState } = this.room;
+ if (!isCandleToDawnRule(gameState.selectedRule)) {
+ throw new Error('本局没有启用烛尽天明');
+ }
+ if (!gameState.candleSelectionPending) throw new Error('烛的初始状态已经确定');
+ if (gameState.candleSelectorPlayerId !== playerId) {
+ throw new Error('只有庄家队友可以选择烛的初始状态');
+ }
+ if (typeof isLit !== 'boolean') throw new Error('请选择点燃或熄灭烛');
+
+ const selector = this.room.findPlayerById(playerId);
+ gameState.candleLit = isLit;
+ gameState.candleSelectionPending = false;
+ const result = {
+ selectorPlayerId: playerId,
+ selectorPlayerName: selector?.name || '未知玩家',
+ isLit,
+ source
+ };
+ this.io.to(this.room.id).emit('candle_initial_state_selected', result);
+ logger.info(
+ `房间 ${this.room.id} 烛尽天明:${result.selectorPlayerName}选择初始${isLit ? '点燃' : '熄灭'}`
+ );
+ return result;
+ }
+
+ applyCandleTransitionAtRoundEnd() {
+ const { gameState } = this.room;
+ if (!isCandleToDawnRule(gameState.selectedRule)) return null;
+
+ const fourthPlay = gameState.currentRoundPlays[3] || null;
+ const fourthCards = fourthPlay?.originalCards || fourthPlay?.cards || [];
+ const colors = fourthCards.map(card => this.getCandleCardColor(card));
+ const triggerColor = colors.length > 0 && colors.every(color => color === 'red')
+ ? 'red'
+ : colors.length > 0 && colors.every(color => color === 'black')
+ ? 'black'
+ : 'mixed';
+ const previousLit = Boolean(gameState.candleLit);
+ const nextLit = triggerColor === 'red'
+ ? true
+ : triggerColor === 'black'
+ ? false
+ : previousLit;
+ const fourthPlayer = fourthPlay ? this.room.findPlayerById(fourthPlay.playerId) : null;
+ const transition = {
+ round: gameState.currentRound,
+ previousLit,
+ nextLit,
+ changed: previousLit !== nextLit,
+ triggerColor,
+ fourthPlayerId: fourthPlay?.playerId || null,
+ fourthPlayerName: fourthPlayer?.name || null
+ };
+ gameState.candleLastTransition = transition;
+ // 本轮计分已经完成,现在才把烛态切换给下一轮。
+ gameState.candleLit = nextLit;
+ logger.info(
+ `房间 ${this.room.id} 烛尽天明:第${transition.round}轮结算后` +
+ `第四手${triggerColor === 'mixed' ? '非纯色,烛态不变' : `为纯${triggerColor === 'red' ? '红' : '黑'},下轮${nextLit ? '点燃' : '熄灭'}`}`
+ );
+ return transition;
+ }
+
+ /**
+ * 围三阙一按每次合法出牌原子化记录实体普通牌花色。
+ * 同一次出牌合并后若四种花色齐全,则整批记录作废,从下一位玩家重新开始。
+ */
+ recordEncircleThreeMissingOnePlay(cards = []) {
+ const { gameState } = this.room;
+ if (!isEncircleThreeMissingOneRule(gameState.selectedRule)) return null;
+
+ const previousSuits = [...gameState.encircleThreeMissingOneSeenSuits];
+ const playedSuits = [];
+ for (const card of cards) {
+ if (
+ !ENCIRCLE_THREE_MISSING_ONE_SUITS.includes(card?.suit)
+ || card?.rank === gameState.trumpRank
+ || playedSuits.includes(card.suit)
+ ) {
+ continue;
+ }
+ playedSuits.push(card.suit);
+ }
+
+ const combinedSuits = [...previousSuits];
+ for (const suit of playedSuits) {
+ if (!combinedSuits.includes(suit)) combinedSuits.push(suit);
+ }
+ const addedSuits = combinedSuits.filter(suit => !previousSuits.includes(suit));
+ const flushed = combinedSuits.length === ENCIRCLE_THREE_MISSING_ONE_SUITS.length;
+ gameState.encircleThreeMissingOneSeenSuits = flushed ? [] : combinedSuits;
+
+ if (flushed) {
+ logger.info(
+ `房间 ${this.room.id} 围三阙一:本次出牌使四种花色齐全,清空记录并从下一位玩家重新开始`
+ );
+ }
+
+ return {
+ previousSuits,
+ playedSuits,
+ addedSuits,
+ seenSuits: [...gameState.encircleThreeMissingOneSeenSuits],
+ flushed
+ };
+ }
+
+ /**
+ * 每次出牌已经即时完成花色记录;整墩仍按旧主比较与计分,
+ * 到墩末才把当前三种记录的缺门花色应用为下一轮主花色。
+ */
+ applyEncircleThreeMissingOneAtRoundEnd(completedRound = this.room.gameState.currentRound) {
+ const { gameState } = this.room;
+ if (!isEncircleThreeMissingOneRule(gameState.selectedRule)) return null;
+
+ // 最后一轮之后不存在“下一轮”,不能污染终局展示的本局主花色。
+ const hasNextRound = this.room.players.some(player => this.getPlayableCardCount(player) > 0);
+ if (!hasNextRound) return null;
+
+ const seenSuits = gameState.encircleThreeMissingOneSeenSuits;
+ if (seenSuits.length !== 3) return null;
+
+ const missingSuit = ENCIRCLE_THREE_MISSING_ONE_SUITS.find(
+ suit => !seenSuits.includes(suit)
+ );
+ const previousTrumpSuit = gameState.trumpSuit;
+ const replaced = Boolean(missingSuit && missingSuit !== previousTrumpSuit);
+ const nextTrumpSuit = replaced ? missingSuit : previousTrumpSuit;
+ const transition = {
+ triggerRound: completedRound,
+ effectiveRound: completedRound + 1,
+ recordedSuits: [...seenSuits],
+ missingSuit,
+ previousTrumpSuit,
+ nextTrumpSuit,
+ replaced
+ };
+
+ gameState.trumpSuit = nextTrumpSuit;
+ gameState.encircleThreeMissingOneSeenSuits = [];
+ gameState.encircleThreeMissingOneLastTransition = transition;
+ this.io.to(this.room.id).emit('encircle_three_missing_one_transition', transition);
+ if (replaced) {
+ this.io.to(this.room.id).emit('trump_updated', {
+ trumpSuit: nextTrumpSuit,
+ trumpRank: gameState.trumpRank,
+ encircleThreeMissingOne: transition
+ });
+ }
+ logger.info(
+ `房间 ${this.room.id} 围三阙一:第${completedRound}轮记录 ${seenSuits.join('、')},` +
+ `缺 ${missingSuit},${replaced ? `下轮改为 ${nextTrumpSuit} 主` : '缺门即当前主花色,主花色不变'}`
+ );
+ return transition;
+ }
+
+ getGentlemanPromiseSuitCounts(player) {
+ const { trumpSuit, trumpRank } = this.room.gameState;
+ const eligibleCategories = GENTLEMAN_PROMISE_SUITS.filter(suit => (
+ suit === 'trump' || suit !== trumpSuit
+ ));
+ const counts = Object.fromEntries(eligibleCategories.map(suit => [suit, 0]));
+ (player?.cards || []).forEach(card => {
+ const effectiveSuit = getEffectiveSuit(card, trumpSuit, trumpRank);
+ if (Object.hasOwn(counts, effectiveSuit)) counts[effectiveSuit] += 1;
+ });
+ return counts;
+ }
+
+ getGentlemanPromiseEligibleSuits(player) {
+ const counts = this.getGentlemanPromiseSuitCounts(player);
+ const minimumCount = Math.min(...Object.values(counts));
+ return {
+ counts,
+ minimumCount,
+ eligibleSuits: Object.keys(counts).filter(suit => counts[suit] === minimumCount)
+ };
+ }
+
+ hasPendingGentlemanPromiseSelection() {
+ return this.room.gameState.gentlemanPromisePendingPlayerIds.size > 0;
+ }
+
+ assertGentlemanPromiseSelectionComplete() {
+ if (!this.hasPendingGentlemanPromiseSelection()) return;
+ const pendingNames = Array.from(this.room.gameState.gentlemanPromisePendingPlayerIds)
+ .map(playerId => this.room.findPlayerById(playerId)?.name)
+ .filter(Boolean);
+ throw new Error(`请等待 ${pendingNames.join('、') || '玩家'} 完成最短花色声明`);
+ }
+
+ /** 埋底完成后,四家按最终手牌公开声明自己的最短有效花色。 */
+ activateGentlemanPromise() {
+ const { gameState, players } = this.room;
+ if (!isGentlemanPromiseRule(gameState.selectedRule)) return null;
+
+ gameState.gentlemanPromiseDeclarationsByPlayerId.clear();
+ gameState.gentlemanPromisePendingPlayerIds = new Set(players.map(player => player.id));
+ this.gentlemanPromiseOptionsByPlayerId.clear();
+ const plans = players.map(player => ({
+ player,
+ ...this.getGentlemanPromiseEligibleSuits(player)
+ }));
+ plans.forEach(plan => {
+ this.gentlemanPromiseOptionsByPlayerId.set(plan.player.id, plan.eligibleSuits);
+ });
+
+ this.io.to(this.room.id).emit('gentleman_promise_selection_started', {
+ pendingPlayerIds: players.map(player => player.id)
+ });
+
+ plans.forEach(plan => {
+ const { player, eligibleSuits, counts, minimumCount } = plan;
+ if (eligibleSuits.length === 1) {
+ this.selectGentlemanPromiseSuit(player.id, eligibleSuits[0], { source: 'system' });
+ return;
+ }
+ if (player.isBot) {
+ const sample = Number(this.random());
+ const boundedSample = Number.isFinite(sample)
+ ? Math.min(0.999999999, Math.max(0, sample))
+ : 0;
+ const selectedSuit = eligibleSuits[Math.floor(boundedSample * eligibleSuits.length)];
+ this.selectGentlemanPromiseSuit(player.id, selectedSuit, { source: 'bot' });
+ return;
+ }
+ this.io.to(player.socketId).emit('gentleman_promise_selection_required', {
+ eligibleSuits,
+ suitCounts: counts,
+ minimumCount
+ });
+ });
+
+ this.broadcastRoomUpdate();
+ return {
+ pending: this.hasPendingGentlemanPromiseSelection(),
+ declarationsByPlayerId: Object.fromEntries(gameState.gentlemanPromiseDeclarationsByPlayerId)
+ };
+ }
+
+ selectGentlemanPromiseSuit(playerId, suit, { source = 'player' } = {}) {
+ const { gameState } = this.room;
+ if (!isGentlemanPromiseRule(gameState.selectedRule)) {
+ throw new Error('本局没有启用君子一言');
+ }
+ if (!gameState.gentlemanPromisePendingPlayerIds.has(playerId)) {
+ throw new Error('你已经完成最短花色声明');
+ }
+ const eligibleSuits = this.gentlemanPromiseOptionsByPlayerId.get(playerId) || [];
+ if (!eligibleSuits.includes(suit)) {
+ throw new Error('只能声明自己并列最少的有效花色');
+ }
+
+ const player = this.room.findPlayerById(playerId);
+ gameState.gentlemanPromiseDeclarationsByPlayerId.set(playerId, suit);
+ gameState.gentlemanPromisePendingPlayerIds.delete(playerId);
+ this.gentlemanPromiseOptionsByPlayerId.delete(playerId);
+ const result = {
+ playerId,
+ playerName: player?.name || '未知玩家',
+ suit,
+ source,
+ pendingPlayerIds: Array.from(gameState.gentlemanPromisePendingPlayerIds)
+ };
+ this.io.to(this.room.id).emit('gentleman_promise_declared', result);
+ if (result.pendingPlayerIds.length === 0) {
+ this.io.to(this.room.id).emit('gentleman_promise_completed', {
+ declarationsByPlayerId: Object.fromEntries(gameState.gentlemanPromiseDeclarationsByPlayerId)
+ });
+ }
+ this.broadcastRoomUpdate();
+ logger.info(
+ `房间 ${this.room.id} 君子一言:${result.playerName} ` +
+ `${source === 'system' ? '由系统直接' : ''}声明 ${suit}`
+ );
+ return { ...result, pending: result.pendingPlayerIds.length > 0 };
+ }
+
+ getHiddenDragonRankCounts(player) {
+ const trumpRank = this.room.gameState.trumpRank;
+ const eligibleRanks = HIDDEN_DRAGON_RANKS.filter(rank => rank !== trumpRank);
+ const counts = Object.fromEntries(eligibleRanks.map(rank => [rank, 0]));
+ (player?.cards || []).forEach(card => {
+ const rank = card.originalRank || card.rank;
+ if (Object.hasOwn(counts, rank)) counts[rank] += 1;
+ });
+ return counts;
+ }
+
+ getHiddenDragonEligibleRanks(player) {
+ const counts = this.getHiddenDragonRankCounts(player);
+ const maximumCount = Math.max(...Object.values(counts));
+ return {
+ counts,
+ maximumCount,
+ eligibleRanks: Object.keys(counts).filter(rank => counts[rank] === maximumCount)
+ };
+ }
+
+ hasPendingHiddenDragonSelection() {
+ return this.room.gameState.hiddenDragonPendingPlayerIds.size > 0;
+ }
+
+ assertHiddenDragonSelectionComplete() {
+ if (!this.hasPendingHiddenDragonSelection()) return;
+ const pendingNames = Array.from(this.room.gameState.hiddenDragonPendingPlayerIds)
+ .map(playerId => this.room.findPlayerById(playerId)?.name)
+ .filter(Boolean);
+ throw new Error(`请等待 ${pendingNames.join('、') || '玩家'} 完成潜龙点数声明`);
+ }
+
+ /** 埋底完成后,四家按最终手牌公开声明数量最多的非级牌点数。 */
+ activateHiddenDragonInAbyss() {
+ const { gameState, players } = this.room;
+ if (!isHiddenDragonInAbyssRule(gameState.selectedRule)) return null;
+
+ gameState.hiddenDragonDeclarationsByPlayerId.clear();
+ gameState.hiddenDragonPendingPlayerIds = new Set(players.map(player => player.id));
+ gameState.hiddenDragonPlayedRanksByPlayerId.clear();
+ gameState.hiddenDragonEvaluatedPlayerIds.clear();
+ gameState.hiddenDragonResults = [];
+ this.hiddenDragonOptionsByPlayerId.clear();
+ const plans = players.map(player => ({
+ player,
+ ...this.getHiddenDragonEligibleRanks(player)
+ }));
+ plans.forEach(plan => {
+ this.hiddenDragonOptionsByPlayerId.set(plan.player.id, plan.eligibleRanks);
+ });
+
+ this.io.to(this.room.id).emit('hidden_dragon_selection_started', {
+ pendingPlayerIds: players.map(player => player.id)
+ });
+
+ plans.forEach(plan => {
+ const { player, eligibleRanks, counts, maximumCount } = plan;
+ if (eligibleRanks.length === 1) {
+ this.selectHiddenDragonRank(player.id, eligibleRanks[0], { source: 'system' });
+ return;
+ }
+ if (player.isBot) {
+ const sample = Number(this.random());
+ const boundedSample = Number.isFinite(sample)
+ ? Math.min(0.999999999, Math.max(0, sample))
+ : 0;
+ const selectedRank = eligibleRanks[Math.floor(boundedSample * eligibleRanks.length)];
+ this.selectHiddenDragonRank(player.id, selectedRank, { source: 'bot' });
+ return;
+ }
+ this.io.to(player.socketId).emit('hidden_dragon_selection_required', {
+ eligibleRanks,
+ rankCounts: counts,
+ maximumCount,
+ trumpRank: gameState.trumpRank
+ });
+ });
+
+ this.broadcastRoomUpdate();
+ return {
+ pending: this.hasPendingHiddenDragonSelection(),
+ declarationsByPlayerId: Object.fromEntries(gameState.hiddenDragonDeclarationsByPlayerId)
+ };
+ }
+
+ selectHiddenDragonRank(playerId, rank, { source = 'player' } = {}) {
+ const { gameState } = this.room;
+ if (!isHiddenDragonInAbyssRule(gameState.selectedRule)) {
+ throw new Error('本局没有启用潜龙在渊');
+ }
+ if (!gameState.hiddenDragonPendingPlayerIds.has(playerId)) {
+ throw new Error('你已经完成潜龙点数声明');
+ }
+ const eligibleRanks = this.hiddenDragonOptionsByPlayerId.get(playerId) || [];
+ if (!eligibleRanks.includes(rank)) {
+ throw new Error('只能声明自己并列最多的非级牌点数');
+ }
+
+ const player = this.room.findPlayerById(playerId);
+ gameState.hiddenDragonDeclarationsByPlayerId.set(playerId, rank);
+ gameState.hiddenDragonPendingPlayerIds.delete(playerId);
+ this.hiddenDragonOptionsByPlayerId.delete(playerId);
+ const result = {
+ playerId,
+ playerName: player?.name || '未知玩家',
+ rank,
+ source,
+ pendingPlayerIds: Array.from(gameState.hiddenDragonPendingPlayerIds)
+ };
+ this.io.to(this.room.id).emit('hidden_dragon_declared', result);
+ if (result.pendingPlayerIds.length === 0) {
+ this.io.to(this.room.id).emit('hidden_dragon_completed', {
+ declarationsByPlayerId: Object.fromEntries(gameState.hiddenDragonDeclarationsByPlayerId)
+ });
+ }
+ this.broadcastRoomUpdate();
+ logger.info(
+ `房间 ${this.room.id} 潜龙在渊:${result.playerName} ` +
+ `${source === 'system' ? '由系统直接' : ''}声明 ${rank}`
+ );
+ return { ...result, pending: result.pendingPlayerIds.length > 0 };
+ }
+
+ /** 记录本次实体牌点数,并在手牌首次降至12张或更少时立即完成一次性判定。 */
+ recordHiddenDragonPlay(player, cards) {
+ const { gameState } = this.room;
+ if (!isHiddenDragonInAbyssRule(gameState.selectedRule)) return null;
+
+ const previousPlayedRanks = Array.from(
+ gameState.hiddenDragonPlayedRanksByPlayerId.get(player.id) || []
+ );
+ const snapshot = {
+ playerId: player.id,
+ previousPlayedRanks,
+ previousEvaluated: gameState.hiddenDragonEvaluatedPlayerIds.has(player.id),
+ previousAttackerScore: gameState.attackerScore,
+ previousResultsLength: gameState.hiddenDragonResults.length,
+ resolution: null
+ };
+ const playedRanks = new Set(previousPlayedRanks);
+ (cards || []).forEach(card => {
+ const rank = card.originalRank || card.rank;
+ if (HIDDEN_DRAGON_RANKS.includes(rank)) playedRanks.add(rank);
+ });
+ gameState.hiddenDragonPlayedRanksByPlayerId.set(player.id, playedRanks);
+
+ const remainingCount = this.getPlayableCardCount(player);
+ if (snapshot.previousEvaluated || remainingCount > 12) return snapshot;
+
+ gameState.hiddenDragonEvaluatedPlayerIds.add(player.id);
+ const declaredRank = gameState.hiddenDragonDeclarationsByPlayerId.get(player.id) || null;
+ const success = Boolean(declaredRank) && !playedRanks.has(declaredRank);
+ const playerIndex = this.room.getPlayerIndex(player.id);
+ const dealerIndex = this.room.getPlayerIndex(gameState.buryingPlayerId);
+ const isAttackerTeam = this.isAttackerPlayerIndex(playerIndex, dealerIndex);
+ const attackerScoreDelta = success ? (isAttackerTeam ? 10 : -10) : 0;
+ gameState.attackerScore += attackerScoreDelta;
+ const resolution = {
+ playerId: player.id,
+ playerName: player.name,
+ declaredRank,
+ remainingCount,
+ round: gameState.currentRound,
+ success,
+ team: isAttackerTeam ? 'attacker' : 'dealer',
+ awardedPoints: success ? 10 : 0,
+ attackerScoreDelta,
+ attackerScore: gameState.attackerScore
+ };
+ gameState.hiddenDragonResults.push(resolution);
+ snapshot.resolution = resolution;
+ this.io.to(this.room.id).emit('hidden_dragon_resolved', resolution);
+ logger.info(
+ `房间 ${this.room.id} 潜龙在渊:${player.name} 手牌首次降至${remainingCount}张,` +
+ `${success ? `未打出 ${declaredRank},所属阵营获得10分` : `已经打出 ${declaredRank},未得分`}`
+ );
+ return snapshot;
+ }
+
+ getAntinomySplitFaceKeys() {
+ const { gameState } = this.room;
+ if (!isAntinomyRule(gameState.selectedRule)) return [];
+ return Array.from(gameState.antinomyDeclarationsByPlayerId.values())
+ .filter(declaration => declaration?.effective)
+ .map(declaration => declaration.faceKey);
+ }
+
+ hasPendingAntinomySelection() {
+ return this.room.gameState.antinomyPendingPlayerIds.size > 0;
+ }
+
+ assertAntinomySelectionComplete() {
+ if (!this.hasPendingAntinomySelection()) return;
+ const pendingNames = Array.from(this.room.gameState.antinomyPendingPlayerIds)
+ .map(playerId => this.room.findPlayerById(playerId)?.name)
+ .filter(Boolean);
+ throw new Error(`二律背反:请等待 ${pendingNames.join('、') || '玩家'} 完成选择`);
+ }
+
+ getAntinomyPublicDeclarations() {
+ return Object.fromEntries(
+ Array.from(
+ this.room.gameState.antinomyDeclarationsByPlayerId,
+ ([playerId, declaration]) => [playerId, { ...declaration }]
+ )
+ );
+ }
+
+ recomputeAntinomyDeclarations() {
+ const declarations = this.room.gameState.antinomyDeclarationsByPlayerId;
+ const counts = new Map();
+ declarations.forEach(declaration => {
+ counts.set(declaration.faceKey, (counts.get(declaration.faceKey) || 0) + 1);
+ });
+ declarations.forEach((declaration, playerId) => {
+ const duplicateCount = counts.get(declaration.faceKey) || 0;
+ declarations.set(playerId, {
+ ...declaration,
+ duplicateCount,
+ effective: duplicateCount === 1
+ });
+ });
+ return this.getAntinomyPublicDeclarations();
+ }
+
+ beginAntinomySelection(playerIds, {
+ stage = 'opening',
+ triggerRound = null
+ } = {}) {
+ const { gameState } = this.room;
+ if (!isAntinomyRule(gameState.selectedRule)) return null;
+
+ const pendingPlayerIds = [...new Set(playerIds || [])].filter(
+ playerId => Boolean(this.room.findPlayerById(playerId))
+ );
+ this.antinomyPendingSelections.clear();
+ gameState.antinomyPendingPlayerIds = new Set(pendingPlayerIds);
+ gameState.antinomySelectionStage = pendingPlayerIds.length > 0 ? stage : null;
+ gameState.antinomyTriggerRound = pendingPlayerIds.length > 0 ? triggerRound : null;
+ if (stage === 'opening') gameState.antinomyDeclarationsByPlayerId.clear();
+ if (pendingPlayerIds.length === 0) return null;
+
+ this.io.to(this.room.id).emit('antinomy_selection_started', {
+ stage,
+ triggerRound,
+ pendingPlayerIds
+ });
+
+ pendingPlayerIds.forEach(playerId => {
+ const player = this.room.findPlayerById(playerId);
+ if (player?.isBot) {
+ const suitSample = Math.min(0.999999999, Math.max(0, Number(this.random()) || 0));
+ const rankSample = Math.min(0.999999999, Math.max(0, Number(this.random()) || 0));
+ this.selectAntinomyCard(
+ player.id,
+ ANTINOMY_SUITS[Math.floor(suitSample * ANTINOMY_SUITS.length)],
+ ANTINOMY_RANKS[Math.floor(rankSample * ANTINOMY_RANKS.length)],
+ { source: 'bot' }
+ );
+ return;
+ }
+ this.io.to(player.socketId).emit('antinomy_selection_required', {
+ stage,
+ triggerRound,
+ eligibleSuits: [...ANTINOMY_SUITS],
+ eligibleRanks: [...ANTINOMY_RANKS],
+ currentDeclaration: gameState.antinomyDeclarationsByPlayerId.get(player.id) || null
+ });
+ });
+ this.broadcastRoomUpdate();
+ return {
+ stage,
+ triggerRound,
+ pendingPlayerIds: Array.from(gameState.antinomyPendingPlayerIds)
+ };
+ }
+
+ /** 庄家埋底完成后才启动四家选择;摸牌阶段不会提前触发。 */
+ activateAntinomy() {
+ if (!isAntinomyRule(this.room.gameState.selectedRule)) return null;
+ return this.beginAntinomySelection(
+ this.room.players.map(player => player.id),
+ { stage: 'opening', triggerRound: 0 }
+ );
+ }
+
+ selectAntinomyCard(playerId, suit, rank, { source = 'player' } = {}) {
+ const { gameState } = this.room;
+ if (!isAntinomyRule(gameState.selectedRule)) {
+ throw new Error('本局没有启用二律背反');
+ }
+ if (!gameState.antinomyPendingPlayerIds.has(playerId)) {
+ throw new Error('你当前不需要重新指定牌面');
+ }
+ if (!ANTINOMY_SUITS.includes(suit) || !ANTINOMY_RANKS.includes(rank)) {
+ throw new Error('二律背反只能指定普通花色的2至A');
+ }
+
+ const player = this.room.findPlayerById(playerId);
+ const faceKey = getAntinomyFaceKey(suit, rank);
+ this.antinomyPendingSelections.set(playerId, { suit, rank, faceKey });
+ gameState.antinomyPendingPlayerIds.delete(playerId);
+ const pendingPlayerIds = Array.from(gameState.antinomyPendingPlayerIds);
+ this.io.to(this.room.id).emit('antinomy_selection_submitted', {
+ playerId,
+ playerName: player?.name || '未知玩家',
+ source,
+ pendingPlayerIds
+ });
+
+ if (pendingPlayerIds.length > 0) {
+ this.broadcastRoomUpdate();
+ return { pending: true, pendingPlayerIds };
+ }
+
+ const stage = gameState.antinomySelectionStage;
+ const triggerRound = gameState.antinomyTriggerRound;
+ this.antinomyPendingSelections.forEach((declaration, declarationPlayerId) => {
+ gameState.antinomyDeclarationsByPlayerId.set(declarationPlayerId, {
+ ...declaration,
+ playerName: this.room.findPlayerById(declarationPlayerId)?.name || '未知玩家'
+ });
+ });
+ this.antinomyPendingSelections.clear();
+ const declarationsByPlayerId = this.recomputeAntinomyDeclarations();
+ gameState.antinomySelectionStage = null;
+ gameState.antinomyTriggerRound = null;
+ const result = {
+ stage,
+ triggerRound,
+ declarationsByPlayerId,
+ splitFaceKeys: this.getAntinomySplitFaceKeys()
+ };
+ this.io.to(this.room.id).emit('antinomy_declarations_revealed', result);
+ this.broadcastRoomUpdate();
+ logger.info(
+ `房间 ${this.room.id} 二律背反:${stage === 'opening' ? '开局' : `第${triggerRound}轮后`}声明同时亮出`
+ );
+ return { pending: false, ...result };
+ }
+
+ getAntinomyReselectionPlayerIds() {
+ const { gameState } = this.room;
+ if (!isAntinomyRule(gameState.selectedRule)) return [];
+ const playedFaceKeys = new Set(
+ gameState.currentRoundPlays.flatMap(play => play.originalCards || play.cards || [])
+ .map(card => getAntinomyFaceKey(
+ card.originalSuit || card.suit,
+ card.originalRank || card.rank
+ ))
+ .filter(Boolean)
+ );
+ return Array.from(gameState.antinomyDeclarationsByPlayerId.entries())
+ .filter(([, declaration]) => playedFaceKeys.has(declaration.faceKey))
+ .map(([playerId]) => playerId);
+ }
+
+ getRiceToMulberryPointCards(player) {
+ return (player?.cards || []).filter(card => (
+ !card.isRiceToMulberryTransformed && getCardPoints(card) > 0
+ ));
+ }
+
+ getRiceToMulberryRequiredCount(player) {
+ return Math.floor(this.getRiceToMulberryPointCards(player).length / 2);
+ }
+
+ hasPendingRiceToMulberrySelection() {
+ return this.room.gameState.riceToMulberryPendingPlayerIds.size > 0;
+ }
+
+ assertRiceToMulberrySelectionComplete() {
+ if (!this.hasPendingRiceToMulberrySelection()) return;
+ const pendingNames = Array.from(this.room.gameState.riceToMulberryPendingPlayerIds)
+ .map(playerId => this.room.findPlayerById(playerId)?.name)
+ .filter(Boolean);
+ throw new Error(`改稻为桑:请等待 ${pendingNames.join('、') || '闲家'} 完成分牌选择`);
+ }
+
+ selectBotRiceToMulberryCards(player) {
+ const { trumpSuit, trumpRank } = this.room.gameState;
+ const requiredCount = this.getRiceToMulberryRequiredCount(player);
+ return this.getRiceToMulberryPointCards(player)
+ .sort((left, right) => {
+ const trumpDiff = Number(isTrumpCard(right, trumpSuit, trumpRank))
+ - Number(isTrumpCard(left, trumpSuit, trumpRank));
+ if (trumpDiff !== 0) return trumpDiff;
+ const pointDiff = getCardPoints(left) - getCardPoints(right);
+ if (pointDiff !== 0) return pointDiff;
+ return getCardStrength(left, trumpSuit, trumpRank)
+ - getCardStrength(right, trumpSuit, trumpRank);
+ })
+ .slice(0, requiredCount);
+ }
+
+ /** 埋底后由两名闲家各自改造向下取整的一半分牌。 */
+ activateChangeRiceToMulberry() {
+ const { gameState, players } = this.room;
+ if (!isChangeRiceToMulberryRule(gameState.selectedRule)) return null;
+ if (players.length !== 4) throw new Error('改稻为桑仅支持四人局');
+
+ const dealer = this.room.findPlayerById(gameState.buryingPlayerId)
+ || this.room.findPlayerByIndex(gameState.dealerPlayerIndex);
+ const dealerIndex = dealer ? this.room.getPlayerIndex(dealer.id) : -1;
+ if (dealerIndex < 0) throw new Error('改稻为桑:无法确定庄家');
+
+ const attackers = players.filter((player, playerIndex) => (
+ this.isAttackerPlayerIndex(playerIndex, dealerIndex)
+ ));
+ gameState.riceToMulberryPendingPlayerIds = new Set(
+ attackers.map(player => player.id)
+ );
+ gameState.riceToMulberryCompletedPlayerIds.clear();
+
+ this.io.to(this.room.id).emit('rice_to_mulberry_selection_started', {
+ playerIds: attackers.map(player => player.id)
+ });
+
+ attackers.forEach(player => {
+ const pointCards = this.getRiceToMulberryPointCards(player);
+ const requiredCount = Math.floor(pointCards.length / 2);
+ if (player.isBot || requiredCount === 0) {
+ const cards = player.isBot
+ ? this.selectBotRiceToMulberryCards(player)
+ : [];
+ this.selectRiceToMulberryCards(player.id, cards.map(card => card.id), {
+ source: player.isBot ? 'bot' : 'automatic'
+ });
+ return;
+ }
+ if (player.socketId) {
+ this.io.to(player.socketId).emit('rice_to_mulberry_selection_required', {
+ requiredCount,
+ eligibleCardIds: pointCards.map(card => card.id)
+ });
+ }
+ });
+
+ this.broadcastRoomUpdate();
+ return {
+ pending: this.hasPendingRiceToMulberrySelection(),
+ pendingPlayerIds: Array.from(gameState.riceToMulberryPendingPlayerIds)
+ };
+ }
+
+ selectRiceToMulberryCards(playerId, cardIds, { source = 'player' } = {}) {
+ const { gameState } = this.room;
+ if (!isChangeRiceToMulberryRule(gameState.selectedRule)) {
+ throw new Error('本局没有启用改稻为桑');
+ }
+ if (!gameState.riceToMulberryPendingPlayerIds.has(playerId)) {
+ throw new Error('你不需要进行改稻为桑选择,或已经完成');
+ }
+ const player = this.room.findPlayerById(playerId);
+ if (!player) throw new Error('玩家不存在');
+
+ const eligibleCards = this.getRiceToMulberryPointCards(player);
+ const eligibleById = new Map(eligibleCards.map(card => [card.id, card]));
+ const uniqueCardIds = [...new Set(cardIds || [])];
+ const requiredCount = Math.floor(eligibleCards.length / 2);
+ if (uniqueCardIds.length !== requiredCount || uniqueCardIds.length !== (cardIds || []).length) {
+ throw new Error(`改稻为桑必须选择 ${requiredCount} 张不同的分牌`);
+ }
+ const selectedCards = uniqueCardIds.map(cardId => eligibleById.get(cardId));
+ if (selectedCards.some(card => !card)) {
+ throw new Error('改稻为桑只能选择当前手牌中未改造的分牌');
+ }
+
+ const { trumpSuit, trumpRank } = gameState;
+ selectedCards.forEach(card => {
+ const wasTrump = isTrumpCard(card, trumpSuit, trumpRank);
+ card.originalSuit = card.originalSuit || card.suit;
+ card.originalRank = card.originalRank || card.rank;
+ if (wasTrump) {
+ card.suit = Suits.JOKER;
+ card.rank = Ranks.BIG_JOKER;
+ } else {
+ card.rank = Ranks.ACE;
+ }
+ card.isRiceToMulberryTransformed = true;
+ card.value = card.calculateValue();
+ });
+ player.cards = DeckService.autoSortCards(player.cards);
+ gameState.riceToMulberryPendingPlayerIds.delete(player.id);
+ gameState.riceToMulberryCompletedPlayerIds.add(player.id);
+
+ const publicResult = {
+ playerId: player.id,
+ playerName: player.name,
+ transformedCount: selectedCards.length,
+ source,
+ pendingPlayerIds: Array.from(gameState.riceToMulberryPendingPlayerIds)
+ };
+ this.io.to(this.room.id).emit('rice_to_mulberry_transformed', publicResult);
+ if (player.socketId) {
+ this.io.to(player.socketId).emit('rice_to_mulberry_hand_updated', {
+ cards: player.cards.map(card => card.toJSON ? card.toJSON() : card)
+ });
+ }
+ if (!this.hasPendingRiceToMulberrySelection()) {
+ this.io.to(this.room.id).emit('rice_to_mulberry_completed', {
+ playerIds: Array.from(gameState.riceToMulberryCompletedPlayerIds)
+ });
+ }
+ this.broadcastRoomUpdate();
+ logger.info(
+ `房间 ${this.room.id} 改稻为桑:${player.name} 将 ${selectedCards.length} 张分牌改造并清零分值`
+ );
+ return {
+ ...publicResult,
+ pending: this.hasPendingRiceToMulberrySelection(),
+ transformedCards: selectedCards.map(card => card.toJSON ? card.toJSON() : card)
+ };
+ }
+
+ getAdministrativeReviewSuitOptions() {
+ const trumpSuit = this.room.gameState.trumpSuit;
+ return THREE_TIGERS_SUITS.filter(suit => suit !== trumpSuit);
+ }
+
+ hasPendingAdministrativeReviewSelection() {
+ const review = this.room.gameState.administrativeReview;
+ return isAdministrativeReviewRule(this.room.gameState.selectedRule)
+ && Boolean(review)
+ && (!review.suit || !review.rank);
+ }
+
+ assertAdministrativeReviewSelectionComplete() {
+ if (this.hasPendingAdministrativeReviewSelection()) {
+ throw new Error('请等待闲家完成行政审查声明');
+ }
+ }
+
+ /** 庄家锁定后底牌继续封存,由上下家在首张牌前公开声明审查条件。 */
+ startAdministrativeReview(dealer) {
+ const { gameState, players } = this.room;
+ if (!isAdministrativeReviewRule(gameState.selectedRule)) return null;
+ if (players.length !== 4) throw new Error('行政审查仅支持四人局');
+
+ const dealerIndex = this.room.getPlayerIndex(dealer.id);
+ const suitSelector = this.room.findPlayerByIndex((dealerIndex + 1) % players.length);
+ const dealerTeammate = this.room.findPlayerByIndex((dealerIndex + 2) % players.length);
+ const rankSelector = this.room.findPlayerByIndex((dealerIndex + 3) % players.length);
+ const eligibleSuits = this.getAdministrativeReviewSuitOptions();
+ const eligibleRanks = [...HIDDEN_DRAGON_RANKS];
+ gameState.administrativeReview = {
+ dealerPlayerId: dealer.id,
+ dealerTeammatePlayerId: dealerTeammate.id,
+ suitSelectorPlayerId: suitSelector.id,
+ rankSelectorPlayerId: rankSelector.id,
+ suit: null,
+ rank: null,
+ suitMatched: false,
+ rankMatched: false,
+ isPlayStarted: false,
+ isBottomReleased: false,
+ isBuryingPending: false,
+ isBuried: false
+ };
+ gameState.phase = GamePhases.PLAYING;
+ gameState.firstPlayerId = dealer.id;
+ gameState.currentPlayerIndex = null;
+ gameState.roundStartPlayerIndex = dealerIndex;
+ gameState.currentRound = 0;
+ gameState.playMode = PlayModes.ORDERED;
+ gameState.playersPlayedThisRound.clear();
+
+ this.io.to(this.room.id).emit('administrative_review_selection_started', {
+ dealerPlayerId: dealer.id,
+ dealerPlayerName: dealer.name,
+ suitSelectorPlayerId: suitSelector.id,
+ suitSelectorPlayerName: suitSelector.name,
+ rankSelectorPlayerId: rankSelector.id,
+ rankSelectorPlayerName: rankSelector.name
+ });
+
+ const requests = [
+ { player: suitSelector, type: 'suit', options: eligibleSuits },
+ { player: rankSelector, type: 'rank', options: eligibleRanks }
+ ];
+ requests.forEach(({ player, type, options }) => {
+ if (player.isBot) {
+ const sample = Number(this.random());
+ const boundedSample = Number.isFinite(sample)
+ ? Math.min(0.999999999, Math.max(0, sample))
+ : 0;
+ const value = options[Math.floor(boundedSample * options.length)];
+ this.selectAdministrativeReviewDeclaration(player.id, type, value, { source: 'bot' });
+ return;
+ }
+ this.io.to(player.socketId).emit('administrative_review_selection_required', {
+ type,
+ eligibleOptions: options,
+ trumpSuit: gameState.trumpSuit,
+ trumpRank: gameState.trumpRank
+ });
+ });
+
+ this.broadcastRoomUpdate();
+ if (!this.hasPendingAdministrativeReviewSelection() && this.onBotTurn) {
+ this.onBotTurn();
+ }
+ return {
+ pending: this.hasPendingAdministrativeReviewSelection(),
+ ...gameState.administrativeReview
+ };
+ }
+
+ selectAdministrativeReviewDeclaration(playerId, type, value, { source = 'player' } = {}) {
+ const { gameState } = this.room;
+ const review = gameState.administrativeReview;
+ if (!isAdministrativeReviewRule(gameState.selectedRule) || !review) {
+ throw new Error('本局没有启用行政审查');
+ }
+ if (!['suit', 'rank'].includes(type)) throw new Error('无效的行政审查声明类型');
+ const expectedPlayerId = type === 'suit'
+ ? review.suitSelectorPlayerId
+ : review.rankSelectorPlayerId;
+ if (playerId !== expectedPlayerId) throw new Error('你不是本项行政审查的声明者');
+ if (review[type]) throw new Error('你已经完成行政审查声明');
+ const eligibleOptions = type === 'suit'
+ ? this.getAdministrativeReviewSuitOptions()
+ : HIDDEN_DRAGON_RANKS;
+ if (!eligibleOptions.includes(value)) {
+ throw new Error(type === 'suit' ? '只能指定一种当前副花色' : '只能指定2至A中的一个点数');
+ }
+
+ review[type] = value;
+ const player = this.room.findPlayerById(playerId);
+ const result = {
+ playerId,
+ playerName: player?.name || '未知玩家',
+ type,
+ value,
+ source
+ };
+ this.io.to(this.room.id).emit('administrative_review_declared', result);
+ const pending = this.hasPendingAdministrativeReviewSelection();
+ if (!pending) {
+ this.beginAdministrativeReviewPlay();
+ } else {
+ this.broadcastRoomUpdate();
+ }
+ logger.info(
+ `房间 ${this.room.id} 行政审查:${result.playerName}公开指定` +
+ `${type === 'suit' ? `副花色 ${value}` : `点数 ${value}`}`
+ );
+ return { ...result, pending };
+ }
+
+ beginAdministrativeReviewPlay() {
+ const { gameState } = this.room;
+ const review = gameState.administrativeReview;
+ if (!review || review.isPlayStarted) return null;
+ const dealer = this.room.findPlayerById(review.dealerPlayerId);
+ const dealerIndex = this.room.getPlayerIndex(dealer.id);
+ review.isPlayStarted = true;
+ gameState.phase = GamePhases.PLAYING;
+ gameState.firstPlayerId = dealer.id;
+ gameState.currentPlayerIndex = dealerIndex;
+ gameState.roundStartPlayerIndex = dealerIndex;
+ gameState.currentRound = 1;
+ gameState.playMode = PlayModes.ORDERED;
+ gameState.playersPlayedThisRound.clear();
+ gameState.currentRoundPlays = [];
+ gameState.leadingPattern = null;
+ gameState.currentWinnerIndex = null;
+ this.roundManager = new RoundManager(this.room);
+
+ this.io.to(this.room.id).emit('administrative_review_completed', {
+ suit: review.suit,
+ rank: review.rank,
+ dealerPlayerId: dealer.id,
+ dealerPlayerName: dealer.name
+ });
+ this.io.to(this.room.id).emit('first_player_set', {
+ playerId: dealer.id,
+ playerName: dealer.name,
+ currentPlayerIndex: dealerIndex
+ });
+ this.io.to(this.room.id).emit('phase_changed', {
+ phase: GamePhases.PLAYING,
+ message: `行政审查声明完成,${dealer.name}先出牌;底牌仍封存`
+ });
+ this.broadcastRoomUpdate();
+ return { ...review };
+ }
+
+ /** 只有庄家和固定队友的实体出牌会推进两项公开审查条件。 */
+ recordAdministrativeReviewPlay(player, cards) {
+ const { gameState } = this.room;
+ const review = gameState.administrativeReview;
+ if (
+ !isAdministrativeReviewRule(gameState.selectedRule)
+ || !review?.isPlayStarted
+ || review.isBottomReleased
+ || ![review.dealerPlayerId, review.dealerTeammatePlayerId].includes(player.id)
+ ) return null;
+
+ const snapshot = {
+ previousSuitMatched: review.suitMatched,
+ previousRankMatched: review.rankMatched
+ };
+ review.suitMatched = review.suitMatched || cards.some(card => (
+ getEffectiveSuit(card, gameState.trumpSuit, gameState.trumpRank) === review.suit
+ ));
+ review.rankMatched = review.rankMatched || cards.some(card => (
+ (card.originalRank || card.rank) === review.rank
+ ));
+ if (
+ snapshot.previousSuitMatched !== review.suitMatched
+ || snapshot.previousRankMatched !== review.rankMatched
+ ) {
+ this.io.to(this.room.id).emit('administrative_review_progressed', {
+ playerId: player.id,
+ playerName: player.name,
+ suit: review.suit,
+ rank: review.rank,
+ suitMatched: review.suitMatched,
+ rankMatched: review.rankMatched
+ });
+ }
+ return snapshot;
+ }
+
+ releaseAdministrativeReviewBottomIfReady() {
+ const { gameState } = this.room;
+ const review = gameState.administrativeReview;
+ if (
+ !isAdministrativeReviewRule(gameState.selectedRule)
+ || !review?.suitMatched
+ || !review?.rankMatched
+ || review.isBottomReleased
+ ) return null;
+
+ const dealer = this.room.findPlayerById(review.dealerPlayerId);
+ const sealedBottomCards = [...gameState.bottomCards];
+ sealedBottomCards.forEach(card => dealer.addCard(card));
+ dealer.cards = DeckService.autoSortCards(dealer.cards);
+ review.isBottomReleased = true;
+ review.isBuryingPending = true;
+ gameState.phase = GamePhases.BURYING;
+ this.io.to(dealer.socketId).emit('bottom_cards_received', {
+ bottomCards: sealedBottomCards.map(card => card.toJSON()),
+ totalCards: dealer.cards.length,
+ administrativeReview: true
+ });
+ this.io.to(this.room.id).emit('administrative_review_burying_unlocked', {
+ dealerPlayerId: dealer.id,
+ dealerPlayerName: dealer.name,
+ bottomCardsCount: sealedBottomCards.length,
+ suit: review.suit,
+ rank: review.rank
+ });
+ this.io.to(this.room.id).emit('phase_changed', {
+ phase: GamePhases.BURYING,
+ message: `行政审查条件满足,${dealer.name}查看底牌并埋12张`
+ });
+
+ if (dealer.isBot) {
+ if (this.botActionTimer) clearTimeout(this.botActionTimer);
+ this.botActionTimer = setTimeout(() => {
+ try {
+ const cardsToBury = this.selectBotCardsToBury(dealer, gameState.bottomCardsCount);
+ this.buryCards(dealer.id, cardsToBury.map(card => card.id));
+ } catch (error) {
+ logger.error(`行政审查Bot庄家 ${dealer.name} 自动埋底失败:`, error);
+ } finally {
+ this.botActionTimer = null;
+ }
+ }, 500);
+ }
+ logger.info(`房间 ${this.room.id} 行政审查条件全部满足,向 ${dealer.name} 发放封存底牌`);
+ return {
+ dealerPlayerId: dealer.id,
+ dealerPlayerName: dealer.name,
+ bottomCardsCount: sealedBottomCards.length
+ };
+ }
+
+ buryAdministrativeReviewCards(playerId, cardIds) {
+ const { gameState } = this.room;
+ const review = gameState.administrativeReview;
+ if (!review?.isBuryingPending || !review.isBottomReleased || review.isBuried) {
+ throw new Error('行政审查尚未开放埋底');
+ }
+ if (playerId !== review.dealerPlayerId) throw new Error('只有庄家可以埋底');
+ const requiredCount = gameState.bottomCardsCount;
+ if (!Array.isArray(cardIds) || cardIds.length !== requiredCount || new Set(cardIds).size !== requiredCount) {
+ throw new Error(`必须埋${requiredCount}张不同的牌`);
+ }
+ const dealer = this.room.findPlayerById(playerId);
+ const cardsToBury = cardIds.map(cardId => dealer.cards.find(card => card.id === cardId));
+ if (cardsToBury.some(card => !card)) throw new Error('选择的牌不在手中');
+
+ dealer.removeCards(cardIds);
+ gameState.bottomCards = cardsToBury;
+ review.isBuryingPending = false;
+ review.isBuried = true;
+ gameState.phase = GamePhases.PLAYING;
+ this.io.to(this.room.id).emit('cards_buried', {
+ playerId: dealer.id,
+ playerName: dealer.name,
+ skipped: false,
+ isSecondary: false,
+ completed: true,
+ administrativeReview: true
+ });
+ this.io.to(this.room.id).emit('administrative_review_buried', {
+ dealerPlayerId: dealer.id,
+ dealerPlayerName: dealer.name,
+ bottomCardsCount: requiredCount,
+ currentPlayerIndex: gameState.currentPlayerIndex,
+ currentRound: gameState.currentRound
+ });
+ this.io.to(this.room.id).emit('phase_changed', {
+ phase: GamePhases.PLAYING,
+ message: isMainstayRule(gameState.selectedRule) && gameState.trumpSuit !== Suits.NO_TRUMP
+ ? '行政审查埋底完成,开始中流砥柱'
+ : `行政审查埋底完成,继续第${gameState.currentRound}轮`
+ });
+ this.broadcastRoomUpdate();
+ const mainstayStarted = this.startMainstay({ completionMode: 'resume' });
+ if (!mainstayStarted && this.onBotTurn) this.onBotTurn();
+ logger.info(`房间 ${this.room.id} 行政审查:${dealer.name}埋底完成,续接原牌局`);
+ return {
+ completed: true,
+ administrativeReview: true,
+ dealer,
+ cards: cardsToBury,
+ currentPlayerIndex: gameState.currentPlayerIndex,
+ currentRound: gameState.currentRound
+ };
+ }
+
+ emitFocusFigureTeamEvent(teamState, event, payload) {
+ teamState.playerIds.forEach(playerId => {
+ const player = this.room.findPlayerById(playerId);
+ if (player?.socketId) this.io.to(player.socketId).emit(event, payload);
+ });
+ }
+
+ getFocusFigureTeamForPlayer(playerId) {
+ return this.room.gameState.focusFigureTeams.find(team => team.playerIds.includes(playerId)) || null;
+ }
+
+ hasPendingFocusFigureVote() {
+ return isFocusFigureRule(this.room.gameState.selectedRule) &&
+ this.room.gameState.isFocusFigureVotingStarted &&
+ this.room.gameState.focusFigureTeams.some(team => !team.isFinalized);
+ }
+
+ assertFocusFigureVoteComplete() {
+ if (this.hasPendingFocusFigureVote()) {
+ throw new Error('请等待两队完成焦点人物表决');
+ }
+ }
+
+ promptFocusFigureTeam(teamState) {
+ if (!teamState || teamState.isFinalized) return;
+ const nominee = this.room.findPlayerById(teamState.nomineePlayerId);
+ const payload = {
+ team: teamState.team,
+ attempt: teamState.attempt,
+ nomineePlayerId: nominee?.id || null,
+ nomineePlayerName: nominee?.name || '未知玩家'
+ };
+
+ teamState.playerIds.forEach(playerId => {
+ const player = this.room.findPlayerById(playerId);
+ if (!player?.isBot && player?.socketId) {
+ this.io.to(player.socketId).emit('focus_figure_vote_required', payload);
+ }
+ });
+
+ // Bot始终同意当前队内候选,避免自动牌局陷入无意义的反复轮换。
+ teamState.playerIds.forEach(playerId => {
+ const player = this.room.findPlayerById(playerId);
+ if (player?.isBot && !teamState.votes.has(playerId)) {
+ this.submitFocusFigureVote(player.id, true, {
+ team: teamState.team,
+ attempt: teamState.attempt,
+ source: 'bot'
+ });
+ }
+ });
+ }
+
+ /** 规则确定时先随机生成两队的初始候选,但暂不向任何客户端公开。 */
+ initializeFocusFigureCandidates() {
+ const { gameState, players } = this.room;
+ if (!isFocusFigureRule(gameState.selectedRule)) return null;
+ if (players.length !== 4) throw new Error('焦点人物仅支持四人局');
+
+ gameState.focusFigureTeams = [0, 1].map(parity => {
+ const teammates = players.filter((_, index) => index % 2 === parity);
+ const sample = Number(this.random());
+ const nomineeOffset = Number.isFinite(sample) && sample >= 0.5 ? 1 : 0;
+ return {
+ team: parity + 1,
+ playerIds: teammates.map(player => player.id),
+ nomineePlayerId: teammates[nomineeOffset].id,
+ finalPlayerId: null,
+ attempt: 1,
+ votes: new Map(),
+ isFinalized: false
+ };
+ });
+ gameState.isFocusFigureVotingStarted = false;
+ gameState.isFocusFigureRevealed = false;
+ gameState.focusFigureCapturedPointsByPlayerId = new Map(
+ players.map(player => [player.id, 0])
+ );
+ return gameState.focusFigureTeams;
+ }
+
+ /** 埋底后公开各自队内候选并开始秘密表决;任一反对就轮换候选。 */
+ activateFocusFigure() {
+ const { gameState, players } = this.room;
+ if (!isFocusFigureRule(gameState.selectedRule)) return null;
+ if (players.length !== 4) throw new Error('焦点人物仅支持四人局');
+ if (gameState.focusFigureTeams.length !== 2) this.initializeFocusFigureCandidates();
+
+ gameState.isFocusFigureVotingStarted = true;
+ gameState.isFocusFigureRevealed = false;
+ gameState.focusFigureCapturedPointsByPlayerId = new Map(
+ players.map(player => [player.id, 0])
+ );
+ gameState.focusFigureTeams.forEach(team => {
+ team.finalPlayerId = null;
+ team.attempt = 1;
+ team.votes = new Map();
+ team.isFinalized = false;
+ });
+
+ this.io.to(this.room.id).emit('focus_figure_voting_started', {
+ teamCount: gameState.focusFigureTeams.length
+ });
+ gameState.focusFigureTeams.forEach(team => this.promptFocusFigureTeam(team));
+ this.broadcastRoomUpdate();
+ return { pending: this.hasPendingFocusFigureVote() };
+ }
+
+ submitFocusFigureVote(playerId, agree, { team = null, attempt = null, source = 'player' } = {}) {
+ const { gameState } = this.room;
+ if (!isFocusFigureRule(gameState.selectedRule)) {
+ throw new Error('本局没有启用焦点人物');
+ }
+ if (typeof agree !== 'boolean') throw new Error('表决结果必须为同意或反对');
+
+ const teamState = this.getFocusFigureTeamForPlayer(playerId);
+ if (!teamState) throw new Error('你不属于任何焦点人物表决队伍');
+ if (team !== null && Number(team) !== teamState.team) throw new Error('不能参与另一队的表决');
+ if (teamState.isFinalized) throw new Error('你所在队伍已经确定焦点人物');
+ if (attempt !== null && Number(attempt) !== teamState.attempt) {
+ throw new Error('这轮焦点候选已经变更,请对当前候选重新表决');
+ }
+ if (teamState.votes.has(playerId)) throw new Error('你已经提交本轮表决');
+
+ const player = this.room.findPlayerById(playerId);
+ teamState.votes.set(playerId, agree);
+ this.emitFocusFigureTeamEvent(teamState, 'focus_figure_vote_recorded', {
+ team: teamState.team,
+ attempt: teamState.attempt,
+ voterPlayerId: playerId,
+ voterPlayerName: player?.name || '未知玩家',
+ agree,
+ source,
+ voteCount: teamState.votes.size
+ });
+
+ let nomineeChanged = false;
+ if (teamState.votes.size === teamState.playerIds.length) {
+ const unanimouslyApproved = teamState.playerIds.every(id => teamState.votes.get(id) === true);
+ if (unanimouslyApproved) {
+ teamState.isFinalized = true;
+ teamState.finalPlayerId = teamState.nomineePlayerId;
+ const focusPlayer = this.room.findPlayerById(teamState.finalPlayerId);
+ this.emitFocusFigureTeamEvent(teamState, 'focus_figure_team_finalized', {
+ team: teamState.team,
+ attempt: teamState.attempt,
+ focusPlayerId: focusPlayer?.id || null,
+ focusPlayerName: focusPlayer?.name || '未知玩家'
+ });
+ } else {
+ const previousNomineePlayerId = teamState.nomineePlayerId;
+ teamState.nomineePlayerId = teamState.playerIds.find(id => id !== previousNomineePlayerId);
+ teamState.attempt += 1;
+ teamState.votes.clear();
+ nomineeChanged = true;
+ const nominee = this.room.findPlayerById(teamState.nomineePlayerId);
+ this.emitFocusFigureTeamEvent(teamState, 'focus_figure_nominee_changed', {
+ team: teamState.team,
+ attempt: teamState.attempt,
+ nomineePlayerId: nominee?.id || null,
+ nomineePlayerName: nominee?.name || '未知玩家'
+ });
+ this.promptFocusFigureTeam(teamState);
+ }
+ }
+
+ const pending = this.hasPendingFocusFigureVote();
+ if (!pending) {
+ this.io.to(this.room.id).emit('focus_figure_voting_completed', {
+ message: '两队均已完成焦点人物表决;己方焦点队内可见,双方焦点将在终局公开'
+ });
+ }
+ this.broadcastRoomUpdate();
+ return {
+ pending,
+ team: teamState.team,
+ teamFinalized: teamState.isFinalized,
+ nomineeChanged,
+ attempt: teamState.attempt
+ };
+ }
+
+ recordFocusFigureCapturedRoundPoints() {
+ const { gameState } = this.room;
+ if (!isFocusFigureRule(gameState.selectedRule)) return null;
+ const capturedByPlayerId = {};
+ gameState.currentRoundPlays.forEach(play => {
+ const points = calculateRoundPoints(play.cards, card => this.getRuleCardPoints(card));
+ if (points <= 0) return;
+ const total = (gameState.focusFigureCapturedPointsByPlayerId.get(play.playerId) || 0) + points;
+ gameState.focusFigureCapturedPointsByPlayerId.set(play.playerId, total);
+ capturedByPlayerId[play.playerId] = points;
+ });
+ return capturedByPlayerId;
+ }
+
+ drawPlannedEconomyRoundCards(completedRound) {
+ const { gameState, players } = this.room;
+ if (!isPlannedEconomyRule(gameState.selectedRule)) return null;
+ if (gameState.phase !== GamePhases.PLAYING || gameState.plannedEconomyReserveCards.length === 0) {
+ return null;
+ }
+
+ const drawCount = Math.min(players.length, gameState.plannedEconomyReserveCards.length);
+ const draws = [];
+ for (let index = 0; index < drawCount; index++) {
+ const player = players[index];
+ const card = gameState.plannedEconomyReserveCards.shift();
+ if (!player || !card) break;
+ player.addCard(card);
+ player.cards = DeckService.autoSortCards(player.cards);
+ draws.push({
+ playerId: player.id,
+ playerName: player.name,
+ card: card.toJSON(),
+ cardsCount: player.cards.length
+ });
+ }
+ if (draws.length === 0) return null;
+
+ gameState.plannedEconomyDrawRounds += 1;
+ logger.info(
+ `房间 ${this.room.id} 计划经济:第${completedRound}轮结束后四家各摸1张,` +
+ `封存牌剩余 ${gameState.plannedEconomyReserveCards.length} 张`
+ );
+ return {
+ round: completedRound,
+ draws,
+ drawCount: draws.length,
+ remainingCards: gameState.plannedEconomyReserveCards.length,
+ animationDuration: PLANNED_ECONOMY_DRAW_ANIMATION_MS
+ };
+ }
+
+ finalizeFocusFigureScoring(bottomScoreResult) {
+ const { gameState } = this.room;
+ if (!isFocusFigureRule(gameState.selectedRule)) return null;
+
+ const dealerIndex = this.room.getPlayerIndex(gameState.buryingPlayerId);
+ const focusPlayerIds = new Set(
+ gameState.focusFigureTeams.map(team => team.finalPlayerId).filter(Boolean)
+ );
+ const players = this.room.players.map((player, index) => {
+ const capturedPoints = gameState.focusFigureCapturedPointsByPlayerId.get(player.id) || 0;
+ const isFocus = focusPlayerIds.has(player.id);
+ return {
+ playerId: player.id,
+ playerName: player.name,
+ team: (index % 2) + 1,
+ side: index % 2 === dealerIndex % 2 ? 'dealer' : 'attacker',
+ isFocus,
+ capturedPoints,
+ countedPoints: isFocus ? capturedPoints * 2 : 0
+ };
+ });
+ const focusTrickScore = players.reduce((total, player) => total + player.countedPoints, 0);
+ const normalBottomScore = bottomScoreResult?.bottomScoreGained || 0;
+ const totalScore = focusTrickScore + normalBottomScore;
+ gameState.attackerScore = totalScore;
+ gameState.isFocusFigureRevealed = true;
+
+ const teams = gameState.focusFigureTeams.map(team => {
+ const focusPlayer = this.room.findPlayerById(team.finalPlayerId);
+ const teamParity = this.room.getPlayerIndex(team.finalPlayerId) % 2;
+ return {
+ team: team.team,
+ side: teamParity === dealerIndex % 2 ? 'dealer' : 'attacker',
+ focusPlayerId: focusPlayer?.id || null,
+ focusPlayerName: focusPlayer?.name || '未知玩家'
+ };
+ });
+ logger.info(
+ `房间 ${this.room.id} 焦点人物终局揭晓:逐墩焦点分 ${focusTrickScore},` +
+ `正常底牌分 ${normalBottomScore},闲家总分 ${totalScore}`
+ );
+ return { teams, players, focusTrickScore, normalBottomScore, totalScore };
+ }
+
+ applyRepeatedExhaustionAtRoundEnd(winnerIndex) {
+ const { gameState } = this.room;
+ if (!isRepeatedExhaustionRule(gameState.selectedRule)) return null;
+ const winner = this.room.findPlayerByIndex(winnerIndex);
+ if (!winner) return null;
+
+ if (gameState.repeatedExhaustionPlayerId === winner.id) {
+ gameState.repeatedExhaustionStreak += 1;
+ } else {
+ gameState.repeatedExhaustionPlayerId = winner.id;
+ gameState.repeatedExhaustionStreak = 1;
+ }
+ const streak = gameState.repeatedExhaustionStreak;
+ const penalty = streak >= 3 ? (streak - 2) * 5 : 0;
+ const dealerIndex = this.room.getPlayerIndex(gameState.buryingPlayerId);
+ const winnerIsAttacker = this.isAttackerPlayerIndex(winnerIndex, dealerIndex);
+ const scoreDelta = penalty === 0 ? 0 : (winnerIsAttacker ? -penalty : penalty);
+ gameState.attackerScore += scoreDelta;
+ gameState.repeatedExhaustionLastPenalty = penalty;
+ gameState.repeatedExhaustionLastScoreDelta = scoreDelta;
+
+ if (penalty > 0) {
+ logger.info(
+ `房间 ${this.room.id} 再衰三竭:${winner.name} 连续第${streak}轮最大,` +
+ `失去${penalty}分,闲家总分变化${scoreDelta > 0 ? '+' : ''}${scoreDelta}至${gameState.attackerScore}`
+ );
+ }
+ return {
+ playerId: winner.id,
+ playerName: winner.name,
+ streak,
+ penalty,
+ scoreDelta,
+ winnerIsAttacker
+ };
+ }
+
+ resolveOutwardHarmonyInnerDivisionAtRoundEnd(completedRound) {
+ const { gameState } = this.room;
+ if (!isOutwardHarmonyInnerDivisionRule(gameState.selectedRule)) return null;
+ const dealerIndex = this.room.getPlayerIndex(gameState.buryingPlayerId);
+ if (dealerIndex < 0 || this.room.players.length !== 4) return null;
+
+ const createTeamResult = (side, playerIndexes) => {
+ const players = playerIndexes.map(playerIndex => {
+ const player = this.room.findPlayerByIndex(playerIndex);
+ const play = gameState.currentRoundPlays.find(
+ candidate => candidate.playerIndex === playerIndex
+ );
+ const profile = getSuitlessPatternProfile(
+ play,
+ gameState.trumpSuit,
+ gameState.trumpRank,
+ gameState.selectedRule
+ );
+ return {
+ playerIndex,
+ playerId: player?.id || null,
+ playerName: player?.name || '未知玩家',
+ patternKey: profile.key,
+ patternLabel: profile.label,
+ components: profile.components
+ };
+ });
+ return {
+ side,
+ players,
+ mismatched: players.length === 2
+ && players[0].patternKey !== players[1].patternKey
+ };
+ };
+
+ const dealerTeam = createTeamResult('dealer', [
+ dealerIndex,
+ (dealerIndex + 2) % this.room.players.length
+ ]);
+ const attackerTeam = createTeamResult('attacker', [
+ (dealerIndex + 1) % this.room.players.length,
+ (dealerIndex + 3) % this.room.players.length
+ ]);
+ const attackerAward = dealerTeam.mismatched ? OUTWARD_HARMONY_AWARD : 0;
+ const dealerAward = attackerTeam.mismatched ? OUTWARD_HARMONY_AWARD : 0;
+ const attackerScoreDelta = attackerAward - dealerAward;
+ gameState.attackerScore += attackerScoreDelta;
+
+ const result = {
+ round: completedRound,
+ triggered: dealerTeam.mismatched || attackerTeam.mismatched,
+ dealerTeam,
+ attackerTeam,
+ dealerAward,
+ attackerAward,
+ attackerScoreDelta,
+ attackerScore: gameState.attackerScore
+ };
+ if (result.triggered) {
+ logger.info(
+ `房间 ${this.room.id} 貌合神离:庄家方${dealerTeam.mismatched ? '牌型不一致' : '牌型一致'},` +
+ `闲家方${attackerTeam.mismatched ? '牌型不一致' : '牌型一致'};` +
+ `闲家分数变化 ${attackerScoreDelta > 0 ? '+' : ''}${attackerScoreDelta}`
+ );
+ }
+ return result;
+ }
+
+ updateCardCooldownRestrictionsForNextRound() {
+ const { gameState } = this.room;
+ const type = getCardCooldownType(gameState.selectedRule);
+ if (!type) return null;
+
+ const valuesByPlayerId = new Map();
+ gameState.currentRoundPlays.forEach(play => {
+ valuesByPlayerId.set(
+ play.playerId,
+ Array.from(new Set(
+ play.cards.map(card => getCardCooldownValue(card, type, gameState)).filter(Boolean)
+ ))
+ );
+ });
+ gameState.cardCooldownType = type;
+ gameState.cardCooldownValuesByPlayerId = valuesByPlayerId;
+ return {
+ type,
+ valuesByPlayerId: Object.fromEntries(valuesByPlayerId)
+ };
+ }
+
+ resolveEnduringPlay(playerId, cards, pattern) {
+ const { gameState } = this.room;
+ if (!isEnduringRule(gameState.selectedRule)) {
+ return { comparisonPattern: pattern, inheritance: null };
+ }
+
+ const previousPlay = gameState.enduringLastPlaysByPlayerId.get(playerId);
+ const resolution = resolveEnduringComparison(
+ { cards, pattern },
+ previousPlay,
+ gameState.trumpSuit,
+ gameState.trumpRank,
+ this.getRuleRuntimeContext()
+ );
+ if (!resolution.inherited) {
+ return { comparisonPattern: pattern, inheritance: null };
+ }
+
+ return {
+ comparisonPattern: resolution.comparisonPattern,
+ inheritance: {
+ sourceCards: [...resolution.sourceCards],
+ sourcePattern: resolution.sourcePattern,
+ inheritedComponentCount: resolution.inheritedComponentCount
+ }
+ };
+ }
+
+ updateEnduringHistoryAtRoundEnd() {
+ const { gameState } = this.room;
+ if (!isEnduringRule(gameState.selectedRule)) return null;
+
+ const nextHistory = new Map();
+ for (const play of gameState.currentRoundPlays) {
+ nextHistory.set(play.playerId, {
+ cards: [...play.cards],
+ pattern: play.pattern,
+ comparisonPattern: play.comparisonPattern || play.pattern,
+ // 连续继承时保留最初提供高牌力的牌面,让视觉提示不会越传越偏。
+ displaySourceCards: play.enduringInheritance?.sourceCards?.length
+ ? [...play.enduringInheritance.sourceCards]
+ : [...play.cards]
+ });
+ }
+ gameState.enduringLastPlaysByPlayerId = nextHistory;
+ return nextHistory;
+ }
+
+ isDreamKillingSleeping(playerId) {
+ return this.room.gameState.dreamKillingSleepingPlayerIds.has(playerId);
+ }
+
+ canActivateDreamKilling(playerId) {
+ const { gameState } = this.room;
+ const player = this.room.findPlayerById(playerId);
+ if (!isDreamKillingRule(gameState.selectedRule) || !player) return false;
+ if (gameState.phase !== GamePhases.PLAYING || player.cards.length === 0) return false;
+ if (this.isDreamKillingSleeping(playerId)) return false;
+ return player.cards.every(card => !isTrumpCard(card, gameState.trumpSuit, gameState.trumpRank));
+ }
+
+ activateDreamKilling(playerId) {
+ const { gameState } = this.room;
+ const player = this.room.findPlayerById(playerId);
+ if (!player) throw new Error('玩家不存在');
+ if (!isDreamKillingRule(gameState.selectedRule)) throw new Error('本局规则不是梦中杀人');
+ if (gameState.phase !== GamePhases.PLAYING) throw new Error('进入出牌阶段后才能发动梦中杀人');
+ if (player.cards.length === 0) throw new Error('已经没有手牌,不能发动梦中杀人');
+ if (this.isDreamKillingSleeping(playerId)) throw new Error('你已经处于梦中');
+ if (player.cards.some(card => isTrumpCard(card, gameState.trumpSuit, gameState.trumpRank))) {
+ throw new Error('手牌中仍有主牌,不能发动梦中杀人');
+ }
+
+ gameState.dreamKillingSleepingPlayerIds.add(player.id);
+ logger.info(`房间 ${this.room.id} 玩家 ${player.name} 发动梦中杀人,手牌暗置并交由系统随机出牌`);
+ return {
+ id: ActiveSkillIds.DREAM_KILLING,
+ name: '梦中杀人',
+ playerId: player.id,
+ playerName: player.name,
+ sleeping: true
+ };
+ }
+
+ resolveDreamKillingPlay(player, cards, pattern, wasDreaming) {
+ if (!wasDreaming || !isDreamKillingRule(this.room.gameState.selectedRule)) return null;
+ const firstPlay = this.room.gameState.currentRoundPlays[0] || null;
+ const firstCards = firstPlay?.cards?.length ? firstPlay.cards : cards;
+ const firstPattern = firstPlay?.pattern || pattern;
+ const sameEffectiveSuit = Boolean(pattern?.suit && pattern.suit === firstPattern?.suit);
+ const rankSignature = values => values.map(card => card.rank).sort().join('|');
+ const sameRank = rankSignature(cards) === rankSignature(firstCards);
+ const success = sameEffectiveSuit || sameRank;
+
+ if (success) {
+ this.room.gameState.dreamKillingSleepingPlayerIds.delete(player.id);
+ logger.info(
+ `房间 ${this.room.id} 梦中杀人:${player.name} 随机出牌命中` +
+ `${sameEffectiveSuit ? '首家花色' : '首家点数'},本轮视为最大并醒来`
+ );
+ }
+ return {
+ wasSleeping: true,
+ success,
+ awakened: success,
+ matchedSuit: sameEffectiveSuit,
+ matchedRank: sameRank
+ };
+ }
+
+ resolveInviteIntoUrnAtRoundEnd(completedRound) {
+ const { gameState } = this.room;
+ if (!isInviteIntoUrnRule(gameState.selectedRule)) return null;
+ const declarations = gameState.inviteIntoUrnDeclarations.filter(
+ declaration => declaration.round === completedRound
+ );
+ const dealerIndex = this.room.getPlayerIndex(gameState.buryingPlayerId);
+ let scoreDelta = 0;
+ const resolvedDeclarations = declarations.map(declaration => {
+ const targetPlay = gameState.currentRoundPlays.find(
+ play => play.playerId === declaration.targetPlayerId
+ );
+ const matchingCardCount = (targetPlay?.originalCards || targetPlay?.cards || []).filter(
+ card => card.suit === declaration.suit && card.rank === declaration.rank
+ ).length;
+ const triggered = matchingCardCount > 0;
+ const targetIndex = this.room.getPlayerIndex(declaration.targetPlayerId);
+ const targetIsAttacker = this.isAttackerPlayerIndex(targetIndex, dealerIndex);
+ const declarationScoreDelta = triggered ? (targetIsAttacker ? -5 : 5) : 0;
+ scoreDelta += declarationScoreDelta;
+ return {
+ ...declaration,
+ triggered,
+ matchingCardCount,
+ penalty: triggered ? 5 : 0,
+ targetIsAttacker,
+ scoreDelta: declarationScoreDelta
+ };
+ });
+
+ gameState.attackerScore += scoreDelta;
+ gameState.inviteIntoUrnDeclarations = gameState.inviteIntoUrnDeclarations.filter(
+ declaration => declaration.round !== completedRound
+ );
+ const result = {
+ round: completedRound,
+ declarations: resolvedDeclarations,
+ triggeredCount: resolvedDeclarations.filter(declaration => declaration.triggered).length,
+ scoreDelta,
+ attackerScore: gameState.attackerScore
+ };
+ gameState.inviteIntoUrnLastResult = result;
+ if (result.triggeredCount > 0) {
+ logger.info(
+ `房间 ${this.room.id} 请君入瓮:触发 ${result.triggeredCount} 项,` +
+ `闲家分数变化 ${scoreDelta > 0 ? '+' : ''}${scoreDelta}`
+ );
+ }
+ return result;
+ }
+
+ initializeOldHorse(firstPlayerId) {
+ const { gameState } = this.room;
+ if (!isOldHorseStillHasStrengthRule(gameState.selectedRule)) return null;
+ gameState.oldHorseRightHolderPlayerIds = new Set(
+ firstPlayerId ? [firstPlayerId] : []
+ );
+ gameState.oldHorseProtectedPlayerId = null;
+ gameState.oldHorseLastAbsolutePlay = null;
+ return {
+ rightHolderPlayerIds: Array.from(gameState.oldHorseRightHolderPlayerIds),
+ protectedPlayerId: null
+ };
+ }
+
+ updateOldHorseAtRoundEnd(winnerIndex) {
+ const { gameState } = this.room;
+ if (!isOldHorseStillHasStrengthRule(gameState.selectedRule)) return null;
+ const winner = this.room.findPlayerByIndex(winnerIndex);
+ if (!winner) return null;
+ const wasNewHolder = !gameState.oldHorseRightHolderPlayerIds.has(winner.id);
+ if (wasNewHolder) gameState.oldHorseRightHolderPlayerIds.add(winner.id);
+ const completedAllHolders = wasNewHolder
+ && gameState.oldHorseRightHolderPlayerIds.size === this.room.players.length;
+ if (completedAllHolders) {
+ gameState.oldHorseProtectedPlayerId = winner.id;
+ logger.info(
+ `房间 ${this.room.id} 老骥伏枥:${winner.name} 最后首次获得牌权,` +
+ '下一次合法首发绝对最大'
+ );
+ }
+ return {
+ rightHolderPlayerIds: Array.from(gameState.oldHorseRightHolderPlayerIds),
+ newlyAcquiredPlayerId: wasNewHolder ? winner.id : null,
+ protectedPlayerId: gameState.oldHorseProtectedPlayerId,
+ armedNow: completedAllHolders
+ };
+ }
+
+ isOldHorseAbsoluteLead(playerId, isLeading) {
+ const { gameState } = this.room;
+ return Boolean(
+ isLeading
+ && isOldHorseStillHasStrengthRule(gameState.selectedRule)
+ && gameState.oldHorseProtectedPlayerId === playerId
+ );
+ }
+
+ consumeOldHorseAbsoluteLead(player, cards) {
+ const { gameState } = this.room;
+ gameState.oldHorseProtectedPlayerId = null;
+ gameState.oldHorseLastAbsolutePlay = {
+ round: gameState.currentRound,
+ playerId: player.id,
+ playerName: player.name,
+ cardsCount: cards.length
+ };
+ logger.info(
+ `房间 ${this.room.id} 老骥伏枥:${player.name} 的合法首发视为绝对最大`
+ );
+ return { ...gameState.oldHorseLastAbsolutePlay };
+ }
+
+ resolveTrumpWinsAtRoundEnd(completedRound) {
+ const { gameState } = this.room;
+ if (!isTrumpWinsRule(gameState.selectedRule)) return null;
+ const players = gameState.currentRoundPlays.map((play, playOrder) => ({
+ playerIndex: play.playerIndex,
+ playerId: play.playerId,
+ playerName: this.room.findPlayerById(play.playerId)?.name || '未知玩家',
+ playOrder,
+ points: calculateRoundPoints(play.originalCards || play.cards, getCardPoints)
+ }));
+ const highestPoints = Math.max(...players.map(player => player.points), 0);
+ // currentRoundPlays 本身就是实际出牌顺序;find 天然让并列时先出者获权。
+ const leader = players.find(player => player.points === highestPoints) || null;
+ const result = {
+ round: completedRound,
+ highestPoints,
+ leaderPlayerIndex: leader?.playerIndex ?? null,
+ leaderPlayerId: leader?.playerId || null,
+ leaderPlayerName: leader?.playerName || null,
+ players
+ };
+ gameState.trumpWinsLastResult = result;
+ return result;
+ }
+
+ getStrawBoatBorrowingArrowsPublicDecision(
+ decision = this.room.gameState.strawBoatBorrowingArrowsDecision
+ ) {
+ if (!decision) return null;
+ return {
+ id: decision.id,
+ round: decision.round,
+ playerId: decision.playerId,
+ playerName: decision.playerName,
+ leadingPoints: decision.leadingPoints,
+ borrowedCard: decision.borrowedCard?.toJSON
+ ? decision.borrowedCard.toJSON()
+ : decision.borrowedCard
+ };
+ }
+
+ prepareStrawBoatBorrowingArrowsAtRoundEnd(completedRound, winnerIndex) {
+ const { gameState } = this.room;
+ if (!isStrawBoatBorrowingArrowsRule(gameState.selectedRule)) return null;
+ if (gameState.strawBoatBorrowingArrowsDecision) {
+ return this.getStrawBoatBorrowingArrowsPublicDecision();
+ }
+
+ const leadingPlay = gameState.currentRoundPlays[0];
+ if (!leadingPlay || leadingPlay.playerIndex % 2 === winnerIndex % 2) return null;
+
+ const leadingCards = leadingPlay.originalCards || leadingPlay.cards || [];
+ const leadingPoints = calculateRoundPoints(leadingCards, getCardPoints);
+ if (leadingPoints < 10) return null;
+
+ const player = this.room.findPlayerById(leadingPlay.playerId);
+ if (!player || !player.cards.some(card => getCardPoints(card) === 0)) return null;
+
+ let borrowedCard = null;
+ let borrowedStrength = -Infinity;
+ for (const play of gameState.currentRoundPlays) {
+ for (const card of play.originalCards || play.cards || []) {
+ if (getCardPoints(card) !== 0) continue;
+ const strength = getCardStrength(
+ card,
+ gameState.trumpSuit,
+ gameState.trumpRank,
+ gameState.selectedRule
+ );
+ // 同强度时保留实际出牌顺序中更早出现的牌,确保判定稳定且全场一致。
+ if (strength > borrowedStrength) {
+ borrowedCard = card;
+ borrowedStrength = strength;
+ }
+ }
+ }
+ if (!borrowedCard) return null;
+
+ const decision = {
+ id: `straw-boat-${completedRound}-${player.id}`,
+ round: completedRound,
+ playerId: player.id,
+ playerName: player.name,
+ leadingPoints,
+ borrowedCard
+ };
+ gameState.strawBoatBorrowingArrowsDecision = decision;
+ logger.info(
+ `房间 ${this.room.id} 草船借箭:${player.name} 首置 ${leadingPoints} 分失守,等待决定是否换牌`
+ );
+ return this.getStrawBoatBorrowingArrowsPublicDecision(decision);
+ }
+
+ hasPendingStrawBoatBorrowingArrowsDecision() {
+ return Boolean(this.room.gameState.strawBoatBorrowingArrowsDecision);
+ }
+
+ selectStrawBoatBorrowingArrowsForBot(playerId) {
+ const decision = this.room.gameState.strawBoatBorrowingArrowsDecision;
+ if (!decision || decision.playerId !== playerId) return null;
+ const player = this.room.findPlayerById(playerId);
+ if (!player) return null;
+
+ const { trumpSuit, trumpRank, selectedRule } = this.room.gameState;
+ const discardableCards = player.cards
+ .filter(card => getCardPoints(card) === 0)
+ .sort((left, right) => (
+ getCardStrength(left, trumpSuit, trumpRank, selectedRule)
+ - getCardStrength(right, trumpSuit, trumpRank, selectedRule)
+ ));
+ const weakestCard = discardableCards[0] || null;
+ if (!weakestCard) return { accept: false, cardId: null };
+
+ const borrowedStrength = getCardStrength(
+ decision.borrowedCard,
+ trumpSuit,
+ trumpRank,
+ selectedRule
+ );
+ const weakestStrength = getCardStrength(
+ weakestCard,
+ trumpSuit,
+ trumpRank,
+ selectedRule
+ );
+ const accept = borrowedStrength > weakestStrength;
+ return { accept, cardId: accept ? weakestCard.id : null };
+ }
+
+ resolveStrawBoatBorrowingArrows(playerId, { accept = false, cardId = null } = {}) {
+ const { gameState } = this.room;
+ const decision = gameState.strawBoatBorrowingArrowsDecision;
+ if (!decision) throw new Error('当前没有待处理的草船借箭');
+ if (decision.playerId !== playerId) throw new Error('只有本轮首置位玩家可以决定草船借箭');
+
+ const player = this.room.findPlayerById(playerId);
+ if (!player) throw new Error('玩家不存在');
+
+ let discardedCard = null;
+ if (accept) {
+ discardedCard = player.cards.find(card => card.id === cardId) || null;
+ if (!discardedCard) throw new Error('请选择一张仍在手中的牌弃置');
+ if (getCardPoints(discardedCard) !== 0) throw new Error('草船借箭只能弃置非分数牌');
+
+ player.cards = player.cards.filter(card => card.id !== discardedCard.id);
+ player.shownCards?.delete?.(discardedCard.id);
+ player.cards.push(decision.borrowedCard);
+ player.cards = DeckService.autoSortCards(player.cards);
+ }
+
+ const borrowedCard = decision.borrowedCard?.toJSON
+ ? decision.borrowedCard.toJSON()
+ : decision.borrowedCard;
+ const publicDiscardedCard = discardedCard?.toJSON ? discardedCard.toJSON() : discardedCard;
+ const result = {
+ decisionId: decision.id,
+ round: decision.round,
+ playerId: player.id,
+ playerName: player.name,
+ accepted: Boolean(accept),
+ leadingPoints: decision.leadingPoints,
+ discardedCard: publicDiscardedCard,
+ borrowedCard,
+ message: accept
+ ? `${player.name} 发动草船借箭:公开弃置一张非分数牌,并获得本轮最大非分数牌`
+ : `${player.name} 放弃发动草船借箭`
+ };
+ gameState.strawBoatBorrowingArrowsDecision = null;
+ gameState.strawBoatBorrowingArrowsLastResult = result;
+ logger.info(`房间 ${this.room.id} ${result.message}`);
+ return {
+ ...result,
+ handCards: player.cards.map(card => card.toJSON ? card.toJSON() : card)
+ };
+ }
+
+ findRoundWinningPlay(plays = this.room.gameState.currentRoundPlays) {
+ if (!Array.isArray(plays) || plays.length === 0) return null;
+ const { gameState } = this.room;
+ const comparablePlays = plays.filter(play => !play.lureTigerSilenced);
+ if (comparablePlays.length === 0) return null;
+ let winner = comparablePlays[0];
+ for (const play of comparablePlays.slice(1)) {
+ if (winner.oldHorseAbsolute) continue;
+ if (play.oldHorseAbsolute) {
+ winner = play;
+ continue;
+ }
+ const comparison = compareCards(
+ {
+ cards: play.comparisonCards || play.cards,
+ pattern: play.comparisonPattern || play.pattern,
+ playerIndex: play.playerIndex
+ },
+ {
+ cards: winner.comparisonCards || winner.cards,
+ pattern: winner.comparisonPattern || winner.pattern,
+ playerIndex: winner.playerIndex
+ },
+ gameState.leadingPattern?.suit,
+ gameState.trumpSuit,
+ gameState.trumpRank,
+ this.getRuleRuntimeContext(),
+ gameState.leadingPattern
+ );
+ if (comparison > 0) winner = play;
+ }
+ return winner;
+ }
+
+ recomputeRoundWinner(plays = this.room.gameState.currentRoundPlays) {
+ const winner = this.findRoundWinningPlay(plays);
+ if (!winner) return null;
+ this.room.gameState.currentWinnerIndex = winner.playerIndex;
+ return winner;
+ }
+
+ getFearOfBreakingVaseProtectedCards(play) {
+ const cards = play?.originalCards || play?.cards || [];
+ const faceCounts = new Map();
+ for (const card of cards) {
+ const faceKey = `${card.suit}:${card.rank}`;
+ faceCounts.set(faceKey, (faceCounts.get(faceKey) || 0) + 1);
+ }
+ const pairCount = [...faceCounts.values()].reduce(
+ (total, count) => total + Math.floor(count / 2),
+ 0
+ );
+ const jokerCount = cards.filter(card => card.suit === Suits.JOKER).length;
+ return {
+ pairCount,
+ jokerCount,
+ qualifies: pairCount >= 2 || jokerCount >= 2
+ };
+ }
+
+ isFearOfBreakingVaseRuff(play, leadingPlay) {
+ if (!play || !leadingPlay || play.playerId === leadingPlay.playerId) return false;
+ const { gameState } = this.room;
+ const leadingPattern = leadingPlay.comparisonPattern || leadingPlay.pattern;
+ const playPattern = play.comparisonPattern || play.pattern;
+ const leadingSuit = leadingPattern?.suit;
+ if (!leadingSuit || leadingSuit === 'trump') return false;
+
+ const leadIsInferior = isThreeSixNineGradesRule(gameState.selectedRule)
+ && Boolean(gameState.inferiorSuit)
+ && leadingSuit === gameState.inferiorSuit;
+ const playIsOrdinarySide = playPattern?.suit !== 'trump'
+ && playPattern?.suit !== gameState.inferiorSuit;
+ const usesRuffSuit = playPattern?.suit === 'trump'
+ || (leadIsInferior && playIsOrdinarySide);
+ if (!usesRuffSuit) return false;
+
+ return compareCards(
+ {
+ cards: play.comparisonCards || play.cards,
+ pattern: playPattern,
+ playerIndex: play.playerIndex
+ },
+ {
+ cards: leadingPlay.comparisonCards || leadingPlay.cards,
+ pattern: leadingPattern,
+ playerIndex: leadingPlay.playerIndex
+ },
+ leadingSuit,
+ gameState.trumpSuit,
+ gameState.trumpRank,
+ this.getRuleRuntimeContext(),
+ leadingPattern
+ ) > 0;
+ }
+
+ resolveFearOfBreakingVaseAtRoundEnd(winnerIndex, completedRound) {
+ const { gameState, players } = this.room;
+ const plays = gameState.currentRoundPlays;
+ if (
+ !isFearOfBreakingVaseRule(gameState.selectedRule)
+ || players.length !== 4
+ || plays.length !== players.length
+ ) {
+ return null;
+ }
+
+ const winnerPlay = plays.find(play => play.playerIndex === winnerIndex);
+ const firstPlay = plays[0];
+ const secondPlay = plays[1];
+ if (!winnerPlay || !firstPlay || !secondPlay) return null;
+
+ const firstTeammate = this.getFixedTeammate(firstPlay.playerId);
+ const firstTeammatePlay = plays.find(play => play.playerId === firstTeammate?.id);
+ const protectedCards = this.getFearOfBreakingVaseProtectedCards(firstTeammatePlay);
+ const leaderHurtVessel = winnerPlay.playerId === firstPlay.playerId
+ && protectedCards.qualifies;
+
+ const ruffPlays = plays.filter(play => (
+ this.isFearOfBreakingVaseRuff(play, firstPlay)
+ ));
+ const secondTeammate = this.getFixedTeammate(secondPlay.playerId);
+ const strongestWithoutSecond = this.findRoundWinningPlay(
+ plays.filter(play => play.playerId !== secondPlay.playerId)
+ );
+ const secondHurtVessel = winnerPlay.playerId === secondPlay.playerId
+ && ruffPlays.length === 1
+ && ruffPlays[0].playerId === secondPlay.playerId
+ && strongestWithoutSecond?.playerId === secondTeammate?.id;
+
+ if (!leaderHurtVessel && !secondHurtVessel) return null;
+
+ const triggerType = leaderHurtVessel
+ ? 'leader_over_protected_teammate'
+ : 'sole_second_ruff_over_teammate';
+ const vesselPlayer = leaderHurtVessel ? firstTeammate : secondTeammate;
+ const dealerIndex = this.room.getPlayerIndex(gameState.buryingPlayerId);
+ const winnerIsAttacker = this.isAttackerPlayerIndex(winnerIndex, dealerIndex);
+ const scoreDelta = winnerIsAttacker
+ ? -FEAR_OF_BREAKING_VASE_PENALTY
+ : FEAR_OF_BREAKING_VASE_PENALTY;
+ gameState.attackerScore += scoreDelta;
+
+ const result = {
+ triggered: true,
+ triggerType,
+ round: completedRound,
+ winnerPlayerIndex: winnerIndex,
+ winnerPlayerId: winnerPlay.playerId,
+ winnerPlayerName: this.room.findPlayerById(winnerPlay.playerId)?.name || '未知玩家',
+ vesselPlayerId: vesselPlayer?.id || null,
+ vesselPlayerName: vesselPlayer?.name || '未知玩家',
+ pairCount: leaderHurtVessel ? protectedCards.pairCount : 0,
+ jokerCount: leaderHurtVessel ? protectedCards.jokerCount : 0,
+ winnerIsAttacker,
+ penalizedSide: winnerIsAttacker ? 'attacker' : 'dealer',
+ penalty: FEAR_OF_BREAKING_VASE_PENALTY,
+ scoreDelta,
+ attackerScore: gameState.attackerScore
+ };
+ logger.info(
+ `房间 ${this.room.id} 投鼠忌器:${result.winnerPlayerName}一方误伤队友` +
+ `${result.vesselPlayerName},失去${result.penalty}分;` +
+ `闲家总分变化${scoreDelta > 0 ? '+' : ''}${scoreDelta}至${gameState.attackerScore}`
+ );
+ return result;
+ }
+
+ hasPendingDestroyDykeDecision() {
+ return Boolean(this.room.gameState.destroyDykeDecision);
+ }
+
+ assertDestroyDykeDecisionComplete() {
+ const pending = this.room.gameState.destroyDykeDecision;
+ if (!pending) return;
+ throw new Error(`毁堤淹田:请等待${pending.dealerPlayerName || '庄家'}决定是否发动`);
+ }
+
+ getDestroyDykePublicDecision() {
+ const pending = this.room.gameState.destroyDykeDecision;
+ if (!pending) return null;
+ return {
+ round: pending.round,
+ dealerPlayerId: pending.dealerPlayerId,
+ dealerPlayerName: pending.dealerPlayerName,
+ winnerPlayerId: pending.winnerPlayerId,
+ winnerPlayerName: pending.winnerPlayerName,
+ roundPoints: pending.roundPoints
+ };
+ }
+
+ /** 闲家赢得有分的一轮后,暂存第四手,等庄家在实际计分前决定是否发动。 */
+ holdDestroyDykeRoundForDecision({
+ requestingPlayerId,
+ turnPlayerId,
+ playerIndex,
+ cardIds
+ }) {
+ const { gameState } = this.room;
+ if (
+ !isDestroyDykeFloodFieldsRule(gameState.selectedRule)
+ || this.isFinalizingDestroyDykeRound
+ || gameState.destroyDykeUsed
+ || gameState.destroyDykeDisaster
+ || gameState.destroyDykeDecision
+ ) {
+ return null;
+ }
+
+ const winnerIndex = gameState.currentWinnerIndex;
+ const dealerIndex = this.room.getPlayerIndex(gameState.buryingPlayerId);
+ if (!this.isAttackerPlayerIndex(winnerIndex, dealerIndex)) return null;
+
+ const roundCards = gameState.currentRoundPlays.flatMap(play => play.cards || []);
+ const roundPoints = calculateRoundPoints(
+ roundCards,
+ card => this.getRuleCardPoints(card)
+ );
+
+ const deferredRoundPlay = gameState.currentRoundPlays.at(-1);
+ const deferredHistoryPlay = gameState.playHistory.at(-1);
+ if (
+ deferredRoundPlay?.playerId !== turnPlayerId
+ || deferredHistoryPlay?.playerId !== turnPlayerId
+ ) {
+ throw new Error('毁堤淹田轮末状态异常');
+ }
+
+ const dealer = this.room.findPlayerByIndex(dealerIndex);
+ const winner = this.room.findPlayerByIndex(winnerIndex);
+ gameState.currentRoundPlays.pop();
+ gameState.playHistory.pop();
+ gameState.playersPlayedThisRound.delete(playerIndex);
+ gameState.currentPlayerIndex = null;
+ gameState.trumpAction = null;
+ this.recomputeRoundWinner();
+
+ gameState.destroyDykeDecision = {
+ round: gameState.currentRound,
+ dealerPlayerId: dealer.id,
+ dealerPlayerName: dealer.name,
+ winnerPlayerId: winner.id,
+ winnerPlayerName: winner.name,
+ roundPoints,
+ deferredFinalPlay: {
+ requestingPlayerId,
+ turnPlayerId,
+ playerIndex,
+ cardIds: [...cardIds],
+ cards: deferredRoundPlay.originalCards || deferredRoundPlay.cards
+ }
+ };
+ return this.getDestroyDykePublicDecision();
+ }
+
+ selectDestroyDykeForBot() {
+ const pending = this.room.gameState.destroyDykeDecision;
+ if (!pending) return false;
+ const gameWouldEnd = this.room.players.every(player => this.getPlayableCardCount(player) === 0);
+ return !gameWouldEnd && pending.roundPoints >= 10;
+ }
+
+ respondDestroyDyke(playerId, accept) {
+ const { gameState } = this.room;
+ const pending = gameState.destroyDykeDecision;
+ if (!pending) throw new Error('当前没有待处理的毁堤淹田决定');
+ if (pending.dealerPlayerId !== playerId) throw new Error('只有庄家可以发动毁堤淹田');
+
+ const deferred = pending.deferredFinalPlay;
+ const accepted = accept === true;
+ gameState.destroyDykeDecision = null;
+ if (accepted) {
+ gameState.destroyDykeUsed = true;
+ gameState.destroyDykeDisaster = {
+ triggerRound: pending.round,
+ voidedPoints: pending.roundPoints,
+ roundsElapsed: 0,
+ disasterAttackerPoints: 0
+ };
+ gameState.destroyDykeLastResult = {
+ status: 'activated',
+ round: pending.round,
+ voidedPoints: pending.roundPoints
+ };
+ } else {
+ gameState.destroyDykeLastResult = {
+ status: 'declined',
+ round: pending.round,
+ roundPoints: pending.roundPoints
+ };
+ }
+ this.io.to(this.room.id).emit(
+ accepted ? 'destroy_dyke_activated' : 'destroy_dyke_declined',
+ {
+ accepted,
+ round: pending.round,
+ dealerPlayerId: pending.dealerPlayerId,
+ dealerPlayerName: pending.dealerPlayerName,
+ voidedPoints: accepted ? pending.roundPoints : 0,
+ roundPoints: pending.roundPoints
+ }
+ );
+
+ const finalPlayer = this.room.findPlayerById(deferred.turnPlayerId);
+ gameState.currentPlayerIndex = deferred.playerIndex;
+ deferred.cards.forEach(card => {
+ if (!finalPlayer.cards.some(held => held.id === card.id)) finalPlayer.addCard(card);
+ });
+ finalPlayer.cards = DeckService.autoSortCards(finalPlayer.cards);
+
+ let roundResult;
+ this.isFinalizingDestroyDykeRound = true;
+ try {
+ roundResult = this.playCards(
+ deferred.requestingPlayerId,
+ deferred.cardIds,
+ deferred.requestingPlayerId === deferred.turnPlayerId ? null : deferred.turnPlayerId
+ );
+ } finally {
+ this.isFinalizingDestroyDykeRound = false;
+ }
+
+ return {
+ accepted,
+ round: pending.round,
+ dealerPlayerId: pending.dealerPlayerId,
+ dealerPlayerName: pending.dealerPlayerName,
+ voidedPoints: accepted ? pending.roundPoints : 0,
+ roundPoints: pending.roundPoints,
+ roundResult
+ };
+ }
+
+ resolveDestroyDykeIncident(reason, round = this.room.gameState.currentRound) {
+ const { gameState } = this.room;
+ const disaster = gameState.destroyDykeDisaster;
+ if (!disaster) return null;
+ const returnedPoints = disaster.voidedPoints;
+ const incidentBonus = 20;
+ const scoreDelta = returnedPoints + incidentBonus;
+ gameState.attackerScore += scoreDelta;
+ const result = {
+ status: 'incident',
+ reason,
+ round,
+ ...disaster,
+ returnedPoints,
+ incidentBonus,
+ scoreDelta,
+ attackerScore: gameState.attackerScore
+ };
+ gameState.destroyDykeDisaster = null;
+ gameState.destroyDykeLastResult = result;
+ this.io.to(this.room.id).emit('destroy_dyke_disaster_resolved', result);
+ logger.info(
+ `房间 ${this.room.id} 毁堤淹田事发:返还 ${returnedPoints} 分并额外增加20分,` +
+ `闲家总分 ${gameState.attackerScore}`
+ );
+ return result;
+ }
+
+ advanceDestroyDykeDisasterAtRoundEnd({
+ round,
+ winnerIsAttacker,
+ roundPoints,
+ gameEnding = false
+ }) {
+ const { gameState } = this.room;
+ const disaster = gameState.destroyDykeDisaster;
+ if (!disaster) return null;
+ if (round === disaster.triggerRound) {
+ return { status: 'activated', ...disaster };
+ }
+ if (round < disaster.triggerRound) return null;
+
+ disaster.roundsElapsed += 1;
+ if (winnerIsAttacker) disaster.disasterAttackerPoints += roundPoints;
+ if (disaster.disasterAttackerPoints >= 20) {
+ return this.resolveDestroyDykeIncident('attacker_reached_20', round);
+ }
+ if (disaster.roundsElapsed >= 3) {
+ // 末轮恰好也是第三个灾期轮时,闲家拿底仍然算“事发”,不能先让灾期自然失效。
+ if (gameEnding && winnerIsAttacker) {
+ return this.resolveDestroyDykeIncident('attacker_won_bottom', round);
+ }
+ const result = {
+ status: 'expired',
+ reason: 'three_rounds_completed',
+ round,
+ ...disaster,
+ attackerScore: gameState.attackerScore
+ };
+ gameState.destroyDykeDisaster = null;
+ gameState.destroyDykeLastResult = result;
+ this.io.to(this.room.id).emit('destroy_dyke_disaster_resolved', result);
+ logger.info(
+ `房间 ${this.room.id} 毁堤淹田灾期结束:闲家三轮仅得 ` +
+ `${result.disasterAttackerPoints} 分,作废的 ${result.voidedPoints} 分永久作废`
+ );
+ return result;
+ }
+ const result = { status: 'active', round, ...disaster };
+ this.io.to(this.room.id).emit('destroy_dyke_disaster_updated', result);
+ return result;
+ }
+
+ updateRecordOnFileAtRoundEnd({
+ completedRound,
+ hasPointCards,
+ hasLevelOrJoker,
+ hasNextRound
+ }) {
+ const gameState = this.room.gameState;
+ if (!isRecordOnFileRule(gameState.selectedRule)) return null;
+
+ const wasActive = gameState.recordOnFileActiveRound === completedRound;
+ const nextActiveRound = (hasPointCards || hasLevelOrJoker) && hasNextRound
+ ? completedRound + 1
+ : null;
+ gameState.recordOnFileLastActiveRound = wasActive ? completedRound : null;
+ gameState.recordOnFileActiveRound = nextActiveRound;
+
+ return {
+ completedRound,
+ wasActive,
+ hadPointCards: Boolean(hasPointCards),
+ hadLevelOrJoker: Boolean(hasLevelOrJoker),
+ nextActiveRound
+ };
+ }
+
+ hasPendingNinePrincesDecision() {
+ return Boolean(this.room.gameState.ninePrincesDecision);
+ }
+
+ assertNinePrincesDecisionComplete() {
+ const decision = this.room.gameState.ninePrincesDecision;
+ if (!decision) return;
+ throw new Error(`请等待${decision.playerName || '本轮赢家'}完成九子夺嫡`);
+ }
+
+ getNinePrincesEligibleCards(player) {
+ const { trumpSuit, trumpRank } = this.room.gameState;
+ return (player?.cards || []).filter(card => {
+ const promotedFace = shiftStrengthCompensationCardFace(
+ card,
+ trumpSuit,
+ trumpRank,
+ 1
+ );
+ return promotedFace.suit !== card.suit || promotedFace.rank !== card.rank;
+ });
+ }
+
+ getNinePrincesPrivateDecision(
+ decision = this.room.gameState.ninePrincesDecision
+ ) {
+ if (!decision) return null;
+ const player = this.room.findPlayerById(decision.playerId);
+ const eligibleCardIdSet = new Set(decision.eligibleCardIds || []);
+ const candidates = this.getNinePrincesEligibleCards(player)
+ .filter(card => eligibleCardIdSet.has(card.id))
+ .map(card => ({
+ card: card.toJSON ? card.toJSON() : card,
+ promotedFace: shiftStrengthCompensationCardFace(
+ card,
+ this.room.gameState.trumpSuit,
+ this.room.gameState.trumpRank,
+ 1
+ )
+ }));
+ return {
+ decisionId: decision.decisionId,
+ round: decision.round,
+ playerId: decision.playerId,
+ playerName: decision.playerName,
+ candidates
+ };
+ }
+
+ prepareNinePrincesDecisionAtRoundEnd({
+ winner,
+ completedRound,
+ roundPlays
+ }) {
+ const { gameState } = this.room;
+ if (
+ !isNinePrincesSuccessionRule(gameState.selectedRule)
+ || gameState.ninePrincesResolved
+ || gameState.ninePrincesDecision
+ || !winner
+ ) {
+ return null;
+ }
+
+ const winnerTeamIndex = this.getPlayerTeamIndex(winner.id);
+ const capturedOpponentPointCard = (roundPlays || []).some(play => (
+ this.getPlayerTeamIndex(play.playerId) !== winnerTeamIndex
+ && (play.originalCards || play.cards || []).some(
+ card => this.getRuleCardPoints(card) > 0
+ )
+ ));
+ if (!capturedOpponentPointCard) return null;
+
+ const eligibleCards = this.getNinePrincesEligibleCards(winner);
+ if (eligibleCards.length === 0) return null;
+
+ const decision = {
+ decisionId: `nine-princes-${completedRound}-${winner.id}`,
+ round: completedRound,
+ playerId: winner.id,
+ playerName: winner.name,
+ eligibleCardIds: eligibleCards.map(card => card.id)
+ };
+ gameState.ninePrincesDecision = decision;
+ logger.info(
+ `房间 ${this.room.id} 九子夺嫡:第${completedRound}轮赢家 ${winner.name} ` +
+ `收下了对方分牌,等待选择一张手牌晋升`
+ );
+ return {
+ decisionId: decision.decisionId,
+ round: decision.round,
+ playerId: decision.playerId,
+ playerName: decision.playerName,
+ pending: true
+ };
+ }
+
+ chooseNinePrincesBotCard(player, decision) {
+ const eligibleCardIdSet = new Set(decision?.eligibleCardIds || []);
+ const candidates = this.getNinePrincesEligibleCards(player)
+ .filter(card => eligibleCardIdSet.has(card.id));
+ if (candidates.length === 0) return null;
+ const { trumpSuit, trumpRank, selectedRule } = this.room.gameState;
+ return [...candidates].sort((left, right) => {
+ const leftFace = shiftStrengthCompensationCardFace(left, trumpSuit, trumpRank, 1);
+ const rightFace = shiftStrengthCompensationCardFace(right, trumpSuit, trumpRank, 1);
+ const leftBecomesWhite = left.rank !== Ranks.WHITE_JOKER
+ && leftFace.rank === Ranks.WHITE_JOKER;
+ const rightBecomesWhite = right.rank !== Ranks.WHITE_JOKER
+ && rightFace.rank === Ranks.WHITE_JOKER;
+ if (leftBecomesWhite !== rightBecomesWhite) return leftBecomesWhite ? -1 : 1;
+ return getCardStrength(
+ { ...right, ...rightFace },
+ trumpSuit,
+ trumpRank,
+ selectedRule
+ ) - getCardStrength(
+ { ...left, ...leftFace },
+ trumpSuit,
+ trumpRank,
+ selectedRule
+ );
+ })[0];
+ }
+
+ beginNinePrincesDecision() {
+ const decision = this.room.gameState.ninePrincesDecision;
+ if (!decision) return null;
+ if (this.hasPendingTimeReversalDecision()) {
+ return {
+ decisionId: decision.decisionId,
+ round: decision.round,
+ playerId: decision.playerId,
+ playerName: decision.playerName,
+ pending: true,
+ deferred: true
+ };
+ }
+
+ const player = this.room.findPlayerById(decision.playerId);
+ if (!player) {
+ this.room.gameState.ninePrincesDecision = null;
+ return null;
+ }
+ if (player.isBot) {
+ const card = this.chooseNinePrincesBotCard(player, decision);
+ const result = this.respondNinePrincesDecision(player.id, card?.id || null, {
+ automatic: true
+ });
+ const { privateResult: _privateResult, ...publicResult } = result;
+ return publicResult;
+ }
+
+ const payload = this.getNinePrincesPrivateDecision(decision);
+ if (player.socketId) {
+ this.io.to(player.socketId).emit('nine_princes_selection_required', payload);
+ }
+ this.io.to(this.room.id).emit('nine_princes_decision_pending', {
+ decisionId: decision.decisionId,
+ round: decision.round,
+ playerId: decision.playerId,
+ playerName: decision.playerName
+ });
+ return {
+ decisionId: decision.decisionId,
+ round: decision.round,
+ playerId: decision.playerId,
+ playerName: decision.playerName,
+ pending: true
+ };
+ }
+
+ respondNinePrincesDecision(playerId, cardId = null, { automatic = false } = {}) {
+ const { gameState } = this.room;
+ const decision = gameState.ninePrincesDecision;
+ if (!decision || decision.playerId !== playerId) {
+ throw new Error('当前没有等待你的九子夺嫡选择');
+ }
+ if (this.hasPendingTimeReversalDecision()) {
+ throw new Error('请先等待时间倒流窗口结束');
+ }
+ const player = this.room.findPlayerById(playerId);
+ if (!player) throw new Error('玩家不存在');
+
+ let promoted = false;
+ let becameWhite = false;
+ let scoreDelta = 0;
+ let privateResult = null;
+ if (cardId) {
+ if (!decision.eligibleCardIds.includes(cardId)) {
+ throw new Error('这张牌不能用于九子夺嫡');
+ }
+ const card = player.cards.find(candidate => candidate.id === cardId);
+ if (!card) throw new Error('所选手牌已不存在');
+ const previousFace = { suit: card.suit, rank: card.rank };
+ const promotedFace = shiftStrengthCompensationCardFace(
+ card,
+ gameState.trumpSuit,
+ gameState.trumpRank,
+ 1
+ );
+ if (
+ promotedFace.suit === previousFace.suit
+ && promotedFace.rank === previousFace.rank
+ ) {
+ throw new Error('这张牌已经无法继续提升');
+ }
+
+ card.ninePrincesScoringSuit = card.ninePrincesScoringSuit
+ || card.originalSuit
+ || card.suit;
+ card.ninePrincesScoringRank = card.ninePrincesScoringRank
+ || card.originalRank
+ || card.rank;
+ card.suit = promotedFace.suit;
+ card.rank = promotedFace.rank;
+ card.isNinePrincesPromoted = true;
+ card.ninePrincesPromotionCount = (Number(card.ninePrincesPromotionCount) || 0) + 1;
+ card.ninePrincesPermanentSuit = promotedFace.suit;
+ card.ninePrincesPermanentRank = promotedFace.rank;
+ card.value = card.calculateValue();
+ player.cards = DeckService.autoSortCards(player.cards);
+ promoted = true;
+ becameWhite = previousFace.rank !== Ranks.WHITE_JOKER
+ && promotedFace.rank === Ranks.WHITE_JOKER;
+
+ if (becameWhite) {
+ const dealer = this.room.findPlayerById(gameState.buryingPlayerId);
+ const winnerTeamIndex = this.getPlayerTeamIndex(player.id);
+ const dealerTeamIndex = this.getPlayerTeamIndex(dealer?.id);
+ scoreDelta = winnerTeamIndex === dealerTeamIndex ? -10 : 10;
+ gameState.attackerScore += scoreDelta;
+ gameState.ninePrincesResolved = true;
+ }
+
+ privateResult = {
+ decisionId: decision.decisionId,
+ round: decision.round,
+ previousFace,
+ promotedFace,
+ becameWhite,
+ teamBonus: becameWhite ? 10 : 0,
+ cards: player.cards.map(candidate => candidate.toJSON())
+ };
+ if (player.socketId) {
+ this.io.to(player.socketId).emit('nine_princes_hand_updated', privateResult);
+ }
+ this.updateRuleHandVisibilityAfterCardChange(player);
+ }
+
+ const publicResult = {
+ decisionId: decision.decisionId,
+ round: decision.round,
+ playerId: player.id,
+ playerName: player.name,
+ promoted,
+ skipped: !promoted,
+ becameWhite,
+ teamBonus: becameWhite ? 10 : 0,
+ attackerScoreDelta: scoreDelta,
+ attackerScore: gameState.attackerScore,
+ ruleResolved: gameState.ninePrincesResolved,
+ automatic: Boolean(automatic)
+ };
+ gameState.ninePrincesDecision = null;
+ gameState.ninePrincesLastResult = publicResult;
+ this.io.to(this.room.id).emit('nine_princes_resolved', publicResult);
+ logger.info(
+ `房间 ${this.room.id} 九子夺嫡:${player.name}` +
+ `${promoted ? '完成手牌晋升' : '放弃晋升'}` +
+ `${becameWhite ? ',升为白王并令所在阵营获得10分,规则终止' : ''}`
+ );
+ return {
+ ...publicResult,
+ privateResult
+ };
+ }
+
+ resolveDestroyDykeAtGameEnd() {
+ const disaster = this.room.gameState.destroyDykeDisaster;
+ if (!disaster || disaster.roundsElapsed >= 3) return null;
+ return this.resolveDestroyDykeIncident('game_ended_early');
+ }
+
+ hasPendingAmbiguousChoice() {
+ return Boolean(this.room.gameState.ambiguousRoundDecision);
+ }
-export class GameEngine {
- constructor(room, io) {
- this.room = room;
- this.io = io;
- this.drawingManager = null;
- this.roundManager = null;
+ assertAmbiguousChoiceComplete() {
+ const pending = this.room.gameState.ambiguousRoundDecision;
+ if (!pending) return;
+ const player = this.room.findPlayerById(pending.currentPlayerId);
+ throw new Error(`请等待 ${player?.name || '玩家'} 选择模棱两可的最终出牌`);
}
- /**
- * 开始游戏 - 进入准备等待阶段
- */
- startGame() {
- logger.info(`房间 ${this.room.id} 开始游戏 - 等待玩家准备`);
+ getAmbiguousChoiceRequest(playerId = null) {
+ const pending = this.room.gameState.ambiguousRoundDecision;
+ if (!pending) return null;
+ const targetPlayerId = playerId || pending.currentPlayerId;
+ const selection = pending.selections.find(item => item.playerId === targetPlayerId);
+ if (!selection) return null;
+ return {
+ round: pending.round,
+ playerId: selection.playerId,
+ playerName: selection.playerName,
+ position: selection.position,
+ usageConsumed: selection.usageConsumed,
+ options: selection.options.map(option => ({
+ index: option.index,
+ cards: option.cards.map(card => card.toJSON ? card.toJSON() : card)
+ }))
+ };
+ }
- // 重置游戏状态
- this.room.gameState.reset();
+ holdAmbiguousRoundForChoice({
+ requestingPlayerId,
+ turnPlayerId,
+ playerIndex,
+ cardIds
+ }) {
+ const { gameState } = this.room;
+ if (!isAmbiguousRule(gameState.selectedRule) || this.isFinalizingAmbiguousRound) {
+ return null;
+ }
+ const ambiguousPlays = gameState.currentRoundPlays.filter(
+ play => Array.isArray(play.ambiguousOptions) && play.ambiguousOptions.length === 2
+ );
+ if (ambiguousPlays.length === 0) return null;
+
+ const deferredRoundPlay = gameState.currentRoundPlays.at(-1);
+ const deferredHistoryPlay = gameState.playHistory.at(-1);
+ if (
+ deferredRoundPlay?.playerId !== turnPlayerId
+ || deferredHistoryPlay?.playerId !== turnPlayerId
+ ) {
+ throw new Error('模棱两可轮末状态异常');
+ }
- // 重置所有玩家的准备状态
- this.room.players.forEach(player => {
- player.isReady = false;
- });
+ gameState.currentRoundPlays.pop();
+ gameState.playHistory.pop();
+ gameState.playersPlayedThisRound.delete(playerIndex);
+ gameState.currentPlayerIndex = null;
+ gameState.trumpAction = null;
+ this.recomputeRoundWinner();
+
+ const selections = ambiguousPlays
+ .map(play => ({
+ playerId: play.playerId,
+ playerName: this.room.findPlayerById(play.playerId)?.name || '未知玩家',
+ position: play.roundPosition,
+ usageConsumed: Boolean(play.ambiguousUsageConsumed),
+ selectedOptionIndex: null,
+ options: play.ambiguousOptions
+ }))
+ .sort((a, b) => b.position - a.position);
+ const queuePlayerIds = selections.map(selection => selection.playerId);
+ gameState.ambiguousRoundDecision = {
+ round: gameState.currentRound,
+ currentPlayerId: queuePlayerIds[0],
+ queuePlayerIds,
+ selections,
+ deferredFinalPlay: {
+ requestingPlayerId,
+ turnPlayerId,
+ playerIndex,
+ cardIds: [...cardIds],
+ cards: deferredRoundPlay.originalCards || deferredRoundPlay.cards
+ }
+ };
- // 标记为等待准备状态(保持在WAITING阶段)
- this.room.gameState.isWaitingForReady = true;
+ return {
+ round: gameState.currentRound,
+ currentPlayerId: queuePlayerIds[0],
+ queuePlayerIds: [...queuePlayerIds],
+ selections: selections.map(selection => ({
+ playerId: selection.playerId,
+ playerName: selection.playerName,
+ position: selection.position,
+ usageConsumed: selection.usageConsumed,
+ options: selection.options.map(option => ({
+ index: option.index,
+ cards: option.cards.map(card => card.toJSON ? card.toJSON() : card)
+ }))
+ })),
+ currentRequest: this.getAmbiguousChoiceRequest(queuePlayerIds[0])
+ };
+ }
- // Bot自动准备
- this.room.players.filter(p => p.isBot).forEach(bot => {
- bot.isReady = true;
- });
+ resolveAmbiguousChoice(playerId, optionIndex) {
+ const { gameState } = this.room;
+ const pending = gameState.ambiguousRoundDecision;
+ if (!pending || pending.currentPlayerId !== playerId) {
+ throw new Error('当前没有轮到你选择模棱两可的最终出牌');
+ }
+ const normalizedOptionIndex = Number(optionIndex);
+ if (![0, 1].includes(normalizedOptionIndex)) {
+ throw new Error('请选择方案A或方案B');
+ }
+ const selection = pending.selections.find(item => item.playerId === playerId);
+ const play = gameState.currentRoundPlays.find(item => item.playerId === playerId);
+ const selectedOption = selection?.options.find(
+ option => option.index === normalizedOptionIndex
+ );
+ if (!selection || !play || !selectedOption) {
+ throw new Error('模棱两可的出牌方案不存在');
+ }
- // 广播进入准备等待状态
- this.io.to(this.room.id).emit('game_started', {
- phase: GamePhases.WAITING,
- isWaitingForReady: true,
- config: this.room.config
+ const player = this.room.findPlayerById(playerId);
+ const primaryCards = play.originalCards || play.cards;
+ if (normalizedOptionIndex === 1) {
+ const primaryIds = new Set(primaryCards.map(card => card.id));
+ const selectedIds = new Set(selectedOption.cards.map(card => card.id));
+ primaryCards.forEach(card => {
+ if (!selectedIds.has(card.id) && !player.cards.some(held => held.id === card.id)) {
+ player.addCard(card);
+ }
+ });
+ player.cards = player.cards.filter(card => (
+ !selectedIds.has(card.id) || primaryIds.has(card.id)
+ ));
+ player.cards = DeckService.autoSortCards(player.cards);
+ }
+
+ play.cards = selectedOption.cards;
+ play.originalCards = selectedOption.cards;
+ play.comparisonCards = selectedOption.cards;
+ play.pattern = selectedOption.pattern;
+ play.comparisonPattern = selectedOption.pattern;
+ play.ambiguousSelectedOptionIndex = normalizedOptionIndex;
+ const historyPlay = [...gameState.playHistory]
+ .reverse()
+ .find(item => item.playerId === playerId && item.activeSkillId === ActiveSkillIds.AMBIGUOUS);
+ if (historyPlay) {
+ historyPlay.cards = selectedOption.cards.map(card => card.toJSON ? card.toJSON() : card);
+ historyPlay.ambiguousSelectedOptionIndex = normalizedOptionIndex;
+ }
+ selection.selectedOptionIndex = normalizedOptionIndex;
+ this.recomputeRoundWinner();
+
+ pending.queuePlayerIds.shift();
+ pending.currentPlayerId = pending.queuePlayerIds[0] || null;
+ const choice = {
+ round: pending.round,
+ playerId,
+ playerName: selection.playerName,
+ position: selection.position,
+ optionIndex: normalizedOptionIndex,
+ cards: selectedOption.cards.map(card => card.toJSON ? card.toJSON() : card),
+ usageConsumed: selection.usageConsumed
+ };
+ const handCards = player.cards.map(card => card.toJSON ? card.toJSON() : card);
+ if (pending.currentPlayerId) {
+ return {
+ completed: false,
+ choice,
+ handCards,
+ nextDecision: this.getAmbiguousChoiceRequest(pending.currentPlayerId)
+ };
+ }
+
+ const resolution = {
+ round: pending.round,
+ selections: pending.selections.map(item => {
+ const option = item.options.find(candidate => candidate.index === item.selectedOptionIndex);
+ return {
+ playerId: item.playerId,
+ playerName: item.playerName,
+ position: item.position,
+ optionIndex: item.selectedOptionIndex,
+ usageConsumed: item.usageConsumed,
+ cards: option.cards.map(card => card.toJSON ? card.toJSON() : card)
+ };
+ })
+ };
+ const deferred = pending.deferredFinalPlay;
+ gameState.ambiguousRoundDecision = null;
+ gameState.currentPlayerIndex = deferred.playerIndex;
+ deferred.cards.forEach(card => {
+ if (!this.room.findPlayerById(deferred.turnPlayerId).cards.some(held => held.id === card.id)) {
+ this.room.findPlayerById(deferred.turnPlayerId).addCard(card);
+ }
});
+ this.room.findPlayerById(deferred.turnPlayerId).cards = DeckService.autoSortCards(
+ this.room.findPlayerById(deferred.turnPlayerId).cards
+ );
+
+ let roundResult;
+ this.isFinalizingAmbiguousRound = true;
+ try {
+ roundResult = this.playCards(
+ deferred.requestingPlayerId,
+ deferred.cardIds,
+ deferred.requestingPlayerId === deferred.turnPlayerId ? null : deferred.turnPlayerId
+ );
+ } finally {
+ this.isFinalizingAmbiguousRound = false;
+ }
+ roundResult.ambiguousResolution = resolution;
+ return {
+ completed: true,
+ choice,
+ handCards,
+ resolution,
+ roundResult
+ };
+ }
+
+ getThreeTigersPlaySuit(cards = []) {
+ if (!Array.isArray(cards) || cards.length === 0) return null;
+ const suit = cards[0]?.suit;
+ if (!THREE_TIGERS_SUITS.includes(suit)) return null;
+ const { trumpSuit, trumpRank } = this.room.gameState;
+ return cards.every(card => (
+ card?.suit === suit && !isTrumpCard(card, trumpSuit, trumpRank)
+ )) ? suit : null;
}
/**
- * 玩家准备/取消准备
+ * 每次落牌后重新统计本轮各副花色的“人头”。第三名玩家落下同一副花色的整手副牌时,
+ * 当场把该花色已经打出的牌和之后的牌统一降四级,并按主牌重新计算赢家。
+ * 主花色牌、级牌和王不计人数,也不参与转换。
+ * comparisonCards 只用于显示和胜负;play.cards 始终保留实体牌面供计分使用。
*/
- playerReady(playerId) {
- if (!this.room.gameState.isWaitingForReady) {
- throw new Error('当前不在准备等待阶段');
+ applyThreeTigersAfterPlay(triggeredByPlayerId = null) {
+ const { gameState } = this.room;
+ if (!isThreeTigersRule(gameState.selectedRule)) return null;
+
+ const plays = gameState.currentRoundPlays;
+ const previousState = gameState.threeTigersRoundState;
+ const suitContributors = Object.fromEntries(
+ THREE_TIGERS_SUITS.map(suit => [suit, []])
+ );
+ for (const play of plays) {
+ const suit = this.getThreeTigersPlaySuit(play.cards);
+ if (suit) suitContributors[suit].push(play.playerId);
+ }
+ const suitCounts = Object.fromEntries(
+ THREE_TIGERS_SUITS.map(suit => [suit, suitContributors[suit].length])
+ );
+
+ let triggeredSuit = previousState?.triggeredSuit || null;
+ if (triggeredSuit && suitCounts[triggeredSuit] < 3) triggeredSuit = null;
+ if (!triggeredSuit) {
+ triggeredSuit = THREE_TIGERS_SUITS.find(suit => suitCounts[suit] >= 3) || null;
}
- const player = this.room.findPlayerById(playerId);
- if (!player) {
- throw new Error('玩家不存在');
+ const wasActive = Boolean(previousState?.triggeredSuit);
+ const isActive = Boolean(triggeredSuit);
+ const triggeredNow = isActive && !wasActive;
+ const reverted = wasActive && !isActive;
+
+ for (const play of plays) {
+ play.comparisonCards = isActive
+ ? transformThreeTigersCards(
+ play.cards,
+ triggeredSuit,
+ gameState.trumpRank,
+ gameState.trumpSuit
+ )
+ : play.cards;
+ play.comparisonPattern = detectPattern(
+ play.comparisonCards,
+ gameState.trumpSuit,
+ gameState.trumpRank,
+ this.getRuleRuntimeContext()
+ );
}
+ const winner = this.recomputeRoundWinner();
+
+ const state = {
+ round: gameState.currentRound,
+ triggeredSuit,
+ triggeredAtPlayCount: isActive
+ ? (previousState?.triggeredAtPlayCount || plays.length)
+ : null,
+ triggeredByPlayerId: isActive
+ ? (previousState?.triggeredByPlayerId || triggeredByPlayerId)
+ : null,
+ contributingPlayerIds: isActive ? [...suitContributors[triggeredSuit]] : [],
+ suitCounts
+ };
+ gameState.threeTigersRoundState = state;
- if (player.isBot) {
- throw new Error('Bot无需准备');
+ return {
+ ...state,
+ active: isActive,
+ triggeredNow,
+ reverted,
+ plays: plays.map(play => ({
+ playerId: play.playerId,
+ playerName: this.room.findPlayerById(play.playerId)?.name || '未知玩家',
+ cards: (play.comparisonCards || play.cards).map(
+ card => card?.toJSON ? card.toJSON() : card
+ )
+ })),
+ currentWinningPlayerId: winner?.playerId || null
+ };
+ }
+
+ applyMagicTrickAtRoundEnd() {
+ const { gameState } = this.room;
+ const selection = gameState.magicTrickSelection;
+ if (!isMagicTrickRule(gameState.selectedRule) || !selection) return null;
+ if (selection.round !== gameState.currentRound) {
+ gameState.magicTrickSelection = null;
+ return null;
}
- // 切换准备状态
- player.isReady = !player.isReady;
- logger.info(`房间 ${this.room.id} 玩家 ${player.name} ${player.isReady ? '已准备' : '取消准备'}`);
+ const [firstTargetId, secondTargetId] = selection.targetPlayerIds;
+ const firstIndex = gameState.currentRoundPlays.findIndex(play => play.playerId === firstTargetId);
+ const secondIndex = gameState.currentRoundPlays.findIndex(play => play.playerId === secondTargetId);
+ if (firstIndex < 0 || secondIndex < 0) {
+ gameState.magicTrickSelection = null;
+ return null;
+ }
- // 检查是否所有真人玩家都准备好了
- const humanPlayers = this.room.players.filter(p => !p.isBot);
- const allReady = humanPlayers.every(p => p.isReady);
+ const firstPlay = gameState.currentRoundPlays[firstIndex];
+ const secondPlay = gameState.currentRoundPlays[secondIndex];
+ const keepSeat = (play, payload) => ({
+ ...payload,
+ playerIndex: play.playerIndex,
+ playerId: play.playerId
+ });
+ gameState.currentRoundPlays[firstIndex] = keepSeat(firstPlay, secondPlay);
+ gameState.currentRoundPlays[secondIndex] = keepSeat(secondPlay, firstPlay);
+ const winner = this.recomputeRoundWinner();
+ this.recordActiveSkillUse(selection.playerId, ActiveSkillIds.MAGIC_TRICK);
+ gameState.magicTrickSelection = null;
+
+ const activator = this.room.findPlayerById(selection.playerId);
+ const result = {
+ triggered: true,
+ round: selection.round,
+ activatorPlayerId: selection.playerId,
+ activatorPlayerName: activator?.name || '未知玩家',
+ targetPlayerIds: [firstTargetId, secondTargetId],
+ targetPlayerNames: [firstPlay, secondPlay].map(play => (
+ this.room.findPlayerById(play.playerId)?.name || '未知玩家'
+ )),
+ winnerPlayerId: winner?.playerId || null,
+ plays: gameState.currentRoundPlays.map(play => ({
+ playerId: play.playerId,
+ playerName: this.room.findPlayerById(play.playerId)?.name || '未知玩家',
+ cards: play.cards.map(card => card.toJSON ? card.toJSON() : card)
+ }))
+ };
+ logger.info(
+ `房间 ${this.room.id} 魔术戏法揭晓:交换 ${result.targetPlayerNames.join(' 与 ')} 的结算出牌`
+ );
+ return result;
+ }
- if (allReady) {
- logger.info(`房间 ${this.room.id} 所有玩家准备完毕,开始发牌`);
- // 清除准备等待状态
- this.room.gameState.isWaitingForReady = false;
- this.startDrawing();
+ applyAveragePoolingAtRoundEnd() {
+ const { gameState } = this.room;
+ if (!isAveragePoolingRule(gameState.selectedRule)) return null;
+ const supportedTypes = new Set([PatternTypes.SINGLE, PatternTypes.PAIR]);
+ const leadingPattern = gameState.leadingPattern;
+ const pooledTeams = [];
+ const leadingPlay = gameState.currentRoundPlays[0];
+ const validRuffPlays = leadingPattern?.suit === 'trump'
+ ? []
+ : gameState.currentRoundPlays.slice(1).filter(play => {
+ if (play.pattern?.suit !== 'trump') return false;
+ if (leadingPattern?.type !== PatternTypes.THROW) {
+ return play.pattern?.type === leadingPattern?.type
+ && play.pattern?.length === leadingPattern?.length;
+ }
+ return compareCards(
+ { cards: play.cards, pattern: play.pattern, playerIndex: play.playerIndex },
+ {
+ cards: leadingPlay.cards,
+ pattern: leadingPlay.pattern,
+ playerIndex: leadingPlay.playerIndex
+ },
+ leadingPattern.suit,
+ gameState.trumpSuit,
+ gameState.trumpRank,
+ this.getRuleRuntimeContext(),
+ leadingPattern
+ ) > 0;
+ });
+
+ if (validRuffPlays.length > 0) {
+ // Complete legal ruffs form a higher tier than pooling. Resolve them at
+ // raw trump strength and do not emit pooling metadata/badges for a result
+ // that never participated in deciding the trick.
+ const winner = this.recomputeRoundWinner(validRuffPlays);
+ return {
+ triggered: false,
+ teams: [],
+ exclusiveTeam: null,
+ ruffPriority: true,
+ ruffPlayerIds: validRuffPlays.map(play => play.playerId),
+ winnerPlayerId: winner?.playerId || null
+ };
+ }
+ const isCompetitiveSuit = play => play.pattern?.suit === leadingPattern?.suit || (
+ leadingPattern?.suit !== 'trump' && play.pattern?.suit === 'trump'
+ );
+ const leadingSingleChannels = leadingPattern?.type === PatternTypes.THROW
+ && Array.isArray(leadingPattern.components)
+ && leadingPattern.components.length > 0
+ && leadingPattern.components.every(component => component.type === PatternTypes.SINGLE)
+ && leadingPattern.components.reduce(
+ (total, component) => total + (component.length ?? component.cards?.length ?? 1),
+ 0
+ ) === leadingPattern.length;
+
+ if (leadingSingleChannels) {
+ // A throw made only of singles creates one pooling channel per card. For
+ // this rule only, a pair (or another stronger grouping) may be split
+ // downward into single channels. This does not change ordinary throw
+ // comparison: outside average pooling, a pair still cannot beat AK.
+ for (const parity of [0, 1]) {
+ const teamPlays = gameState.currentRoundPlays.filter(
+ play => play.playerIndex % 2 === parity
+ );
+ if (teamPlays.length !== 2 || teamPlays.some(play => !isCompetitiveSuit(play))) continue;
+
+ const channelStrengths = teamPlays.map(play => play.cards
+ .map(card => getCardStrength(
+ card,
+ gameState.trumpSuit,
+ gameState.trumpRank,
+ this.getRuleRuntimeContext()
+ ))
+ .sort((left, right) => right - left));
+ if (channelStrengths.some(strengths => strengths.length !== leadingPattern.length)) continue;
+
+ const averageStrengths = channelStrengths[0].map((strength, index) =>
+ (strength + channelStrengths[1][index]) / 2
+ );
+ const averageStrength = Math.max(...averageStrengths);
+ teamPlays.forEach((play, playIndex) => {
+ play.comparisonPattern = {
+ ...(play.comparisonPattern || play.pattern),
+ type: PatternTypes.THROW,
+ components: averageStrengths.map(strength => ({
+ type: PatternTypes.SINGLE,
+ cards: [],
+ length: 1,
+ strength
+ })),
+ strength: averageStrength
+ };
+ play.averagePooling = {
+ originalStrengths: channelStrengths[playIndex],
+ averageStrengths,
+ averageStrength
+ };
+ });
+ pooledTeams.push({
+ team: parity + 1,
+ playerIds: teamPlays.map(play => play.playerId),
+ originalStrengths: channelStrengths,
+ averageStrengths,
+ averageStrength,
+ channelType: PatternTypes.SINGLE
+ });
+ }
+ } else {
+ for (const parity of [0, 1]) {
+ const eligible = gameState.currentRoundPlays.filter(play => {
+ const type = play.pattern?.type;
+ if (play.playerIndex % 2 !== parity || !supportedTypes.has(type)) return false;
+ if (type !== leadingPattern?.type) return false;
+ return isCompetitiveSuit(play);
+ });
+ if (eligible.length !== 2 || eligible[0].pattern.type !== eligible[1].pattern.type) continue;
+
+ const strengths = eligible.map(play => play.pattern.strength);
+ const averageStrength = strengths.reduce((total, strength) => total + strength, 0) / strengths.length;
+ eligible.forEach(play => {
+ play.comparisonPattern = {
+ ...(play.comparisonPattern || play.pattern),
+ strength: averageStrength
+ };
+ play.averagePooling = {
+ originalStrength: play.pattern.strength,
+ averageStrength
+ };
+ });
+ pooledTeams.push({
+ team: parity + 1,
+ playerIds: eligible.map(play => play.playerId),
+ originalStrengths: strengths,
+ averageStrength
+ });
+ }
}
- return allReady;
+ let winner = null;
+ if (pooledTeams.length === 1) {
+ // Average pooling is resolved at team level first. A team whose two plays
+ // both enter the pool beats a team that cannot form a pool (for example,
+ // a pair plus two loose cards). Only then do we resolve which member of
+ // the pooled team owns the trick; equal averaged strengths keep the
+ // earlier play ahead, while a valid trump still outranks a side suit.
+ const pooledTeam = pooledTeams[0];
+ const pooledPlayerIds = new Set(pooledTeam.playerIds);
+ const pooledTeamPlays = gameState.currentRoundPlays.filter(play =>
+ pooledPlayerIds.has(play.playerId)
+ );
+ winner = this.recomputeRoundWinner(pooledTeamPlays);
+ pooledTeam.winsByPoolingAdvantage = true;
+ } else {
+ // If both teams pool, compare their averaged plays normally. If neither
+ // team pools, this simply preserves the ordinary trick result.
+ winner = this.recomputeRoundWinner();
+ }
+ return {
+ triggered: pooledTeams.length > 0,
+ teams: pooledTeams,
+ exclusiveTeam: pooledTeams.length === 1 ? pooledTeams[0].team : null,
+ ruffPriority: false,
+ ruffPlayerIds: [],
+ winnerPlayerId: winner?.playerId || null
+ };
}
- /**
- * 开始发牌
- */
- startDrawing() {
- // 创建并启动摸牌管理器
- this.drawingManager = new DrawingPhaseManager(this.room, this.io);
- this.drawingManager.start();
+ applyJointHarmonyAtRoundEnd() {
+ const { gameState } = this.room;
+ if (!isJointHarmonyRule(gameState.selectedRule)) return null;
+ const cardSignature = play => play.cards
+ .map(card => `${card.suit}:${card.rank}`)
+ .sort()
+ .join('|');
+ const matchingTeams = [];
+
+ for (const parity of [0, 1]) {
+ const teamPlays = gameState.currentRoundPlays.filter(play => play.playerIndex % 2 === parity);
+ if (teamPlays.length !== 2 || cardSignature(teamPlays[0]) !== cardSignature(teamPlays[1])) continue;
+ teamPlays.forEach(play => {
+ play.jointHarmony = true;
+ });
+ matchingTeams.push({
+ team: parity + 1,
+ playerIds: teamPlays.map(play => play.playerId),
+ laterPlayerId: teamPlays[1].playerId,
+ laterPlayerIndex: teamPlays[1].playerIndex
+ });
+ }
- // 广播开始发牌
- this.io.to(this.room.id).emit('start_drawing', {
- phase: GamePhases.DRAWING
- });
+ if (matchingTeams.length === 1) {
+ gameState.currentWinnerIndex = matchingTeams[0].laterPlayerIndex;
+ }
+ return {
+ triggered: matchingTeams.length > 0,
+ bothTeams: matchingTeams.length === 2,
+ teams: matchingTeams,
+ winnerPlayerId: matchingTeams.length === 1 ? matchingTeams[0].laterPlayerId : null
+ };
+ }
+
+ /** 返回当前座位的实际操作者;普通座位仍由自己操作。 */
+ getTurnControllerPlayer(turnPlayerId) {
+ const { openHandPlayerId, openHandControllerPlayerId } = this.room.gameState;
+ const controllerId = turnPlayerId === openHandPlayerId
+ ? openHandControllerPlayerId
+ : turnPlayerId;
+ return this.room.findPlayerById(controllerId);
}
/**
@@ -135,6 +10169,10 @@ export class GameEngine {
// 自动排序
player.cards = DeckService.autoSortCards(player.cards);
+ if (isTwoGhostsKnockDoorRule(this.room.gameState.selectedRule)) {
+ this.updateRuleHandVisibilityAfterCardChange(player);
+ }
+
logger.info(`房间 ${this.room.id} 埋底玩家: ${player.name}`);
return player;
@@ -148,16 +10186,37 @@ export class GameEngine {
throw new Error('当前不是埋底阶段');
}
- if (this.room.gameState.buryingPlayerId !== playerId) {
+ if (isPeopleCommuneRule(this.room.gameState.selectedRule)) {
+ return this.buryPeopleCommuneCards(playerId, cardIds);
+ }
+ if (isAdministrativeReviewRule(this.room.gameState.selectedRule)) {
+ return this.buryAdministrativeReviewCards(playerId, cardIds);
+ }
+
+ const activeBuryingPlayerId = this.room.gameState.secondaryBuryingPlayerId ||
+ this.room.gameState.buryingPlayerId;
+ if (activeBuryingPlayerId !== playerId) {
throw new Error('你不是埋底玩家');
}
- const requiredCount = this.room.config.bottomCardsCount;
+ const requiredCount = this.room.gameState.bottomCardsCount;
if (cardIds.length !== requiredCount) {
throw new Error(`必须埋${requiredCount}张牌`);
}
const player = this.room.findPlayerById(playerId);
+ const dealer = this.room.findPlayerById(this.room.gameState.buryingPlayerId);
+ const isSecondary = Boolean(this.room.gameState.secondaryBuryingPlayerId);
+ if (!player || !dealer) {
+ throw new Error('埋底玩家不存在');
+ }
+ if (
+ isReformAndOpeningUpRule(this.room.gameState.selectedRule) &&
+ !isSecondary &&
+ this.room.players.length !== 4
+ ) {
+ throw new Error('改革开放仅支持四人局');
+ }
// 找出要埋的牌
const cardsTobury = player.cards.filter(card => cardIds.includes(card.id));
@@ -169,28 +10228,225 @@ export class GameEngine {
player.removeCards(cardIds);
this.room.gameState.bottomCards = cardsTobury;
- logger.info(`房间 ${this.room.id} 埋底完成`);
+ if (isReformAndOpeningUpRule(this.room.gameState.selectedRule) && !isSecondary) {
+ const dealerIndex = this.room.getPlayerIndex(dealer.id);
+ const secondaryBuryingPlayer = this.room.findPlayerByIndex((dealerIndex + 2) % 4);
+ cardsTobury.forEach(card => secondaryBuryingPlayer.addCard(card));
+ secondaryBuryingPlayer.cards = DeckService.autoSortCards(secondaryBuryingPlayer.cards);
+ this.room.gameState.bottomCards = [];
+ this.room.gameState.secondaryBuryingPlayerId = secondaryBuryingPlayer.id;
+ this.room.gameState.reformAndOpeningUpTeammatePlayerId = secondaryBuryingPlayer.id;
+
+ const result = {
+ completed: false,
+ skipped: false,
+ isSecondary: false,
+ dealer,
+ secondaryBuryingPlayer,
+ transferredCards: cardsTobury
+ };
+ logger.info(`房间 ${this.room.id} 改革开放:底牌交给 ${secondaryBuryingPlayer.name} 重新埋底`);
+ this.announceBuryResult(player, result);
+ return result;
+ }
+
+ this.room.gameState.secondaryBuryingPlayerId = null;
+ logger.info(`房间 ${this.room.id} ${isSecondary ? '再埋底' : '埋底'}完成`);
// 进入出牌阶段
this.room.gameState.phase = GamePhases.PLAYING;
- // 埋底玩家自动成为首发玩家,开始第一轮
- const playerIndex = this.room.getPlayerIndex(playerId);
- this.room.gameState.firstPlayerId = playerId;
- this.room.gameState.currentPlayerIndex = playerIndex;
- this.room.gameState.roundStartPlayerIndex = playerIndex;
+ // 一马当先只替换第一轮首发座位;庄家及底牌归属始终保持不变。
+ // 欢乐成双换位后必须按庄家身份重新定位,不能沿用换位前的物理座位索引。
+ const openingDealer = isHappyTwinsRule(this.room.gameState.selectedRule)
+ ? this.room.findPlayerById(this.room.gameState.happyTwins?.dealerPlayerId) || dealer
+ : dealer;
+ const dealerIndex = this.room.getPlayerIndex(openingDealer.id);
+ const firstPlayerIndex = isOneHorseLeadsRule(this.room.gameState.selectedRule)
+ ? (dealerIndex + 2) % this.room.players.length
+ : dealerIndex;
+ const firstPlayer = this.room.findPlayerByIndex(firstPlayerIndex);
+ this.room.gameState.firstPlayerId = firstPlayer.id;
+ this.room.gameState.currentPlayerIndex = firstPlayerIndex;
+ this.room.gameState.roundStartPlayerIndex = firstPlayerIndex;
this.room.gameState.currentRound = 1;
this.room.gameState.playMode = PlayModes.ORDERED;
this.room.gameState.playersPlayedThisRound.clear();
+ this.beginIronEvidenceRound();
// 创建回合管理器
this.roundManager = new RoundManager(this.room);
- logger.info(`房间 ${this.room.id} 进入出牌阶段,${player.name} 先出牌`);
+ // 神兵牌只在埋底完成、正式出牌后出现,不向埋底阶段泄露额外信息。
+ this.initializeDivineWeaponCards();
+ // 第二战场从出牌开始累计各家自己的参赛牌,不另行生成公共牌。
+ this.initializeSecondBattlefield();
+ this.initializeWoodenOx();
+ this.applyStrengthCompensationForRound();
+ this.openWoodenOxRoundWindow();
+ this.initializeCandleToDawn(dealer);
+ this.initializeOldHorse(firstPlayer.id);
+
+ // 明牌必须晚于埋底,避免庄家根据队友手牌决定底牌。
+ this.activatePerfectStrategy(dealer);
+ this.activateRuleHandVisibility();
+ // 十面埋伏也必须在埋底完成后、第一张牌打出前暗选。
+ this.activateTenSidedAmbush(dealer);
+ // 三权分立由初始2、3、4号位并行暗选,全部完成前禁止出第一张牌。
+ this.activateThreePowers();
+ // 君子一言按埋底后的最终手牌统计;并列最短花色全部声明前禁止出第一张牌。
+ this.activateGentlemanPromise();
+ // 潜龙在渊同样按埋底后的最终手牌统计;并列最多点数由对应玩家声明。
+ this.activateHiddenDragonInAbyss();
+ this.activateWaitingRabbit();
+ this.activateAntinomy();
+ this.activateChangeRiceToMulberry();
+ // 焦点人物的两队表决同样发生在埋底之后;候选与票型只发给本队。
+ this.activateFocusFigure();
+
+ logger.info(
+ `房间 ${this.room.id} 进入出牌阶段,${firstPlayer.name} 先出牌` +
+ (firstPlayer.id !== dealer.id ? `(庄家仍为 ${dealer.name})` : '')
+ );
+
+ const result = {
+ completed: true,
+ skipped: requiredCount === 0,
+ isSecondary,
+ firstPlayer
+ };
+ this.announceBuryResult(player, result);
+ return result;
+ }
+
+ /** “人民公社”:庄家起按行动方向依次各埋两张,四家完成后才开始出牌。 */
+ buryPeopleCommuneCards(playerId, cardIds) {
+ const { gameState, players } = this.room;
+ if (gameState.peopleCommuneCurrentBuryingPlayerId !== playerId) {
+ throw new Error('当前还没有轮到你埋底');
+ }
+ if (!Array.isArray(cardIds) || cardIds.length !== 2 || new Set(cardIds).size !== 2) {
+ throw new Error('人民公社必须埋两张不同的牌');
+ }
+
+ const player = this.room.findPlayerById(playerId);
+ const dealer = this.room.findPlayerById(gameState.buryingPlayerId);
+ if (!player || !dealer) throw new Error('埋底玩家不存在');
+ if (gameState.peopleCommuneBuriedCardsByPlayerId.has(playerId)) {
+ throw new Error('你已经完成埋底');
+ }
+
+ const cardsToBury = cardIds.map(cardId => player.cards.find(card => card.id === cardId));
+ if (cardsToBury.some(card => !card)) throw new Error('选择的牌不在手中');
+
+ player.removeCards(cardIds);
+ gameState.peopleCommuneBuriedCardsByPlayerId.set(playerId, cardsToBury);
+ const submittedCount = gameState.peopleCommuneBuriedCardsByPlayerId.size;
+ const nextPlayerId = gameState.peopleCommuneBuryingOrder.find(
+ currentPlayerId => !gameState.peopleCommuneBuriedCardsByPlayerId.has(currentPlayerId)
+ ) || null;
+ gameState.peopleCommuneCurrentBuryingPlayerId = nextPlayerId;
+
+ if (nextPlayerId) {
+ const nextPlayer = this.room.findPlayerById(nextPlayerId);
+ this.io.to(this.room.id).emit('cards_buried', {
+ playerId: player.id,
+ playerName: player.name,
+ skipped: false,
+ isSecondary: false,
+ completed: false,
+ peopleCommune: true,
+ submittedCount,
+ totalCount: players.length
+ });
+ this.io.to(this.room.id).emit('burying_player_set', {
+ playerId: nextPlayer.id,
+ playerName: nextPlayer.name,
+ peopleCommune: true
+ });
+ this.broadcastRoomUpdate();
+ this.schedulePeopleCommuneBotBury(nextPlayer);
+ return {
+ completed: false,
+ peopleCommune: true,
+ submittedCount,
+ nextPlayer
+ };
+ }
+
+ gameState.bottomCards = gameState.peopleCommuneBuryingOrder.flatMap(
+ currentPlayerId => gameState.peopleCommuneBuriedCardsByPlayerId.get(currentPlayerId) || []
+ );
+ logger.info(`房间 ${this.room.id} 人民公社:四家各埋两张,进入出牌阶段`);
+ return this.completePeopleCommuneBurying(player, dealer);
+ }
+ schedulePeopleCommuneBotBury(player) {
+ if (!player?.isBot || !isPeopleCommuneRule(this.room.gameState.selectedRule)) return false;
+ if (this.peopleCommuneBuryTimer) clearTimeout(this.peopleCommuneBuryTimer);
+ const timer = setTimeout(() => {
+ try {
+ const cardsToBury = this.selectBotCardsToBury(player, 2);
+ this.buryCards(player.id, cardsToBury.map(card => card.id));
+ } catch (error) {
+ logger.error(`人民公社 Bot ${player.name} 自动埋底失败:`, error);
+ } finally {
+ if (this.peopleCommuneBuryTimer === timer) {
+ this.peopleCommuneBuryTimer = null;
+ }
+ }
+ }, 350);
+ this.peopleCommuneBuryTimer = timer;
return true;
}
+ completePeopleCommuneBurying(actor, dealer) {
+ const gameState = this.room.gameState;
+ gameState.secondaryBuryingPlayerId = null;
+ gameState.phase = GamePhases.PLAYING;
+
+ const dealerIndex = this.room.getPlayerIndex(dealer.id);
+ const firstPlayer = this.room.findPlayerByIndex(dealerIndex);
+ gameState.firstPlayerId = firstPlayer.id;
+ gameState.currentPlayerIndex = dealerIndex;
+ gameState.roundStartPlayerIndex = dealerIndex;
+ gameState.currentRound = 1;
+ gameState.playMode = PlayModes.ORDERED;
+ gameState.playersPlayedThisRound.clear();
+ this.beginIronEvidenceRound();
+
+ this.roundManager = new RoundManager(this.room);
+ this.initializeDivineWeaponCards();
+ this.initializeSecondBattlefield();
+ this.initializeWoodenOx();
+ this.applyStrengthCompensationForRound();
+ this.openWoodenOxRoundWindow();
+ this.initializeCandleToDawn(dealer);
+ this.initializeOldHorse(firstPlayer.id);
+ this.activatePerfectStrategy(dealer);
+ this.activateRuleHandVisibility();
+ this.activateTenSidedAmbush(dealer);
+ this.activateThreePowers();
+ this.activateGentlemanPromise();
+ this.activateHiddenDragonInAbyss();
+ this.activateWaitingRabbit();
+ this.activateAntinomy();
+ this.activateChangeRiceToMulberry();
+ this.activateFocusFigure();
+
+ const result = {
+ completed: true,
+ skipped: false,
+ isSecondary: false,
+ peopleCommune: true,
+ submittedCount: this.room.players.length,
+ totalCount: this.room.players.length,
+ firstPlayer
+ };
+ this.announceBuryResult(actor, result);
+ return result;
+ }
+
/**
* 设置首发玩家并开始第一轮
*/
@@ -213,9 +10469,22 @@ export class GameEngine {
this.room.gameState.roundStartPlayerIndex = playerIndex;
this.room.gameState.currentRound = 1;
this.room.gameState.playersPlayedThisRound.clear();
+ this.beginIronEvidenceRound();
+ if (isLureTigerFromMountainRule(this.room.gameState.selectedRule)) {
+ this.clearLureTigerSilenceForRound(1);
+ }
// 创建回合管理器
this.roundManager = new RoundManager(this.room);
+ this.initializeDivineWeaponCards();
+ this.initializeSecondBattlefield();
+ this.initializeWoodenOx();
+ this.applyStrengthCompensationForRound();
+ this.openWoodenOxRoundWindow();
+ this.initializeOldHorse(playerId);
+ this.activateAntinomy();
+ this.activateChangeRiceToMulberry();
+ this.beginOpeningAfterglowDecision(this.room.findPlayerById(playerId));
logger.info(`房间 ${this.room.id} 首发玩家: ${this.room.findPlayerById(playerId).name}`);
@@ -225,15 +10494,126 @@ export class GameEngine {
/**
* 出牌 - 按照双升规则,有序出牌
*/
- playCards(playerId, cardIds) {
+ playCards(
+ requestingPlayerId,
+ cardIds,
+ controlledPlayerId = null,
+ activeSkillId = null,
+ playOptions = {}
+ ) {
+ const politicalReviewPending = this.preparePoliticalReviewForPlay(
+ requestingPlayerId,
+ cardIds,
+ controlledPlayerId,
+ activeSkillId,
+ playOptions
+ );
+ if (politicalReviewPending && !politicalReviewPending.approved) {
+ return {
+ politicalReviewDeferred: true,
+ politicalReviewPending,
+ gameFinished: false
+ };
+ }
+
+ const result = this.commitPlayCards(
+ requestingPlayerId,
+ politicalReviewPending?.cardIds || cardIds,
+ controlledPlayerId,
+ activeSkillId,
+ playOptions
+ );
+ if (
+ politicalReviewPending?.approved
+ && this.room.gameState.politicalReviewApproval?.id === politicalReviewPending.approvalId
+ ) {
+ this.room.gameState.politicalReviewApproval = null;
+ }
+ return result;
+ }
+
+ commitPlayCards(
+ requestingPlayerId,
+ cardIds,
+ controlledPlayerId = null,
+ activeSkillId = null,
+ playOptions = {}
+ ) {
if (this.room.gameState.phase !== GamePhases.PLAYING) {
throw new Error('当前不是出牌阶段');
}
+ if (this.hasPendingSurrenderDecision()) {
+ const decision = this.room.gameState.surrenderCurrentDecision;
+ throw new Error(
+ `请等待${decision?.teammatePlayerName || '玩家'}完成投降表决`
+ );
+ }
+ if (this.hasPendingMainstayAction()) {
+ throw new Error('请先完成中流砥柱');
+ }
+ const ironEvidenceModeForPlay = this.ensureIronEvidenceRoundMode();
+ if (this.hasPendingStrawBoatBorrowingArrowsDecision()) {
+ throw new Error('请先完成草船借箭');
+ }
+ if (this.room.gameState.cardExchange?.stage === 'round') {
+ throw new Error('请先完成本轮换牌');
+ }
+ if (this.room.gameState.equivalentReciprocityChallenge) {
+ throw new Error('请先完成等价互惠拼点');
+ }
+ if (this.hasPendingMutualSupportAction()) {
+ throw new Error('请先完成同舟共济交牌');
+ }
+ this.assertNinePrincesDecisionComplete();
+ this.assertTimeReversalDecisionComplete();
+ this.assertForbiddenMagicDecisionComplete();
+ this.assertLureTigerDecisionComplete();
+ this.assertIcebergSelectionComplete();
+ this.assertTenSidedAmbushSelectionComplete();
+ this.assertWaitingRabbitReady();
+ this.assertThreePowersSelectionComplete();
+ this.assertGentlemanPromiseSelectionComplete();
+ this.assertHiddenDragonSelectionComplete();
+ this.assertAntinomySelectionComplete();
+ this.assertRiceToMulberrySelectionComplete();
+ this.assertDestroyDykeDecisionComplete();
+ this.assertAdministrativeReviewSelectionComplete();
+ this.assertCandleSelectionComplete();
+ this.assertFocusFigureVoteComplete();
+ this.assertLastStandDecisionComplete();
+ this.assertTeammateCheerDecisionComplete();
+ this.assertAfterglowDecisionComplete();
+ this.assertAmbiguousChoiceComplete();
+ this.assertWoodenOxRoundWindowComplete();
+
+ const requester = this.room.findPlayerById(requestingPlayerId);
+ const turnPlayerId = controlledPlayerId || requestingPlayerId;
+ const player = this.room.findPlayerById(turnPlayerId);
+
+ if (!requester || !player) {
+ throw new Error('玩家不存在');
+ }
- const player = this.room.findPlayerById(playerId);
+ const { openHandPlayerId, openHandControllerPlayerId } = this.room.gameState;
+ const isProxyPlay = turnPlayerId === openHandPlayerId;
+ if (isProxyPlay) {
+ if (requestingPlayerId !== openHandControllerPlayerId) {
+ if (requestingPlayerId === openHandPlayerId) {
+ throw new Error('本局你的手牌由庄家代为操作');
+ }
+ throw new Error('只有庄家可以代替明手玩家出牌');
+ }
+ } else if (turnPlayerId !== requestingPlayerId) {
+ throw new Error('不能代替该玩家出牌');
+ }
- if (!player) {
- throw new Error('玩家不存在');
+ const wasDreamKillingSleeping = this.isDreamKillingSleeping(turnPlayerId);
+ const isDreamKillingRandomPlay = playOptions?.dreamKillingRandom === true;
+ if (wasDreamKillingSleeping && !isDreamKillingRandomPlay) {
+ throw new Error('你仍在梦中,本次出牌由系统随机完成');
+ }
+ if (isDreamKillingRandomPlay && !wasDreamKillingSleeping) {
+ throw new Error('该玩家当前不在梦中');
}
// 如果还没有回合管理器,说明还未设置首发玩家
@@ -242,7 +10622,7 @@ export class GameEngine {
}
// 获取玩家索引
- const playerIndex = this.room.getPlayerIndex(playerId);
+ const playerIndex = this.room.getPlayerIndex(turnPlayerId);
// 验证是否轮到该玩家出牌
if (!this.roundManager.canPlayerPlay(playerIndex)) {
@@ -254,45 +10634,372 @@ export class GameEngine {
}
// 验证牌是否在手中
+ const woodenOxMule = this.getWoodenOxMuleHeldBy(player.id);
+ const woodenOxStoredCard = woodenOxMule?.storedCard || null;
let validCards = player.cards.filter(card => cardIds.includes(card.id));
+ if (woodenOxStoredCard && cardIds.includes(woodenOxStoredCard.id)) {
+ validCards.push(woodenOxStoredCard);
+ }
// 保存原始请求的牌(用于在甩牌失败时通知客户端哪些牌需要被退回)
const originalRequestedCardIds = [...cardIds];
const originalRequestedCards = [...validCards];
if (validCards.length !== cardIds.length) {
throw new Error('选择的牌不在手中');
}
+ if (isMutualSupportRule(this.room.gameState.selectedRule)) {
+ const owedCards = this.getMutualSupportOwedCount(player.id);
+ const selectedHandCardCount = validCards.filter(card => (
+ player.cards.some(handCard => handCard.id === card.id)
+ )).length;
+ if (player.cards.length - selectedHandCardCount < owedCards) {
+ throw new Error(`同舟共济:本轮结束时还需为队友保留${owedCards}张返还牌`);
+ }
+ }
+
+ // 时间倒流必须保存“任何一张牌尚未打出”的状态。首份合法出牌请求在修改牌局前建快照。
+ if (isTimeReversalRule(this.room.gameState.selectedRule)) {
+ this.captureTimeReversalRoundSnapshot();
+ }
const trumpSuit = this.room.gameState.trumpSuit;
const trumpRank = this.room.gameState.trumpRank;
+ const activeRuleContext = this.getRuleRuntimeContext();
// 判断是首发还是跟牌
const isLeading = this.room.gameState.currentRoundPlays.length === 0;
+ const lureTigerSilencedForPlay = this.isLureTigerSilenced(player.id);
+ const activeBushGateRestriction = this.room.gameState.bushGateRestriction;
+ const bushGateReplayRestriction = isLeading
+ && activeBushGateRestriction?.round === this.room.gameState.currentRound
+ && activeBushGateRestriction?.leaderPlayerId === player.id
+ ? {
+ ...activeBushGateRestriction,
+ forbiddenCardIds: [...(activeBushGateRestriction.forbiddenCardIds || [])],
+ returnedCards: (activeBushGateRestriction.returnedCards || []).map(card => ({ ...card }))
+ }
+ : null;
+ const oldHorseAbsolute = this.isOldHorseAbsoluteLead(player.id, isLeading);
+ if (
+ isLeading
+ && isRitesCollapseRule(this.room.gameState.selectedRule)
+ && validCards.some(card => card.rank === Ranks.ACE)
+ && player.cards.some(card => card.rank !== Ranks.ACE)
+ ) {
+ throw new Error('礼崩乐坏:一号位不能主动打出A');
+ }
+ const cooldownRequiredCount = isLeading
+ ? 1
+ : (this.room.gameState.leadingPattern?.length || 1);
+ const ruleDisabledCards = getRuleDisabledCards({
+ gameState: this.room.gameState,
+ playerId: player.id,
+ playerCards: player.cards,
+ requiredCount: cooldownRequiredCount,
+ isLeading
+ });
+ const ruleDisabledIds = new Set(ruleDisabledCards.map(card => card.id));
+ if (validCards.some(card => ruleDisabledIds.has(card.id))) {
+ const type = getCardCooldownType(this.room.gameState.selectedRule);
+ throw new Error(
+ isBushGateRule(this.room.gameState.selectedRule)
+ ? '布什戈门:重新首发不能包含刚刚被收回的任意一张牌'
+ : type === 'rank'
+ ? '冷却时间:上轮打出的点数本轮不能再次打出'
+ : type === 'suit'
+ ? '时间冷却:上轮打出的花色本轮不能再次打出'
+ : '鸟尽弓藏:该花色的分数牌已经全部打出,不能再主动打出该花色'
+ );
+ }
+ const rulePlayableHandCards = getRulePlayableCards({
+ gameState: this.room.gameState,
+ playerId: player.id,
+ playerCards: player.cards,
+ requiredCount: cooldownRequiredCount,
+ isLeading
+ });
+ let activeSkill = activeSkillId
+ ? this.validateActiveSkillPlay(player, activeSkillId, validCards, isLeading)
+ : null;
+ const isOneCountryTwoSystems = isOneCountryTwoSystemsRule(
+ this.room.gameState.selectedRule
+ );
+ let effectiveCards = isOneCountryTwoSystems
+ ? mapOneCountryCards(
+ validCards,
+ playerIndex,
+ this.room.gameState.oneCountryResolved
+ )
+ : validCards;
+ let effectiveHandCards = isOneCountryTwoSystems
+ ? mapOneCountryCards(
+ rulePlayableHandCards,
+ playerIndex,
+ this.room.gameState.oneCountryResolved
+ )
+ : rulePlayableHandCards;
+ let jokerSubstitutions = [];
+ let clusterAnalysisSubstitutions = [];
+ let forbiddenMagicSubstitutions = [];
+ let divineWeaponTransformation = null;
+ let illusionAndRealitySuit = null;
+ let ambiguousAlternativeCards = null;
+ let ambiguousAlternativePattern = null;
+ const isForbiddenMagicActive = Boolean(
+ isForbiddenMagicRule(this.room.gameState.selectedRule)
+ && this.room.gameState.forbiddenMagicActivePlayerIds.has(player.id)
+ );
+ if (!isForbiddenMagicActive && (playOptions.forbiddenMagicSubstitutions || []).length > 0) {
+ throw new Error('请先确认发动禁术秘法');
+ }
+ if (isForbiddenMagicActive) {
+ const resolution = resolveForbiddenMagicPlay({
+ selectedCards: validCards,
+ handCards: rulePlayableHandCards,
+ substitutions: playOptions.forbiddenMagicSubstitutions,
+ leadingPattern: isLeading ? null : this.room.gameState.leadingPattern,
+ trumpSuit,
+ trumpRank,
+ activeRule: activeRuleContext
+ });
+ if (!resolution.valid) throw new Error(resolution.message);
+ effectiveCards = resolution.effectiveCards;
+ effectiveHandCards = resolution.effectiveHandCards;
+ forbiddenMagicSubstitutions = resolution.substitutions;
+ }
+ if (activeSkill?.id === ActiveSkillIds.STEALING_BEAMS) {
+ const resolution = resolveJokerSubstitutionPlay({
+ selectedCards: validCards,
+ handCards: player.cards,
+ substitutions: playOptions.jokerSubstitutions,
+ leadingPattern: isLeading ? null : this.room.gameState.leadingPattern,
+ trumpSuit,
+ trumpRank,
+ activeRule: activeRuleContext
+ });
+ if (!resolution.valid) throw new Error(resolution.message);
+ if (!resolution.usesSkill) {
+ activeSkill = null;
+ } else {
+ effectiveCards = resolution.effectiveCards;
+ effectiveHandCards = resolution.effectiveHandCards;
+ jokerSubstitutions = resolution.substitutions;
+ }
+ }
+ if (activeSkill?.id === ActiveSkillIds.DIVINE_WEAPON) {
+ const resolution = this.resolveDivineWeaponPlay(
+ player,
+ validCards,
+ rulePlayableHandCards,
+ playOptions
+ );
+ effectiveCards = resolution.effectiveCards;
+ effectiveHandCards = resolution.effectiveHandCards;
+ divineWeaponTransformation = resolution;
+ }
+ if (activeSkill?.id === ActiveSkillIds.CLUSTER_ANALYSIS) {
+ const resolution = resolveClusterAnalysisPlay({
+ selectedCards: validCards,
+ handCards: rulePlayableHandCards,
+ substitutions: playOptions.clusterAnalysisSubstitutions,
+ leadingPattern: isLeading ? null : this.room.gameState.leadingPattern,
+ trumpSuit,
+ trumpRank,
+ activeRule: activeRuleContext
+ });
+ if (!resolution.valid) throw new Error(resolution.message);
+ effectiveCards = resolution.effectiveCards;
+ effectiveHandCards = resolution.effectiveHandCards;
+ clusterAnalysisSubstitutions = resolution.substitutions;
+ }
+ if (activeSkill?.id === ActiveSkillIds.ILLUSION_AND_REALITY) {
+ const leadingSuit = this.room.gameState.leadingPattern?.suit;
+ if (isLeading || !leadingSuit) {
+ throw new Error('虚虚实实只能在跟牌时发动');
+ }
+ if (leadingSuit === 'trump') {
+ throw new Error('虚虚实实只能虚置当前要求跟出的副花色');
+ }
+ const virtualizedCards = rulePlayableHandCards.filter(card => (
+ getEffectiveSuit(card, trumpSuit, trumpRank) === leadingSuit
+ ));
+ if (virtualizedCards.length === 0 || virtualizedCards.length % 2 === 0) {
+ throw new Error('虚虚实实要求对应副花色恰好剩余奇数张');
+ }
+ if (validCards.some(card => (
+ getEffectiveSuit(card, trumpSuit, trumpRank) === leadingSuit
+ ))) {
+ throw new Error('发动虚虚实实时不能打出被虚置的副花色牌');
+ }
+ const requiredCount = this.room.gameState.leadingPattern.length || 1;
+ if (rulePlayableHandCards.length - virtualizedCards.length < requiredCount) {
+ throw new Error(`虚置后其余手牌不足${requiredCount}张,不能发动虚虚实实`);
+ }
+ effectiveHandCards = effectiveHandCards.filter(card => (
+ getEffectiveSuit(card, trumpSuit, trumpRank) !== leadingSuit
+ ));
+ illusionAndRealitySuit = leadingSuit;
+ }
+ const afterglowWasActive = Boolean(
+ isAfterglowRule(this.room.gameState.selectedRule)
+ && trumpSuit !== Suits.NO_TRUMP
+ && this.room.gameState.afterglowActivePlayerIds.has(player.id)
+ );
+ const afterglowHeldTrumps = afterglowWasActive
+ ? rulePlayableHandCards.filter(card => isTrumpCard(card, trumpSuit, trumpRank))
+ : [];
+ const afterglowActiveForPlay = afterglowWasActive && afterglowHeldTrumps.length > 0;
+ if (afterglowWasActive && afterglowHeldTrumps.length === 0) {
+ this.room.gameState.afterglowActivePlayerIds.delete(player.id);
+ }
+ if (afterglowActiveForPlay) {
+ if (!validCards.every(card => isTrumpCard(card, trumpSuit, trumpRank))) {
+ throw new Error('回光返照:只要手中仍有主牌,本次出牌就只能由主牌组成,不能混入副牌');
+ }
+ effectiveCards = this.transformAfterglowCards(effectiveCards);
+ }
+ const playRuleContext = activeSkill?.id === ActiveSkillIds.BELT_AND_ROAD
+ ? { ...activeRuleContext, beltAndRoadSkillActive: true }
+ : activeRuleContext;
+ const treatedAsSmall = activeSkill?.id === ActiveSkillIds.SUBSTITUTE_SACRIFICE;
+ // “暗度陈仓”由跟牌玩家主动发动;“无人生还”则让每墩二至四号位自动暗置。
+ // 两者共用同一套私发牌面、隐藏当前赢家及轮末统一翻牌流程。
+ const concealed = activeSkill?.id === ActiveSkillIds.CONCEALED_PASSAGE
+ || (isNoOneSurvivesRule(this.room.gameState.selectedRule) && !isLeading);
+
+ if (activeSkill?.id === ActiveSkillIds.AMBIGUOUS) {
+ const alternativeCardIds = Array.isArray(playOptions.ambiguousAlternativeCardIds)
+ ? [...new Set(playOptions.ambiguousAlternativeCardIds)]
+ : [];
+ ambiguousAlternativeCards = player.cards.filter(card => (
+ alternativeCardIds.includes(card.id)
+ ));
+ if (ambiguousAlternativeCards.length !== alternativeCardIds.length) {
+ throw new Error('模棱两可的备选牌不在手中');
+ }
+ const primarySignature = [...validCards.map(card => card.id)].sort().join('|');
+ const alternativeSignature = [...alternativeCardIds].sort().join('|');
+ if (primarySignature === alternativeSignature) {
+ throw new Error('模棱两可必须展示两种不同的出牌方式');
+ }
+ const alternativeValidation = validateFollowingPlay(
+ ambiguousAlternativeCards,
+ rulePlayableHandCards,
+ this.room.gameState.leadingPattern,
+ trumpSuit,
+ trumpRank,
+ activeRuleContext
+ );
+ if (!alternativeValidation.valid) {
+ throw new Error(`模棱两可方案B不合法:${alternativeValidation.message}`);
+ }
+ ambiguousAlternativePattern = alternativeValidation.pattern || detectPattern(
+ ambiguousAlternativeCards,
+ trumpSuit,
+ trumpRank,
+ activeRuleContext
+ );
+ }
let pattern;
+ let comparisonPattern = null;
+ let enduringInheritance = null;
+ let dreamKilling = null;
let throwResult = null;
let throwFailed = false;
+ const detectDreamRandomPattern = cards => {
+ const detected = detectPattern(cards, trumpSuit, trumpRank, playRuleContext);
+ if (detected.type !== PatternTypes.INVALID) return detected;
+ const effectiveSuits = [...new Set(
+ cards.map(card => getEffectiveSuit(card, trumpSuit, trumpRank))
+ )];
+ return {
+ type: PatternTypes.INVALID,
+ suit: effectiveSuits.length === 1 ? effectiveSuits[0] : 'mixed',
+ length: cards.length,
+ strength: cards.length > 0
+ ? Math.max(...cards.map(card => getCardStrength(
+ card,
+ trumpSuit,
+ trumpRank,
+ playRuleContext
+ )))
+ : 0
+ };
+ };
if (isLeading) {
- // 首发出牌验证
- const validation = validateLeadingPlay(validCards, trumpSuit, trumpRank);
- if (!validation.valid) {
- throw new Error(validation.message);
+ if (isDreamKillingRandomPlay) {
+ pattern = detectDreamRandomPattern(effectiveCards);
+ } else {
+ // 首发出牌验证
+ const validation = validateLeadingPlay(
+ effectiveCards,
+ trumpSuit,
+ trumpRank,
+ playRuleContext
+ );
+ if (!validation.valid) {
+ throw new Error(validation.message);
+ }
+ pattern = validation.pattern;
+ }
+
+ const isBeltAndRoadLead = pattern.type === PatternTypes.BELT_AND_ROAD;
+ if (activeSkill?.id === ActiveSkillIds.BELT_AND_ROAD && !isBeltAndRoadLead) {
+ throw new Error('一带一路必须首发同一有效花色的两张非对子单牌');
}
- pattern = validation.pattern;
// 检测是否是甩牌(多个组件的组合)
- const parsed = parseThrowCombination(validCards, trumpSuit, trumpRank);
- if (parsed.valid && parsed.components.length > 1) {
+ const parsed = parseThrowCombination(
+ effectiveCards,
+ trumpSuit,
+ trumpRank,
+ playRuleContext
+ );
+ if (
+ !isDreamKillingRandomPlay
+ && pattern.type !== PatternTypes.BELT_AND_ROAD
+ && parsed.valid
+ && parsed.components.length > 1
+ ) {
// 这是一个甩牌尝试
logger.info(`玩家 ${player.name} 尝试甩牌,包含 ${parsed.components.length} 个组件`);
// 获取其他玩家的手牌
const otherPlayersCards = this.room.players
- .filter(p => p.id !== playerId)
- .map(p => p.cards);
+ .filter(p => p.id !== turnPlayerId && !this.isLureTigerSilenced(p.id))
+ .map(p => {
+ const playableCards = getRulePlayableCards({
+ gameState: this.room.gameState,
+ playerId: p.id,
+ playerCards: p.cards,
+ requiredCount: validCards.length,
+ isLeading: false
+ });
+ const heldStoredCard = this.getWoodenOxStoredCardForPlayer(p.id);
+ const availableCards = heldStoredCard
+ ? [...playableCards, heldStoredCard]
+ : playableCards;
+ const ruleEffectiveCards = this.room.gameState.forbiddenMagicActivePlayerIds.has(p.id)
+ ? demoteForbiddenMagicHand(availableCards, trumpSuit, trumpRank)
+ : availableCards;
+ return isOneCountryTwoSystems
+ ? mapOneCountryCards(
+ ruleEffectiveCards,
+ this.room.getPlayerIndex(p.id),
+ this.room.gameState.oneCountryResolved
+ )
+ : ruleEffectiveCards;
+ });
// 验证甩牌
- throwResult = validateThrow(validCards, otherPlayersCards, trumpSuit, trumpRank);
+ throwResult = validateThrow(
+ effectiveCards,
+ otherPlayersCards,
+ trumpSuit,
+ trumpRank,
+ playRuleContext
+ );
if (!throwResult.success) {
// 甩牌失败,强制出小
@@ -300,11 +11007,53 @@ export class GameEngine {
throwFailed = true;
// 替换为强制出的牌
- validCards = throwResult.forcedCards;
- cardIds = validCards.map(c => c.id);
+ effectiveCards = throwResult.forcedCards;
+ const forcedIds = new Set(effectiveCards.map(card => card.id));
+ validCards = validCards.filter(card => forcedIds.has(card.id));
+ cardIds = validCards.map(card => card.id);
+ jokerSubstitutions = jokerSubstitutions.filter(substitution =>
+ forcedIds.has(substitution.cardId)
+ );
+ clusterAnalysisSubstitutions = clusterAnalysisSubstitutions.filter(substitution =>
+ forcedIds.has(substitution.cardId)
+ );
+ forbiddenMagicSubstitutions = forbiddenMagicSubstitutions.filter(substitution =>
+ forcedIds.has(substitution.cardId)
+ );
+ if (
+ divineWeaponTransformation
+ && !forcedIds.has(divineWeaponTransformation.sourceCard.id)
+ ) {
+ // 甩牌失败后若真正打出的最小组件不含转化牌,本次不消耗技能。
+ activeSkill = null;
+ divineWeaponTransformation = null;
+ effectiveCards = validCards;
+ }
+ if (
+ activeSkill?.id === ActiveSkillIds.STEALING_BEAMS
+ && jokerSubstitutions.length === 0
+ ) {
+ // 甩牌失败后若强制打出的最小组件不含已转换的王,本次不消耗偷梁换柱。
+ activeSkill = null;
+ effectiveCards = validCards;
+ effectiveHandCards = rulePlayableHandCards;
+ }
+ if (
+ activeSkill?.id === ActiveSkillIds.CLUSTER_ANALYSIS
+ && clusterAnalysisSubstitutions.length === 0
+ ) {
+ activeSkill = null;
+ effectiveCards = validCards;
+ effectiveHandCards = rulePlayableHandCards;
+ }
// 重新检测牌型
- pattern = detectPattern(validCards, trumpSuit, trumpRank);
+ pattern = detectPattern(
+ effectiveCards,
+ trumpSuit,
+ trumpRank,
+ playRuleContext
+ );
} else {
// 甩牌成功
logger.info(`甩牌成功!包含组件:${throwResult.components.map(c => c.type).join(', ')}`);
@@ -320,42 +11069,146 @@ export class GameEngine {
}
}
+ const enduringResolution = this.resolveEnduringPlay(turnPlayerId, effectiveCards, pattern);
+ comparisonPattern = enduringResolution.comparisonPattern;
+ enduringInheritance = enduringResolution.inheritance;
+ dreamKilling = this.resolveDreamKillingPlay(
+ player,
+ validCards,
+ pattern,
+ wasDreamKillingSleeping
+ );
+
// 记录首发牌型
this.room.gameState.leadingPattern = pattern;
this.room.gameState.currentWinnerIndex = playerIndex;
} else {
// 跟牌验证
const leadingPattern = this.room.gameState.leadingPattern;
- const validation = validateFollowingPlay(
+ if (isDreamKillingRandomPlay) {
+ if (validCards.length !== leadingPattern.length) {
+ throw new Error(`梦中随机出牌必须抽取 ${leadingPattern.length} 张牌`);
+ }
+ pattern = detectDreamRandomPattern(effectiveCards);
+ } else if (afterglowActiveForPlay) {
+ if (validCards.length !== leadingPattern.length) {
+ throw new Error(`回光返照:本轮必须打出 ${leadingPattern.length} 张牌`);
+ }
+ const detectedPattern = detectPattern(
+ effectiveCards,
+ trumpSuit,
+ trumpRank,
+ playRuleContext
+ );
+ const effectiveSuits = [...new Set(
+ effectiveCards.map(card => getEffectiveSuit(card, trumpSuit, trumpRank))
+ )];
+ pattern = detectedPattern.type === PatternTypes.INVALID
+ ? {
+ type: 'afterglow_free_play',
+ suit: effectiveSuits.length === 1 ? effectiveSuits[0] : 'mixed',
+ length: validCards.length,
+ strength: Math.max(...effectiveCards.map(card => getCardStrength(
+ card,
+ trumpSuit,
+ trumpRank,
+ playRuleContext
+ )))
+ }
+ : detectedPattern;
+ } else if (treatedAsSmall) {
+ const detectedPattern = detectPattern(
+ effectiveCards,
+ trumpSuit,
+ trumpRank,
+ playRuleContext
+ );
+ pattern = detectedPattern.type === PatternTypes.INVALID
+ ? {
+ type: 'active_skill_discard',
+ suit: null,
+ length: validCards.length,
+ strength: Number.NEGATIVE_INFINITY
+ }
+ : detectedPattern;
+ } else {
+ const validation = validateFollowingPlay(
+ effectiveCards,
+ effectiveHandCards,
+ leadingPattern,
+ trumpSuit,
+ trumpRank,
+ playRuleContext
+ );
+ if (!validation.valid) {
+ throw new Error(validation.message);
+ }
+ pattern = validation.pattern || detectPattern(
+ effectiveCards,
+ trumpSuit,
+ trumpRank,
+ playRuleContext
+ );
+ }
+
+ const enduringResolution = this.resolveEnduringPlay(turnPlayerId, effectiveCards, pattern);
+ comparisonPattern = enduringResolution.comparisonPattern;
+ enduringInheritance = enduringResolution.inheritance;
+ dreamKilling = this.resolveDreamKillingPlay(
+ player,
validCards,
- player.cards,
- leadingPattern,
- trumpSuit,
- trumpRank
+ pattern,
+ wasDreamKillingSleeping
);
- if (!validation.valid) {
- throw new Error(validation.message);
- }
- pattern = validation.pattern || detectPattern(validCards, trumpSuit, trumpRank);
// 比较大小,更新当前最大者
const currentWinnerIndex = this.room.gameState.currentWinnerIndex;
const currentWinnerPlay = this.room.gameState.currentRoundPlays.find(
p => p.playerIndex === currentWinnerIndex
);
+ const firstSuccessfulDreamPlay = this.room.gameState.currentRoundPlays.find(
+ play => play.dreamKilling?.success
+ );
- if (currentWinnerPlay) {
+ if (dreamKilling?.success) {
+ // 同一轮多人命中时严格保留先出者;后出的成功牌仍会醒来,但不能夺走本轮牌权。
+ if (!firstSuccessfulDreamPlay) {
+ this.room.gameState.currentWinnerIndex = playerIndex;
+ }
+ } else if (
+ currentWinnerPlay
+ && !currentWinnerPlay.oldHorseAbsolute
+ && !treatedAsSmall
+ && !lureTigerSilencedForPlay
+ && !firstSuccessfulDreamPlay
+ ) {
const comparison = compareCards(
- { cards: validCards, pattern, playerIndex },
- { cards: currentWinnerPlay.cards, pattern: currentWinnerPlay.pattern, playerIndex: currentWinnerIndex },
+ { cards: effectiveCards, pattern: comparisonPattern, playerIndex },
+ {
+ cards: currentWinnerPlay.comparisonCards || currentWinnerPlay.cards,
+ pattern: currentWinnerPlay.comparisonPattern || currentWinnerPlay.pattern,
+ playerIndex: currentWinnerIndex
+ },
this.room.gameState.leadingPattern.suit,
trumpSuit,
- trumpRank
+ trumpRank,
+ playRuleContext,
+ this.room.gameState.leadingPattern
);
if (comparison > 0) {
// 新出的牌更大
const previousWinnerIndex = this.room.gameState.currentWinnerIndex;
+ const previousWinner = this.room.findPlayerByIndex(previousWinnerIndex);
+ const recordTrumpAction = type => {
+ this.room.gameState.trumpAction = {
+ type,
+ playerId: player.id,
+ playerName: player.name,
+ targetPlayerId: previousWinner?.id || currentWinnerPlay.playerId || null,
+ targetPlayerName: previousWinner?.name || currentWinnerPlay.playerName || null
+ };
+ };
this.room.gameState.currentWinnerIndex = playerIndex;
logger.info(`房间 ${this.room.id} 玩家 ${player.name} 的牌更大`);
@@ -363,24 +11216,37 @@ export class GameEngine {
const newIsTrump = pattern.suit === 'trump';
const oldIsTrump = currentWinnerPlay.pattern.suit === 'trump';
const leadIsTrump = this.room.gameState.leadingPattern.suit === 'trump';
+ const leadIsInferior = isThreeSixNineGradesRule(this.room.gameState.selectedRule)
+ && Boolean(this.room.gameState.inferiorSuit)
+ && this.room.gameState.leadingPattern.suit === this.room.gameState.inferiorSuit;
+ const newIsOrdinarySide = leadIsInferior
+ && pattern.suit !== 'trump'
+ && pattern.suit !== this.room.gameState.inferiorSuit;
+ const oldIsOrdinarySide = leadIsInferior
+ && currentWinnerPlay.pattern.suit !== 'trump'
+ && currentWinnerPlay.pattern.suit !== this.room.gameState.inferiorSuit;
// 只有首牌是副牌时,才能有"毙了"和"盖毙"
if (!leadIsTrump) {
- if (newIsTrump && oldIsTrump) {
+ if (leadIsInferior && (
+ (newIsTrump && (oldIsTrump || oldIsOrdinarySide))
+ || (newIsOrdinarySide && oldIsOrdinarySide)
+ )) {
+ recordTrumpAction('overtrump');
+ logger.info(`房间 ${this.room.id} 玩家 ${player.name} 盖毙!`);
+ } else if (
+ leadIsInferior
+ && ((newIsTrump && !oldIsTrump) || (newIsOrdinarySide && !oldIsOrdinarySide))
+ ) {
+ recordTrumpAction('trump');
+ logger.info(`房间 ${this.room.id} 玩家 ${player.name} 毙了!`);
+ } else if (newIsTrump && oldIsTrump) {
// 盖毙:首牌是副牌,之前有人用主牌"毙了",现在用更大的主牌再压掉
- this.room.gameState.trumpAction = {
- type: 'overtrump',
- playerId: player.id,
- playerName: player.name
- };
+ recordTrumpAction('overtrump');
logger.info(`房间 ${this.room.id} 玩家 ${player.name} 盖毙!`);
} else if (newIsTrump && !oldIsTrump) {
// 毙了:首牌是副牌,用主牌压副牌
- this.room.gameState.trumpAction = {
- type: 'trump',
- playerId: player.id,
- playerName: player.name
- };
+ recordTrumpAction('trump');
logger.info(`房间 ${this.room.id} 玩家 ${player.name} 毙了!`);
}
}
@@ -388,38 +11254,362 @@ export class GameEngine {
}
}
- // 移除牌
- player.removeCards(cardIds);
+ const priorAmbiguousActivation = activeSkill?.id === ActiveSkillIds.AMBIGUOUS
+ && this.room.gameState.currentRoundPlays.some(
+ play => play.activeSkillId === ActiveSkillIds.AMBIGUOUS
+ );
+ const activeSkillUseConsumed = Boolean(
+ activeSkill
+ && activeSkill.usageLimit !== null
+ && !(activeSkill.id === ActiveSkillIds.AMBIGUOUS && priorAmbiguousActivation)
+ );
+ const ambiguousOptions = activeSkill?.id === ActiveSkillIds.AMBIGUOUS
+ ? [
+ { index: 0, cards: validCards, pattern },
+ { index: 1, cards: ambiguousAlternativeCards, pattern: ambiguousAlternativePattern }
+ ]
+ : null;
+ const activeSkillActivation = activeSkill ? {
+ id: activeSkill.id,
+ name: activeSkill.name,
+ playerId: player.id,
+ playerName: player.name,
+ usageConsumed: activeSkillUseConsumed,
+ treatedAsSmall,
+ concealed,
+ ignoredSuit: illusionAndRealitySuit,
+ divineWeaponTransformation: divineWeaponTransformation?.publicInfo || null,
+ clusterAnalysisSubstitutions
+ } : null;
+ if (activeSkill) {
+ if (activeSkillUseConsumed) {
+ this.recordActiveSkillUse(player.id, activeSkill.id);
+ }
+ if (activeSkill.id === ActiveSkillIds.DIVINE_WEAPON) {
+ this.room.gameState.divineWeaponUsedThisRound = true;
+ this.room.gameState.divineWeaponUsedByPlayerId = player.id;
+ this.room.gameState.divineWeaponUsedCardId = divineWeaponTransformation.targetCard.id;
+ }
+ logger.info(`房间 ${this.room.id} 玩家 ${player.name} 发动 ${activeSkill.name}`);
+ }
+
+ const playedDisplayCards = [
+ ActiveSkillIds.DIVINE_WEAPON,
+ ActiveSkillIds.STEALING_BEAMS,
+ ActiveSkillIds.CLUSTER_ANALYSIS
+ ].includes(activeSkill?.id) || isForbiddenMagicActive || afterglowActiveForPlay
+ ? effectiveCards
+ : validCards;
+
+ // 甩牌失败时只以最终被强制打出的牌判断首次出现。
+ let tenSidedAmbushReveal = this.revealTenSidedAmbushIfNeeded(validCards, player);
+ let threePowersReveal = this.revealThreePowersIfNeeded(validCards, player);
+
+ // 盒中牌可以和手牌一起打出,但它不在玩家的实体手牌数组中。
+ const playedWoodenOxCardIds = woodenOxStoredCard && validCards.some(
+ card => card.id === woodenOxStoredCard.id
+ ) ? [woodenOxStoredCard.id] : [];
+ const playedWoodenOxCardIdSet = new Set(playedWoodenOxCardIds);
+ player.removeCards(cardIds.filter(cardId => !playedWoodenOxCardIdSet.has(cardId)));
+ const ironEvidenceBigJokerIds = this.recordIronEvidenceBigJokers(validCards);
+ if (playedWoodenOxCardIds.length > 0 && woodenOxMule) {
+ woodenOxMule.storedCard = null;
+ this.emitWoodenOxPrivateState(player.id);
+ this.io.to(this.room.id).emit('wooden_ox_card_played', {
+ playerId: player.id,
+ playerName: player.name,
+ teamIndex: woodenOxMule.teamIndex,
+ cardId: playedWoodenOxCardIds[0]
+ });
+ }
+ let afterglowExpired = null;
+ if (
+ afterglowActiveForPlay
+ && this.getAfterglowTrumpCards(player).length === 0
+ ) {
+ this.room.gameState.afterglowActivePlayerIds.delete(player.id);
+ afterglowExpired = {
+ playerId: player.id,
+ playerName: player.name,
+ round: this.room.gameState.currentRound
+ };
+ }
+ const lastStandRequest = this.requestLastStandIfEligible(player);
+ let wholeHandExchange = null;
+ let roundCardExchange = null;
+ let plannedEconomyDraw = null;
+ const encircleThreeMissingOneRecordUpdate = this.recordEncircleThreeMissingOnePlay(validCards);
+ const waitingRabbitPointEntries = this.recordWaitingRabbitPointAppearances(validCards);
+ const hiddenDragonUpdate = this.recordHiddenDragonPlay(player, validCards);
+ const administrativeReviewUpdate = this.recordAdministrativeReviewPlay(player, validCards);
// 记录当前轮出牌
this.room.gameState.currentRoundPlays.push({
playerIndex,
- playerId,
- cards: validCards,
- pattern
+ playerId: turnPlayerId,
+ cards: playedDisplayCards,
+ // 鸟尽弓藏按未转化的实体牌统计,并且只在整墩完成后固化,避免撤回也被误计。
+ originalCards: validCards,
+ comparisonCards: effectiveCards,
+ pattern,
+ comparisonPattern: comparisonPattern || pattern,
+ enduringInheritance,
+ activeSkillId: activeSkill?.id || null,
+ activeSkillName: activeSkill?.name || null,
+ treatedAsSmall,
+ concealed,
+ jokerSubstitutions,
+ clusterAnalysisSubstitutions,
+ forbiddenMagicSubstitutions,
+ dreamKilling,
+ oldHorseAbsolute,
+ woodenOxCardIds: playedWoodenOxCardIds,
+ afterglowActive: afterglowActiveForPlay,
+ roundPosition: this.room.gameState.currentRoundPlays.length + 1,
+ ambiguousOptions,
+ ambiguousUsageConsumed: activeSkillUseConsumed,
+ ironEvidenceMode: ironEvidenceModeForPlay,
+ ironEvidenceBigJokerIds,
+ waitingRabbitPointEntries,
+ lureTigerSilenced: lureTigerSilencedForPlay
});
+ const oldHorseAbsolutePlay = oldHorseAbsolute
+ ? this.consumeOldHorseAbsoluteLead(player, validCards)
+ : null;
+
+ const threeTigersTransformation = this.applyThreeTigersAfterPlay(turnPlayerId);
+ const threeTigersPlayedCards = threeTigersTransformation?.active
+ ? threeTigersTransformation.plays.find(play => play.playerId === turnPlayerId)?.cards || null
+ : null;
+
// 记录出牌历史
this.room.gameState.playHistory.push({
- playerId,
+ round: this.room.gameState.currentRound,
+ playerId: turnPlayerId,
playerName: player.name,
playerIndex,
+ controllerPlayerId: requestingPlayerId,
+ controllerPlayerName: requester.name,
+ isProxy: isProxyPlay,
+ activeSkillId: activeSkill?.id || null,
+ activeSkillName: activeSkill?.name || null,
+ treatedAsSmall,
+ concealed,
cards: validCards.map(c => c.toJSON()),
+ jokerSubstitutions,
+ clusterAnalysisSubstitutions,
+ forbiddenMagicSubstitutions,
+ divineWeaponTransformation: divineWeaponTransformation?.publicInfo || null,
+ enduringInheritance: enduringInheritance ? {
+ sourceCards: enduringInheritance.sourceCards.map(card => card.toJSON ? card.toJSON() : card),
+ sourcePatternType: enduringInheritance.sourcePattern?.type || null,
+ inheritedComponentCount: enduringInheritance.inheritedComponentCount
+ } : null,
+ dreamKilling,
+ dreamKillingRandom: isDreamKillingRandomPlay,
+ oldHorseAbsolute,
+ bushGateReplayRestriction,
+ woodenOxCardIds: playedWoodenOxCardIds,
+ woodenOxTeamIndex: woodenOxMule?.teamIndex ?? null,
+ afterglowActive: afterglowActiveForPlay,
+ afterglowExpiredAfterPlay: Boolean(afterglowExpired),
+ encircleThreeMissingOneSeenSuitsBeforePlay:
+ encircleThreeMissingOneRecordUpdate?.previousSuits || null,
+ activeSkillUseConsumed,
+ ambiguousOptions: ambiguousOptions?.map(option => ({
+ index: option.index,
+ cards: option.cards.map(card => card.toJSON ? card.toJSON() : card)
+ })) || null,
+ ironEvidenceMode: ironEvidenceModeForPlay,
+ ironEvidenceBigJokerIds,
+ waitingRabbitPointEntries: waitingRabbitPointEntries?.map(entry => ({
+ card: entry.card.toJSON ? entry.card.toJSON() : entry.card,
+ cardId: entry.cardId,
+ firstAppearance: entry.firstAppearance,
+ points: entry.points
+ })) || null,
+ lureTigerSilenced: lureTigerSilencedForPlay,
+ hiddenDragonUpdate,
+ administrativeReviewUpdate,
timestamp: new Date()
});
+ // 守株待兔只在整轮结算后换牌,本次出牌过程本身不即时替换牌桌。
+ const waitingRabbitExchange = null;
+
+ if (bushGateReplayRestriction) {
+ this.room.gameState.bushGateRestriction = null;
+ this.room.gameState.bushGateLastResult = {
+ ...(this.room.gameState.bushGateLastResult || bushGateReplayRestriction),
+ replayCompleted: true,
+ replayCards: validCards.map(card => card.toJSON ? card.toJSON() : card)
+ };
+ }
+
logger.info(`房间 ${this.room.id} 玩家 ${player.name} 出牌 ${cardIds.length} 张`);
+ // 回合结束时 currentWinnerIndex 会立即为下一回合重置,因此先保存本次出牌后
+ // 真正领先的玩家,让客户端能在清桌前正确显示最后一手的“大”标记。
+ let currentWinningPlayer = this.room.findPlayerByIndex(
+ this.room.gameState.currentWinnerIndex
+ );
+ let currentWinningPlayerId = currentWinningPlayer?.id || null;
+ const hasConcealedRoundPlay = this.room.gameState.currentRoundPlays.some(
+ play => play.concealed
+ );
+ let publicCurrentWinningPlayerId = hasConcealedRoundPlay
+ ? null
+ : currentWinningPlayerId;
+
// 更新回合状态
logger.info(`[调试] 调用 onPlayerPlayed 前: playersPlayedThisRound.size=${this.room.gameState.playersPlayedThisRound.size}, players.length=${this.room.players.length}`);
const roundUpdate = this.roundManager.onPlayerPlayed(playerIndex);
logger.info(`[调试] 调用 onPlayerPlayed 后: playersPlayedThisRound.size=${this.room.gameState.playersPlayedThisRound.size}, roundUpdate.type=${roundUpdate?.type}`);
+ if (
+ roundUpdate?.type === 'round_ended'
+ && !this.isFinalizingDestroyDykeRound
+ ) {
+ const destroyDykeDecision = this.holdDestroyDykeRoundForDecision({
+ requestingPlayerId,
+ turnPlayerId,
+ playerIndex,
+ cardIds: validCards.map(card => card.id)
+ });
+ if (destroyDykeDecision) {
+ roundUpdate.type = 'destroy_dyke_decision_pending';
+ roundUpdate.currentPlayerIndex = null;
+ roundUpdate.destroyDyke = destroyDykeDecision;
+ return {
+ playerId: turnPlayerId,
+ playerName: player.name,
+ controllerPlayerId: requestingPlayerId,
+ controllerPlayerName: requester.name,
+ isProxy: isProxyPlay,
+ playedCards: playedDisplayCards.map(card => card.toJSON ? card.toJSON() : card),
+ remainingCount: this.getPlayableCardCount(player),
+ gameFinished: false,
+ roundUpdate,
+ roundWinner: null,
+ currentWinningPlayerId: publicCurrentWinningPlayerId,
+ threeTigersTransformation,
+ activeSkillActivation,
+ treatedAsSmall,
+ concealed,
+ roundReveal: null,
+ tenSidedAmbushReveal,
+ threePowersReveal,
+ trumpAction: null,
+ lastStandRequest,
+ destroyDykeDecision,
+ destroyDykeDecisionPending: true,
+ jokerSubstitutions,
+ clusterAnalysisSubstitutions,
+ forbiddenMagicSubstitutions,
+ enduringInheritance: null,
+ dreamKilling,
+ oldHorseAbsolute,
+ oldHorseAbsolutePlay,
+ throwFailed: null,
+ waitingRabbitExchange
+ };
+ }
+ }
+
+ if (
+ roundUpdate?.type === 'round_ended'
+ && !this.isFinalizingAmbiguousRound
+ ) {
+ const ambiguousDecision = this.holdAmbiguousRoundForChoice({
+ requestingPlayerId,
+ turnPlayerId,
+ playerIndex,
+ cardIds
+ });
+ if (ambiguousDecision) {
+ roundUpdate.type = 'ambiguous_choice_pending';
+ roundUpdate.currentPlayerIndex = null;
+ roundUpdate.ambiguous = ambiguousDecision;
+ return {
+ playerId: turnPlayerId,
+ playerName: player.name,
+ controllerPlayerId: requestingPlayerId,
+ controllerPlayerName: requester.name,
+ isProxy: isProxyPlay,
+ playedCards: (waitingRabbitExchange?.tableCards || playedDisplayCards)
+ .map(card => card.toJSON ? card.toJSON() : card),
+ remainingCount: this.getPlayableCardCount(player),
+ gameFinished: false,
+ roundUpdate,
+ roundWinner: null,
+ currentWinningPlayerId: null,
+ threeTigersTransformation,
+ activeSkillActivation,
+ treatedAsSmall,
+ concealed,
+ roundReveal: null,
+ tenSidedAmbushReveal,
+ threePowersReveal,
+ trumpAction: null,
+ lastStandRequest,
+ ambiguousOptions: ambiguousOptions?.map(option => ({
+ index: option.index,
+ cards: option.cards.map(card => card.toJSON ? card.toJSON() : card)
+ })) || null,
+ ambiguousDecision,
+ ambiguousDecisionPending: true,
+ jokerSubstitutions,
+ clusterAnalysisSubstitutions,
+ forbiddenMagicSubstitutions,
+ enduringInheritance: null,
+ dreamKilling,
+ oldHorseAbsolute,
+ oldHorseAbsolutePlay,
+ throwFailed: null,
+ waitingRabbitExchange
+ };
+ }
+ }
+
// 检查本轮是否结束
let roundWinner = null;
let roundScoreInfo = null;
+ let roundReveal = null;
+ let turnDirectionChange = null;
+ let smallestPlayer = null;
+ let averagePooling = null;
+ let jointHarmony = null;
+ let magicTrick = null;
+ let abruptStop = null;
+ let divineWeaponRefresh = null;
+ let secondBattlefield = null;
+ let mutualSupportReturn = null;
+ let culturalRevolutionTransition = null;
+ let encircleThreeMissingOneTransition = null;
+ let inviteIntoUrn = null;
+ let oldHorse = null;
+ let trumpWins = null;
+ let strawBoatDecision = null;
+ let striveUpstreamOrder = null;
+ let defenseAsOffenseNextStatus = null;
+ let defenseAsOffenseTransition = null;
+ let waitingRabbitDecision = null;
+ let antinomyReselection = null;
+ let antinomyReselectionPlayerIds = [];
+ let destroyDykeRoundResult = null;
+ let surrenderDecision = null;
+ let ninePrinces = null;
if (this.room.gameState.playersPlayedThisRound.size === this.room.players.length) {
logger.info(`[调试] 检测到轮次结束,当前轮: ${this.room.gameState.currentRound}`);
+ // 魔术戏法只在四家都按各自真实手牌完成出牌后,交换两个座位的结算结果。
+ magicTrick = this.applyMagicTrickAtRoundEnd();
+ // 两条队内规则都必须等四家出完后统一判定,避免先出者的临时牌力提前固化牌权。
+ averagePooling = this.applyAveragePoolingAtRoundEnd();
+ jointHarmony = this.applyJointHarmonyAtRoundEnd();
+ currentWinningPlayer = this.room.findPlayerByIndex(this.room.gameState.currentWinnerIndex);
+ currentWinningPlayerId = currentWinningPlayer?.id || null;
+ publicCurrentWinningPlayerId = hasConcealedRoundPlay ? null : currentWinningPlayerId;
// 本轮结束,确定获胜者
const winnerIndex = this.room.gameState.currentWinnerIndex;
const winner = this.room.findPlayerByIndex(winnerIndex);
@@ -429,74 +11619,689 @@ export class GameEngine {
playerName: winner.name
};
+ let nextRoundLeaderIndex = winnerIndex;
+ oldHorse = this.updateOldHorseAtRoundEnd(winnerIndex);
+ trumpWins = this.resolveTrumpWinsAtRoundEnd(roundUpdate?.round);
+ if (Number.isInteger(trumpWins?.leaderPlayerIndex)) {
+ nextRoundLeaderIndex = trumpWins.leaderPlayerIndex;
+ }
+ if (isRespectEldersAndChildrenRule(this.room.gameState.selectedRule)) {
+ const comparisonPlays = this.room.gameState.currentRoundPlays.map(play => ({
+ ...play,
+ cards: play.comparisonCards || play.cards
+ }));
+ const smallestPlay = findSmallestPlayForRespectElders(
+ comparisonPlays,
+ this.room.gameState.trumpSuit,
+ this.room.gameState.trumpRank,
+ this.room.gameState.selectedRule
+ );
+ const smallest = smallestPlay
+ ? this.room.findPlayerByIndex(smallestPlay.playerIndex)
+ : null;
+ if (smallest) {
+ nextRoundLeaderIndex = smallestPlay.playerIndex;
+ smallestPlayer = {
+ playerIndex: smallestPlay.playerIndex,
+ playerId: smallest.id,
+ playerName: smallest.name
+ };
+ logger.info(
+ `房间 ${this.room.id} 尊老爱幼:${smallest.name} 打出本轮最小牌,下轮先出`
+ );
+ }
+ }
+ if (isStriveUpstreamRule(this.room.gameState.selectedRule)) {
+ const comparisonPlays = this.room.gameState.currentRoundPlays.map(play => ({
+ ...play,
+ cards: play.comparisonCards || play.cards
+ }));
+ const rankedPlays = rankRoundPlaysByRespectOrder(
+ comparisonPlays,
+ this.room.gameState.trumpSuit,
+ this.room.gameState.trumpRank,
+ this.room.gameState.selectedRule
+ );
+ const playerIndexes = rankedPlays.map(play => play.playerIndex);
+ if (playerIndexes.length === this.room.players.length) {
+ this.room.gameState.striveUpstreamPlayOrder = playerIndexes;
+ nextRoundLeaderIndex = playerIndexes[0];
+ // “力争上游”的桌面“大”表示完整牌力排序的第一名,也就是实际
+ // 取得下轮牌权的人;普通一墩的赢家仍单独用于本轮常规计分。
+ const strongestPlayer = this.room.findPlayerByIndex(playerIndexes[0]);
+ currentWinningPlayerId = strongestPlayer?.id || null;
+ publicCurrentWinningPlayerId = hasConcealedRoundPlay
+ ? null
+ : currentWinningPlayerId;
+ striveUpstreamOrder = {
+ triggerRound: roundUpdate?.round ?? this.room.gameState.currentRound,
+ playerIndexes,
+ players: playerIndexes.map(playerIndex => {
+ const orderedPlayer = this.room.findPlayerByIndex(playerIndex);
+ return {
+ playerIndex,
+ playerId: orderedPlayer?.id || null,
+ playerName: orderedPlayer?.name || '未知玩家'
+ };
+ })
+ };
+ logger.info(
+ `房间 ${this.room.id} 力争上游:下轮顺序为 ` +
+ striveUpstreamOrder.players.map(orderedPlayer => orderedPlayer.playerName).join(' → ')
+ );
+ }
+ }
+ defenseAsOffenseNextStatus = this.getDefenseAsOffenseStatusForNextRound(
+ roundUpdate?.round ?? this.room.gameState.currentRound
+ );
+
logger.info(`房间 ${this.room.id} 第${this.room.gameState.currentRound}轮结束,${winner.name} 获胜`);
// 计算本轮得分
const currentLeadingPattern = this.room.gameState.leadingPattern;
- const allRoundCards = this.room.gameState.currentRoundPlays.flatMap(play => play.cards);
- const roundPoints = calculateRoundPoints(allRoundCards);
- const roundPointCards = extractPointCards(allRoundCards);
+ const scoringRoundPlays = this.room.gameState.currentRoundPlays.filter(
+ play => !play.lureTigerSilenced
+ );
+ const allRoundCards = scoringRoundPlays.flatMap(play => play.cards);
+ const allRoundOriginalCards = scoringRoundPlays.flatMap(
+ play => play.originalCards || play.cards
+ );
+ const allAppearedOriginalCards = this.room.gameState.currentRoundPlays.flatMap(
+ play => play.originalCards || play.cards
+ );
+ recordBirdsGoneBowHiddenPointCards({
+ gameState: this.room.gameState,
+ cards: allRoundOriginalCards
+ });
+ const candleLitForRound = isCandleToDawnRule(this.room.gameState.selectedRule)
+ ? this.room.gameState.candleLit
+ : null;
+ const basePointResolver = isCandleToDawnRule(this.room.gameState.selectedRule)
+ ? card => this.getCandleRoundCardPoints(card, candleLitForRound)
+ : card => this.getRuleCardPoints(card);
+ const weighingThousandJinScoring = this.getWeighingThousandJinRoundScoring();
+ const pointResolver = card => this.adjustWeighingThousandJinCardPoints(
+ basePointResolver(card),
+ weighingThousandJinScoring
+ );
+ const waitingRabbitPointEntries = isWaitingRabbitRule(this.room.gameState.selectedRule)
+ ? this.room.gameState.currentRoundPlays.flatMap(
+ play => play.waitingRabbitPointEntries || []
+ )
+ : null;
+ const unweightedBaseRoundPoints = waitingRabbitPointEntries
+ ? waitingRabbitPointEntries.reduce((sum, entry) => sum + entry.points, 0)
+ : calculateRoundPoints(allRoundCards, basePointResolver);
+ const baseRoundPoints = waitingRabbitPointEntries
+ ? waitingRabbitPointEntries.reduce(
+ (sum, entry) => sum + this.adjustWeighingThousandJinCardPoints(
+ entry.points,
+ weighingThousandJinScoring
+ ),
+ 0
+ )
+ : calculateRoundPoints(allRoundCards, pointResolver);
+ const originalRoundPoints = isCandleToDawnRule(this.room.gameState.selectedRule)
+ ? calculateRoundPoints(allRoundCards, getCardPoints)
+ : unweightedBaseRoundPoints;
+ const standardRoundPointMultiplier = getOddEvenRoundMultiplier(
+ this.room.gameState.selectedRule,
+ this.room.gameState.currentRound
+ );
+ const ironEvidenceScoring = isIronEvidenceRule(this.room.gameState.selectedRule)
+ ? calculateIronEvidenceRoundScoring(
+ allRoundOriginalCards,
+ baseRoundPoints,
+ this.ensureIronEvidenceRoundMode()
+ )
+ : null;
+ const roundPointMultiplier = ironEvidenceScoring?.multiplier
+ ?? standardRoundPointMultiplier;
+ const roundPoints = ironEvidenceScoring?.roundPoints
+ ?? baseRoundPoints * standardRoundPointMultiplier;
+ // 烛会使某些5变为0分,但它仍是牌面分牌,收分区仍保留原牌。
+ const roundPointCards = waitingRabbitPointEntries
+ ? waitingRabbitPointEntries
+ .filter(entry => entry.firstAppearance && entry.points > 0)
+ .map(entry => entry.card)
+ : extractPointCards(
+ allRoundCards,
+ isCandleToDawnRule(this.room.gameState.selectedRule)
+ ? getCardPoints
+ : basePointResolver
+ );
+ const ambushCardCount = this.countTenSidedAmbushCards(allRoundCards);
+ const ambushPoints = ambushCardCount * TEN_SIDED_AMBUSH_CARD_POINTS;
+
+ if (
+ isRouteSwingRule(this.room.gameState.selectedRule)
+ && allRoundCards.some(card => getCardPoints(card) >= 10)
+ ) {
+ const previousDirection = this.room.gameState.turnDirection;
+ const nextDirection = previousDirection === TurnOrders.CLOCKWISE
+ ? TurnOrders.COUNTER_CLOCKWISE
+ : TurnOrders.CLOCKWISE;
+ this.room.gameState.turnDirection = nextDirection;
+ turnDirectionChange = {
+ previousDirection,
+ nextDirection,
+ triggerRound: this.room.gameState.currentRound
+ };
+ logger.info(
+ `房间 ${this.room.id} 路线摇摆:第${this.room.gameState.currentRound}轮结束后` +
+ `出牌方向改为${nextDirection === TurnOrders.CLOCKWISE ? '顺时针' : '逆时针'}`
+ );
+ }
// 获取庄家索引
const dealerIndex = this.room.getPlayerIndex(this.room.gameState.buryingPlayerId);
// 判断赢家是否是闲家
- const winnerIsAttacker = isAttacker(winnerIndex, dealerIndex, this.room.players.length);
+ const winnerIsAttacker = this.isAttackerPlayerIndex(winnerIndex, dealerIndex);
+ const focusFigureScoringPending = isFocusFigureRule(this.room.gameState.selectedRule);
+ const destroyDykeVoidsRound = Boolean(
+ isDestroyDykeFloodFieldsRule(this.room.gameState.selectedRule)
+ && this.room.gameState.destroyDykeDisaster?.triggerRound === this.room.gameState.currentRound
+ && winnerIsAttacker
+ );
+ const accidentInsuranceExcess = isAccidentInsuranceRule(this.room.gameState.selectedRule)
+ ? Math.max(0, roundPoints - 30)
+ : 0;
+ const accidentInsuranceWithheld = winnerIsAttacker ? accidentInsuranceExcess : 0;
+ const accidentInsuranceBonus = winnerIsAttacker ? 0 : accidentInsuranceExcess;
+ const attackerRoundPointsAwarded = focusFigureScoringPending
+ ? null
+ : winnerIsAttacker
+ ? destroyDykeVoidsRound ? 0 : roundPoints - accidentInsuranceWithheld
+ : accidentInsuranceBonus;
+ const ambushScoreDelta = ambushPoints > 0
+ ? (winnerIsAttacker ? -ambushPoints : ambushPoints)
+ : 0;
+ const ambushAttackerNetCardDelta = ambushCardCount > 0
+ ? (winnerIsAttacker ? ambushCardCount : -ambushCardCount)
+ : 0;
if (winnerIsAttacker && roundPoints > 0) {
- // 闲家赢了且有分数牌,收集分数牌
- this.room.gameState.collectedPointCards.push(...roundPointCards);
- this.room.gameState.attackerScore += roundPoints;
- logger.info(`闲家获得 ${roundPoints} 分,总分: ${this.room.gameState.attackerScore}`);
+ if (destroyDykeVoidsRound) {
+ logger.info(
+ `房间 ${this.room.id} 毁堤淹田:第${this.room.gameState.currentRound}轮的 ` +
+ `${roundPoints} 分暂时作废`
+ );
+ } else {
+ // 闲家赢了且有分数牌,收集分数牌
+ this.room.gameState.collectedPointCards.push(...roundPointCards);
+ if (focusFigureScoringPending) {
+ this.recordFocusFigureCapturedRoundPoints();
+ logger.info(`焦点人物:闲家收下 ${roundPointCards.length} 张分牌,实际分数留待终局揭晓`);
+ } else {
+ this.room.gameState.attackerScore += attackerRoundPointsAwarded;
+ logger.info(`闲家获得 ${attackerRoundPointsAwarded} 分,总分: ${this.room.gameState.attackerScore}`);
+ }
+ if (accidentInsuranceWithheld > 0) {
+ logger.info(
+ `房间 ${this.room.id} 意外保险:闲家赢得 ${roundPoints} 分,` +
+ `超出30分的 ${accidentInsuranceWithheld} 分不计入闲家得分`
+ );
+ }
+ }
+ }
+ if (accidentInsuranceBonus > 0) {
+ this.room.gameState.attackerScore += accidentInsuranceBonus;
+ logger.info(
+ `房间 ${this.room.id} 意外保险:庄家方赢得 ${roundPoints} 分,` +
+ `超出30分的 ${accidentInsuranceBonus} 分补给闲家,总分: ${this.room.gameState.attackerScore}`
+ );
+ }
+ if (ambushScoreDelta !== 0) {
+ this.room.gameState.attackerScore += ambushScoreDelta;
+ this.room.gameState.tenSidedAmbushAttackerNetCardCount += ambushAttackerNetCardDelta;
+ logger.info(
+ `十面埋伏:本墩 ${ambushCardCount} 张 ${this.room.gameState.tenSidedAmbushRank},` +
+ `闲家分数变化 ${ambushScoreDelta > 0 ? '+' : ''}${ambushScoreDelta},` +
+ `总分 ${this.room.gameState.attackerScore}`
+ );
+ }
+
+ if (weighingThousandJinScoring) {
+ logger.info(
+ `房间 ${this.room.id} 上称千斤:庄家本轮完整牌力第` +
+ `${weighingThousandJinScoring.dealerRank},压过` +
+ `${weighingThousandJinScoring.outrankedAttackerCount}名闲家;` +
+ `${unweightedBaseRoundPoints}分调整为${baseRoundPoints}分`
+ );
+ }
+
+ if (ironEvidenceScoring) {
+ this.room.gameState.ironEvidenceLastResult = {
+ round: this.room.gameState.currentRound,
+ ...ironEvidenceScoring,
+ winnerPlayerId: winner.id,
+ winnerPlayerName: winner.name,
+ winnerIsAttacker
+ };
+ if (ironEvidenceScoring.specialCardCount > 0) {
+ logger.info(
+ ironEvidenceScoring.mode === 'zero'
+ ? `房间 ${this.room.id} 铁证如山:本轮出现 ${ironEvidenceScoring.specialCardCount} 张铁证牌,` +
+ `${baseRoundPoints} 分清零`
+ : `房间 ${this.room.id} 铁证如山:本轮出现 ${ironEvidenceScoring.specialCardCount} 张铁证牌,` +
+ `${baseRoundPoints} × ${ironEvidenceScoring.multiplier} = ${roundPoints} 分`
+ );
+ }
}
+ destroyDykeRoundResult = this.advanceDestroyDykeDisasterAtRoundEnd({
+ round: this.room.gameState.currentRound,
+ winnerIsAttacker,
+ roundPoints,
+ gameEnding: this.room.players.every(
+ candidate => this.getPlayableCardCount(candidate) === 0
+ )
+ });
+
+ const repeatedExhaustion = this.applyRepeatedExhaustionAtRoundEnd(winnerIndex);
+ secondBattlefield = this.applySecondBattlefieldAtRoundEnd();
+ inviteIntoUrn = this.resolveInviteIntoUrnAtRoundEnd(roundUpdate?.round);
+ const outwardHarmonyInnerDivision = this.resolveOutwardHarmonyInnerDivisionAtRoundEnd(
+ roundUpdate?.round
+ );
+ const fearOfBreakingVase = this.resolveFearOfBreakingVaseAtRoundEnd(
+ winnerIndex,
+ roundUpdate?.round ?? this.room.gameState.currentRound
+ );
+
roundScoreInfo = {
+ baseRoundPoints,
+ originalRoundPoints,
+ roundPointMultiplier,
roundPoints,
- roundPointCards: roundPointCards.map(c => c.toJSON()),
+ weighingThousandJin: weighingThousandJinScoring
+ ? {
+ ...weighingThousandJinScoring,
+ originalRoundPoints: unweightedBaseRoundPoints,
+ adjustedRoundPoints: baseRoundPoints
+ }
+ : null,
+ ironEvidence: ironEvidenceScoring
+ ? { ...this.room.gameState.ironEvidenceLastResult }
+ : null,
+ destroyDyke: destroyDykeRoundResult,
+ roundPointCards: roundPointCards.map(c => c.toJSON ? c.toJSON() : c),
+ ambushRank: this.room.gameState.isTenSidedAmbushRevealed
+ ? this.room.gameState.tenSidedAmbushRank
+ : null,
+ ambushCardCount,
+ ambushPoints,
+ ambushScoreDelta,
+ ambushAttackerNetCardDelta,
+ ambushAttackerNetCardCount: this.room.gameState.tenSidedAmbushAttackerNetCardCount,
+ attackerRoundPointsAwarded,
+ accidentInsuranceExcess,
+ accidentInsuranceWithheld,
+ accidentInsuranceBonus,
+ repeatedExhaustion,
+ repeatedExhaustionPenalty: repeatedExhaustion?.penalty || 0,
+ repeatedExhaustionScoreDelta: repeatedExhaustion?.scoreDelta || 0,
+ secondBattlefield,
+ inviteIntoUrn,
+ outwardHarmonyInnerDivision,
+ fearOfBreakingVase,
+ focusFigureScoringPending,
winnerIsAttacker,
- attackerScore: this.room.gameState.attackerScore,
- collectedPointCards: this.room.gameState.collectedPointCards.map(c => c.toJSON())
+ attackerScore: focusFigureScoringPending ? null : this.room.gameState.attackerScore,
+ collectedPointCards: this.room.gameState.collectedPointCards.map(c => c.toJSON ? c.toJSON() : c)
};
+ if (isCandleToDawnRule(this.room.gameState.selectedRule)) {
+ roundScoreInfo.candleLit = candleLitForRound;
+ // 上面的本轮分数和闲家总分都已完成结算,再决定下一轮烛态。
+ roundScoreInfo.candleTransition = this.applyCandleTransitionAtRoundEnd();
+ }
+
// 保存最后一轮信息(用于底牌计算)
this.room.gameState.lastRoundLeadingPattern = currentLeadingPattern;
this.room.gameState.lastRoundWinnerIndex = winnerIndex;
+ // 本轮仍使用旧主完整结算;这里只为下一轮应用围三阙一的缺门花色。
+ encircleThreeMissingOneTransition = this.applyEncircleThreeMissingOneAtRoundEnd(
+ roundUpdate?.round ?? this.room.gameState.currentRound
+ );
+
+ if (isThreeTigersRule(this.room.gameState.selectedRule)) {
+ const state = this.room.gameState.threeTigersRoundState;
+ this.room.gameState.threeTigersLastRoundState = state
+ ? {
+ ...state,
+ contributingPlayerIds: [...(state.contributingPlayerIds || [])],
+ suitCounts: { ...(state.suitCounts || {}) }
+ }
+ : null;
+ }
+
+ abruptStop = this.getAbruptStopAtRoundEnd();
+
+ if (this.room.gameState.currentRoundPlays.some(play => play.concealed)) {
+ roundReveal = {
+ round: this.room.gameState.currentRound,
+ plays: this.room.gameState.currentRoundPlays.map(play => ({
+ playerId: play.playerId,
+ playerName: this.room.findPlayerById(play.playerId)?.name || '未知玩家',
+ cards: play.cards.map(card => card.toJSON ? card.toJSON() : card),
+ concealed: Boolean(play.concealed),
+ activeSkillId: play.activeSkillId || null,
+ activeSkillName: play.activeSkillName || null,
+ lureTigerSilenced: Boolean(play.lureTigerSilenced),
+ ironEvidenceMode: play.ironEvidenceMode || null,
+ jokerSubstitutions: play.jokerSubstitutions || []
+ }))
+ };
+ }
+
+ // 目标牌先完整参与本轮牌力与首次计分;全部结算完成后才开放拿回窗口。
+ waitingRabbitDecision = this.prepareWaitingRabbitDecisionAtRoundEnd();
+
+ // 整手交换只在一整轮全部出完并完成本轮计分后检查,不能由先出牌者提前触发。
+ wholeHandExchange = this.applyWholeHandExchangeAtRoundEnd();
+ // 单张交换/暗弃同样只在本轮全部出完、分数判定完成后开始;四家暗选,收齐后同时处理。
+ roundCardExchange = this.prepareRoundCardExchangeAtRoundEnd(allRoundCards)
+ || this.prepareRoundDiscardAtRoundEnd(allRoundCards);
+
+ // 分数和胜负已结算、桌面牌尚未清除时确定“箭”。决定可以跨过清桌动画等待,
+ // 但下一轮出牌会被冻结,直到首置位明确发动或放弃。
+ strawBoatDecision = this.prepareStrawBoatBorrowingArrowsAtRoundEnd(
+ roundUpdate?.round ?? this.room.gameState.currentRound,
+ winnerIndex
+ );
+
+ // 当前四家的实际出牌成为下一轮各自的点数/花色冷却来源。
+ this.updateCardCooldownRestrictionsForNextRound();
+ // 经久不衰同样只在四家都出完后才把本轮固化为“上轮”。
+ this.updateEnduringHistoryAtRoundEnd();
+
+ // 必须趁本轮牌仍在桌上时确定哪些声明者需要重选;具体选择从下一轮开始前启动。
+ antinomyReselectionPlayerIds = this.getAntinomyReselectionPlayerIds();
+
+ const recordOnFileTransition = this.updateRecordOnFileAtRoundEnd({
+ completedRound: roundUpdate?.round ?? this.room.gameState.currentRound,
+ hasPointCards: allAppearedOriginalCards.some(card => getCardPoints(card) > 0),
+ hasLevelOrJoker: this.room.gameState.currentRoundPlays
+ .flatMap(play => play.cards || [])
+ .some(card => (
+ card.suit === Suits.JOKER
+ || card.rank === this.room.gameState.trumpRank
+ )),
+ hasNextRound: this.room.players.some(
+ roundPlayer => this.getPlayableCardCount(roundPlayer) > 0
+ )
+ });
+
+ ninePrinces = this.prepareNinePrincesDecisionAtRoundEnd({
+ winner,
+ completedRound: roundUpdate?.round ?? this.room.gameState.currentRound,
+ roundPlays: scoringRoundPlays
+ });
+
// 清空当前轮出牌记录,准备下一轮
this.room.gameState.currentRoundPlays = [];
this.room.gameState.leadingPattern = null;
- // 设置下一轮的首发玩家(获胜者)
- this.room.gameState.roundStartPlayerIndex = winnerIndex;
- this.room.gameState.currentPlayerIndex = winnerIndex;
+ // 通常由获胜者首发;特殊规则可以改写下轮首发与完整行动顺序。
+ this.room.gameState.roundStartPlayerIndex = nextRoundLeaderIndex;
+ this.room.gameState.currentPlayerIndex = nextRoundLeaderIndex;
this.room.gameState.currentWinnerIndex = null;
this.room.gameState.playersPlayedThisRound.clear();
this.room.gameState.currentRound++;
+ this.room.gameState.threeTigersRoundState = null;
+ this.beginIronEvidenceRound();
+
+ if (
+ antinomyReselectionPlayerIds.length > 0
+ && !this.room.players.every(roundPlayer => this.getPlayableCardCount(roundPlayer) === 0)
+ ) {
+ antinomyReselection = this.beginAntinomySelection(
+ antinomyReselectionPlayerIds,
+ {
+ stage: 'round',
+ triggerRound: roundUpdate?.round ?? this.room.gameState.currentRound - 1
+ }
+ );
+ }
+
+ defenseAsOffenseTransition = this.applyDefenseAsOffenseForRound(
+ defenseAsOffenseNextStatus,
+ { emit: false }
+ );
+
+ culturalRevolutionTransition = this.expireCulturalRevolutionAtRoundEnd(
+ roundUpdate?.round ?? this.room.gameState.currentRound - 1
+ );
+
+ mutualSupportReturn = this.prepareMutualSupportReturnsAtRoundEnd(
+ roundUpdate?.round ?? this.room.gameState.currentRound - 1
+ );
+
+ const strengthCompensation = this.room.players.every(
+ roundPlayer => this.getPlayableCardCount(roundPlayer) === 0
+ ) ? null : this.applyStrengthCompensationForRound({ emit: false });
+
+ if (!this.room.players.every(roundPlayer => this.getPlayableCardCount(roundPlayer) === 0)) {
+ divineWeaponRefresh = this.refreshUsedDivineWeaponCards();
+ }
+
+ // 计划经济的20张封存牌只在庄家埋底、正式出牌后的轮末发放。
+ // 每轮四家按座位顺序各摸1张,因此前五轮每家打1张后又补回1张。
+ plannedEconomyDraw = this.drawPlannedEconomyRoundCards(roundUpdate?.round ?? this.room.gameState.currentRound - 1);
+
+ if (!this.room.players.every(roundPlayer => this.getPlayableCardCount(roundPlayer) === 0)) {
+ this.openWoodenOxRoundWindow();
+ }
+
+ if (isTimeReversalRule(this.room.gameState.selectedRule)) {
+ // 第四家出完后的两秒仍属于刚结束的这一轮:所有人都可继续预备,下一轮不得抢跑。
+ this.room.gameState.timeReversalDecisionState = 'holding';
+ this.room.gameState.timeReversalWindowRound = roundUpdate?.round ?? null;
+ } else if (ninePrinces) {
+ ninePrinces = this.beginNinePrincesDecision() || ninePrinces;
+ }
// 更新roundUpdate
if (roundUpdate) {
roundUpdate.roundWinner = roundWinner;
roundUpdate.nextRound = this.room.gameState.currentRound;
- roundUpdate.scoreInfo = roundScoreInfo;
+ roundUpdate.scoreInfo = isLostInFogRule(this.room.gameState.selectedRule)
+ ? { hidden: true }
+ : roundScoreInfo;
+ roundUpdate.turnDirectionChange = turnDirectionChange;
+ roundUpdate.smallestPlayer = smallestPlayer;
+ roundUpdate.nextRoundLeader = trumpWins?.leaderPlayerId
+ ? {
+ playerIndex: trumpWins.leaderPlayerIndex,
+ playerId: trumpWins.leaderPlayerId,
+ playerName: trumpWins.leaderPlayerName
+ }
+ : smallestPlayer || roundWinner;
+ roundUpdate.plannedEconomyDraw = plannedEconomyDraw ? {
+ round: plannedEconomyDraw.round,
+ drawCount: plannedEconomyDraw.drawCount,
+ remainingCards: plannedEconomyDraw.remainingCards
+ } : null;
+ roundUpdate.averagePooling = averagePooling;
+ roundUpdate.jointHarmony = jointHarmony;
+ roundUpdate.magicTrick = magicTrick;
+ roundUpdate.abruptStop = abruptStop;
+ roundUpdate.divineWeaponRefresh = divineWeaponRefresh;
+ roundUpdate.secondBattlefield = secondBattlefield;
+ roundUpdate.candleTransition = roundScoreInfo?.candleTransition || null;
+ roundUpdate.strengthCompensation = strengthCompensation;
+ roundUpdate.mutualSupportReturnPending = Boolean(mutualSupportReturn);
+ roundUpdate.culturalRevolutionTransition = culturalRevolutionTransition;
+ roundUpdate.encircleThreeMissingOneTransition = encircleThreeMissingOneTransition;
+ roundUpdate.recordOnFile = recordOnFileTransition;
+ roundUpdate.ninePrinces = ninePrinces;
+ roundUpdate.antinomyReselection = antinomyReselection;
+ roundUpdate.inviteIntoUrn = inviteIntoUrn;
+ roundUpdate.oldHorse = oldHorse;
+ roundUpdate.trumpWins = trumpWins;
+ roundUpdate.strawBoatBorrowingArrows = strawBoatDecision;
+ roundUpdate.striveUpstreamOrder = striveUpstreamOrder;
+ roundUpdate.defenseAsOffense = defenseAsOffenseTransition;
+ roundUpdate.threeTigers = isThreeTigersRule(this.room.gameState.selectedRule)
+ ? {
+ ...(this.room.gameState.threeTigersLastRoundState || {}),
+ plays: threeTigersTransformation?.plays || [],
+ currentWinningPlayerId: threeTigersTransformation?.currentWinningPlayerId || null
+ }
+ : null;
}
}
// 获取毙牌动作
- const trumpAction = this.room.gameState.trumpAction;
+ // 暗牌尚未在轮末揭示时,“毙了/盖毙”动画同样会泄露牌面性质。
+ // 最后一手会先广播统一揭牌,再允许广播其毙牌结果。
+ const trumpAction = hasConcealedRoundPlay && !roundReveal
+ ? null
+ : this.room.gameState.trumpAction;
// 清空以防重复发送
this.room.gameState.trumpAction = null;
+ this.releaseAdministrativeReviewBottomIfReady();
+
+ this.updateRuleHandVisibilityAfterCardChange(player, {
+ roundEnded: roundUpdate?.type === 'round_ended'
+ });
+
+ const teammateCheerRequest = this.requestTeammateCheerIfEligible(player, {
+ triggerRound: roundUpdate?.round ?? this.room.gameState.currentRound
+ });
+ const afterglowRequest = this.requestAfterglowIfEligible(player, {
+ triggerRound: roundUpdate?.round ?? this.room.gameState.currentRound
+ });
+
// 检查游戏是否结束(所有玩家手牌为0)
- const allPlayersFinished = this.room.players.every(p => p.cards.length === 0);
- if (allPlayersFinished) {
+ const allPlayersFinished = this.room.players.every(p => this.getPlayableCardCount(p) === 0);
+ const abruptlyStopped = Boolean(abruptStop?.triggered);
+ if (
+ roundUpdate?.type === 'round_ended'
+ && !this.hasPendingTimeReversalDecision()
+ ) {
+ surrenderDecision = this.prepareSurrenderReview({
+ completedRound: roundUpdate.round,
+ finishGameAfterReview: allPlayersFinished || abruptlyStopped
+ });
+ } else if (
+ roundUpdate?.type === 'round_ended'
+ && (allPlayersFinished || abruptlyStopped)
+ && this.room.gameState.surrenderRequests.size > 0
+ ) {
+ // 时间倒流优先决定这一墩是否成立;若不回溯,稍后仍需在正常终局前处理投降。
+ this.room.gameState.surrenderFinishGameAfterReview = true;
+ }
+ if (
+ roundUpdate?.type === 'round_ended'
+ && !allPlayersFinished
+ && !abruptlyStopped
+ && !surrenderDecision
+ && isForbiddenMagicRule(this.room.gameState.selectedRule)
+ ) {
+ this.enqueueForbiddenMagicRoundDecisions();
+ }
+ if (
+ roundUpdate?.type === 'round_ended'
+ && !allPlayersFinished
+ && !abruptlyStopped
+ && !surrenderDecision
+ && isLureTigerFromMountainRule(this.room.gameState.selectedRule)
+ ) {
+ this.enqueueLureTigerRoundDecisions();
+ }
+ const timeReversalPending = this.hasPendingTimeReversalDecision();
+ const mutualSupportPending = this.hasPendingMutualSupportAction();
+ const strawBoatPending = this.hasPendingStrawBoatBorrowingArrowsDecision();
+ const teammateCheerPending = this.hasPendingTeammateCheerDecision();
+ const afterglowPending = this.hasPendingAfterglowDecision();
+ const ninePrincesPending = this.hasPendingNinePrincesDecision();
+ if (
+ (allPlayersFinished || abruptlyStopped)
+ && !timeReversalPending
+ && !mutualSupportPending
+ && !strawBoatPending
+ && !teammateCheerPending
+ && !afterglowPending
+ && !ninePrincesPending
+ && !surrenderDecision
+ && this.room.gameState.surrenderRequests.size === 0
+ ) {
this.finishGame();
+ if (!tenSidedAmbushReveal && this.room.gameState.bottomScoreResult?.ambushRevealedFromBottom) {
+ tenSidedAmbushReveal = {
+ rank: this.room.gameState.bottomScoreResult.ambushRank,
+ source: 'bottom',
+ playerId: null,
+ playerName: null
+ };
+ }
+ const bottomThreePowersReveal = this.room.gameState.bottomScoreResult?.threePowersRevealFromBottom;
+ if (bottomThreePowersReveal?.slots?.length) {
+ threePowersReveal = threePowersReveal?.slots?.length
+ ? {
+ ...threePowersReveal,
+ slots: [...threePowersReveal.slots, ...bottomThreePowersReveal.slots]
+ }
+ : bottomThreePowersReveal;
+ }
return {
- playedCards: validCards.map(c => c.toJSON()),
- remainingCount: player.cards.length,
+ playerId: turnPlayerId,
+ playerName: player.name,
+ controllerPlayerId: requestingPlayerId,
+ controllerPlayerName: requester.name,
+ isProxy: isProxyPlay,
+ playedCards: (waitingRabbitExchange?.tableCards || threeTigersPlayedCards || playedDisplayCards).map(
+ c => c.toJSON ? c.toJSON() : c
+ ),
+ remainingCount: this.getPlayableCardCount(player),
gameFinished: true,
roundUpdate,
roundWinner,
+ currentWinningPlayerId: publicCurrentWinningPlayerId,
+ threeTigersTransformation,
+ activeSkillActivation,
+ ambiguousOptions: ambiguousOptions?.map(option => ({
+ index: option.index,
+ cards: option.cards.map(card => card.toJSON ? card.toJSON() : card)
+ })) || null,
+ treatedAsSmall,
+ lureTigerSilenced: lureTigerSilencedForPlay,
+ concealed,
+ roundReveal,
+ tenSidedAmbushReveal,
+ threePowersReveal,
trumpAction,
+ wholeHandExchange,
+ roundCardExchange,
+ plannedEconomyDraw,
+ mutualSupportReturn,
+ strawBoatDecision,
+ surrenderDecision: null,
+ timeReversalPending: false,
+ forbiddenMagicDecisionPending: false,
+ lastStandRequest,
+ teammateCheerRequest,
+ afterglowRequest,
+ afterglowExpired,
+ jokerSubstitutions,
+ clusterAnalysisSubstitutions,
+ forbiddenMagicSubstitutions,
+ enduringInheritance: enduringInheritance ? {
+ sourceCards: enduringInheritance.sourceCards.map(card => card.toJSON ? card.toJSON() : card),
+ sourcePatternType: enduringInheritance.sourcePattern?.type || null,
+ inheritedComponentCount: enduringInheritance.inheritedComponentCount
+ } : null,
+ dreamKilling,
+ oldHorseAbsolute,
+ oldHorseAbsolutePlay,
+ ironEvidenceMode: ironEvidenceModeForPlay,
+ waitingRabbitDecision: this.getWaitingRabbitPublicDecision(waitingRabbitDecision),
+ waitingRabbitExchange,
throwFailed: throwFailed ? {
message: '甩牌失败,强制出小',
attemptedCards: originalRequestedCardIds.length,
@@ -507,12 +12312,58 @@ export class GameEngine {
}
return {
- playedCards: validCards.map(c => c.toJSON()),
- remainingCount: player.cards.length,
+ playerId: turnPlayerId,
+ playerName: player.name,
+ controllerPlayerId: requestingPlayerId,
+ controllerPlayerName: requester.name,
+ isProxy: isProxyPlay,
+ playedCards: (waitingRabbitExchange?.tableCards || threeTigersPlayedCards || playedDisplayCards).map(
+ c => c.toJSON ? c.toJSON() : c
+ ),
+ remainingCount: this.getPlayableCardCount(player),
gameFinished: false,
roundUpdate,
roundWinner,
+ currentWinningPlayerId: publicCurrentWinningPlayerId,
+ threeTigersTransformation,
+ activeSkillActivation,
+ ambiguousOptions: ambiguousOptions?.map(option => ({
+ index: option.index,
+ cards: option.cards.map(card => card.toJSON ? card.toJSON() : card)
+ })) || null,
+ treatedAsSmall,
+ lureTigerSilenced: lureTigerSilencedForPlay,
+ concealed,
+ roundReveal,
+ tenSidedAmbushReveal,
+ threePowersReveal,
trumpAction,
+ wholeHandExchange,
+ roundCardExchange,
+ plannedEconomyDraw,
+ mutualSupportReturn,
+ strawBoatDecision,
+ surrenderDecision,
+ timeReversalPending,
+ forbiddenMagicDecisionPending: this.hasPendingForbiddenMagicDecision(),
+ lastStandRequest,
+ teammateCheerRequest,
+ afterglowRequest,
+ afterglowExpired,
+ jokerSubstitutions,
+ clusterAnalysisSubstitutions,
+ forbiddenMagicSubstitutions,
+ enduringInheritance: enduringInheritance ? {
+ sourceCards: enduringInheritance.sourceCards.map(card => card.toJSON ? card.toJSON() : card),
+ sourcePatternType: enduringInheritance.sourcePattern?.type || null,
+ inheritedComponentCount: enduringInheritance.inheritedComponentCount
+ } : null,
+ dreamKilling,
+ oldHorseAbsolute,
+ oldHorseAbsolutePlay,
+ ironEvidenceMode: ironEvidenceModeForPlay,
+ waitingRabbitDecision: this.getWaitingRabbitPublicDecision(waitingRabbitDecision),
+ waitingRabbitExchange,
throwFailed: throwFailed ? {
message: '甩牌失败,强制出小',
attemptedCards: originalRequestedCardIds.length,
@@ -526,16 +12377,37 @@ export class GameEngine {
* 撤回上次出牌 - 将上次自己打的牌收回手中
* 只有当下一位玩家还未出牌时才能撤回
*/
- undoLastPlay(playerId) {
+ undoLastPlay(requestingPlayerId) {
if (this.room.gameState.phase !== GamePhases.PLAYING) {
throw new Error('当前不是出牌阶段');
}
-
- const player = this.room.findPlayerById(playerId);
- if (!player) {
+ this.assertNinePrincesDecisionComplete();
+ this.assertTimeReversalDecisionComplete();
+ this.assertDestroyDykeDecisionComplete();
+ this.assertForbiddenMagicDecisionComplete();
+ this.assertLureTigerDecisionComplete();
+ this.assertIcebergSelectionComplete();
+ this.assertTenSidedAmbushSelectionComplete();
+ this.assertWaitingRabbitReady();
+ this.assertLastStandDecisionComplete();
+ this.assertTeammateCheerDecisionComplete();
+ this.assertAfterglowDecisionComplete();
+ this.assertAmbiguousChoiceComplete();
+
+ const requester = this.room.findPlayerById(requestingPlayerId);
+ if (!requester) {
throw new Error('玩家不存在');
}
+ const { openHandPlayerId, openHandControllerPlayerId } = this.room.gameState;
+ if (requestingPlayerId === openHandPlayerId) {
+ throw new Error('本局你的手牌由庄家代为操作');
+ }
+ const controllablePlayerIds = new Set([requestingPlayerId]);
+ if (requestingPlayerId === openHandControllerPlayerId && openHandPlayerId) {
+ controllablePlayerIds.add(openHandPlayerId);
+ }
+
// 检查是否有出牌记录
if (this.room.gameState.playHistory.length === 0) {
throw new Error('没有可撤回的出牌记录');
@@ -544,7 +12416,7 @@ export class GameEngine {
// 找到该玩家最后一次出牌记录
let lastPlayIndex = -1;
for (let i = this.room.gameState.playHistory.length - 1; i >= 0; i--) {
- if (this.room.gameState.playHistory[i].playerId === playerId) {
+ if (controllablePlayerIds.has(this.room.gameState.playHistory[i].playerId)) {
lastPlayIndex = i;
break;
}
@@ -554,48 +12426,301 @@ export class GameEngine {
throw new Error('没有找到你的出牌记录');
}
+ const lastPlay = this.room.gameState.playHistory[lastPlayIndex];
+ if (lastPlay.waitingRabbitExchange?.accepted) {
+ throw new Error('守株待兔换牌已经完成,本次出牌不能撤回');
+ }
+ if (lastPlay.dreamKillingRandom) {
+ throw new Error('梦中随机出牌不能撤回');
+ }
+ const roundPlayIndex = this.room.gameState.currentRoundPlays.findLastIndex(
+ play => play.playerId === lastPlay.playerId && play.playerIndex === lastPlay.playerIndex
+ );
+ if (roundPlayIndex === -1) {
+ throw new Error('本轮已经结算,无法撤回');
+ }
+
// 检查是否有后续的其他玩家出牌记录
const hasSubsequentPlays = this.room.gameState.playHistory
.slice(lastPlayIndex + 1)
- .some(play => play.playerId !== playerId);
+ .some(play => play.playerId !== lastPlay.playerId);
if (hasSubsequentPlays) {
throw new Error('下一位玩家已经出牌,无法撤回');
}
- const lastPlay = this.room.gameState.playHistory[lastPlayIndex];
+ const player = this.room.findPlayerById(lastPlay.playerId);
- // 将牌返回给玩家 - 需要从JSON重新创建Card实例
+ // 将牌返回给玩家;若它来自木牛流马,则必须放回盒中而不能混入手牌。
+ const woodenOxCardIdSet = new Set(lastPlay.woodenOxCardIds || []);
+ const restoredWoodenOxCards = [];
lastPlay.cards.forEach(cardData => {
// 从id解析出copyIndex: "suit-rank-copyIndex"
const parts = cardData.id.split('-');
const copyIndex = parseInt(parts[2]) || 0;
const card = new Card(cardData.suit, cardData.rank, copyIndex);
- player.addCard(card);
+ card.id = cardData.id;
+ card.originalSuit = cardData.originalSuit || null;
+ card.originalRank = cardData.originalRank || null;
+ card.isLastStandTrump = Boolean(cardData.isLastStandTrump);
+ card.isUnarmed = Boolean(cardData.isUnarmed);
+ card.isStrengthCompensated = Boolean(cardData.isStrengthCompensated);
+ card.strengthCompensationDelta = Number(cardData.strengthCompensationDelta) || 0;
+ card.isDefenseAsOffenseBoosted = Boolean(cardData.isDefenseAsOffenseBoosted);
+ card.defenseAsOffenseDelta = Number(cardData.defenseAsOffenseDelta) || 0;
+ card.isTeammateCheered = Boolean(cardData.isTeammateCheered);
+ card.isAfterglowBoosted = Boolean(cardData.isAfterglowBoosted);
+ card.isRiceToMulberryTransformed = Boolean(cardData.isRiceToMulberryTransformed);
+ card.isNinePrincesPromoted = Boolean(cardData.isNinePrincesPromoted);
+ card.ninePrincesPromotionCount = Number(cardData.ninePrincesPromotionCount) || 0;
+ card.ninePrincesPermanentSuit = cardData.ninePrincesPermanentSuit || null;
+ card.ninePrincesPermanentRank = cardData.ninePrincesPermanentRank || null;
+ card.ninePrincesScoringSuit = cardData.ninePrincesScoringSuit || null;
+ card.ninePrincesScoringRank = cardData.ninePrincesScoringRank || null;
+ card.value = card.calculateValue();
+ if (woodenOxCardIdSet.has(card.id)) {
+ const mule = this.room.gameState.woodenOxMulesByTeam.get(lastPlay.woodenOxTeamIndex);
+ if (!mule || mule.holderPlayerId !== player.id || mule.storedCard) {
+ throw new Error('木牛流马状态已变化,无法撤回该次出牌');
+ }
+ mule.storedCard = card;
+ restoredWoodenOxCards.push(cardData);
+ } else {
+ player.addCard(card);
+ }
});
// 自动排序
player.cards = DeckService.autoSortCards(player.cards);
+ if (restoredWoodenOxCards.length > 0) this.emitWoodenOxPrivateState(player.id);
// 恢复回合状态
const playerIndex = lastPlay.playerIndex;
this.room.gameState.playersPlayedThisRound.delete(playerIndex);
+ // 撤掉桌面上的本次出牌,并重新计算首牌与当前最大者。
+ this.room.gameState.currentRoundPlays.splice(roundPlayIndex, 1);
+ const remainingRoundPlays = this.room.gameState.currentRoundPlays;
+ if (lastPlay.oldHorseAbsolute) {
+ this.room.gameState.oldHorseProtectedPlayerId = player.id;
+ this.room.gameState.oldHorseLastAbsolutePlay = null;
+ }
+ this.room.gameState.leadingPattern = remainingRoundPlays[0]?.pattern || null;
+ const threeTigersTransformation = this.applyThreeTigersAfterPlay();
+ if (remainingRoundPlays.length === 0) {
+ this.room.gameState.currentWinnerIndex = null;
+ } else {
+ const comparablePlays = remainingRoundPlays.filter(play => !play.lureTigerSilenced);
+ let winningPlay = comparablePlays[0];
+ for (const play of comparablePlays.slice(1)) {
+ if (winningPlay.oldHorseAbsolute) continue;
+ if (play.oldHorseAbsolute) {
+ winningPlay = play;
+ continue;
+ }
+ if (play.treatedAsSmall) continue;
+ const comparison = compareCards(
+ { ...play, pattern: play.comparisonPattern || play.pattern },
+ { ...winningPlay, pattern: winningPlay.comparisonPattern || winningPlay.pattern },
+ this.room.gameState.leadingPattern.suit,
+ this.room.gameState.trumpSuit,
+ this.room.gameState.trumpRank,
+ this.getRuleRuntimeContext(),
+ this.room.gameState.leadingPattern
+ );
+ if (comparison > 0) winningPlay = play;
+ }
+ this.room.gameState.currentWinnerIndex = winningPlay.playerIndex;
+ }
+
// 如果在有序模式下,恢复当前玩家索引
if (this.room.gameState.playMode === PlayModes.ORDERED) {
this.room.gameState.currentPlayerIndex = playerIndex;
}
+ let teammateCheerReverted = null;
+ let teammateCheerRestoredCards = [];
+ const teammateCheerResolution = lastPlay.teammateCheerResolution;
+ if (teammateCheerResolution?.accepted) {
+ const buffedPlayer = this.room.findPlayerById(
+ teammateCheerResolution.teammatePlayerId
+ );
+ if (buffedPlayer) {
+ const snapshotsById = new Map(
+ (teammateCheerResolution.buffedCardsBefore || []).map(snapshot => [snapshot.id, snapshot])
+ );
+ buffedPlayer.cards.forEach(card => {
+ const snapshot = snapshotsById.get(card.id);
+ if (!snapshot) return;
+ card.suit = snapshot.suit;
+ card.rank = snapshot.rank;
+ card.originalSuit = snapshot.originalSuit;
+ card.originalRank = snapshot.originalRank;
+ card.isTeammateCheered = snapshot.isTeammateCheered;
+ card.value = card.calculateValue();
+ });
+ buffedPlayer.cards = DeckService.autoSortCards(buffedPlayer.cards);
+ teammateCheerRestoredCards = buffedPlayer.cards.map(card => (
+ card.toJSON ? card.toJSON() : card
+ ));
+ teammateCheerReverted = {
+ playerId: player.id,
+ playerName: player.name,
+ buffedPlayerId: buffedPlayer.id,
+ buffedPlayerName: buffedPlayer.name
+ };
+ }
+ this.room.gameState.teammateCheerUsedPlayerIds.delete(player.id);
+ this.room.gameState.teammateCheerBuffedPlayerIds.delete(
+ teammateCheerResolution.teammatePlayerId
+ );
+ }
+ if (
+ teammateCheerResolution
+ && this.room.gameState.teammateCheerLastResult?.playerId === player.id
+ ) {
+ this.room.gameState.teammateCheerLastResult = null;
+ }
+
+ let afterglowReverted = null;
+ let afterglowRestoredCards = [];
+ const afterglowResolution = lastPlay.afterglowResolution;
+ if (afterglowResolution?.accepted) {
+ const snapshotsById = new Map(
+ (afterglowResolution.boostedCardsBefore || []).map(snapshot => [snapshot.id, snapshot])
+ );
+ player.cards.forEach(card => {
+ const snapshot = snapshotsById.get(card.id);
+ if (!snapshot) return;
+ card.suit = snapshot.suit;
+ card.rank = snapshot.rank;
+ card.originalSuit = snapshot.originalSuit;
+ card.originalRank = snapshot.originalRank;
+ card.isAfterglowBoosted = snapshot.isAfterglowBoosted;
+ card.value = card.calculateValue();
+ });
+ player.cards = DeckService.autoSortCards(player.cards);
+ afterglowRestoredCards = player.cards.map(card => (
+ card.toJSON ? card.toJSON() : card
+ ));
+ this.room.gameState.afterglowUsedPlayerIds.delete(player.id);
+ this.room.gameState.afterglowActivePlayerIds.delete(player.id);
+ afterglowReverted = {
+ playerId: player.id,
+ playerName: player.name,
+ activationReverted: true
+ };
+ } else if (lastPlay.afterglowExpiredAfterPlay) {
+ this.room.gameState.afterglowActivePlayerIds.add(player.id);
+ afterglowReverted = {
+ playerId: player.id,
+ playerName: player.name,
+ activationReverted: false,
+ effectRestored: true
+ };
+ }
+ if (
+ afterglowResolution
+ && this.room.gameState.afterglowLastResult?.playerId === player.id
+ ) {
+ this.room.gameState.afterglowLastResult = null;
+ }
+
+ if (
+ isEncircleThreeMissingOneRule(this.room.gameState.selectedRule)
+ && Array.isArray(lastPlay.encircleThreeMissingOneSeenSuitsBeforePlay)
+ ) {
+ this.room.gameState.encircleThreeMissingOneSeenSuits = [
+ ...lastPlay.encircleThreeMissingOneSeenSuitsBeforePlay
+ ];
+ }
+
// 移除出牌记录
+ for (const cardId of lastPlay.ironEvidenceBigJokerIds || []) {
+ this.room.gameState.ironEvidencePlayedBigJokerIds.delete(cardId);
+ }
+ for (const entry of lastPlay.waitingRabbitPointEntries || []) {
+ if (entry.firstAppearance) {
+ this.room.gameState.waitingRabbitSeenPointCardIds.delete(entry.cardId);
+ }
+ }
+ if (lastPlay.hiddenDragonUpdate) {
+ const update = lastPlay.hiddenDragonUpdate;
+ this.room.gameState.hiddenDragonPlayedRanksByPlayerId.set(
+ update.playerId,
+ new Set(update.previousPlayedRanks || [])
+ );
+ if (update.previousEvaluated) {
+ this.room.gameState.hiddenDragonEvaluatedPlayerIds.add(update.playerId);
+ } else {
+ this.room.gameState.hiddenDragonEvaluatedPlayerIds.delete(update.playerId);
+ }
+ this.room.gameState.attackerScore = update.previousAttackerScore;
+ this.room.gameState.hiddenDragonResults.splice(update.previousResultsLength);
+ if (update.resolution) {
+ this.io.to(this.room.id).emit('hidden_dragon_reverted', {
+ playerId: update.playerId,
+ playerName: player.name,
+ declaredRank: update.resolution.declaredRank
+ });
+ }
+ }
+ if (lastPlay.administrativeReviewUpdate && this.room.gameState.administrativeReview) {
+ this.room.gameState.administrativeReview.suitMatched =
+ lastPlay.administrativeReviewUpdate.previousSuitMatched;
+ this.room.gameState.administrativeReview.rankMatched =
+ lastPlay.administrativeReviewUpdate.previousRankMatched;
+ }
this.room.gameState.playHistory.splice(lastPlayIndex, 1);
+ if (lastPlay.activeSkillId && lastPlay.activeSkillUseConsumed !== false) {
+ this.restoreActiveSkillUse(lastPlay.playerId, lastPlay.activeSkillId);
+ if (lastPlay.activeSkillId === ActiveSkillIds.DIVINE_WEAPON) {
+ this.room.gameState.divineWeaponUsedThisRound = false;
+ this.room.gameState.divineWeaponUsedByPlayerId = null;
+ this.room.gameState.divineWeaponUsedCardId = null;
+ }
+ }
+
+ if (lastPlay.bushGateReplayRestriction) {
+ this.room.gameState.bushGateRestriction = {
+ ...lastPlay.bushGateReplayRestriction,
+ forbiddenCardIds: [...(lastPlay.bushGateReplayRestriction.forbiddenCardIds || [])],
+ returnedCards: (lastPlay.bushGateReplayRestriction.returnedCards || []).map(
+ card => ({ ...card })
+ )
+ };
+ this.room.gameState.bushGateLastResult = {
+ ...(this.room.gameState.bushGateLastResult || lastPlay.bushGateReplayRestriction),
+ replayCompleted: false,
+ replayCards: []
+ };
+ }
+
+ this.updateRuleHandVisibilityAfterCardChange(player);
+ this.requestLastStandIfEligible(player);
+
logger.info(`房间 ${this.room.id} 玩家 ${player.name} 撤回了出牌`);
return {
- playerId,
+ playerId: player.id,
playerName: player.name,
- cards: lastPlay.cards,
- remainingCount: player.cards.length
+ controllerPlayerId: requestingPlayerId,
+ controllerPlayerName: requester.name,
+ isProxy: player.id === openHandPlayerId,
+ restoredActiveSkillId: lastPlay.activeSkillId || null,
+ restoredActiveSkillName: lastPlay.activeSkillName || null,
+ concealed: Boolean(lastPlay.concealed),
+ cards: lastPlay.cards.filter(card => !woodenOxCardIdSet.has(card.id)),
+ woodenOxCards: restoredWoodenOxCards,
+ remainingCount: this.getPlayableCardCount(player),
+ restoredOldHorseAbsolute: Boolean(lastPlay.oldHorseAbsolute),
+ restoredBushGateRestriction: Boolean(lastPlay.bushGateReplayRestriction),
+ teammateCheerReverted,
+ teammateCheerRestoredCards,
+ afterglowReverted,
+ afterglowRestoredCards,
+ threeTigersTransformation
};
}
@@ -603,7 +12728,57 @@ export class GameEngine {
/**
* 结束游戏
*/
+ getAbruptStopAtRoundEnd() {
+ const { gameState } = this.room;
+ if (!isAbruptStopRule(gameState.selectedRule)) return null;
+ const triggeringPlayers = this.room.players.filter(player => player.cards.length < 5);
+ if (triggeringPlayers.length === 0) return null;
+ return {
+ triggered: true,
+ round: gameState.currentRound,
+ triggeringPlayers: triggeringPlayers.map(player => ({
+ playerId: player.id,
+ playerName: player.name,
+ remainingCount: player.cards.length
+ }))
+ };
+ }
+
+ applyAbruptStopBonus() {
+ const { gameState } = this.room;
+ if (!isAbruptStopRule(gameState.selectedRule)) return null;
+ const dealer = this.room.findPlayerById(gameState.buryingPlayerId);
+ if (!dealer) return null;
+ const pointResolver = card => this.getRuleCardPoints(card);
+ const remainingPointCards = extractPointCards(dealer.cards, pointResolver);
+ const dealerRemainingPoints = calculateRoundPoints(dealer.cards, pointResolver);
+ const attackerBonus = dealerRemainingPoints / 2;
+ const scoreBeforeBonus = gameState.attackerScore;
+ gameState.attackerScore += attackerBonus;
+ logger.info(
+ `房间 ${this.room.id} 戛然而止:庄家 ${dealer.name} 剩余牌面分 ${dealerRemainingPoints},` +
+ `闲家获得一半 ${attackerBonus} 分`
+ );
+ return {
+ dealerPlayerId: dealer.id,
+ dealerPlayerName: dealer.name,
+ dealerRemainingCards: dealer.cards.map(card => card.toJSON ? card.toJSON() : card),
+ dealerRemainingPointCards: remainingPointCards.map(card => card.toJSON ? card.toJSON() : card),
+ dealerRemainingPoints,
+ attackerBonus,
+ scoreBeforeBonus,
+ totalScore: gameState.attackerScore
+ };
+ }
+
finishGame() {
+ // 若牌局在三轮灾期走完前结束,先按“事发”补回分数和20分,再结算底牌与升级。
+ const destroyDykeGameEndResult = this.resolveDestroyDykeAtGameEnd()
+ || (
+ this.room.gameState.destroyDykeLastResult?.reason === 'attacker_won_bottom'
+ ? this.room.gameState.destroyDykeLastResult
+ : null
+ );
this.room.gameState.phase = GamePhases.REVEALING;
this.room.gameState.endTime = new Date();
@@ -618,6 +12793,45 @@ export class GameEngine {
// 保存当前局的主牌信息到底牌结果中
bottomScoreResult.currentGameTrumpSuit = currentGameTrumpSuit;
bottomScoreResult.currentGameTrumpRank = currentGameTrumpRank;
+ if (destroyDykeGameEndResult) {
+ bottomScoreResult.destroyDyke = destroyDykeGameEndResult;
+ bottomScoreResult.totalScore = this.room.gameState.attackerScore;
+ }
+
+ // 戛然而止只补庄家本人剩余手牌的一半牌面分;底牌已按最后一轮胜负正常结算。
+ const abruptStopResult = this.applyAbruptStopBonus();
+ if (abruptStopResult) {
+ Object.assign(bottomScoreResult, {
+ abruptStop: abruptStopResult,
+ totalScore: this.room.gameState.attackerScore
+ });
+ }
+
+ // 焦点只改逐墩分牌;底牌已在上一步按普通规则独立结算。
+ const focusFigureResult = this.finalizeFocusFigureScoring(bottomScoreResult);
+ if (focusFigureResult) {
+ Object.assign(bottomScoreResult, {
+ focusFigure: focusFigureResult,
+ baseScore: focusFigureResult.focusTrickScore,
+ totalScore: focusFigureResult.totalScore
+ });
+ }
+
+ // 轮末暗弃的庄家方分牌必须等全局结束后公开并补分,避免中途分数变化泄密。
+ const lingeringDiscardResult = this.applyLingeringDiscardBonus();
+ if (lingeringDiscardResult) {
+ Object.assign(bottomScoreResult, lingeringDiscardResult, {
+ totalScore: this.room.gameState.attackerScore
+ });
+ }
+
+ // 迷雾牌必须晚于所有逐墩分和底牌分结算;补分后才计算胜负与升级。
+ const mistyFogResult = this.applyMistyFogBonus();
+ if (mistyFogResult) {
+ Object.assign(bottomScoreResult, mistyFogResult, {
+ totalScore: this.room.gameState.attackerScore
+ });
+ }
this.room.gameState.bottomScoreResult = bottomScoreResult;
logger.info(`底牌结果: ${bottomScoreResult.resultText}, 底牌分数: ${bottomScoreResult.bottomPoints}, 倍数: ${bottomScoreResult.bottomMultiplier}, 闲家总分: ${this.room.gameState.attackerScore}`);
@@ -629,7 +12843,61 @@ export class GameEngine {
logger.info(`升级结果: ${upgradeResult.attackerWon ? '闲家获胜' : '庄家获胜'}, 庄家升${upgradeResult.dealerLevelUp}级, 闲家升${upgradeResult.attackerLevelUp}级`);
// 重置下一局准备状态
- this.room.players.forEach(p => p.isReadyForNext = false);
+ this.room.players.forEach(p => p.isReadyForNext = p.isBot);
+
+ // 下一局规则选择权归本局最后一轮最大的玩家。
+ this.room.gameState.nextRuleChooserIndex = this.room.gameState.lastRoundWinnerIndex;
+ }
+
+ /** 终局只公开庄家方两人暗弃的分牌,并按牌面分补给闲家。 */
+ applyLingeringDiscardBonus() {
+ if (!isLingeringDiscardRule(this.room.gameState.selectedRule)) return null;
+
+ const dealerIndex = this.room.getPlayerIndex(this.room.gameState.buryingPlayerId);
+ const dealerTeamEntries = (this.room.gameState.lingeringDiscardedCards || []).filter(entry =>
+ !this.isAttackerPlayerIndex(entry.playerIndex, dealerIndex)
+ );
+ const cards = dealerTeamEntries.map(entry => entry.card);
+ const pointCards = extractPointCards(cards);
+ const lingeringDiscardPoints = calculateRoundPoints(pointCards);
+ const lingeringDiscardBonus = lingeringDiscardPoints;
+ const scoreBeforeLingeringDiscard = this.room.gameState.attackerScore;
+ this.room.gameState.attackerScore += lingeringDiscardBonus;
+
+ logger.info(
+ `弃掷逦迤:终局公开庄家方 ${pointCards.length} 张分牌,牌面分 ${lingeringDiscardPoints},` +
+ `闲家补 ${lingeringDiscardBonus} 分,总分 ${this.room.gameState.attackerScore}`
+ );
+
+ return {
+ lingeringDiscardCards: pointCards.map(card => card.toJSON ? card.toJSON() : card),
+ lingeringDiscardPoints,
+ lingeringDiscardBonus,
+ scoreBeforeLingeringDiscard
+ };
+ }
+
+ /** 终局才公开迷雾牌,并把其中总分的一半补给闲家。 */
+ applyMistyFogBonus() {
+ if (!isHeavyFogRule(this.room.gameState.selectedRule)) return null;
+
+ const cards = this.room.gameState.mistyFogCards || [];
+ const mistyFogPoints = calculateRoundPoints(cards);
+ const mistyFogBonus = mistyFogPoints / 2;
+ const scoreBeforeMistyFog = this.room.gameState.attackerScore;
+ this.room.gameState.attackerScore += mistyFogBonus;
+
+ logger.info(
+ `迷雾重重:公开 ${cards.length} 张迷雾牌,牌面分 ${mistyFogPoints},` +
+ `闲家补 ${mistyFogBonus} 分,总分 ${this.room.gameState.attackerScore}`
+ );
+
+ return {
+ mistyFogCards: cards.map(card => card.toJSON ? card.toJSON() : card),
+ mistyFogPoints,
+ mistyFogBonus,
+ scoreBeforeMistyFog
+ };
}
/**
@@ -640,38 +12908,142 @@ export class GameEngine {
const dealerIndex = this.room.getPlayerIndex(this.room.gameState.buryingPlayerId);
// 判断最后一轮赢家是否是闲家
- const attackerWonLastRound = isAttacker(lastRoundWinnerIndex, dealerIndex, this.room.players.length);
+ const attackerWonLastRound = this.isAttackerPlayerIndex(lastRoundWinnerIndex, dealerIndex);
- // 计算底牌分数
- const bottomPoints = calculateBottomPoints(this.room.gameState.bottomCards);
+ if (isPeopleCommuneRule(this.room.gameState.selectedRule)) {
+ return this.calculatePeopleCommuneBottomScore({
+ attackerWonLastRound,
+ dealerIndex
+ });
+ }
+
+ // 底牌是终局最后公开的牌;若重载点数此前从未出现,在这里统一揭晓并照常计分。
+ const threePowersRevealFromBottom = this.revealThreePowersIfNeeded(
+ this.room.gameState.bottomCards,
+ null,
+ 'bottom'
+ );
+ const bottomPoints = calculateBottomPoints(
+ this.room.gameState.bottomCards,
+ card => this.getRuleCardPoints(card)
+ );
+ const ambushCardCount = this.countTenSidedAmbushCards(this.room.gameState.bottomCards);
+ const ambushPoints = ambushCardCount * TEN_SIDED_AMBUSH_CARD_POINTS;
// 计算倍数
const bottomMultiplier = calculateBottomMultiplier(this.room.gameState.lastRoundLeadingPattern);
- // 如果闲家赢了最后一轮,获得底牌分数
- if (attackerWonLastRound && bottomPoints > 0) {
- const bottomScoreGained = bottomPoints * bottomMultiplier;
+ const normalBottomScore = attackerWonLastRound ? bottomPoints * bottomMultiplier : 0;
+ // 闲家抠底时伏击牌扣分;庄家守底时伏击牌反向给闲家加分。
+ const ambushScoreDelta = ambushPoints > 0
+ ? (attackerWonLastRound ? -ambushPoints : ambushPoints) * bottomMultiplier
+ : 0;
+ const ambushAttackerNetCardDelta = ambushCardCount > 0
+ ? (attackerWonLastRound ? ambushCardCount : -ambushCardCount) * bottomMultiplier
+ : 0;
+ const bottomScoreGained = normalBottomScore + ambushScoreDelta;
+
+ if (bottomScoreGained !== 0) {
this.room.gameState.attackerScore += bottomScoreGained;
// 底牌分数牌不加入 collectedPointCards,只在底牌结果中单独展示
- logger.info(`闲家拿底,获得 ${bottomPoints} x ${bottomMultiplier} = ${bottomScoreGained} 分`);
+ logger.info(
+ `底牌结算:常规 ${normalBottomScore},十面埋伏 ${ambushScoreDelta},` +
+ `闲家净变化 ${bottomScoreGained}`
+ );
+ }
+ if (ambushAttackerNetCardDelta !== 0) {
+ this.room.gameState.tenSidedAmbushAttackerNetCardCount += ambushAttackerNetCardDelta;
+ }
+
+ const ambushRevealedFromBottom = Boolean(
+ ambushCardCount > 0 && !this.room.gameState.isTenSidedAmbushRevealed
+ );
+ if (ambushRevealedFromBottom) {
+ this.room.gameState.isTenSidedAmbushRevealed = true;
}
// 生成结果摘要
- return generateScoringSummary({
- collectedPointCards: this.room.gameState.collectedPointCards.map(c => c.toJSON ? c.toJSON() : c),
- attackerScore: this.room.gameState.attackerScore,
- bottomCards: this.room.gameState.bottomCards.map(c => c.toJSON()),
- attackerWonLastRound,
- bottomMultiplier,
- bottomPoints
- });
+ return {
+ ...generateScoringSummary({
+ collectedPointCards: this.room.gameState.collectedPointCards.map(c => c.toJSON ? c.toJSON() : c),
+ attackerScore: this.room.gameState.attackerScore,
+ bottomCards: this.room.gameState.bottomCards.map(c => c.toJSON()),
+ attackerWonLastRound,
+ bottomMultiplier,
+ bottomPoints,
+ bottomScoreGained,
+ ambushRank: ambushCardCount > 0 ? this.room.gameState.tenSidedAmbushRank : null,
+ ambushCardCount,
+ ambushPoints,
+ ambushScoreDelta,
+ ambushAttackerNetCardDelta,
+ ambushAttackerNetCardCount: this.room.gameState.tenSidedAmbushAttackerNetCardCount,
+ ambushRevealedFromBottom
+ }),
+ threePowersRevealFromBottom
+ };
+ }
+
+ calculatePeopleCommuneBottomScore({ attackerWonLastRound, dealerIndex }) {
+ const { gameState } = this.room;
+ const dealerBuriedCards = [];
+ const attackerBuriedCards = [];
+
+ for (const [playerId, cards] of gameState.peopleCommuneBuriedCardsByPlayerId.entries()) {
+ const playerIndex = this.room.getPlayerIndex(playerId);
+ const target = this.isAttackerPlayerIndex(playerIndex, dealerIndex)
+ ? attackerBuriedCards
+ : dealerBuriedCards;
+ target.push(...cards);
+ }
+
+ const pointResolver = card => this.getRuleCardPoints(card);
+ const dealerBuriedPoints = calculateBottomPoints(dealerBuriedCards, pointResolver);
+ const attackerBuriedPoints = calculateBottomPoints(attackerBuriedCards, pointResolver);
+ const capturedCards = attackerWonLastRound ? dealerBuriedCards : attackerBuriedCards;
+ const capturedPoints = attackerWonLastRound ? dealerBuriedPoints : attackerBuriedPoints;
+ const bottomMultiplier = calculateBottomMultiplier(gameState.lastRoundLeadingPattern);
+ const bottomScoreGained = capturedPoints * bottomMultiplier * (attackerWonLastRound ? 1 : -1);
+ const scoreBeforeBottom = gameState.attackerScore;
+ gameState.attackerScore += bottomScoreGained;
+
+ logger.info(
+ `人民公社底牌结算:${attackerWonLastRound ? '闲家抄庄家底' : '庄家方抄闲家底'},` +
+ `对方埋分 ${capturedPoints} × ${bottomMultiplier},闲家变化 ${bottomScoreGained}`
+ );
+
+ return {
+ ...generateScoringSummary({
+ collectedPointCards: gameState.collectedPointCards.map(
+ card => card.toJSON ? card.toJSON() : card
+ ),
+ attackerScore: gameState.attackerScore,
+ bottomCards: gameState.bottomCards.map(card => card.toJSON ? card.toJSON() : card),
+ attackerWonLastRound,
+ bottomMultiplier,
+ bottomPoints: capturedPoints,
+ bottomScoreGained
+ }),
+ resultText: attackerWonLastRound ? '闲家抄庄家底' : '庄家方抄闲家底',
+ scoreBeforeBottom,
+ peopleCommune: {
+ capturedSide: attackerWonLastRound ? 'dealer' : 'attacker',
+ capturedCards: capturedCards.map(card => card.toJSON ? card.toJSON() : card),
+ capturedPoints,
+ dealerBuriedCards: dealerBuriedCards.map(card => card.toJSON ? card.toJSON() : card),
+ dealerBuriedPoints,
+ attackerBuriedCards: attackerBuriedCards.map(card => card.toJSON ? card.toJSON() : card),
+ attackerBuriedPoints,
+ scoreDelta: bottomScoreGained
+ }
+ };
}
/**
* 计算升级
*/
- calculateUpgrade() {
+ calculateUpgrade(forcedOutcome = null) {
const attackerScore = this.room.gameState.attackerScore;
const dealerIndex = this.room.getPlayerIndex(this.room.gameState.buryingPlayerId);
@@ -681,11 +13053,14 @@ export class GameEngine {
}
// 计算升级数
- const { attackerWon, dealerLevelUp, attackerLevelUp } = calculateLevelUpgrade(attackerScore);
+ const { attackerWon, dealerLevelUp, attackerLevelUp } = forcedOutcome
+ || calculateLevelUpgrade(attackerScore);
+
+ const dealerPlayer = this.room.findPlayerByIndex(dealerIndex);
- // 确定庄家队伍和闲家队伍
- // 队伍1: 索引0和2, 队伍2: 索引1和3
- const dealerTeam = dealerIndex % 2 === 0 ? 1 : 2;
+ // 欢乐成双中队伍仍按换位前的玩家组合;其他规则按当前固定座位奇偶分队。
+ const dealerTeamIndex = this.getPlayerTeamIndex(dealerPlayer?.id) ?? (dealerIndex % 2);
+ const dealerTeam = dealerTeamIndex + 1;
const attackerTeam = dealerTeam === 1 ? 2 : 1;
// 升级前的等级
@@ -693,10 +13068,9 @@ export class GameEngine {
const oldAttackerLevel = attackerTeam === 1 ? this.room.gameState.team1Level : this.room.gameState.team2Level;
// 升级
- let newDealerLevel = upgradeLevel(oldDealerLevel, dealerLevelUp);
- let newAttackerLevel = upgradeLevel(oldAttackerLevel, attackerLevelUp);
+ const newDealerLevel = upgradeLevel(oldDealerLevel, dealerLevelUp);
+ const newAttackerLevel = upgradeLevel(oldAttackerLevel, attackerLevelUp);
- // 更新队伍等级
if (dealerTeam === 1) {
this.room.gameState.team1Level = newDealerLevel;
this.room.gameState.team2Level = newAttackerLevel;
@@ -705,9 +13079,38 @@ export class GameEngine {
this.room.gameState.team2Level = newDealerLevel;
}
- // 计算下一局庄家
- const nextDealerIndex = getNextDealerIndex(dealerIndex, attackerWon, this.room.players.length);
- this.room.gameState.dealerPlayerIndex = nextDealerIndex;
+ // 势如破竹下庄家方获胜时原庄家连庄;闲家获胜仍按常规换庄。
+ const dealerContinues = !attackerWon &&
+ isIrresistibleForceRule(this.room.gameState.selectedRule);
+ const shouldRestoreHappyTwins = isHappyTwinsRule(this.room.gameState.selectedRule)
+ && this.room.gameState.happyTwins?.active
+ && !this.room.gameState.happyTwins?.restored;
+
+ let provisionalNextDealerPlayer = null;
+ if (dealerContinues) {
+ provisionalNextDealerPlayer = dealerPlayer;
+ } else if (shouldRestoreHappyTwins) {
+ provisionalNextDealerPlayer = attackerWon
+ // 换位后的庄家下家,就是换位前的庄家上家。
+ ? this.room.findPlayerByIndex((dealerIndex + 1) % this.room.players.length)
+ // 只换座位不换队伍,庄家方获胜仍由庄家的固定队友接庄。
+ : this.getFixedTeammate(dealerPlayer?.id);
+ } else {
+ provisionalNextDealerPlayer = this.room.findPlayerByIndex(
+ getNextDealerIndex(dealerIndex, attackerWon, this.room.players.length)
+ );
+ }
+ if (!provisionalNextDealerPlayer) throw new Error('找不到下一局庄家');
+
+ const provisionalNextDealerIndex = this.room.getPlayerIndex(provisionalNextDealerPlayer.id);
+ let nextDealerIndex = provisionalNextDealerIndex;
+ let happyTwinsRestore = null;
+ if (shouldRestoreHappyTwins) {
+ happyTwinsRestore = this.restoreHappyTwinsPositions(provisionalNextDealerPlayer.id);
+ nextDealerIndex = happyTwinsRestore?.nextDealerIndex ?? provisionalNextDealerIndex;
+ } else {
+ this.room.gameState.dealerPlayerIndex = nextDealerIndex;
+ }
// 更新下一局的级牌(根据下一局庄家的等级)
const nextDealerTeam = nextDealerIndex % 2 === 0 ? 1 : 2;
@@ -717,7 +13120,6 @@ export class GameEngine {
logger.info(`下一局庄家: 玩家${nextDealerIndex}, 等级: ${nextDealerLevel}, 级牌: ${this.room.gameState.trumpRank}`);
// 获取玩家名称
- const dealerPlayer = this.room.findPlayerByIndex(dealerIndex);
const nextDealerPlayer = this.room.findPlayerByIndex(nextDealerIndex);
return {
@@ -732,6 +13134,12 @@ export class GameEngine {
newAttackerLevel,
currentDealerIndex: dealerIndex,
currentDealerName: dealerPlayer ? dealerPlayer.name : '未知',
+ dealerContinues,
+ happyTwins: happyTwinsRestore ? {
+ positionsRestored: true,
+ nextDealerPlayerId: happyTwinsRestore.nextDealerPlayerId,
+ restoredOrderPlayerIds: happyTwinsRestore.restoredOrderPlayerIds
+ } : null,
nextDealerIndex,
nextDealerName: nextDealerPlayer ? nextDealerPlayer.name : '未知',
nextDealerLevel,
@@ -770,9 +13178,6 @@ export class GameEngine {
startNextGame() {
this.room.resetForNewGame();
- // 重置游戏状态
- this.room.gameState.reset();
-
// 所有玩家自动准备
this.room.players.forEach(player => {
player.isReady = true;
@@ -789,14 +13194,18 @@ export class GameEngine {
trumpRank: this.room.gameState.trumpRank
});
- // 直接开始发牌
- this.startDrawing();
+ // 先由上一局最后一轮赢家完成二选一,再开始发牌。
+ this.startRuleSelection();
}
/**
* 重新开始游戏(房主手动重启)
*/
restartGame() {
+ if (isBurnTheBoatsRule(this.room.gameState.selectedRule)) {
+ throw new Error('破釜沉舟规则禁止重开');
+ }
+ this.cleanup();
this.room.resetForNewGame();
this.startGame();
}
@@ -814,6 +13223,23 @@ export class GameEngine {
this.drawingManager = null;
}
+ if (this.botActionTimer) {
+ clearTimeout(this.botActionTimer);
+ this.botActionTimer = null;
+ }
+ if (this.peopleCommuneBuryTimer) {
+ clearTimeout(this.peopleCommuneBuryTimer);
+ this.peopleCommuneBuryTimer = null;
+ }
+
+ if (this.timeReversalDecisionTimer) {
+ clearTimeout(this.timeReversalDecisionTimer);
+ this.timeReversalDecisionTimer = null;
+ }
+ this.timeReversalRoundSnapshot = null;
+
+ this.cardExchangeSelections.clear();
+
// 停止回合管理器(如果有需要清理的资源)
if (this.roundManager) {
this.roundManager = null;
diff --git a/tractor-game-simulator/server/src/services/RoomManager.js b/tractor-game-simulator/server/src/services/RoomManager.js
index 5670d92..5261224 100644
--- a/tractor-game-simulator/server/src/services/RoomManager.js
+++ b/tractor-game-simulator/server/src/services/RoomManager.js
@@ -40,6 +40,16 @@ export class RoomManager {
return null;
}
+ findRoomByResumeToken(resumeToken) {
+ if (!resumeToken) return null;
+ for (const room of this.rooms.values()) {
+ if (room.players.some(player => player.resumeToken === resumeToken)) {
+ return room;
+ }
+ }
+ return null;
+ }
+
getRoomList() {
return this.getAllRooms().map(room => ({
id: room.id,
@@ -47,6 +57,10 @@ export class RoomManager {
playerCount: room.players.length,
maxPlayers: room.config.maxPlayers,
phase: room.gameState.phase,
+ gameState: {
+ phase: room.gameState.phase,
+ isWaitingForReady: room.gameState.isWaitingForReady
+ },
createdAt: room.createdAt
}));
}
diff --git a/tractor-game-simulator/server/src/services/RoundManager.js b/tractor-game-simulator/server/src/services/RoundManager.js
index c3ee877..ce89e4b 100644
--- a/tractor-game-simulator/server/src/services/RoundManager.js
+++ b/tractor-game-simulator/server/src/services/RoundManager.js
@@ -1,4 +1,5 @@
import { PlayModes, TurnOrders } from '../utils/constants.js';
+import { isStriveUpstreamRule } from '../rules/ruleRegistry.js';
export class RoundManager {
constructor(room) {
@@ -89,19 +90,56 @@ export class RoundManager {
* 默认逆时针(从玩家视角,向右传递)
*/
moveToNextPlayer(currentPlayerIndex) {
+ const playerCount = this.room.players.length;
+
+ if (
+ isStriveUpstreamRule(this.gameState.selectedRule)
+ && this.gameState.striveUpstreamPlayOrder.length === playerCount
+ ) {
+ const currentOrderIndex = this.gameState.striveUpstreamPlayOrder.indexOf(currentPlayerIndex);
+ if (currentOrderIndex >= 0) {
+ for (let offset = 1; offset <= playerCount; offset += 1) {
+ const candidateIndex = this.gameState.striveUpstreamPlayOrder[
+ (currentOrderIndex + offset) % playerCount
+ ];
+ if (!this.gameState.playersPlayedThisRound.has(candidateIndex)) {
+ this.gameState.currentPlayerIndex = candidateIndex;
+ return candidateIndex;
+ }
+ }
+ return null;
+ }
+ }
+
+ // 部分规则会临时调换同一轮内的出牌次序。此时不能简单落到物理下家,
+ // 否则可能再次轮到已经出过牌的玩家。
+ for (let offset = 1; offset <= playerCount; offset += 1) {
+ const candidateIndex = this.getPlayerIndexAtOffset(currentPlayerIndex, offset);
+ if (!this.gameState.playersPlayedThisRound.has(candidateIndex)) {
+ this.gameState.currentPlayerIndex = candidateIndex;
+ return candidateIndex;
+ }
+ }
+
+ return null;
+ }
+
+ /** 按本房间的固定出牌方向,取得相对座位。 */
+ getPlayerIndexAtOffset(currentPlayerIndex, offset = 1) {
const { turnOrder, customTurnOrder } = this.config;
const playerCount = this.room.players.length;
+ const directionMultiplier = this.gameState.turnDirection === TurnOrders.CLOCKWISE ? -1 : 1;
+ const directedOffset = offset * directionMultiplier;
if (turnOrder === TurnOrders.CUSTOM && customTurnOrder) {
- // 自定义顺序
const currentPos = customTurnOrder.indexOf(currentPlayerIndex);
- const nextPos = (currentPos + 1) % playerCount;
- this.gameState.currentPlayerIndex = customTurnOrder[nextPos];
- } else {
- // 默认逆时针:索引递增
- this.gameState.currentPlayerIndex =
- (currentPlayerIndex + 1) % playerCount;
+ if (currentPos >= 0) {
+ const targetPos = (currentPos + directedOffset + playerCount) % playerCount;
+ return customTurnOrder[targetPos];
+ }
}
+
+ return (currentPlayerIndex + directedOffset + playerCount) % playerCount;
}
/**
diff --git a/tractor-game-simulator/server/src/services/whoDesignedStrategy.js b/tractor-game-simulator/server/src/services/whoDesignedStrategy.js
new file mode 100644
index 0000000..e7fe52a
--- /dev/null
+++ b/tractor-game-simulator/server/src/services/whoDesignedStrategy.js
@@ -0,0 +1,226 @@
+import { BotTypes, Ranks, Suits, normalizeRank } from '../utils/constants.js';
+import { isTrumpCard } from '../utils/cardPatternUtils.js';
+
+const STANDARD_SUITS = Object.freeze([
+ Suits.SPADES,
+ Suits.HEARTS,
+ Suits.CLUBS,
+ Suits.DIAMONDS
+]);
+
+const RANK_ORDER = Object.freeze([
+ Ranks.TWO,
+ Ranks.THREE,
+ Ranks.FOUR,
+ Ranks.FIVE,
+ Ranks.SIX,
+ Ranks.SEVEN,
+ Ranks.EIGHT,
+ Ranks.NINE,
+ Ranks.TEN,
+ Ranks.JACK,
+ Ranks.QUEEN,
+ Ranks.KING,
+ Ranks.ACE
+]);
+
+const POINT_RANKS = new Set([Ranks.FIVE, Ranks.TEN, Ranks.KING]);
+
+function evaluateSuit(cards, trumpRank) {
+ const counts = new Map();
+ cards.forEach(card => counts.set(card.rank, (counts.get(card.rank) || 0) + 1));
+
+ let score = 0;
+ for (const [rank, count] of counts) {
+ const isPair = count >= 2;
+ score += isPair ? 2.2 : 1;
+ if (rank === trumpRank) {
+ score += isPair ? 1 : 0.5;
+ continue;
+ }
+
+ const rankIndex = RANK_ORDER.indexOf(rank);
+ if (rankIndex >= 8) {
+ score += (isPair ? 0.2 : 0.1) * (rankIndex - 7);
+ }
+ }
+ return score;
+}
+
+/**
+ * Port of WhoDesigned/myutils.py call_Snatch for the normal four-suit game.
+ * The original strategy deliberately does not call no-trump.
+ */
+export function chooseWhoDesignedTrumpDeclaration({
+ cards,
+ trumpRank,
+ currentTrumpDeclaration = null,
+ playerId = null
+}) {
+ const level = normalizeRank(trumpRank);
+ if (!RANK_ORDER.includes(level) || !Array.isArray(cards) || cards.length === 0) {
+ return null;
+ }
+
+ const levelSuitCounts = new Map();
+ const scoredSuits = STANDARD_SUITS.map(suit => {
+ const suitCards = cards.filter(card => card.suit === suit);
+ levelSuitCounts.set(
+ suit,
+ suitCards.filter(card => card.rank === level).length
+ );
+ return { suit, score: evaluateSuit(suitCards, level) };
+ }).sort((a, b) => b.score - a.score);
+
+ if (!currentTrumpDeclaration) {
+ const candidate = scoredSuits.find(
+ ({ suit, score }) => levelSuitCounts.get(suit) >= 1 && score >= 5.6
+ );
+ return candidate ? { suit: candidate.suit, count: 1 } : null;
+ }
+
+ // call_Snatch only counters an initial single declaration. The server may
+ // support stronger DLC declarations, but those are outside this bot's model.
+ if (currentTrumpDeclaration.strength >= 2) return null;
+
+ const currentSuitScore = scoredSuits.find(
+ item => item.suit === currentTrumpDeclaration.suit
+ )?.score ?? 0;
+ const candidate = scoredSuits.find(({ suit, score }) => {
+ if (levelSuitCounts.get(suit) < 2) return false;
+ if (
+ playerId
+ && currentTrumpDeclaration.playerId === playerId
+ && currentTrumpDeclaration.suit !== suit
+ ) return false;
+ return currentSuitScore <= 4 || score >= currentSuitScore + 0.5;
+ });
+
+ return candidate ? { suit: candidate.suit, count: 2 } : null;
+}
+
+function cardRankIndex(card) {
+ const index = RANK_ORDER.indexOf(card.rank);
+ return index === -1 ? Number.MAX_SAFE_INTEGER : index;
+}
+
+function sortLowCards(cards) {
+ return [...cards].sort((a, b) => (
+ cardRankIndex(a) - cardRankIndex(b)
+ || String(a.id).localeCompare(String(b.id))
+ ));
+}
+
+/**
+ * Port of move_generator.cover_Pub's priorities, generalized to the room's
+ * configured bottom-card count.
+ */
+export function chooseWhoDesignedCardsToBury(
+ cards,
+ requiredCount,
+ trumpSuit,
+ trumpRank
+) {
+ if (!Array.isArray(cards) || requiredCount <= 0) return [];
+
+ const selected = [];
+ const selectedIds = new Set();
+ const add = card => {
+ if (!card || selectedIds.has(card.id) || selected.length >= requiredCount) return false;
+ selected.push(card);
+ selectedIds.add(card.id);
+ return true;
+ };
+
+ const sideSuitGroups = STANDARD_SUITS
+ .filter(suit => suit !== trumpSuit)
+ .map(suit => {
+ const suitCards = sortLowCards(
+ cards.filter(card => (
+ card.suit === suit
+ && !isTrumpCard(card, trumpSuit, trumpRank)
+ ))
+ );
+ const counts = new Map();
+ suitCards.forEach(card => counts.set(card.rank, (counts.get(card.rank) || 0) + 1));
+ return {
+ suit,
+ singles: suitCards.filter(card => (counts.get(card.rank) || 0) === 1),
+ pairs: suitCards.filter(card => (counts.get(card.rank) || 0) >= 2)
+ };
+ });
+
+ // First make short side suits void with low, non-point singletons.
+ [...sideSuitGroups]
+ .sort((a, b) => (
+ a.singles.filter(card => !POINT_RANKS.has(card.rank) && card.rank !== Ranks.ACE).length
+ - b.singles.filter(card => !POINT_RANKS.has(card.rank) && card.rank !== Ranks.ACE).length
+ ))
+ .forEach(group => {
+ group.singles
+ .filter(card => !POINT_RANKS.has(card.rank) && card.rank !== Ranks.ACE)
+ .forEach(add);
+ });
+
+ // The original bot next sacrifices isolated point cards, one suit at a time.
+ [...sideSuitGroups]
+ .sort((a, b) => a.singles.length - b.singles.length)
+ .forEach(group => {
+ group.singles.filter(card => POINT_RANKS.has(card.rank)).forEach(add);
+ });
+
+ // Break low, non-point side pairs only when there is room for both cards.
+ [...sideSuitGroups]
+ .sort((a, b) => a.pairs.length - b.pairs.length)
+ .forEach(group => {
+ const ranks = [...new Set(
+ group.pairs
+ .filter(card => !POINT_RANKS.has(card.rank) && card.rank !== Ranks.ACE)
+ .map(card => card.rank)
+ )];
+ ranks.forEach(rank => {
+ if (requiredCount - selected.length < 2) return;
+ group.pairs.filter(card => card.rank === rank).slice(0, 2).forEach(add);
+ });
+ });
+
+ // If still short, give up low non-point trump singletons before protected
+ // pairs and high/value cards.
+ const trumpCards = sortLowCards(
+ cards.filter(card => isTrumpCard(card, trumpSuit, trumpRank))
+ );
+ const trumpCounts = new Map();
+ trumpCards.forEach(card => {
+ const key = `${card.suit}:${card.rank}`;
+ trumpCounts.set(key, (trumpCounts.get(key) || 0) + 1);
+ });
+ trumpCards
+ .filter(card => (
+ (trumpCounts.get(`${card.suit}:${card.rank}`) || 0) === 1
+ && !POINT_RANKS.has(card.rank)
+ && card.suit !== Suits.JOKER
+ && card.rank !== trumpRank
+ ))
+ .forEach(add);
+
+ // Defensive completion for unusual rules/hands. Keep the same broad
+ // priorities: side cards, non-points, low cards, then trumps.
+ [...cards]
+ .filter(card => !selectedIds.has(card.id))
+ .sort((a, b) => {
+ const aTrump = isTrumpCard(a, trumpSuit, trumpRank) ? 1 : 0;
+ const bTrump = isTrumpCard(b, trumpSuit, trumpRank) ? 1 : 0;
+ if (aTrump !== bTrump) return aTrump - bTrump;
+ const aPoint = POINT_RANKS.has(a.rank) ? 1 : 0;
+ const bPoint = POINT_RANKS.has(b.rank) ? 1 : 0;
+ if (aPoint !== bPoint) return aPoint - bPoint;
+ return cardRankIndex(a) - cardRankIndex(b);
+ })
+ .forEach(add);
+
+ return selected.slice(0, requiredCount);
+}
+
+export function shouldUseWhoDesignedStrategy(room) {
+ return room?.config?.botType === BotTypes.WHO_DESIGNED;
+}
diff --git a/tractor-game-simulator/server/src/socket/handlers/gameHandlers.js b/tractor-game-simulator/server/src/socket/handlers/gameHandlers.js
index f7368e0..da93c83 100644
--- a/tractor-game-simulator/server/src/socket/handlers/gameHandlers.js
+++ b/tractor-game-simulator/server/src/socket/handlers/gameHandlers.js
@@ -1,5 +1,11 @@
import { GameEngine } from '../../services/GameEngine.js';
-import { GamePhases, normalizeRank } from '../../utils/constants.js';
+import { GamePhases } from '../../utils/constants.js';
+import {
+ isAdministrativeReviewRule,
+ isOpenlyRevealedRule,
+ isPeopleCommuneRule,
+ isReformAndOpeningUpRule
+} from '../../rules/ruleRegistry.js';
import BotService from '../../services/BotService.js';
import logger from '../../utils/logger.js';
@@ -19,6 +25,533 @@ export function getBotServices() {
return botServices;
}
+function emitWholeHandExchange(io, room, exchange) {
+ if (!exchange) return;
+ io.to(room.id).emit('whole_hand_exchange_resolved', exchange);
+ room.players.forEach(player => {
+ if (!player.socketId) return;
+ const incoming = exchange.transfers.find(transfer => transfer.toPlayerId === player.id);
+ io.to(player.socketId).emit('whole_hand_exchange_hand_updated', {
+ ...exchange,
+ fromPlayerId: incoming?.fromPlayerId || null,
+ fromPlayerName: incoming?.fromPlayerName || null,
+ cards: player.cards.map(card => card.toJSON())
+ });
+ });
+}
+
+function emitPlannedEconomyDraw(io, room, draw) {
+ if (!draw?.draws?.length) return;
+ io.to(room.id).emit('planned_economy_cards_drawn', {
+ round: draw.round,
+ draws: draw.draws.map(({ playerId, playerName, cardsCount }) => ({
+ playerId,
+ playerName,
+ cardsCount
+ })),
+ remainingCards: draw.remainingCards,
+ animationDuration: draw.animationDuration
+ });
+ draw.draws.forEach(({ playerId, card, cardsCount }) => {
+ const player = room.findPlayerById(playerId);
+ if (!player?.socketId) return;
+ io.to(player.socketId).emit('card_dealt', {
+ card,
+ totalCards: cardsCount,
+ source: 'planned_economy',
+ round: draw.round
+ });
+ });
+}
+
+function emitEquivalentReciprocityResolution(io, room, result) {
+ if (!result?.resolved) return;
+ const { hands = [], ...publicResult } = result;
+ io.to(room.id).emit('equivalent_reciprocity_resolved', publicResult);
+ hands.forEach(hand => {
+ const player = room.findPlayerById(hand.playerId);
+ if (!player?.socketId) return;
+ io.to(player.socketId).emit('equivalent_reciprocity_hand_updated', {
+ challengeId: result.challengeId,
+ cards: hand.cards,
+ animationDuration: result.animationDuration
+ });
+ });
+}
+
+function getPublicMutualSupportAction(room, action) {
+ if (!action) return null;
+ return {
+ actionId: action.id,
+ stage: action.stage,
+ round: action.round,
+ chooserPlayerId: action.chooserPlayerId,
+ chooserPlayerName: room.findPlayerById(action.chooserPlayerId)?.name || '未知玩家',
+ otherPlayerId: action.otherPlayerId,
+ otherPlayerName: room.findPlayerById(action.otherPlayerId)?.name || '未知玩家',
+ fromPlayerId: action.fromPlayerId,
+ toPlayerId: action.toPlayerId,
+ minCards: action.minCards,
+ maxCards: action.maxCards,
+ requiredCards: action.requiredCards ?? null
+ };
+}
+
+function emitMutualSupportActionRequired(io, room, action) {
+ if (!action) return;
+ const chooser = room.findPlayerById(action.chooserPlayerId);
+ if (!chooser?.socketId || chooser.isBot) return;
+ io.to(chooser.socketId).emit(
+ 'mutual_support_cards_required',
+ getPublicMutualSupportAction(room, action)
+ );
+}
+
+function emitMutualSupportResolution(io, room, result) {
+ if (!result?.resolved || !result.transfer) return;
+ const { hands = [], ...publicTransfer } = result.transfer;
+ io.to(room.id).emit('mutual_support_transfer_resolved', {
+ actionId: result.actionId,
+ stage: result.stage || result.transfer.stage,
+ ...publicTransfer
+ });
+ hands.forEach(hand => {
+ const player = room.findPlayerById(hand.playerId);
+ if (!player?.socketId) return;
+ io.to(player.socketId).emit('mutual_support_hand_updated', {
+ actionId: result.actionId,
+ stage: result.stage || result.transfer.stage,
+ cards: hand.cards,
+ animationDuration: result.transfer.animationDuration
+ });
+ });
+}
+
+function emitCulturalRevolutionExpiry(io, room, transition) {
+ if (!transition) return;
+ io.to(room.id).emit('cultural_revolution_expired', transition);
+ io.to(room.id).emit('trump_updated', {
+ trumpSuit: transition.restoredTrumpSuit,
+ trumpRank: transition.restoredTrumpRank,
+ culturalRevolutionExpired: transition
+ });
+}
+
+function emitCulturalRevolutionActivation(io, room, result) {
+ if (!result) return;
+ io.to(room.id).emit('active_skill_activated', {
+ id: result.activeSkillId,
+ name: result.activeSkillName,
+ playerId: result.playerId,
+ playerName: result.playerName
+ });
+ io.to(room.id).emit('cultural_revolution_activated', result);
+ io.to(room.id).emit('trump_updated', {
+ trumpSuit: result.effectiveTrumpSuit,
+ trumpRank: result.effectiveTrumpRank,
+ culturalRevolution: result
+ });
+}
+
+function emitThreeTigersTransformation(io, room, transformation) {
+ if (!transformation) return;
+ io.to(room.id).emit('three_tigers_transformed', transformation);
+}
+
+function emitConcealedCardsToPlayer(io, room, result) {
+ if (!result?.concealed) return;
+ const concealedPlayer = room.findPlayerById(result.playerId);
+ if (!concealedPlayer?.socketId) return;
+ // 必须先于公共 cards_played 发送。出牌者客户端会先缓存真实牌面,
+ // 从而第一帧就画正面;其他玩家仍只能从公共事件看到牌背与张数。
+ io.to(concealedPlayer.socketId).emit('concealed_cards_played_private', {
+ playerId: result.playerId,
+ cards: result.playedCards
+ });
+}
+
+export function emitCardsPlayed(io, room, result) {
+ // 私有牌面先到达出牌者,随后才广播只含张数的公共事件。
+ emitConcealedCardsToPlayer(io, room, result);
+ io.to(room.id).emit('cards_played', {
+ playerId: result.playerId,
+ playerName: result.playerName,
+ controllerPlayerId: result.controllerPlayerId,
+ controllerPlayerName: result.controllerPlayerName,
+ isProxy: result.isProxy,
+ cards: result.concealed ? [] : result.playedCards,
+ removedCardIds: result.waitingRabbitPlayedCardIds || result.playedCards.map(card => card.id),
+ cardsCount: result.playedCards.length,
+ concealed: result.concealed,
+ treatedAsSmall: result.treatedAsSmall,
+ activeSkillId: result.activeSkillActivation?.id || null,
+ activeSkillName: result.activeSkillActivation?.name || null,
+ jokerSubstitutions: result.jokerSubstitutions || [],
+ clusterAnalysisSubstitutions: result.clusterAnalysisSubstitutions || [],
+ forbiddenMagicSubstitutions: result.forbiddenMagicSubstitutions || [],
+ enduringInheritance: result.enduringInheritance || null,
+ dreamKilling: result.dreamKilling || null,
+ oldHorseAbsolute: Boolean(result.oldHorseAbsolute),
+ lureTigerSilenced: Boolean(result.lureTigerSilenced),
+ ironEvidenceMode: result.ironEvidenceMode || null,
+ waitingRabbitExchange: result.waitingRabbitExchange || null,
+ ambiguousOptions: result.ambiguousOptions || null,
+ remainingCount: result.remainingCount,
+ currentWinningPlayerId: result.currentWinningPlayerId
+ });
+}
+
+function submitAutomaticMutualSupportActions(io, room, gameEngine) {
+ let lastResult = null;
+ while (room.gameState.mutualSupportPendingAction) {
+ const action = room.gameState.mutualSupportPendingAction;
+ const chooser = room.findPlayerById(action.chooserPlayerId);
+ if (!chooser?.isBot) {
+ emitMutualSupportActionRequired(io, room, action);
+ break;
+ }
+ const cardIds = gameEngine.selectMutualSupportCardsForBot(chooser.id);
+ lastResult = gameEngine.submitMutualSupportCards(chooser.id, action.id, cardIds);
+ emitMutualSupportResolution(io, room, lastResult);
+ if (lastResult.gameFinished) {
+ emitFinishedGame(io, room);
+ break;
+ }
+ }
+ return lastResult;
+}
+
+function submitAutomaticEquivalentReciprocityCards(io, room, gameEngine, participantPlayerIds) {
+ let lastResult = null;
+ for (const playerId of participantPlayerIds) {
+ const challenge = room.gameState.equivalentReciprocityChallenge;
+ if (!challenge) break;
+ const player = room.findPlayerById(playerId);
+ if (!player?.isBot || challenge.selectedCardsByPlayerId.has(player.id)) continue;
+ const card = gameEngine.selectEquivalentReciprocityCardForBot(player.id);
+ if (!card) continue;
+ lastResult = gameEngine.submitEquivalentReciprocityCard(player.id, challenge.id, card.id);
+ io.to(room.id).emit('equivalent_reciprocity_selection_recorded', {
+ challengeId: challenge.id,
+ playerId: player.id,
+ playerName: player.name,
+ selectedPlayerIds: lastResult.selectedPlayerIds
+ });
+ if (lastResult.resolved) {
+ emitEquivalentReciprocityResolution(io, room, lastResult);
+ break;
+ }
+ }
+ return lastResult;
+}
+
+function beginRoundCardExchange(io, room, gameEngine, exchange) {
+ if (!exchange) return null;
+ io.to(room.id).emit('card_exchange_started', exchange);
+ io.to(room.id).emit('room_updated', { room: room.toJSON() });
+ return gameEngine.submitAutomaticRoundCardExchanges();
+}
+
+function emitStrawBoatBorrowingArrowsResolution(io, room, result) {
+ if (!result) return;
+ const { handCards = [], ...publicResult } = result;
+ io.to(room.id).emit('straw_boat_borrowing_arrows_resolved', publicResult);
+ const player = room.findPlayerById(result.playerId);
+ if (result.accepted && player?.socketId) {
+ io.to(player.socketId).emit('straw_boat_borrowing_arrows_hand_updated', {
+ decisionId: result.decisionId,
+ cards: handCards
+ });
+ }
+}
+
+function beginWaitingRabbitDecision(io, room, gameEngine, decision) {
+ if (!decision) return null;
+ io.to(room.id).emit('waiting_rabbit_triggered', decision);
+ const chooser = room.findPlayerById(decision.chooserPlayerId);
+ if (chooser?.isBot) {
+ const discardCard = chooser.cards.find(card => gameEngine.getRuleCardPoints(card) === 0);
+ const { roundResult } = gameEngine.resolveWaitingRabbitDecision(chooser.id, {
+ accept: Boolean(discardCard),
+ discardCardId: discardCard?.id || null
+ });
+ emitWaitingRabbitResolution(io, room, roundResult);
+ return roundResult;
+ }
+ if (chooser?.socketId && !chooser.isBot) {
+ io.to(chooser.socketId).emit('waiting_rabbit_exchange_required', {
+ ...decision,
+ eligibleDiscardCardIds: chooser.cards
+ .filter(card => gameEngine.getRuleCardPoints(card) === 0)
+ .map(card => card.id)
+ });
+ }
+ return null;
+}
+
+function emitWaitingRabbitResolution(io, room, result) {
+ if (!result?.waitingRabbitResolution) return;
+ io.to(room.id).emit('waiting_rabbit_resolved', result.waitingRabbitResolution);
+ const chooser = room.findPlayerById(result.waitingRabbitResolution.chooserPlayerId);
+ if (result.waitingRabbitChooserHand && chooser?.socketId) {
+ io.to(chooser.socketId).emit('waiting_rabbit_hand_updated', {
+ decisionId: result.waitingRabbitResolution.id,
+ cards: result.waitingRabbitChooserHand
+ });
+ }
+}
+
+function beginStrawBoatBorrowingArrowsDecision(io, room, gameEngine, decision) {
+ if (!decision) return null;
+ io.to(room.id).emit('straw_boat_borrowing_arrows_required', decision);
+ const player = room.findPlayerById(decision.playerId);
+ if (!player?.isBot) return null;
+
+ const choice = gameEngine.selectStrawBoatBorrowingArrowsForBot(player.id)
+ || { accept: false, cardId: null };
+ const result = gameEngine.resolveStrawBoatBorrowingArrows(player.id, choice);
+ emitStrawBoatBorrowingArrowsResolution(io, room, result);
+ return result;
+}
+
+function emitTeammateCheerResolution(io, room, result) {
+ if (!result) return;
+ const { transformedCards = [], ...publicResult } = result;
+ io.to(room.id).emit(
+ result.accepted ? 'teammate_cheer_activated' : 'teammate_cheer_declined',
+ publicResult
+ );
+ if (result.accepted) {
+ const buffedPlayer = room.findPlayerById(result.buffedPlayerId);
+ if (buffedPlayer?.socketId && !buffedPlayer.isBot) {
+ io.to(buffedPlayer.socketId).emit('teammate_cheer_hand_updated', {
+ sourcePlayerId: result.playerId,
+ sourcePlayerName: result.playerName,
+ cards: transformedCards
+ });
+ }
+ }
+}
+
+function beginTeammateCheerDecision(io, room, gameEngine, decision) {
+ if (!decision) return null;
+ io.to(room.id).emit('teammate_cheer_decision_pending', decision);
+ const player = room.findPlayerById(decision.playerId);
+ if (!player?.isBot) {
+ if (player?.socketId) {
+ io.to(player.socketId).emit('teammate_cheer_decision_required', decision);
+ }
+ return null;
+ }
+
+ const result = gameEngine.respondTeammateCheer(player.id, true);
+ emitTeammateCheerResolution(io, room, result);
+ if (result.gameFinished) emitFinishedGame(io, room);
+ return result;
+}
+
+function broadcastAfterglowResolution(io, room, result) {
+ if (!result) return;
+ const { transformedCards = [], ...publicResult } = result;
+ io.to(room.id).emit(
+ result.accepted ? 'afterglow_activated' : 'afterglow_declined',
+ publicResult
+ );
+ if (!result.accepted) return;
+ const player = room.findPlayerById(result.playerId);
+ if (player?.socketId && !player.isBot) {
+ io.to(player.socketId).emit('afterglow_hand_updated', {
+ cards: transformedCards
+ });
+ }
+}
+
+function beginAfterglowDecision(io, room, gameEngine, decision) {
+ if (!decision) return null;
+ io.to(room.id).emit('afterglow_decision_pending', decision);
+ const player = room.findPlayerById(decision.playerId);
+ if (!player?.isBot) {
+ if (player?.socketId) {
+ io.to(player.socketId).emit('afterglow_decision_required', decision);
+ }
+ return null;
+ }
+
+ const result = gameEngine.respondAfterglow(player.id, true);
+ broadcastAfterglowResolution(io, room, result);
+ return result;
+}
+
+function emitAmbiguousFinalRound(io, room, gameEngine, result) {
+ const roundResult = result?.roundResult;
+ if (!roundResult) return;
+ io.to(room.id).emit('ambiguous_round_resolved', result.resolution);
+ if (roundResult.roundUpdate) {
+ io.to(room.id).emit('round_updated', roundResult.roundUpdate);
+ }
+ if (roundResult.remainingCount === 0) {
+ io.to(room.id).emit('player_finished', {
+ playerId: roundResult.playerId,
+ playerName: roundResult.playerName
+ });
+ }
+ if (roundResult.gameFinished) emitFinishedGame(io, room);
+ io.to(room.id).emit('room_updated', { room: room.toJSON() });
+ if (!roundResult.gameFinished) {
+ triggerBotPlay(io, room, gameEngine).catch(error => {
+ logger.error('模棱两可选择完成后触发Bot出牌失败:', error);
+ });
+ }
+}
+
+function emitAmbiguousChoiceResult(io, room, result) {
+ io.to(room.id).emit('ambiguous_choice_resolved', result.choice);
+ const player = room.findPlayerById(result.choice.playerId);
+ if (player?.socketId && !player.isBot) {
+ io.to(player.socketId).emit('ambiguous_hand_updated', {
+ cards: result.handCards
+ });
+ }
+}
+
+function beginAmbiguousChoiceDecision(io, room, gameEngine, decision) {
+ let request = decision?.currentRequest || decision;
+ if (!request?.playerId) return null;
+ io.to(room.id).emit('ambiguous_choice_pending', {
+ round: request.round,
+ playerId: request.playerId,
+ playerName: request.playerName,
+ position: request.position
+ });
+
+ while (request?.playerId) {
+ const player = room.findPlayerById(request.playerId);
+ if (!player?.isBot) {
+ if (player?.socketId) {
+ io.to(player.socketId).emit('ambiguous_choice_required', request);
+ }
+ return null;
+ }
+ const result = gameEngine.resolveAmbiguousChoice(player.id, 0);
+ emitAmbiguousChoiceResult(io, room, result);
+ if (result.completed) {
+ emitAmbiguousFinalRound(io, room, gameEngine, result);
+ return result;
+ }
+ request = result.nextDecision;
+ io.to(room.id).emit('ambiguous_choice_pending', {
+ round: request.round,
+ playerId: request.playerId,
+ playerName: request.playerName,
+ position: request.position
+ });
+ }
+ return null;
+}
+
+function emitDestroyDykeFinalRound(io, room, gameEngine, response) {
+ const roundResult = response?.roundResult;
+ if (!roundResult) return null;
+ if (roundResult.roundUpdate) {
+ io.to(room.id).emit('round_updated', roundResult.roundUpdate);
+ }
+ if (roundResult.remainingCount === 0) {
+ io.to(room.id).emit('player_finished', {
+ playerId: roundResult.playerId,
+ playerName: roundResult.playerName
+ });
+ }
+ if (roundResult.gameFinished) emitFinishedGame(io, room);
+ io.to(room.id).emit('room_updated', { room: room.toJSON() });
+ if (!roundResult.gameFinished) {
+ triggerBotPlay(io, room, gameEngine).catch(error => {
+ logger.error('毁堤淹田决定完成后触发Bot出牌失败:', error);
+ });
+ }
+ return response;
+}
+
+function beginDestroyDykeDecision(io, room, gameEngine, decision) {
+ if (!decision?.dealerPlayerId) return null;
+ io.to(room.id).emit('destroy_dyke_decision_pending', decision);
+ const dealer = room.findPlayerById(decision.dealerPlayerId);
+ if (!dealer?.isBot) {
+ if (dealer?.socketId) {
+ io.to(dealer.socketId).emit('destroy_dyke_decision_required', decision);
+ }
+ return null;
+ }
+ const response = gameEngine.respondDestroyDyke(
+ dealer.id,
+ gameEngine.selectDestroyDykeForBot()
+ );
+ return emitDestroyDykeFinalRound(io, room, gameEngine, response);
+}
+
+function emitFinishedGame(io, room) {
+ io.to(room.id).emit('bottom_revealed', {
+ bottomCards: room.gameState.bottomCards.map(card => card.toJSON()),
+ bottomScoreResult: room.gameState.bottomScoreResult,
+ upgradeResult: room.gameState.upgradeResult
+ });
+ const result = room.gameState.bottomScoreResult;
+ const hasExtraReveal = result?.mistyFogCards?.length || result?.lingeringDiscardCards?.length;
+ io.to(room.id).emit('phase_changed', {
+ phase: 'revealing',
+ message: result?.surrender
+ ? `${result.surrender.initiatorPlayerName}一方投降,牌局结束`
+ : hasExtraReveal
+ ? '所有玩家已出完牌,查看底牌与终局公开牌'
+ : '所有玩家已出完牌,查看底牌'
+ });
+}
+
+function emitSurrenderDecision(io, room, decision) {
+ if (!decision) return;
+ io.to(room.id).emit('surrender_decision_pending', decision);
+ const teammate = room.findPlayerById(decision.teammatePlayerId);
+ if (teammate?.socketId && !teammate.isBot) {
+ io.to(teammate.socketId).emit('surrender_decision_required', decision);
+ }
+}
+
+function beginSurrenderDecision(io, room, gameEngine, initialDecision = null) {
+ let decision = initialDecision || gameEngine.prepareSurrenderReview();
+ let lastResult = null;
+ while (decision) {
+ emitSurrenderDecision(io, room, decision);
+ const teammate = room.findPlayerById(decision.teammatePlayerId);
+ if (!teammate?.isBot) {
+ io.to(room.id).emit('room_updated', { room: room.toJSON() });
+ return { pending: true, decision, gameFinished: false };
+ }
+
+ lastResult = gameEngine.respondSurrender(teammate.id, true);
+ io.to(room.id).emit('game_surrendered', lastResult.surrender);
+ if (lastResult.gameFinished) {
+ emitFinishedGame(io, room);
+ io.to(room.id).emit('room_updated', { room: room.toJSON() });
+ return lastResult;
+ }
+ decision = lastResult.nextDecision;
+ }
+ return lastResult;
+}
+
+function continueAfterTimeReversalWindow(io, room, gameEngine, result) {
+ io.to(room.id).emit('time_reversal_resolved', result);
+ if (result.gameFinished) emitFinishedGame(io, room);
+ const surrenderResult = !result.gameFinished
+ ? beginSurrenderDecision(io, room, gameEngine, result.surrenderDecision)
+ : null;
+ io.to(room.id).emit('room_updated', { room: room.toJSON() });
+ if (!result.gameFinished && !surrenderResult?.pending && !surrenderResult?.gameFinished) {
+ triggerBotPlay(io, room, gameEngine).catch(error => {
+ logger.error('时间倒流窗口结束后触发Bot出牌失败:', error);
+ });
+ }
+}
+
/**
* 触发当前轮到的Bot自动出牌
*/
@@ -31,6 +564,126 @@ async function triggerBotPlay(io, room, gameEngine) {
logger.warn(`游戏阶段不是PLAYING,当前阶段: ${room.gameState.phase},跳过bot出牌`);
return;
}
+ const surrenderResult = beginSurrenderDecision(io, room, gameEngine);
+ if (surrenderResult?.pending || surrenderResult?.gameFinished) {
+ logger.info('投降申请正在等待队友决定,暂不触发Bot出牌');
+ return;
+ }
+ if (gameEngine.hasPendingSurrenderDecision()) {
+ logger.info('投降申请正在按庄家起顺序处理,暂不触发Bot出牌');
+ return;
+ }
+ if (gameEngine.hasPendingIcebergSelection()) {
+ logger.info('仍有玩家需要选择冰山明牌,暂不触发Bot出牌');
+ return;
+ }
+ if (gameEngine.hasPendingTenSidedAmbushSelection()) {
+ logger.info('庄家队友尚未指定十面埋伏点数,暂不触发Bot出牌');
+ return;
+ }
+ if (
+ gameEngine.hasPendingWaitingRabbitSelection()
+ || gameEngine.hasPendingWaitingRabbitDecision()
+ ) {
+ logger.info('守株待兔仍在暗选或等待换牌决定,暂不触发Bot出牌');
+ return;
+ }
+ if (gameEngine.hasPendingThreePowersSelection()) {
+ logger.info('2、3、4号位尚未完成三权分立暗选,暂不触发Bot出牌');
+ return;
+ }
+ if (gameEngine.hasPendingGentlemanPromiseSelection()) {
+ logger.info('仍有玩家需要完成君子一言最短花色声明,暂不触发Bot出牌');
+ return;
+ }
+ if (gameEngine.hasPendingHiddenDragonSelection()) {
+ logger.info('仍有玩家需要完成潜龙在渊最多点数声明,暂不触发Bot出牌');
+ return;
+ }
+ if (gameEngine.hasPendingAntinomySelection()) {
+ logger.info('二律背反仍在等待选择牌面,暂不触发Bot出牌');
+ return;
+ }
+ if (gameEngine.hasPendingRiceToMulberrySelection()) {
+ logger.info('闲家尚未完成改稻为桑分牌选择,暂不触发Bot出牌');
+ return;
+ }
+ if (gameEngine.hasPendingDestroyDykeDecision()) {
+ logger.info('毁堤淹田正在等待庄家决定,暂不触发Bot出牌');
+ return;
+ }
+ if (gameEngine.hasPendingAdministrativeReviewSelection()) {
+ logger.info('闲家尚未完成行政审查公开声明,暂不触发Bot出牌');
+ return;
+ }
+ if (gameEngine.hasPendingPoliticalReviewDecision()) {
+ logger.info('政治审查正在等待队友决定,暂不触发Bot出牌');
+ return;
+ }
+ if (gameEngine.hasPendingCandleSelection()) {
+ logger.info('庄家队友尚未选择烛的初始状态,暂不触发Bot出牌');
+ return;
+ }
+ if (gameEngine.hasPendingFocusFigureVote()) {
+ logger.info('两队尚未完成焦点人物表决,暂不触发Bot出牌');
+ return;
+ }
+ if (gameEngine.hasPendingMainstayAction()) {
+ logger.info('中流砥柱尚未完成,暂不触发Bot出牌');
+ return;
+ }
+ if (gameEngine.hasPendingLastStandDecision()) {
+ logger.info('仍有玩家需要决定是否发动绝处逢生,暂不触发Bot出牌');
+ return;
+ }
+ if (gameEngine.hasPendingTeammateCheerDecision()) {
+ logger.info('仍有玩家需要决定是否发动队友加油,暂不触发Bot出牌');
+ return;
+ }
+ if (gameEngine.hasPendingAfterglowDecision()) {
+ logger.info('仍有玩家需要决定是否发动回光返照,暂不触发Bot出牌');
+ return;
+ }
+ if (gameEngine.hasPendingAmbiguousChoice()) {
+ logger.info('模棱两可正在等待轮末方案选择,暂不触发Bot出牌');
+ return;
+ }
+ if (gameEngine.hasPendingTimeReversalDecision()) {
+ logger.info('本轮正在等待时间倒流确认,暂不触发Bot出牌');
+ return;
+ }
+ if (gameEngine.hasPendingNinePrincesDecision()) {
+ logger.info('九子夺嫡正在等待本轮赢家选择晋升手牌,暂不触发Bot出牌');
+ return;
+ }
+ if (gameEngine.hasPendingForbiddenMagicDecision()) {
+ logger.info('轮首正在逐个确认禁术秘法,暂不触发Bot出牌');
+ return;
+ }
+ if (gameEngine.hasPendingLureTigerDecision()) {
+ logger.info('轮首正在逐个处理调虎离山,暂不触发Bot出牌');
+ return;
+ }
+ if (gameEngine.hasPendingEquivalentReciprocityChallenge()) {
+ logger.info('等价互惠正在等待双方选择拼点牌,暂不触发Bot出牌');
+ return;
+ }
+ if (gameEngine.hasPendingMutualSupportAction()) {
+ logger.info('同舟共济正在等待交牌或轮末返还,暂不触发Bot出牌');
+ return;
+ }
+ if (gameEngine.hasPendingStrawBoatBorrowingArrowsDecision()) {
+ logger.info('草船借箭正在等待首置位玩家决定,暂不触发Bot出牌');
+ return;
+ }
+ if (gameEngine.hasPendingWoodenOxDecision()) {
+ logger.info('木牛流马轮首操作尚未完成,暂不触发Bot出牌');
+ return;
+ }
+ if (room.gameState.cardExchange?.stage === 'round') {
+ logger.info('仍有玩家需要完成轮末换牌,暂不触发Bot出牌');
+ return;
+ }
// 获取当前应该出牌的玩家
const currentPlayerIndex = room.gameState.currentPlayerIndex;
@@ -45,18 +698,39 @@ async function triggerBotPlay(io, room, gameEngine) {
return;
}
- // 检查当前玩家是否是Bot
- if (!currentPlayer.isBot) {
- logger.info(`当前玩家 ${currentPlayer.name} 不是Bot,等待真人玩家出牌`);
+ // 明手座位由庄家接管;是否自动操作取决于实际操作者,而不是牌的所有者。
+ const controller = gameEngine.getTurnControllerPlayer(currentPlayer.id);
+ if (controller?.isBot && gameEngine.canActivateDreamKilling(currentPlayer.id)) {
+ const activation = gameEngine.activateDreamKilling(currentPlayer.id);
+ io.to(room.id).emit('active_skill_activated', activation);
+ io.to(room.id).emit('dream_killing_started', activation);
+ io.to(room.id).emit('room_updated', { room: room.toJSON() });
+ }
+ const isDreaming = gameEngine.isDreamKillingSleeping(currentPlayer.id);
+ if (!controller?.isBot && !isDreaming) {
+ logger.info(`当前座位 ${currentPlayer.name} 由 ${controller?.name || currentPlayer.name} 操作,等待真人玩家出牌`);
return;
}
// 检查Bot是否还有手牌
- if (currentPlayer.cards.length === 0) {
+ if (gameEngine.getPlayableCardCount(currentPlayer) === 0) {
logger.info(`Bot ${currentPlayer.name} 已经没有手牌了`);
return;
}
+ if (controller?.isBot && !isDreaming) {
+ const declaration = gameEngine.selectCulturalRevolutionForBot(currentPlayer.id);
+ if (declaration) {
+ const activation = gameEngine.activateCulturalRevolution(
+ currentPlayer.id,
+ declaration.declarationType,
+ declaration.value
+ );
+ emitCulturalRevolutionActivation(io, room, activation);
+ io.to(room.id).emit('room_updated', { room: room.toJSON() });
+ }
+ }
+
logger.info(`\n--- 轮到Bot ${currentPlayer.name} 出牌 ---`);
// 获取或创建bot服务
@@ -72,29 +746,68 @@ async function triggerBotPlay(io, room, gameEngine) {
logger.info(`Bot ${currentPlayer.name} 当前手牌数: ${currentPlayer.cards.length}`);
// 延迟一小段时间,模拟思考过程
- await new Promise(resolve => setTimeout(resolve, 1500));
+ await new Promise(resolve => setTimeout(resolve, isDreaming ? 700 : 1500));
// 调用bot获取决策
logger.info(`开始调用BotService.getBotAction...`);
- const cardIds = await botService.getBotAction(
- room.gameState,
- currentPlayer.cards,
- currentPlayerIndex,
- room
- );
+ const woodenOxCard = gameEngine.getWoodenOxStoredCardForPlayer(currentPlayer.id);
+ const woodenOxCards = woodenOxCard ? [woodenOxCard] : [];
+ let cardIds = isDreaming
+ ? botService.getRandomAction(room.gameState, gameEngine.getPlayableCardsForPlayer(currentPlayer))
+ : await botService.getBotAction(
+ room.gameState,
+ currentPlayer.cards,
+ currentPlayerIndex,
+ room,
+ woodenOxCards
+ );
logger.info(`Bot ${currentPlayer.name} 决策完成,选择出牌: ${cardIds.length} 张,卡牌IDs: ${JSON.stringify(cardIds)}`);
// 执行出牌
logger.info(`执行Bot ${currentPlayer.name} 出牌操作...`);
- const result = gameEngine.playCards(currentPlayer.id, cardIds);
+ let result;
+ try {
+ result = gameEngine.playCards(
+ controller.id,
+ cardIds,
+ controller.id === currentPlayer.id ? null : currentPlayer.id,
+ null,
+ isDreaming ? { dreamKillingRandom: true } : {}
+ );
+ } catch (botPlayError) {
+ if (isDreaming) {
+ throw botPlayError;
+ }
+ cardIds = botService.getFallbackAction(
+ room.gameState,
+ currentPlayer.cards,
+ currentPlayer.id,
+ currentPlayerIndex,
+ woodenOxCards
+ );
+ logger.warn(
+ `Bot ${currentPlayer.name} 的策略出牌不合法(${botPlayError.message}),改用合法保底牌: ${JSON.stringify(cardIds)}`
+ );
+ result = gameEngine.playCards(
+ controller.id,
+ cardIds,
+ controller.id === currentPlayer.id ? null : currentPlayer.id,
+ null,
+ isDreaming ? { dreamKillingRandom: true } : {}
+ );
+ }
+ if (result.politicalReviewDeferred) {
+ logger.info(`Bot ${currentPlayer.name} 的出牌正在等待政治审查`);
+ return;
+ }
logger.info(`Bot ${currentPlayer.name} 出牌成功,剩余 ${result.remainingCount} 张牌`);
- // 广播甩牌失败消息
if (result.throwFailed) {
io.to(room.id).emit('throw_failed', {
- playerId: currentPlayer.id,
- playerName: currentPlayer.name,
+ playerId: result.playerId,
+ playerName: result.playerName,
+ isProxy: result.isProxy,
message: result.throwFailed.message,
attemptedCards: result.throwFailed.attemptedCards,
attemptedCardObjects: result.throwFailed.attemptedCardObjects,
@@ -103,19 +816,43 @@ async function triggerBotPlay(io, room, gameEngine) {
}
// 广播bot出牌
- io.to(room.id).emit('cards_played', {
- playerId: currentPlayer.id,
- playerName: currentPlayer.name,
- cards: result.playedCards,
- remainingCount: result.remainingCount
- });
+ if (result.activeSkillActivation) {
+ io.to(room.id).emit('active_skill_activated', result.activeSkillActivation);
+ }
+ emitCardsPlayed(io, room, result);
+ emitThreeTigersTransformation(io, room, result.threeTigersTransformation);
+ if (result.roundUpdate?.strengthCompensation) {
+ gameEngine.emitStrengthCompensationHands(result.roundUpdate.strengthCompensation);
+ }
+ if (result.roundUpdate?.defenseAsOffense) {
+ gameEngine.emitDefenseAsOffenseHands(result.roundUpdate.defenseAsOffense);
+ }
+ if (result.dreamKilling?.awakened) {
+ io.to(room.id).emit('dream_killing_awakened', {
+ playerId: result.playerId,
+ playerName: result.playerName,
+ ...result.dreamKilling
+ });
+ }
+ if (result.roundReveal) {
+ io.to(room.id).emit('concealed_plays_revealed', result.roundReveal);
+ }
+
+ if (result.tenSidedAmbushReveal) {
+ io.to(room.id).emit('ten_sided_ambush_revealed', result.tenSidedAmbushReveal);
+ }
+ if (result.threePowersReveal) {
+ io.to(room.id).emit('three_powers_revealed', result.threePowersReveal);
+ }
// 广播毙牌动作
if (result.trumpAction) {
io.to(room.id).emit('trump_action', {
type: result.trumpAction.type,
playerId: result.trumpAction.playerId,
- playerName: result.trumpAction.playerName
+ playerName: result.trumpAction.playerName,
+ targetPlayerId: result.trumpAction.targetPlayerId,
+ targetPlayerName: result.trumpAction.targetPlayerName
});
}
@@ -123,13 +860,75 @@ async function triggerBotPlay(io, room, gameEngine) {
if (result.roundUpdate) {
io.to(room.id).emit('round_updated', result.roundUpdate);
}
+ const surrenderReviewResult = beginSurrenderDecision(
+ io,
+ room,
+ gameEngine,
+ result.surrenderDecision
+ );
+ if (surrenderReviewResult?.pending || surrenderReviewResult?.gameFinished) {
+ io.to(room.id).emit('room_updated', { room: room.toJSON() });
+ return;
+ }
+ const automaticDestroyDykeResult = beginDestroyDykeDecision(
+ io,
+ room,
+ gameEngine,
+ result.destroyDykeDecision
+ );
+ if (automaticDestroyDykeResult?.roundResult) return;
+ beginWaitingRabbitDecision(io, room, gameEngine, result.waitingRabbitDecision);
+ beginAmbiguousChoiceDecision(io, room, gameEngine, result.ambiguousDecision);
+ const automaticTeammateCheerResult = beginTeammateCheerDecision(
+ io,
+ room,
+ gameEngine,
+ result.teammateCheerRequest
+ );
+ beginAfterglowDecision(io, room, gameEngine, result.afterglowRequest);
+ if (result.afterglowExpired) {
+ io.to(room.id).emit('afterglow_expired', result.afterglowExpired);
+ }
+ beginStrawBoatBorrowingArrowsDecision(
+ io,
+ room,
+ gameEngine,
+ result.strawBoatDecision
+ );
+ emitCulturalRevolutionExpiry(
+ io,
+ room,
+ result.roundUpdate?.culturalRevolutionTransition
+ );
+ if (result.mutualSupportReturn) {
+ submitAutomaticMutualSupportActions(io, room, gameEngine);
+ }
+ emitPlannedEconomyDraw(io, room, result.plannedEconomyDraw);
+ if (result.timeReversalPending) {
+ gameEngine.scheduleTimeReversalDecision();
+ }
+ emitWholeHandExchange(io, room, result.wholeHandExchange);
+ const roundSelectionResult = beginRoundCardExchange(
+ io,
+ room,
+ gameEngine,
+ result.roundCardExchange
+ );
+ if (roundSelectionResult?.gameFinished) {
+ emitFinishedGame(io, room);
+ }
// 检查是否有玩家打完牌
- if (result.remainingCount === 0) {
+ if (
+ result.remainingCount === 0
+ && !result.timeReversalPending
+ && !result.ambiguousDecisionPending
+ && !result.destroyDykeDecisionPending
+ ) {
logger.info(`Bot ${currentPlayer.name} 已打完所有牌`);
io.to(room.id).emit('player_finished', {
- playerId: currentPlayer.id,
- playerName: currentPlayer.name
+ playerId: result.playerId,
+ playerName: result.playerName
});
}
@@ -145,7 +944,11 @@ async function triggerBotPlay(io, room, gameEngine) {
io.to(room.id).emit('phase_changed', {
phase: 'revealing',
- message: '所有玩家已出完牌,查看底牌'
+ message: room.gameState.bottomScoreResult?.abruptStop
+ ? '戛然而止,查看最后一轮与底牌结算'
+ : room.gameState.bottomScoreResult?.mistyFogCards?.length
+ ? '所有玩家已出完牌,查看底牌与迷雾牌'
+ : '所有玩家已出完牌,查看底牌'
});
}
@@ -155,7 +958,11 @@ async function triggerBotPlay(io, room, gameEngine) {
});
// 如果游戏未结束且下一位也是Bot,继续触发
- if (!result.gameFinished && room.gameState.phase === GamePhases.PLAYING) {
+ if (
+ !result.gameFinished
+ && !automaticTeammateCheerResult?.gameFinished
+ && room.gameState.phase === GamePhases.PLAYING
+ ) {
logger.info('检查下一位玩家是否是Bot...');
// 延迟后递归调用,检查下一位玩家
setTimeout(() => {
@@ -176,89 +983,1089 @@ async function triggerBotPlay(io, room, gameEngine) {
}
export function registerGameHandlers(io, socket, roomManager) {
-
- /**
- * 开始游戏(房主)
- */
- socket.on('start_game', ({ roomId }) => {
+ socket.on('request_private_game_state_sync', ({ roomId }) => {
try {
const room = roomManager.getRoom(roomId);
- if (!room) {
- throw new Error('房间不存在');
- }
-
- if (room.hostId !== socket.id) {
- throw new Error('只有房主可以开始游戏');
- }
-
- if (!room.canStart()) {
- throw new Error(`需要${room.config.minPlayers}-${room.config.maxPlayers}名玩家才能开始`);
+ if (!room) throw new Error('房间不存在');
+ const player = room.findPlayerBySocketId(socket.id);
+ if (!player) throw new Error('玩家不存在');
+ const gameEngine = gameEngines.get(room.id);
+ if (!gameEngine) {
+ socket.emit('private_game_state_synced', { roomId, eventCount: 0 });
+ return;
}
- // 创建游戏引擎
- const gameEngine = new GameEngine(room, io);
- gameEngines.set(room.id, gameEngine);
+ const events = gameEngine.getPrivateGameStateSyncEvents(player.id);
+ events.forEach(({ event, payload }) => socket.emit(event, payload));
+ socket.emit('private_game_state_synced', {
+ roomId,
+ eventCount: events.length
+ });
+ } catch (error) {
+ socket.emit('error', { message: error.message });
+ logger.error('恢复玩家私密牌局状态失败:', error);
+ }
+ });
- // 开始游戏
- gameEngine.startGame();
+ socket.on('select_initial_candle_state', ({ roomId, isLit }) => {
+ try {
+ const room = roomManager.getRoom(roomId);
+ if (!room) throw new Error('房间不存在');
+ const player = room.findPlayerBySocketId(socket.id);
+ if (!player) throw new Error('玩家不存在');
+ const gameEngine = gameEngines.get(room.id);
+ if (!gameEngine) throw new Error('游戏未开始');
- // 广播房间状态更新
- io.to(room.id).emit('room_updated', {
- room: room.toJSON()
+ gameEngine.selectInitialCandleState(player.id, isLit);
+ io.to(room.id).emit('room_updated', { room: room.toJSON() });
+ triggerBotPlay(io, room, gameEngine).catch(error => {
+ logger.error('烛的初始状态确定后触发Bot出牌失败:', error);
});
+ } catch (error) {
+ socket.emit('error', { message: error.message });
+ logger.error('选择烛的初始状态失败:', error);
+ }
+ });
+
+ socket.on('request_wooden_ox_private_state', ({ roomId }) => {
+ try {
+ const room = roomManager.getRoom(roomId);
+ if (!room) throw new Error('房间不存在');
+ const player = room.findPlayerBySocketId(socket.id);
+ if (!player) throw new Error('玩家不存在');
+ const gameEngine = gameEngines.get(room.id);
+ if (!gameEngine) throw new Error('游戏未开始');
+ gameEngine.emitWoodenOxPrivateState(player.id);
+ } catch (error) {
+ socket.emit('error', { message: error.message });
+ }
+ });
+
+ socket.on('manage_wooden_ox', ({ roomId, action, cardId = null }) => {
+ try {
+ const room = roomManager.getRoom(roomId);
+ if (!room) throw new Error('房间不存在');
+ const player = room.findPlayerBySocketId(socket.id);
+ if (!player) throw new Error('玩家不存在');
+ const gameEngine = gameEngines.get(room.id);
+ if (!gameEngine) throw new Error('游戏未开始');
+
+ const result = gameEngine.manageWoodenOx(player.id, action, cardId);
+ socket.emit('wooden_ox_action_recorded', result);
+ if (result.completed) {
+ triggerBotPlay(io, room, gameEngine).catch(error => {
+ logger.error('木牛流马轮首操作完成后触发Bot出牌失败:', error);
+ });
+ }
+ } catch (error) {
+ socket.emit('error', { message: error.message });
+ logger.error('操作木牛流马失败:', error);
+ }
+ });
+
+ socket.on('activate_magic_trick', ({ roomId, targetPlayerIds }) => {
+ try {
+ const room = roomManager.getRoom(roomId);
+ if (!room) throw new Error('房间不存在');
+ const player = room.findPlayerBySocketId(socket.id);
+ if (!player) throw new Error('玩家不存在');
+ const gameEngine = gameEngines.get(room.id);
+ if (!gameEngine) throw new Error('游戏未开始');
+
+ const result = gameEngine.prepareMagicTrick(player.id, targetPlayerIds);
+ // 暗选结果只回给发动者;直到整轮结束才会随 round_updated 统一揭晓。
+ socket.emit('magic_trick_prepared', result);
+ io.to(room.id).emit('room_updated', { room: room.toJSON() });
+ } catch (error) {
+ socket.emit('error', { message: error.message });
+ logger.error('准备魔术戏法失败:', error);
+ }
+ });
+
+ socket.on('activate_equivalent_reciprocity', ({ roomId, targetPlayerId }) => {
+ try {
+ const room = roomManager.getRoom(roomId);
+ if (!room) throw new Error('房间不存在');
+ const player = room.findPlayerBySocketId(socket.id);
+ if (!player) throw new Error('玩家不存在');
+ const gameEngine = gameEngines.get(room.id);
+ if (!gameEngine) throw new Error('游戏未开始');
+
+ const result = gameEngine.startEquivalentReciprocity(player.id, targetPlayerId);
+ io.to(room.id).emit('active_skill_activated', {
+ id: result.activeSkillId,
+ name: result.activeSkillName,
+ playerId: result.initiatorPlayerId,
+ playerName: result.initiatorPlayerName
+ });
+ io.to(room.id).emit('equivalent_reciprocity_started', result);
+ result.participantPlayerIds.forEach(participantPlayerId => {
+ const participant = room.findPlayerById(participantPlayerId);
+ if (!participant?.socketId || participant.isBot) return;
+ const opponent = room.findPlayerById(
+ participantPlayerId === result.initiatorPlayerId
+ ? result.targetPlayerId
+ : result.initiatorPlayerId
+ );
+ io.to(participant.socketId).emit('equivalent_reciprocity_card_required', {
+ challengeId: result.challengeId,
+ opponentPlayerId: opponent.id,
+ opponentPlayerName: opponent.name
+ });
+ });
+ const automaticResult = submitAutomaticEquivalentReciprocityCards(
+ io,
+ room,
+ gameEngine,
+ result.participantPlayerIds
+ );
+ io.to(room.id).emit('room_updated', { room: room.toJSON() });
+ if (automaticResult?.resolved) {
+ triggerBotPlay(io, room, gameEngine).catch(error => {
+ logger.error('等价互惠结算后触发Bot出牌失败:', error);
+ });
+ }
+ } catch (error) {
+ socket.emit('error', { message: error.message });
+ logger.error('发动等价互惠失败:', error);
+ }
+ });
+
+ socket.on('submit_equivalent_reciprocity_card', ({ roomId, challengeId, cardId }) => {
+ try {
+ const room = roomManager.getRoom(roomId);
+ if (!room) throw new Error('房间不存在');
+ const player = room.findPlayerBySocketId(socket.id);
+ if (!player) throw new Error('玩家不存在');
+ const gameEngine = gameEngines.get(room.id);
+ if (!gameEngine) throw new Error('游戏未开始');
+
+ const result = gameEngine.submitEquivalentReciprocityCard(player.id, challengeId, cardId);
+ io.to(room.id).emit('equivalent_reciprocity_selection_recorded', {
+ challengeId: result.challengeId,
+ playerId: result.playerId,
+ playerName: result.playerName,
+ selectedPlayerIds: result.selectedPlayerIds
+ });
+ if (result.resolved) {
+ emitEquivalentReciprocityResolution(io, room, result);
+ }
+ io.to(room.id).emit('room_updated', { room: room.toJSON() });
+ if (result.resolved) {
+ triggerBotPlay(io, room, gameEngine).catch(error => {
+ logger.error('等价互惠结算后触发Bot出牌失败:', error);
+ });
+ }
+ } catch (error) {
+ socket.emit('error', { message: error.message });
+ logger.error('提交等价互惠拼点牌失败:', error);
+ }
+ });
+
+ socket.on('activate_mutual_support', ({ roomId, direction, cardIds = [] }) => {
+ try {
+ const room = roomManager.getRoom(roomId);
+ if (!room) throw new Error('房间不存在');
+ const player = room.findPlayerBySocketId(socket.id);
+ if (!player) throw new Error('玩家不存在');
+ const gameEngine = gameEngines.get(room.id);
+ if (!gameEngine) throw new Error('游戏未开始');
+
+ const result = gameEngine.activateMutualSupport(player.id, direction, cardIds);
+ io.to(room.id).emit('active_skill_activated', {
+ id: result.activeSkillId,
+ name: result.activeSkillName,
+ playerId: result.initiatorPlayerId,
+ playerName: result.initiatorPlayerName
+ });
+ io.to(room.id).emit('mutual_support_started', {
+ actionId: result.actionId,
+ round: result.round,
+ direction: result.direction,
+ initiatorPlayerId: result.initiatorPlayerId,
+ initiatorPlayerName: result.initiatorPlayerName,
+ teammatePlayerId: result.teammatePlayerId,
+ teammatePlayerName: result.teammatePlayerName,
+ pending: result.pending,
+ minCards: result.minCards ?? null,
+ maxCards: result.maxCards ?? null
+ });
+ if (result.resolved) emitMutualSupportResolution(io, room, result);
+ const automaticResult = submitAutomaticMutualSupportActions(io, room, gameEngine);
+ io.to(room.id).emit('room_updated', { room: room.toJSON() });
+ if (!room.gameState.mutualSupportPendingAction && !automaticResult?.gameFinished) {
+ triggerBotPlay(io, room, gameEngine).catch(error => {
+ logger.error('同舟共济发动后触发Bot出牌失败:', error);
+ });
+ }
+ } catch (error) {
+ socket.emit('error', { message: error.message });
+ logger.error('发动同舟共济失败:', error);
+ }
+ });
+
+ socket.on('activate_cultural_revolution', ({ roomId, declarationType, value }) => {
+ try {
+ const room = roomManager.getRoom(roomId);
+ if (!room) throw new Error('房间不存在');
+ const player = room.findPlayerBySocketId(socket.id);
+ if (!player) throw new Error('玩家不存在');
+ const gameEngine = gameEngines.get(room.id);
+ if (!gameEngine) throw new Error('游戏未开始');
+
+ const result = gameEngine.activateCulturalRevolution(
+ player.id,
+ declarationType,
+ value
+ );
+ emitCulturalRevolutionActivation(io, room, result);
+ io.to(room.id).emit('room_updated', { room: room.toJSON() });
+ } catch (error) {
+ socket.emit('error', { message: error.message });
+ logger.error('发动文化革命失败:', error);
+ }
+ });
+
+ socket.on('activate_invite_into_urn', ({ roomId, targetPlayerId, suit, rank }) => {
+ try {
+ const room = roomManager.getRoom(roomId);
+ if (!room) throw new Error('房间不存在');
+ const player = room.findPlayerBySocketId(socket.id);
+ if (!player) throw new Error('玩家不存在');
+ const gameEngine = gameEngines.get(room.id);
+ if (!gameEngine) throw new Error('游戏未开始');
+
+ const result = gameEngine.activateInviteIntoUrn(
+ player.id,
+ targetPlayerId,
+ suit,
+ rank
+ );
+ io.to(room.id).emit('active_skill_activated', {
+ id: result.activeSkillId,
+ name: result.activeSkillName,
+ playerId: result.sourcePlayerId,
+ playerName: result.sourcePlayerName
+ });
+ io.to(room.id).emit('invite_into_urn_activated', result);
+ io.to(room.id).emit('room_updated', { room: room.toJSON() });
+ } catch (error) {
+ socket.emit('error', { message: error.message });
+ logger.error('发动请君入瓮失败:', error);
+ }
+ });
+
+ socket.on('submit_mutual_support_cards', ({ roomId, actionId, cardIds = [] }) => {
+ try {
+ const room = roomManager.getRoom(roomId);
+ if (!room) throw new Error('房间不存在');
+ const player = room.findPlayerBySocketId(socket.id);
+ if (!player) throw new Error('玩家不存在');
+ const gameEngine = gameEngines.get(room.id);
+ if (!gameEngine) throw new Error('游戏未开始');
+
+ const result = gameEngine.submitMutualSupportCards(player.id, actionId, cardIds);
+ emitMutualSupportResolution(io, room, result);
+ const automaticResult = submitAutomaticMutualSupportActions(io, room, gameEngine);
+ io.to(room.id).emit('room_updated', { room: room.toJSON() });
+ const gameFinished = result.gameFinished || automaticResult?.gameFinished;
+ if (result.gameFinished) emitFinishedGame(io, room);
+ if (!gameFinished && !room.gameState.mutualSupportPendingAction) {
+ triggerBotPlay(io, room, gameEngine).catch(error => {
+ logger.error('同舟共济交牌完成后触发Bot出牌失败:', error);
+ });
+ }
+ } catch (error) {
+ socket.emit('error', { message: error.message });
+ logger.error('提交同舟共济手牌失败:', error);
+ }
+ });
+
+ socket.on('activate_time_reversal', ({ roomId }) => {
+ try {
+ const room = roomManager.getRoom(roomId);
+ if (!room) throw new Error('房间不存在');
+ const player = room.findPlayerBySocketId(socket.id);
+ if (!player) throw new Error('玩家不存在');
+ const gameEngine = gameEngines.get(room.id);
+ if (!gameEngine) throw new Error('游戏未开始');
+
+ const result = gameEngine.activateTimeReversal(player.id);
+ io.to(room.id).emit('time_reversal_activated', result);
+ io.to(room.id).emit('room_updated', { room: room.toJSON() });
+ } catch (error) {
+ socket.emit('error', { message: error.message });
+ logger.error('预备时间倒流失败:', error);
+ }
+ });
+
+ socket.on('activate_dream_killing', ({ roomId }) => {
+ try {
+ const room = roomManager.getRoom(roomId);
+ if (!room) throw new Error('房间不存在');
+ const player = room.findPlayerBySocketId(socket.id);
+ if (!player) throw new Error('玩家不存在');
+ const gameEngine = gameEngines.get(room.id);
+ if (!gameEngine) throw new Error('游戏未开始');
+
+ const result = gameEngine.activateDreamKilling(player.id);
+ io.to(room.id).emit('active_skill_activated', result);
+ io.to(room.id).emit('dream_killing_started', result);
+ io.to(room.id).emit('room_updated', { room: room.toJSON() });
+ triggerBotPlay(io, room, gameEngine).catch(error => {
+ logger.error('梦中杀人发动后触发随机出牌失败:', error);
+ });
+ } catch (error) {
+ socket.emit('error', { message: error.message });
+ logger.error('发动梦中杀人失败:', error);
+ }
+ });
+
+ socket.on('activate_lure_tiger', ({ roomId }) => {
+ try {
+ const room = roomManager.getRoom(roomId);
+ if (!room) throw new Error('房间不存在');
+ const player = room.findPlayerBySocketId(socket.id);
+ if (!player) throw new Error('玩家不存在');
+ const gameEngine = gameEngines.get(room.id);
+ if (!gameEngine) throw new Error('游戏未开始');
+
+ const result = gameEngine.activateLureTiger(player.id);
+ io.to(room.id).emit('lure_tiger_reserved', result);
+ io.to(room.id).emit('room_updated', { room: room.toJSON() });
+ } catch (error) {
+ socket.emit('error', { message: error.message });
+ logger.error('预备调虎离山失败:', error);
+ }
+ });
+
+ socket.on('respond_lure_tiger', ({ roomId, accept }) => {
+ try {
+ const room = roomManager.getRoom(roomId);
+ if (!room) throw new Error('房间不存在');
+ const player = room.findPlayerBySocketId(socket.id);
+ if (!player) throw new Error('玩家不存在');
+ const gameEngine = gameEngines.get(room.id);
+ if (!gameEngine) throw new Error('游戏未开始');
+
+ const result = gameEngine.respondLureTiger(player.id, accept === true);
+ if (!result.accepted) {
+ io.to(room.id).emit('lure_tiger_declined', result);
+ }
+ io.to(room.id).emit('room_updated', { room: room.toJSON() });
+ if (result.resolved) {
+ triggerBotPlay(io, room, gameEngine).catch(error => {
+ logger.error('调虎离山确认完成后触发Bot出牌失败:', error);
+ });
+ }
+ } catch (error) {
+ socket.emit('error', { message: error.message });
+ logger.error('处理调虎离山确认失败:', error);
+ }
+ });
+
+ socket.on('select_lure_tiger_target', ({ roomId, targetPlayerId }) => {
+ try {
+ const room = roomManager.getRoom(roomId);
+ if (!room) throw new Error('房间不存在');
+ const player = room.findPlayerBySocketId(socket.id);
+ if (!player) throw new Error('玩家不存在');
+ const gameEngine = gameEngines.get(room.id);
+ if (!gameEngine) throw new Error('游戏未开始');
+
+ const result = gameEngine.selectLureTigerTarget(player.id, targetPlayerId);
+ io.to(room.id).emit('active_skill_activated', {
+ id: result.activeSkillId,
+ name: result.activeSkillName,
+ playerId: result.playerId,
+ playerName: result.playerName
+ });
+ io.to(room.id).emit('lure_tiger_activated', result);
+ io.to(room.id).emit('room_updated', { room: room.toJSON() });
+ if (result.resolved) {
+ triggerBotPlay(io, room, gameEngine).catch(error => {
+ logger.error('调虎离山目标选择完成后触发Bot出牌失败:', error);
+ });
+ }
+ } catch (error) {
+ socket.emit('error', { message: error.message });
+ logger.error('选择调虎离山目标失败:', error);
+ }
+ });
+
+ socket.on('activate_forbidden_magic', ({ roomId }) => {
+ try {
+ const room = roomManager.getRoom(roomId);
+ if (!room) throw new Error('房间不存在');
+ const player = room.findPlayerBySocketId(socket.id);
+ if (!player) throw new Error('玩家不存在');
+ const gameEngine = gameEngines.get(room.id);
+ if (!gameEngine) throw new Error('游戏未开始');
+
+ const result = gameEngine.activateForbiddenMagic(player.id);
+ io.to(room.id).emit('forbidden_magic_reserved', result);
+ io.to(room.id).emit('room_updated', { room: room.toJSON() });
+ } catch (error) {
+ socket.emit('error', { message: error.message });
+ logger.error('预备禁术秘法失败:', error);
+ }
+ });
+
+ socket.on('respond_forbidden_magic', ({ roomId, accept }) => {
+ try {
+ const room = roomManager.getRoom(roomId);
+ if (!room) throw new Error('房间不存在');
+ const player = room.findPlayerBySocketId(socket.id);
+ if (!player) throw new Error('玩家不存在');
+ const gameEngine = gameEngines.get(room.id);
+ if (!gameEngine) throw new Error('游戏未开始');
+
+ const result = gameEngine.respondForbiddenMagic(player.id, accept === true);
+ if (result.accepted) {
+ io.to(room.id).emit('active_skill_activated', {
+ id: result.activeSkillId,
+ name: result.activeSkillName,
+ playerId: result.playerId,
+ playerName: result.playerName
+ });
+ io.to(room.id).emit('forbidden_magic_activated', result);
+ } else {
+ io.to(room.id).emit('forbidden_magic_declined', result);
+ }
+ io.to(room.id).emit('room_updated', { room: room.toJSON() });
+ if (result.resolved) {
+ triggerBotPlay(io, room, gameEngine).catch(error => {
+ logger.error('禁术秘法确认完成后触发Bot出牌失败:', error);
+ });
+ }
+ } catch (error) {
+ socket.emit('error', { message: error.message });
+ logger.error('处理禁术秘法确认失败:', error);
+ }
+ });
+
+ socket.on('respond_remove_firewood', ({ roomId, accept }) => {
+ try {
+ const room = roomManager.getRoom(roomId);
+ if (!room) throw new Error('房间不存在');
+ const player = room.findPlayerBySocketId(socket.id);
+ if (!player) throw new Error('玩家不存在');
+ const gameEngine = gameEngines.get(room.id);
+ if (!gameEngine) throw new Error('游戏未开始');
+
+ gameEngine.respondRemoveFirewood(player.id, accept === true);
+ } catch (error) {
+ socket.emit('error', { message: error.message });
+ logger.error('处理釜底抽薪选择失败:', error);
+ }
+ });
+
+ socket.on('respond_mainstay', ({ roomId, accept }) => {
+ try {
+ const room = roomManager.getRoom(roomId);
+ if (!room) throw new Error('房间不存在');
+ const player = room.findPlayerBySocketId(socket.id);
+ if (!player) throw new Error('玩家不存在');
+ const gameEngine = gameEngines.get(room.id);
+ if (!gameEngine) throw new Error('游戏未开始');
+
+ gameEngine.respondMainstay(player.id, accept === true);
+ } catch (error) {
+ socket.emit('error', { message: error.message });
+ logger.error('处理中流砥柱决定失败:', error);
+ }
+ });
+
+ socket.on('submit_mainstay_cards', ({ roomId, actionId, cardIds = [] }) => {
+ try {
+ const room = roomManager.getRoom(roomId);
+ if (!room) throw new Error('房间不存在');
+ const player = room.findPlayerBySocketId(socket.id);
+ if (!player) throw new Error('玩家不存在');
+ const gameEngine = gameEngines.get(room.id);
+ if (!gameEngine) throw new Error('游戏未开始');
+
+ gameEngine.submitMainstayCards(player.id, actionId, cardIds);
+ } catch (error) {
+ socket.emit('error', { message: error.message });
+ logger.error('提交中流砥柱手牌失败:', error);
+ }
+ });
+
+ socket.on('respond_time_reversal', ({ roomId, accept }) => {
+ try {
+ const room = roomManager.getRoom(roomId);
+ if (!room) throw new Error('房间不存在');
+ const player = room.findPlayerBySocketId(socket.id);
+ if (!player) throw new Error('玩家不存在');
+ const gameEngine = gameEngines.get(room.id);
+ if (!gameEngine) throw new Error('游戏未开始');
+
+ const result = gameEngine.respondTimeReversal(player.id, accept === true);
+ if (!result.resolved) {
+ io.to(room.id).emit('time_reversal_response_recorded', result);
+ io.to(room.id).emit('room_updated', { room: room.toJSON() });
+ return;
+ }
+ if (result.accepted) {
+ io.to(room.id).emit('active_skill_activated', {
+ id: 'time_reversal',
+ name: '时间倒流',
+ playerId: result.playerId,
+ playerName: result.playerName
+ });
+ for (const hand of result.hands) {
+ const handOwner = room.findPlayerById(hand.playerId);
+ if (!handOwner?.socketId) continue;
+ io.to(handOwner.socketId).emit('time_reversal_hand_restored', {
+ round: result.round,
+ cards: hand.cards
+ });
+ }
+ }
+ continueAfterTimeReversalWindow(io, room, gameEngine, result);
+ } catch (error) {
+ socket.emit('error', { message: error.message });
+ logger.error('处理时间倒流决定失败:', error);
+ }
+ });
+
+ socket.on('respond_nine_princes', ({ roomId, cardId = null }) => {
+ try {
+ const room = roomManager.getRoom(roomId);
+ if (!room) throw new Error('房间不存在');
+ const player = room.findPlayerBySocketId(socket.id);
+ if (!player) throw new Error('玩家不存在');
+ const gameEngine = gameEngines.get(room.id);
+ if (!gameEngine) throw new Error('游戏未开始');
+
+ gameEngine.respondNinePrincesDecision(player.id, cardId || null);
+ const surrenderResult = beginSurrenderDecision(io, room, gameEngine);
+ io.to(room.id).emit('room_updated', { room: room.toJSON() });
+ if (!surrenderResult?.pending && !surrenderResult?.gameFinished) {
+ triggerBotPlay(io, room, gameEngine).catch(error => {
+ logger.error('九子夺嫡选择完成后触发Bot出牌失败:', error);
+ });
+ }
+ } catch (error) {
+ socket.emit('error', { message: error.message });
+ logger.error('处理九子夺嫡选择失败:', error);
+ }
+ });
+
+ socket.on('respond_last_stand', ({ roomId, accept }) => {
+ try {
+ const room = roomManager.getRoom(roomId);
+ if (!room) throw new Error('房间不存在');
+ const player = room.findPlayerBySocketId(socket.id);
+ if (!player) throw new Error('玩家不存在');
+ const gameEngine = gameEngines.get(room.id);
+ if (!gameEngine) throw new Error('游戏未开始');
+ gameEngine.respondLastStand(player.id, accept === true);
+ triggerBotPlay(io, room, gameEngine).catch(error => {
+ logger.error('绝处逢生决定后触发Bot出牌失败:', error);
+ });
+ } catch (error) {
+ socket.emit('error', { message: error.message });
+ logger.error('处理绝处逢生决定失败:', error);
+ }
+ });
+
+ socket.on('respond_teammate_cheer', ({ roomId, accept }) => {
+ try {
+ const room = roomManager.getRoom(roomId);
+ if (!room) throw new Error('房间不存在');
+ const player = room.findPlayerBySocketId(socket.id);
+ if (!player) throw new Error('玩家不存在');
+ const gameEngine = gameEngines.get(room.id);
+ if (!gameEngine) throw new Error('游戏未开始');
+
+ const result = gameEngine.respondTeammateCheer(player.id, accept === true);
+ emitTeammateCheerResolution(io, room, result);
+ if (result.gameFinished) emitFinishedGame(io, room);
+ io.to(room.id).emit('room_updated', { room: room.toJSON() });
+ if (!result.gameFinished) {
+ triggerBotPlay(io, room, gameEngine).catch(error => {
+ logger.error('队友加油决定后触发Bot出牌失败:', error);
+ });
+ }
+ } catch (error) {
+ socket.emit('error', { message: error.message });
+ logger.error('处理队友加油决定失败:', error);
+ }
+ });
+
+ socket.on('respond_afterglow', ({ roomId, accept }) => {
+ try {
+ const room = roomManager.getRoom(roomId);
+ if (!room) throw new Error('房间不存在');
+ const player = room.findPlayerBySocketId(socket.id);
+ if (!player) throw new Error('玩家不存在');
+ const gameEngine = gameEngines.get(room.id);
+ if (!gameEngine) throw new Error('游戏未开始');
+
+ const result = gameEngine.respondAfterglow(player.id, accept === true);
+ broadcastAfterglowResolution(io, room, result);
+ io.to(room.id).emit('room_updated', { room: room.toJSON() });
+ triggerBotPlay(io, room, gameEngine).catch(error => {
+ logger.error('回光返照决定后触发Bot出牌失败:', error);
+ });
+ } catch (error) {
+ socket.emit('error', { message: error.message });
+ logger.error('处理回光返照决定失败:', error);
+ }
+ });
+
+ socket.on('activate_late_mover_advantage', ({ roomId }) => {
+ try {
+ const room = roomManager.getRoom(roomId);
+ if (!room) throw new Error('房间不存在');
+ const player = room.findPlayerBySocketId(socket.id);
+ if (!player) throw new Error('玩家不存在');
+ const gameEngine = gameEngines.get(room.id);
+ if (!gameEngine) throw new Error('游戏未开始');
+
+ const result = gameEngine.activateLateMoverAdvantage(player.id);
+ io.to(room.id).emit('active_skill_activated', {
+ id: result.activeSkillId,
+ name: result.activeSkillName,
+ playerId: result.playerId,
+ playerName: result.playerName
+ });
+ io.to(room.id).emit('late_mover_advantage_activated', result);
+ io.to(room.id).emit('round_updated', {
+ type: 'turn_changed',
+ currentPlayerIndex: result.currentPlayerIndex,
+ message: result.message
+ });
+ io.to(room.id).emit('room_updated', { room: room.toJSON() });
+
+ triggerBotPlay(io, room, gameEngine).catch(error => {
+ logger.error('后发制人换序后触发Bot出牌失败:', error);
+ });
+ } catch (error) {
+ socket.emit('error', { message: error.message });
+ logger.error('发动后发制人失败:', error);
+ }
+ });
+
+ socket.on('activate_recommend_talent', ({ roomId }) => {
+ try {
+ const room = roomManager.getRoom(roomId);
+ if (!room) throw new Error('房间不存在');
+ const player = room.findPlayerBySocketId(socket.id);
+ if (!player) throw new Error('玩家不存在');
+ const gameEngine = gameEngines.get(room.id);
+ if (!gameEngine) throw new Error('游戏未开始');
+
+ const result = gameEngine.activateRecommendTalent(player.id);
+ io.to(room.id).emit('active_skill_activated', {
+ id: result.activeSkillId,
+ name: result.activeSkillName,
+ playerId: result.playerId,
+ playerName: result.playerName
+ });
+ io.to(room.id).emit('recommend_talent_activated', result);
+ io.to(room.id).emit('round_updated', {
+ type: 'turn_changed',
+ currentPlayerIndex: result.currentPlayerIndex,
+ message: result.message
+ });
+ io.to(room.id).emit('room_updated', { room: room.toJSON() });
+
+ triggerBotPlay(io, room, gameEngine).catch(error => {
+ logger.error('举贤任能换序后触发Bot出牌失败:', error);
+ });
+ } catch (error) {
+ socket.emit('error', { message: error.message });
+ logger.error('发动举贤任能失败:', error);
+ }
+ });
+
+ socket.on('activate_bush_gate', ({ roomId }) => {
+ try {
+ const room = roomManager.getRoom(roomId);
+ if (!room) throw new Error('房间不存在');
+ const player = room.findPlayerBySocketId(socket.id);
+ if (!player) throw new Error('玩家不存在');
+ const gameEngine = gameEngines.get(room.id);
+ if (!gameEngine) throw new Error('游戏未开始');
+
+ const result = gameEngine.activateBushGate(player.id);
+ io.to(room.id).emit('active_skill_activated', {
+ id: result.activeSkillId,
+ name: result.activeSkillName,
+ playerId: result.activatorPlayerId,
+ playerName: result.activatorPlayerName
+ });
+ io.to(room.id).emit('bush_gate_activated', result);
+ io.to(room.id).emit('round_updated', {
+ type: 'turn_changed',
+ currentPlayerIndex: result.currentPlayerIndex,
+ bushGate: result,
+ message: result.message
+ });
+ io.to(room.id).emit('room_updated', { room: room.toJSON() });
+
+ triggerBotPlay(io, room, gameEngine).catch(error => {
+ logger.error('布什戈门退牌后触发Bot出牌失败:', error);
+ });
+ } catch (error) {
+ socket.emit('error', { message: error.message });
+ logger.error('发动布什戈门失败:', error);
+ }
+ });
+
+ /**
+ * 开始游戏(房主)
+ */
+ socket.on('start_game', ({ roomId }) => {
+ try {
+ const room = roomManager.getRoom(roomId);
+ if (!room) {
+ throw new Error('房间不存在');
+ }
+
+ if (room.hostId !== socket.id) {
+ throw new Error('只有房主可以开始游戏');
+ }
+
+ if (room.gameState.phase !== GamePhases.WAITING || room.gameState.isWaitingForReady) {
+ throw new Error('游戏已经开始,请勿重复开始');
+ }
+
+ if (!room.canStart()) {
+ throw new Error(`需要${room.config.minPlayers}-${room.config.maxPlayers}名玩家才能开始`);
+ }
+
+ // 创建游戏引擎
+ const gameEngine = new GameEngine(room, io);
+ gameEngine.onBotTurn = () => {
+ triggerBotPlay(io, room, gameEngine).catch(err => {
+ logger.error('触发bot出牌失败:', err);
+ });
+ };
+ gameEngine.onTimeReversalWindowClosed = result => {
+ continueAfterTimeReversalWindow(io, room, gameEngine, result);
+ };
+ gameEngines.set(room.id, gameEngine);
+
+ // 开始游戏
+ gameEngine.startGame();
+
+ // 广播房间状态更新
+ io.to(room.id).emit('room_updated', {
+ room: room.toJSON()
+ });
+
+ } catch (error) {
+ socket.emit('error', { message: error.message });
+ logger.error('开始游戏失败:', error);
+ }
+ });
+
+ /**
+ * 玩家准备
+ */
+ socket.on('player_ready', ({ roomId }) => {
+ try {
+ const room = roomManager.getRoom(roomId);
+ if (!room) {
+ throw new Error('房间不存在');
+ }
+
+ const player = room.findPlayerBySocketId(socket.id);
+ if (!player) {
+ throw new Error('玩家不存在');
+ }
+
+ const gameEngine = gameEngines.get(room.id);
+ if (!gameEngine) {
+ throw new Error('游戏未开始');
+ }
+
+ const allReady = gameEngine.playerReady(player.id);
+
+ // 广播玩家准备状态
+ io.to(room.id).emit('player_ready_status', {
+ playerId: player.id,
+ playerName: player.name,
+ isReady: player.isReady
+ });
+
+ // 广播房间状态更新
+ io.to(room.id).emit('room_updated', {
+ room: room.toJSON()
+ });
+
+ if (allReady) {
+ // 所有玩家准备完毕
+ io.to(room.id).emit('all_players_ready', {
+ message: room.gameState.isRuleSelectionPending
+ ? '所有玩家已准备,等待规则选择者做出选择'
+ : '所有玩家已准备,开始发牌'
+ });
+ }
+
+ logger.info(`房间 ${room.id} 玩家 ${player.name} 已准备`);
+
+ } catch (error) {
+ socket.emit('error', { message: error.message });
+ logger.error('玩家准备失败:', error);
+ }
+ });
+
+ /**
+ * 摸牌后规则:每位真人玩家暗中确认要交出的两张牌。
+ * 牌面只在 GameEngine 的私有结果事件中发给对应玩家。
+ */
+ socket.on('submit_card_exchange', ({ roomId, cardIds }) => {
+ try {
+ const room = roomManager.getRoom(roomId);
+ if (!room) throw new Error('房间不存在');
+
+ const player = room.findPlayerBySocketId(socket.id);
+ if (!player) throw new Error('玩家不存在');
+
+ const gameEngine = gameEngines.get(room.id);
+ if (!gameEngine) throw new Error('游戏未开始');
+
+ const result = gameEngine.submitOpeningCardExchange(player.id, cardIds);
+ if (result.gameFinished) {
+ emitFinishedGame(io, room);
+ } else if (result.resolved && result.stage === 'round') {
+ triggerBotPlay(io, room, gameEngine).catch(error => {
+ logger.error('轮末换牌完成后触发Bot出牌失败:', error);
+ });
+ }
+ } catch (error) {
+ socket.emit('error', { message: error.message });
+ logger.error('提交换牌失败:', error);
+ }
+ });
+
+ /** 冰山一角:每名真人玩家自行提交需要明置的牌。 */
+ socket.on('submit_iceberg_reveals', ({ roomId, cardIds }) => {
+ try {
+ const room = roomManager.getRoom(roomId);
+ if (!room) throw new Error('房间不存在');
+
+ const player = room.findPlayerBySocketId(socket.id);
+ if (!player) throw new Error('玩家不存在');
+
+ const gameEngine = gameEngines.get(room.id);
+ if (!gameEngine) throw new Error('游戏未开始');
+
+ const result = gameEngine.submitIcebergRevealSelection(player.id, cardIds);
+ if (result.resolved) {
+ triggerBotPlay(io, room, gameEngine).catch(err => {
+ logger.error('冰山明牌确认后触发Bot出牌失败:', err);
+ });
+ }
+ } catch (error) {
+ socket.emit('error', { message: error.message });
+ logger.error('提交冰山明牌失败:', error);
+ }
+ });
+
+ /** 十面埋伏:庄家队友在埋底后暗中指定点数。 */
+ socket.on('select_waiting_rabbit_target', ({ roomId, suit, rank }) => {
+ try {
+ const room = roomManager.getRoom(roomId);
+ if (!room) throw new Error('房间不存在');
+ const player = room.findPlayerBySocketId(socket.id);
+ if (!player) throw new Error('玩家不存在');
+ const gameEngine = gameEngines.get(room.id);
+ if (!gameEngine) throw new Error('游戏未开始');
+
+ const result = gameEngine.selectWaitingRabbitTarget(player.id, suit, rank);
+ if (!result.pending) {
+ triggerBotPlay(io, room, gameEngine).catch(error => {
+ logger.error('守株待兔暗选完成后触发Bot出牌失败:', error);
+ });
+ }
+ } catch (error) {
+ socket.emit('error', { message: error.message });
+ logger.error('选择守株待兔目标牌失败:', error);
+ }
+ });
+
+ socket.on('select_ten_sided_ambush_rank', ({ roomId, rank }) => {
+ try {
+ const room = roomManager.getRoom(roomId);
+ if (!room) throw new Error('房间不存在');
+
+ const player = room.findPlayerBySocketId(socket.id);
+ if (!player) throw new Error('玩家不存在');
+
+ const gameEngine = gameEngines.get(room.id);
+ if (!gameEngine) throw new Error('游戏未开始');
+ gameEngine.selectTenSidedAmbushRank(player.id, rank);
+ triggerBotPlay(io, room, gameEngine).catch(err => {
+ logger.error('十面埋伏选点后触发Bot出牌失败:', err);
+ });
} catch (error) {
socket.emit('error', { message: error.message });
- logger.error('开始游戏失败:', error);
+ logger.error('选择十面埋伏点数失败:', error);
}
});
- /**
- * 玩家准备
- */
- socket.on('player_ready', ({ roomId }) => {
+ /** 三权分立:初始2、3、4号位各自暗选自己的分牌重载点数。 */
+ socket.on('select_three_powers_rank', ({ roomId, sourceRank, rank }) => {
try {
const room = roomManager.getRoom(roomId);
- if (!room) {
- throw new Error('房间不存在');
+ if (!room) throw new Error('房间不存在');
+ const player = room.findPlayerBySocketId(socket.id);
+ if (!player) throw new Error('玩家不存在');
+ const gameEngine = gameEngines.get(room.id);
+ if (!gameEngine) throw new Error('游戏未开始');
+
+ const result = gameEngine.selectThreePowersRank(player.id, sourceRank, rank);
+ if (!result.pending) {
+ triggerBotPlay(io, room, gameEngine).catch(err => {
+ logger.error('三权分立暗选完成后触发Bot出牌失败:', err);
+ });
}
+ } catch (error) {
+ socket.emit('error', { message: error.message });
+ logger.error('选择三权分立重载点数失败:', error);
+ }
+ });
+ /** 君子一言:并列最短有效花色时,由玩家公开选择其中一种。 */
+ socket.on('select_gentleman_promise_suit', ({ roomId, suit }) => {
+ try {
+ const room = roomManager.getRoom(roomId);
+ if (!room) throw new Error('房间不存在');
const player = room.findPlayerBySocketId(socket.id);
- if (!player) {
- throw new Error('玩家不存在');
+ if (!player) throw new Error('玩家不存在');
+ const gameEngine = gameEngines.get(room.id);
+ if (!gameEngine) throw new Error('游戏未开始');
+
+ const result = gameEngine.selectGentlemanPromiseSuit(player.id, suit);
+ if (!result.pending) {
+ triggerBotPlay(io, room, gameEngine).catch(err => {
+ logger.error('君子一言声明完成后触发Bot出牌失败:', err);
+ });
}
+ } catch (error) {
+ socket.emit('error', { message: error.message });
+ logger.error('选择君子一言最短花色失败:', error);
+ }
+ });
+ /** 潜龙在渊:并列最多的非级牌点数由玩家公开选择其中一个。 */
+ socket.on('select_hidden_dragon_rank', ({ roomId, rank }) => {
+ try {
+ const room = roomManager.getRoom(roomId);
+ if (!room) throw new Error('房间不存在');
+ const player = room.findPlayerBySocketId(socket.id);
+ if (!player) throw new Error('玩家不存在');
const gameEngine = gameEngines.get(room.id);
- if (!gameEngine) {
- throw new Error('游戏未开始');
+ if (!gameEngine) throw new Error('游戏未开始');
+
+ const result = gameEngine.selectHiddenDragonRank(player.id, rank);
+ if (!result.pending) {
+ triggerBotPlay(io, room, gameEngine).catch(err => {
+ logger.error('潜龙在渊声明完成后触发Bot出牌失败:', err);
+ });
}
+ } catch (error) {
+ socket.emit('error', { message: error.message });
+ logger.error('选择潜龙在渊最多点数失败:', error);
+ }
+ });
- const allReady = gameEngine.playerReady(player.id);
+ /** 二律背反:选择花色+点数,全部提交后才同时公开。 */
+ socket.on('select_antinomy_card', ({ roomId, suit, rank }) => {
+ try {
+ const room = roomManager.getRoom(roomId);
+ if (!room) throw new Error('房间不存在');
+ const player = room.findPlayerBySocketId(socket.id);
+ if (!player) throw new Error('玩家不存在');
+ const gameEngine = gameEngines.get(room.id);
+ if (!gameEngine) throw new Error('游戏未开始');
- // 广播玩家准备状态
- io.to(room.id).emit('player_ready_status', {
- playerId: player.id,
- playerName: player.name,
- isReady: player.isReady
- });
+ const result = gameEngine.selectAntinomyCard(player.id, suit, rank);
+ if (!result.pending) {
+ triggerBotPlay(io, room, gameEngine).catch(err => {
+ logger.error('二律背反选择完成后触发Bot出牌失败:', err);
+ });
+ }
+ } catch (error) {
+ socket.emit('error', { message: error.message });
+ logger.error('选择二律背反牌面失败:', error);
+ }
+ });
- // 广播房间状态更新
- io.to(room.id).emit('room_updated', {
- room: room.toJSON()
- });
+ /** 改稻为桑:两名闲家在首张牌打出前选择要改造的分牌。 */
+ socket.on('select_rice_to_mulberry_cards', ({ roomId, cardIds = [] }) => {
+ try {
+ const room = roomManager.getRoom(roomId);
+ if (!room) throw new Error('房间不存在');
+ const player = room.findPlayerBySocketId(socket.id);
+ if (!player) throw new Error('玩家不存在');
+ const gameEngine = gameEngines.get(room.id);
+ if (!gameEngine) throw new Error('游戏未开始');
- if (allReady) {
- // 所有玩家准备完毕
- io.to(room.id).emit('all_players_ready', {
- message: '所有玩家已准备,开始发牌'
+ const result = gameEngine.selectRiceToMulberryCards(player.id, cardIds);
+ if (!result.pending) {
+ triggerBotPlay(io, room, gameEngine).catch(err => {
+ logger.error('改稻为桑选择完成后触发Bot出牌失败:', err);
});
}
+ } catch (error) {
+ socket.emit('error', { message: error.message });
+ logger.error('选择改稻为桑分牌失败:', error);
+ }
+ });
- logger.info(`房间 ${room.id} 玩家 ${player.name} 已准备`);
+ /** 行政审查:庄家下家选副花色、上家选点数,两项声明均向全桌公开。 */
+ socket.on('select_administrative_review', ({ roomId, type, value }) => {
+ try {
+ const room = roomManager.getRoom(roomId);
+ if (!room) throw new Error('房间不存在');
+ const player = room.findPlayerBySocketId(socket.id);
+ if (!player) throw new Error('玩家不存在');
+ const gameEngine = gameEngines.get(room.id);
+ if (!gameEngine) throw new Error('游戏未开始');
+
+ const result = gameEngine.selectAdministrativeReviewDeclaration(
+ player.id,
+ type,
+ value
+ );
+ if (!result.pending) {
+ triggerBotPlay(io, room, gameEngine).catch(err => {
+ logger.error('行政审查声明完成后触发Bot出牌失败:', err);
+ });
+ }
+ } catch (error) {
+ socket.emit('error', { message: error.message });
+ logger.error('选择行政审查条件失败:', error);
+ }
+ });
+
+ /** 焦点人物:表决仅在本队内部流转,两队都通过后才允许开始出牌。 */
+ socket.on('vote_focus_figure', ({ roomId, team, attempt, agree }) => {
+ try {
+ const room = roomManager.getRoom(roomId);
+ if (!room) throw new Error('房间不存在');
+ const player = room.findPlayerBySocketId(socket.id);
+ if (!player) throw new Error('玩家不存在');
+ const gameEngine = gameEngines.get(room.id);
+ if (!gameEngine) throw new Error('游戏未开始');
+ const result = gameEngine.submitFocusFigureVote(player.id, agree, { team, attempt });
+ if (!result.pending) {
+ triggerBotPlay(io, room, gameEngine).catch(err => {
+ logger.error('焦点人物表决完成后触发Bot出牌失败:', err);
+ });
+ }
} catch (error) {
socket.emit('error', { message: error.message });
- logger.error('玩家准备失败:', error);
+ logger.error('焦点人物表决失败:', error);
}
});
@@ -281,6 +2088,14 @@ export function registerGameHandlers(io, socket, roomManager) {
throw new Error('游戏未开始');
}
+ if (room.gameState.phase !== GamePhases.BURYING) {
+ throw new Error('当前不是埋底阶段');
+ }
+
+ if (room.gameState.buryingPlayerId) {
+ throw new Error('庄家已经确定,不能重复发放底牌');
+ }
+
// 进入埋底阶段
room.gameState.phase = GamePhases.BURYING;
@@ -331,35 +2146,6 @@ export function registerGameHandlers(io, socket, roomManager) {
gameEngine.buryCards(player.id, cardIds);
- // 广播埋底完成
- io.to(room.id).emit('cards_buried', {
- playerId: player.id,
- playerName: player.name
- });
-
- // 广播首发玩家已设置(埋底玩家自动成为首发)
- io.to(room.id).emit('first_player_set', {
- playerId: player.id,
- playerName: player.name,
- currentPlayerIndex: room.gameState.currentPlayerIndex
- });
-
- // 广播阶段切换
- io.to(room.id).emit('phase_changed', {
- phase: 'playing',
- message: `埋底完成,${player.name} 先出牌`
- });
-
- // 广播房间状态更新
- io.to(room.id).emit('room_updated', {
- room: room.toJSON()
- });
-
- // 触发bot自动出牌
- triggerBotPlay(io, room, gameEngine).catch(err => {
- logger.error('触发bot出牌失败:', err);
- });
-
} catch (error) {
socket.emit('error', { message: error.message });
logger.error('埋底失败:', error);
@@ -415,47 +2201,140 @@ export function registerGameHandlers(io, socket, roomManager) {
});
/**
- * 设置主牌(房主)
+ * 出牌
*/
- socket.on('set_trump', ({ roomId, suit, rank }) => {
+ socket.on('respond_straw_boat_borrowing_arrows', ({ roomId, accept, cardId = null }) => {
try {
const room = roomManager.getRoom(roomId);
- if (!room) {
- throw new Error('房间不存在');
- }
+ if (!room) throw new Error('房间不存在');
+ const player = room.findPlayerBySocketId(socket.id);
+ if (!player) throw new Error('玩家不存在');
+ const gameEngine = gameEngines.get(room.id);
+ if (!gameEngine) throw new Error('游戏未开始');
- if (room.hostId !== socket.id) {
- throw new Error('只有房主可以设置主牌');
- }
+ const result = gameEngine.resolveStrawBoatBorrowingArrows(player.id, {
+ accept: Boolean(accept),
+ cardId
+ });
+ emitStrawBoatBorrowingArrowsResolution(io, room, result);
+ io.to(room.id).emit('room_updated', { room: room.toJSON() });
+ triggerBotPlay(io, room, gameEngine).catch(error => {
+ logger.error('草船借箭处理后触发Bot出牌失败:', error);
+ });
+ } catch (error) {
+ socket.emit('error', { message: error.message });
+ logger.error('处理草船借箭失败:', error);
+ }
+ });
- // 设置主牌(规范化 rank 确保类型一致)
- const normalizedRank = normalizeRank(rank);
- room.gameState.trumpSuit = suit;
- room.gameState.trumpRank = normalizedRank;
+ socket.on('respond_waiting_rabbit', ({ roomId, accept, discardCardId = null }) => {
+ try {
+ const room = roomManager.getRoom(roomId);
+ if (!room) throw new Error('房间不存在');
+ const player = room.findPlayerBySocketId(socket.id);
+ if (!player) throw new Error('玩家不存在');
+ const gameEngine = gameEngines.get(room.id);
+ if (!gameEngine) throw new Error('游戏未开始');
- // 广播主牌更新
- io.to(room.id).emit('trump_updated', {
- trumpSuit: suit,
- trumpRank: normalizedRank
+ const { roundResult } = gameEngine.resolveWaitingRabbitDecision(player.id, {
+ accept: Boolean(accept),
+ discardCardId
});
+ emitWaitingRabbitResolution(io, room, roundResult);
+ io.to(room.id).emit('room_updated', { room: room.toJSON() });
+ triggerBotPlay(io, room, gameEngine).catch(error => {
+ logger.error('守株待兔轮末处理后触发Bot出牌失败:', error);
+ });
+ } catch (error) {
+ socket.emit('error', { message: error.message });
+ logger.error('处理守株待兔换牌失败:', error);
+ }
+ });
- // 广播房间状态更新
- io.to(room.id).emit('room_updated', {
- room: room.toJSON()
+ socket.on('respond_political_review', ({ roomId, returnPlay }) => {
+ try {
+ const room = roomManager.getRoom(roomId);
+ if (!room) throw new Error('房间不存在');
+ const player = room.findPlayerBySocketId(socket.id);
+ if (!player) throw new Error('玩家不存在');
+ const gameEngine = gameEngines.get(room.id);
+ if (!gameEngine) throw new Error('游戏未开始');
+
+ gameEngine.respondPoliticalReview(player.id, returnPlay === true);
+ triggerBotPlay(io, room, gameEngine).catch(error => {
+ logger.error('政治审查决定后触发Bot出牌失败:', error);
});
+ } catch (error) {
+ socket.emit('error', { message: error.message });
+ logger.error('处理政治审查决定失败:', error);
+ }
+ });
+
+ socket.on('request_surrender', ({ roomId }) => {
+ try {
+ const room = roomManager.getRoom(roomId);
+ if (!room) throw new Error('房间不存在');
+ const player = room.findPlayerBySocketId(socket.id);
+ if (!player) throw new Error('玩家不存在');
+ const gameEngine = gameEngines.get(room.id);
+ if (!gameEngine) throw new Error('游戏未开始');
- logger.info(`房间 ${room.id} 主牌已设置: ${suit} ${rank}`);
+ const request = gameEngine.requestSurrender(player.id);
+ io.to(room.id).emit('surrender_requested', request);
+ beginSurrenderDecision(io, room, gameEngine);
+ io.to(room.id).emit('room_updated', { room: room.toJSON() });
+ } catch (error) {
+ socket.emit('error', { message: error.message });
+ logger.error('发起投降失败:', error);
+ }
+ });
+ socket.on('respond_surrender', ({ roomId, accept }) => {
+ try {
+ const room = roomManager.getRoom(roomId);
+ if (!room) throw new Error('房间不存在');
+ const player = room.findPlayerBySocketId(socket.id);
+ if (!player) throw new Error('玩家不存在');
+ const gameEngine = gameEngines.get(room.id);
+ if (!gameEngine) throw new Error('游戏未开始');
+
+ const result = gameEngine.respondSurrender(player.id, accept === true);
+ if (result.accepted) {
+ io.to(room.id).emit('game_surrendered', result.surrender);
+ emitFinishedGame(io, room);
+ } else {
+ io.to(room.id).emit('surrender_rejected', result.rejection);
+ if (result.gameFinished) {
+ emitFinishedGame(io, room);
+ } else if (result.nextDecision) {
+ beginSurrenderDecision(io, room, gameEngine, result.nextDecision);
+ }
+ }
+ io.to(room.id).emit('room_updated', { room: room.toJSON() });
+ if (!result.gameFinished && !result.nextDecision) {
+ triggerBotPlay(io, room, gameEngine).catch(error => {
+ logger.error('投降表决结束后触发牌局继续失败:', error);
+ });
+ }
} catch (error) {
socket.emit('error', { message: error.message });
- logger.error('设置主牌失败:', error);
+ logger.error('处理投降决定失败:', error);
}
});
- /**
- * 出牌
- */
- socket.on('play_cards', ({ roomId, cardIds }) => {
+ socket.on('play_cards', ({
+ roomId,
+ cardIds,
+ controlledPlayerId = null,
+ activeSkillId = null,
+ jokerSubstitutions = [],
+ clusterAnalysisSubstitutions = [],
+ forbiddenMagicSubstitutions = [],
+ divineWeaponCardId = null,
+ divineWeaponSourceCardId = null,
+ ambiguousAlternativeCardIds = [],
+ politicalReviewApprovalId = null
+ }, acknowledge) => {
try {
const room = roomManager.getRoom(roomId);
if (!room) {
@@ -472,13 +2351,35 @@ export function registerGameHandlers(io, socket, roomManager) {
throw new Error('游戏未开始');
}
- const result = gameEngine.playCards(player.id, cardIds);
+ const result = gameEngine.playCards(
+ player.id,
+ cardIds,
+ controlledPlayerId,
+ activeSkillId,
+ {
+ jokerSubstitutions,
+ clusterAnalysisSubstitutions,
+ forbiddenMagicSubstitutions,
+ divineWeaponCardId,
+ divineWeaponSourceCardId,
+ ambiguousAlternativeCardIds,
+ politicalReviewApprovalId
+ }
+ );
+
+ if (typeof acknowledge === 'function') {
+ acknowledge({
+ ok: true,
+ pending: Boolean(result.politicalReviewDeferred)
+ });
+ }
+ if (result.politicalReviewDeferred) return;
- // 广播甩牌失败消息
if (result.throwFailed) {
io.to(room.id).emit('throw_failed', {
- playerId: player.id,
- playerName: player.name,
+ playerId: result.playerId,
+ playerName: result.playerName,
+ isProxy: result.isProxy,
message: result.throwFailed.message,
attemptedCards: result.throwFailed.attemptedCards,
attemptedCardObjects: result.throwFailed.attemptedCardObjects,
@@ -487,19 +2388,43 @@ export function registerGameHandlers(io, socket, roomManager) {
}
// 广播出牌
- io.to(room.id).emit('cards_played', {
- playerId: player.id,
- playerName: player.name,
- cards: result.playedCards,
- remainingCount: result.remainingCount
- });
+ if (result.activeSkillActivation) {
+ io.to(room.id).emit('active_skill_activated', result.activeSkillActivation);
+ }
+ emitCardsPlayed(io, room, result);
+ emitThreeTigersTransformation(io, room, result.threeTigersTransformation);
+ if (result.roundUpdate?.strengthCompensation) {
+ gameEngine.emitStrengthCompensationHands(result.roundUpdate.strengthCompensation);
+ }
+ if (result.roundUpdate?.defenseAsOffense) {
+ gameEngine.emitDefenseAsOffenseHands(result.roundUpdate.defenseAsOffense);
+ }
+ if (result.dreamKilling?.awakened) {
+ io.to(room.id).emit('dream_killing_awakened', {
+ playerId: result.playerId,
+ playerName: result.playerName,
+ ...result.dreamKilling
+ });
+ }
+ if (result.roundReveal) {
+ io.to(room.id).emit('concealed_plays_revealed', result.roundReveal);
+ }
+
+ if (result.tenSidedAmbushReveal) {
+ io.to(room.id).emit('ten_sided_ambush_revealed', result.tenSidedAmbushReveal);
+ }
+ if (result.threePowersReveal) {
+ io.to(room.id).emit('three_powers_revealed', result.threePowersReveal);
+ }
// 广播毙牌动作
if (result.trumpAction) {
io.to(room.id).emit('trump_action', {
type: result.trumpAction.type,
playerId: result.trumpAction.playerId,
- playerName: result.trumpAction.playerName
+ playerName: result.trumpAction.playerName,
+ targetPlayerId: result.trumpAction.targetPlayerId,
+ targetPlayerName: result.trumpAction.targetPlayerName
});
}
@@ -507,12 +2432,74 @@ export function registerGameHandlers(io, socket, roomManager) {
if (result.roundUpdate) {
io.to(room.id).emit('round_updated', result.roundUpdate);
}
+ const surrenderReviewResult = beginSurrenderDecision(
+ io,
+ room,
+ gameEngine,
+ result.surrenderDecision
+ );
+ if (surrenderReviewResult?.pending || surrenderReviewResult?.gameFinished) {
+ io.to(room.id).emit('room_updated', { room: room.toJSON() });
+ return;
+ }
+ const automaticDestroyDykeResult = beginDestroyDykeDecision(
+ io,
+ room,
+ gameEngine,
+ result.destroyDykeDecision
+ );
+ if (automaticDestroyDykeResult?.roundResult) return;
+ beginWaitingRabbitDecision(io, room, gameEngine, result.waitingRabbitDecision);
+ beginAmbiguousChoiceDecision(io, room, gameEngine, result.ambiguousDecision);
+ const automaticTeammateCheerResult = beginTeammateCheerDecision(
+ io,
+ room,
+ gameEngine,
+ result.teammateCheerRequest
+ );
+ beginAfterglowDecision(io, room, gameEngine, result.afterglowRequest);
+ if (result.afterglowExpired) {
+ io.to(room.id).emit('afterglow_expired', result.afterglowExpired);
+ }
+ beginStrawBoatBorrowingArrowsDecision(
+ io,
+ room,
+ gameEngine,
+ result.strawBoatDecision
+ );
+ emitCulturalRevolutionExpiry(
+ io,
+ room,
+ result.roundUpdate?.culturalRevolutionTransition
+ );
+ if (result.mutualSupportReturn) {
+ submitAutomaticMutualSupportActions(io, room, gameEngine);
+ }
+ emitPlannedEconomyDraw(io, room, result.plannedEconomyDraw);
+ if (result.timeReversalPending) {
+ gameEngine.scheduleTimeReversalDecision();
+ }
+ emitWholeHandExchange(io, room, result.wholeHandExchange);
+ const roundSelectionResult = beginRoundCardExchange(
+ io,
+ room,
+ gameEngine,
+ result.roundCardExchange
+ );
+ if (roundSelectionResult?.gameFinished) {
+ emitFinishedGame(io, room);
+ }
// 检查是否有玩家打完牌
- if (result.remainingCount === 0) {
+ if (
+ result.remainingCount === 0
+ && !result.timeReversalPending
+ && !result.ambiguousDecisionPending
+ && !result.destroyDykeDecisionPending
+ ) {
io.to(room.id).emit('player_finished', {
- playerId: player.id,
- playerName: player.name
+ playerId: result.playerId,
+ playerName: result.playerName
});
}
@@ -527,7 +2514,11 @@ export function registerGameHandlers(io, socket, roomManager) {
io.to(room.id).emit('phase_changed', {
phase: 'revealing',
- message: '所有玩家已出完牌,查看底牌'
+ message: room.gameState.bottomScoreResult?.abruptStop
+ ? '戛然而止,查看最后一轮与底牌结算'
+ : room.gameState.bottomScoreResult?.mistyFogCards?.length
+ ? '所有玩家已出完牌,查看底牌与迷雾牌'
+ : '所有玩家已出完牌,查看底牌'
});
}
@@ -537,18 +2528,61 @@ export function registerGameHandlers(io, socket, roomManager) {
});
// 触发下一位bot自动出牌
- if (!result.gameFinished) {
+ if (!result.gameFinished && !automaticTeammateCheerResult?.gameFinished) {
triggerBotPlay(io, room, gameEngine).catch(err => {
logger.error('触发下一位bot出牌失败:', err);
});
}
} catch (error) {
+ if (typeof acknowledge === 'function') {
+ acknowledge({ ok: false, message: error.message });
+ }
socket.emit('error', { message: error.message });
logger.error('出牌失败:', error);
}
});
+ socket.on('respond_ambiguous_choice', ({ roomId, optionIndex }) => {
+ try {
+ const room = roomManager.getRoom(roomId);
+ if (!room) throw new Error('房间不存在');
+ const player = room.findPlayerBySocketId(socket.id);
+ if (!player) throw new Error('玩家不存在');
+ const gameEngine = gameEngines.get(room.id);
+ if (!gameEngine) throw new Error('游戏未开始');
+
+ const result = gameEngine.resolveAmbiguousChoice(player.id, optionIndex);
+ emitAmbiguousChoiceResult(io, room, result);
+ if (result.completed) {
+ emitAmbiguousFinalRound(io, room, gameEngine, result);
+ } else {
+ io.to(room.id).emit('room_updated', { room: room.toJSON() });
+ beginAmbiguousChoiceDecision(io, room, gameEngine, result.nextDecision);
+ }
+ } catch (error) {
+ socket.emit('error', { message: error.message });
+ logger.error('处理模棱两可选择失败:', error);
+ }
+ });
+
+ socket.on('respond_destroy_dyke', ({ roomId, accept }) => {
+ try {
+ const room = roomManager.getRoom(roomId);
+ if (!room) throw new Error('房间不存在');
+ const player = room.findPlayerBySocketId(socket.id);
+ if (!player) throw new Error('玩家不存在');
+ const gameEngine = gameEngines.get(room.id);
+ if (!gameEngine) throw new Error('游戏未开始');
+
+ const response = gameEngine.respondDestroyDyke(player.id, accept === true);
+ emitDestroyDykeFinalRound(io, room, gameEngine, response);
+ } catch (error) {
+ socket.emit('error', { message: error.message });
+ logger.error('处理毁堤淹田决定失败:', error);
+ }
+ });
+
/**
* 跳过(出0张牌)
*/
@@ -588,7 +2622,9 @@ export function registerGameHandlers(io, socket, roomManager) {
io.to(room.id).emit('phase_changed', {
phase: 'revealing',
- message: '所有玩家已出完牌,查看底牌'
+ message: room.gameState.bottomScoreResult?.mistyFogCards?.length
+ ? '所有玩家已出完牌,查看底牌与迷雾牌'
+ : '所有玩家已出完牌,查看底牌'
});
}
@@ -604,9 +2640,7 @@ export function registerGameHandlers(io, socket, roomManager) {
});
- /**
- * 查看我的底牌(埋底玩家)
- */
+ /** 普通规则仅庄家可私密查看;特殊公开规则按各自权限返回底牌。 */
socket.on('view_my_bottom_cards', ({ roomId }) => {
try {
const room = roomManager.getRoom(roomId);
@@ -619,14 +2653,62 @@ export function registerGameHandlers(io, socket, roomManager) {
throw new Error('玩家不存在');
}
- // 只有埋底玩家可以查看
- if (room.gameState.buryingPlayerId !== player.id) {
- throw new Error('只有埋底玩家可以查看底牌');
+ const isPublic = isOpenlyRevealedRule(room.gameState.selectedRule);
+ const isPeopleCommune = isPeopleCommuneRule(room.gameState.selectedRule);
+ const isAdministrativeReview = isAdministrativeReviewRule(room.gameState.selectedRule);
+ const isReformAndOpeningUp = isReformAndOpeningUpRule(room.gameState.selectedRule);
+ const ownPeopleCommuneCards = isPeopleCommune
+ ? room.gameState.peopleCommuneBuriedCardsByPlayerId.get(player.id)
+ : null;
+ const isDealer = room.gameState.buryingPlayerId === player.id;
+ const isReformTeammate = (
+ isReformAndOpeningUp
+ && room.gameState.reformAndOpeningUpTeammatePlayerId === player.id
+ );
+ const isReformBottomFinalized = (
+ isReformAndOpeningUp
+ && room.gameState.phase !== GamePhases.BURYING
+ && !room.gameState.secondaryBuryingPlayerId
+ && room.gameState.bottomCards.length === room.gameState.bottomCardsCount
+ );
+
+ // 人民公社只允许看自己已经埋下的两张牌;其他三家的牌仍然保密。
+ if (isPeopleCommune && !ownPeopleCommuneCards) {
+ throw new Error('请先完成自己的埋牌');
+ }
+ // 改革开放在队友完成再埋底后,由庄家和该队友共同查看最终底牌。
+ // 其他普通规则的私密查看权仍只属于庄家;昭然若揭不限制身份和阶段。
+ const canViewPrivateBottom = isDealer
+ || (isReformBottomFinalized && isReformTeammate);
+ if (!isPublic && !isPeopleCommune && !canViewPrivateBottom) {
+ throw new Error(
+ isReformAndOpeningUp
+ ? '只有本局庄家和庄家队友可以查看底牌'
+ : '只有本局庄家可以查看底牌'
+ );
+ }
+ if (
+ isReformAndOpeningUp
+ && room.gameState.secondaryBuryingPlayerId
+ && isDealer
+ ) {
+ throw new Error('庄家队友尚未完成再埋底');
+ }
+ if (
+ isAdministrativeReview
+ && !room.gameState.administrativeReview?.isBottomReleased
+ ) {
+ throw new Error('行政审查条件尚未满足,庄家暂时不能查看底牌');
}
- // 返回底牌
+ const visibleBottomCards = isPeopleCommune
+ ? ownPeopleCommuneCards
+ : room.gameState.bottomCards;
socket.emit('my_bottom_cards', {
- bottomCards: room.gameState.bottomCards.map(c => c.toJSON())
+ bottomCards: visibleBottomCards.map(c => c.toJSON()),
+ isPublic,
+ isPeopleCommune,
+ isReformAndOpeningUp
});
} catch (error) {
@@ -660,11 +2742,48 @@ export function registerGameHandlers(io, socket, roomManager) {
// 广播撤回
io.to(room.id).emit('play_undone', {
- playerId: player.id,
- playerName: player.name,
- cards: result.cards,
- remainingCount: result.remainingCount
+ playerId: result.playerId,
+ playerName: result.playerName,
+ controllerPlayerId: result.controllerPlayerId,
+ controllerPlayerName: result.controllerPlayerName,
+ isProxy: result.isProxy,
+ cards: result.concealed ? [] : result.cards,
+ cardsCount: result.cards.length,
+ concealed: result.concealed,
+ remainingCount: result.remainingCount,
+ restoredActiveSkillId: result.restoredActiveSkillId,
+ restoredActiveSkillName: result.restoredActiveSkillName
});
+ emitThreeTigersTransformation(io, room, result.threeTigersTransformation);
+ if (result.teammateCheerReverted) {
+ io.to(room.id).emit('teammate_cheer_reverted', result.teammateCheerReverted);
+ const buffedPlayer = room.findPlayerById(result.teammateCheerReverted.buffedPlayerId);
+ if (buffedPlayer?.socketId && !buffedPlayer.isBot) {
+ io.to(buffedPlayer.socketId).emit('teammate_cheer_hand_updated', {
+ sourcePlayerId: result.teammateCheerReverted.playerId,
+ sourcePlayerName: result.teammateCheerReverted.playerName,
+ cards: result.teammateCheerRestoredCards
+ });
+ }
+ }
+ if (result.afterglowReverted) {
+ io.to(room.id).emit('afterglow_reverted', result.afterglowReverted);
+ const afterglowPlayer = room.findPlayerById(result.afterglowReverted.playerId);
+ if (afterglowPlayer?.socketId && !afterglowPlayer.isBot) {
+ io.to(afterglowPlayer.socketId).emit('afterglow_hand_updated', {
+ cards: result.afterglowRestoredCards
+ });
+ }
+ }
+ if (result.concealed) {
+ const concealedPlayer = room.findPlayerById(result.playerId);
+ if (concealedPlayer?.socketId) {
+ io.to(concealedPlayer.socketId).emit('concealed_play_undone_private', {
+ playerId: result.playerId,
+ cards: result.cards
+ });
+ }
+ }
// 广播房间状态更新
io.to(room.id).emit('room_updated', {
@@ -697,10 +2816,18 @@ export function registerGameHandlers(io, socket, roomManager) {
throw new Error('游戏未开始');
}
+ if (room.gameState.phase !== GamePhases.REVEALING) {
+ throw new Error('只能在本局结束后准备下一局');
+ }
+
const allReady = gameEngine.readyForNextGame(player.id);
// 广播准备状态
- const readyCount = room.players.filter(p => p.isReadyForNext).length;
+ // 最后一位准备会同步触发 startNextGame,并立即清空准备标记;这里仍应广播 4/4,
+ // 否则客户端会在“开始下一局”之后误看到一次 0/4。
+ const readyCount = allReady
+ ? room.players.length
+ : room.players.filter(p => p.isReadyForNext).length;
io.to(room.id).emit('player_ready_for_next', {
playerId: player.id,
playerName: player.name,
@@ -764,6 +2891,9 @@ export function registerGameHandlers(io, socket, roomManager) {
*/
socket.on('update_score', ({ roomId, amount }) => {
try {
+ // 另一种同名事件由playerHandlers处理(房主设置指定玩家的绝对分数)。
+ if (typeof amount !== 'number') return;
+
const room = roomManager.getRoom(roomId);
if (!room) {
throw new Error('房间不存在');
@@ -794,6 +2924,9 @@ export function registerGameHandlers(io, socket, roomManager) {
*/
socket.on('update_level', ({ roomId, amount }) => {
try {
+ // 另一种同名事件由playerHandlers处理(房主设置指定玩家的绝对等级)。
+ if (typeof amount !== 'number') return;
+
const room = roomManager.getRoom(roomId);
if (!room) {
throw new Error('房间不存在');
@@ -834,26 +2967,42 @@ export function registerGameHandlers(io, socket, roomManager) {
throw new Error('玩家不存在');
}
- // 设置房间的选中规则(覆盖之前的规则)
- room.gameState.selectedRule = rule;
+ const gameEngine = gameEngines.get(room.id);
+ if (!gameEngine) {
+ throw new Error('游戏未开始');
+ }
- // 广播规则选择给所有玩家
- io.to(room.id).emit('rule_selected', {
- playerId: player.id,
- playerName: player.name,
- rule: rule
- });
+ gameEngine.selectRule(player.id, rule);
// 广播房间状态更新
io.to(room.id).emit('room_updated', {
room: room.toJSON()
});
- logger.info(`房间 ${room.id} 玩家 ${player.name} 选择规则: ${rule.name}`);
-
} catch (error) {
socket.emit('error', { message: error.message });
logger.error('选择规则失败:', error);
}
});
+
+ /**
+ * 双喜临门三选二阶段:仅房主可以把指定位置的候选换成一条新规则。
+ */
+ socket.on('refresh_double_happiness_option', ({ roomId, optionIndex }) => {
+ try {
+ const room = roomManager.getRoom(roomId);
+ if (!room) throw new Error('房间不存在');
+
+ const gameEngine = gameEngines.get(room.id);
+ if (!gameEngine) throw new Error('游戏未开始');
+
+ gameEngine.refreshDoubleHappinessOption(socket.id, optionIndex);
+ io.to(room.id).emit('room_updated', {
+ room: room.toJSON()
+ });
+ } catch (error) {
+ socket.emit('error', { message: error.message });
+ logger.error('刷新双喜临门候选失败:', error);
+ }
+ });
}
diff --git a/tractor-game-simulator/server/src/socket/handlers/playerHandlers.js b/tractor-game-simulator/server/src/socket/handlers/playerHandlers.js
index e4f6504..61028e0 100644
--- a/tractor-game-simulator/server/src/socket/handlers/playerHandlers.js
+++ b/tractor-game-simulator/server/src/socket/handlers/playerHandlers.js
@@ -1,6 +1,17 @@
import logger from '../../utils/logger.js';
import { validateDeclaration } from '../../utils/trumpUtils.js';
import { getGameEngines } from './gameHandlers.js';
+import {
+ isLastStandRule,
+ isOneCountryTwoSystemsRule,
+ isRemoveFirewoodRule,
+ isThreeSixNineGradesRule
+} from '../../rules/ruleRegistry.js';
+import {
+ getOneCountryPublicState,
+ getOneCountryTeamIndex,
+ isOneCountryJokerDeclaration
+} from '../../utils/oneCountryTwoSystemsUtils.js';
export function registerPlayerHandlers(io, socket, roomManager) {
@@ -50,7 +61,7 @@ export function registerPlayerHandlers(io, socket, roomManager) {
/**
* 亮主(摸牌阶段)
*/
- socket.on('declare_trump', ({ roomId, suit, count }) => {
+ socket.on('declare_trump', ({ roomId, suit, count, declarationRole = 'trump' }) => {
try {
const room = roomManager.getRoom(roomId);
if (!room) {
@@ -65,14 +76,69 @@ export function registerPlayerHandlers(io, socket, roomManager) {
if (room.gameState.phase !== 'drawing') {
throw new Error('只能在摸牌阶段亮主');
}
+ if (room.gameState.isTrumpDeclarationLocked || room.gameState.cardExchange) {
+ throw new Error('亮主和反主阶段已经结束');
+ }
const trumpRank = room.gameState.trumpRank;
if (!trumpRank) {
throw new Error('未设置级牌');
}
- // 获取当前亮主信息
- const currentTrump = room.gameState.currentTrumpDeclaration || null;
+ const isThreeSixNine = isThreeSixNineGradesRule(room.gameState.selectedRule);
+ if (!['trump', 'inferior'].includes(declarationRole)) {
+ throw new Error('无效的亮牌类型');
+ }
+ if (declarationRole === 'inferior' && !isThreeSixNine) {
+ throw new Error('本局规则不能亮劣');
+ }
+ if (declarationRole === 'inferior' && suit === 'joker') {
+ throw new Error('王只能用于亮主');
+ }
+ if (
+ declarationRole === 'inferior'
+ && room.gameState.currentTrumpDeclaration?.suit === 'joker'
+ ) {
+ throw new Error('当前已是无主,本局不能再亮劣');
+ }
+
+ if (isLastStandRule(room.gameState.selectedRule) && suit === 'joker') {
+ throw new Error('绝处逢生不能用一对王反主');
+ }
+
+ const isOneCountryTwoSystems = isOneCountryTwoSystemsRule(
+ room.gameState.selectedRule
+ );
+ const playerIndex = room.getPlayerIndex(player.id);
+ const teamIndex = getOneCountryTeamIndex(playerIndex);
+ if (
+ isOneCountryTwoSystems
+ && [...room.gameState.oneCountryDeclarationsByTeam.values()]
+ .some(isOneCountryJokerDeclaration)
+ ) {
+ throw new Error('已有玩家亮出一对王,双方已锁定无主');
+ }
+
+ // 一国两制只在本方内比较亮主强度;另一方的声明不会覆盖本方。
+ const currentTrump = declarationRole === 'inferior'
+ ? room.gameState.currentInferiorDeclaration || null
+ : isOneCountryTwoSystems
+ ? room.gameState.oneCountryDeclarationsByTeam.get(teamIndex) || null
+ : room.gameState.currentTrumpDeclaration || null;
+
+ if (isThreeSixNine && suit !== 'joker') {
+ const existingClaim = room.gameState.threeSixNineClaimedSuits.get(suit);
+ const isOwnCurrentReinforcement = Boolean(
+ existingClaim
+ && existingClaim.playerId === player.id
+ && existingClaim.declarationRole === declarationRole
+ && currentTrump?.playerId === player.id
+ && currentTrump?.suit === suit
+ );
+ if (existingClaim && !isOwnCurrentReinforcement) {
+ throw new Error('该花色已经用于亮主或亮劣');
+ }
+ }
// 验证亮主是否合法
const validation = validateDeclaration(player.cards, suit, count, trumpRank, currentTrump, player.id);
@@ -85,7 +151,7 @@ export function registerPlayerHandlers(io, socket, roomManager) {
const isCounter = currentTrump !== null;
// 记录亮主信息
- room.gameState.currentTrumpDeclaration = {
+ const declaration = {
playerId: player.id,
playerName: player.name,
suit: suit,
@@ -93,15 +159,54 @@ export function registerPlayerHandlers(io, socket, roomManager) {
declarationType: validation.declarationType,
strength: validation.strength,
jokerType: validation.jokerType,
+ declarationRole,
isCounter: isCounter,
cards: validation.cards
};
-
- // 设置主牌花色(除非是亮王,亮王表示无主)
- if (suit !== 'joker') {
- room.gameState.trumpSuit = suit;
+ if (
+ isCounter
+ && isRemoveFirewoodRule(room.gameState.selectedRule)
+ && currentTrump.playerId !== player.id
+ ) {
+ room.gameState.removeFirewoodCounterPairs.push({
+ sequence: room.gameState.removeFirewoodCounterPairs.length + 1,
+ counteredPlayerId: currentTrump.playerId,
+ counteredPlayerName: currentTrump.playerName,
+ counteringPlayerId: player.id,
+ counteringPlayerName: player.name
+ });
+ }
+ if (declarationRole === 'inferior') {
+ room.gameState.currentInferiorDeclaration = declaration;
+ room.gameState.inferiorSuit = suit;
} else {
- room.gameState.trumpSuit = 'no_trump'; // 无主
+ room.gameState.currentTrumpDeclaration = declaration;
+ if (isOneCountryTwoSystems) {
+ room.gameState.oneCountryDeclarationsByTeam.set(teamIndex, declaration);
+ room.gameState.oneCountryResolved = null;
+ }
+
+ // 设置主牌花色(除非是亮王,亮王表示无主)
+ if (suit !== 'joker') {
+ room.gameState.trumpSuit = suit;
+ } else {
+ room.gameState.trumpSuit = 'no_trump'; // 无主
+ if (isThreeSixNine) {
+ room.gameState.currentInferiorDeclaration = null;
+ room.gameState.inferiorSuit = null;
+ }
+ }
+ }
+ if (
+ isThreeSixNine
+ && suit !== 'joker'
+ && !room.gameState.threeSixNineClaimedSuits.has(suit)
+ ) {
+ room.gameState.threeSixNineClaimedSuits.set(suit, {
+ playerId: player.id,
+ playerName: player.name,
+ declarationRole
+ });
}
// 广播亮主成功
@@ -113,16 +218,47 @@ export function registerPlayerHandlers(io, socket, roomManager) {
declarationType: validation.declarationType,
strength: validation.strength,
isCounter: isCounter,
+ declarationRole,
+ teamIndex: isOneCountryTwoSystems ? teamIndex : null,
+ oneCountryTwoSystems: isOneCountryTwoSystems,
cards: validation.cards.map(c => c.toJSON())
});
- // 广播主牌更新
- io.to(room.id).emit('trump_updated', {
- trumpSuit: room.gameState.trumpSuit,
- trumpRank: room.gameState.trumpRank
- });
+ if (declarationRole === 'trump') {
+ // 广播主牌更新
+ io.to(room.id).emit('trump_updated', {
+ trumpSuit: room.gameState.trumpSuit,
+ trumpRank: room.gameState.trumpRank,
+ oneCountryTwoSystems: isOneCountryTwoSystems
+ ? getOneCountryPublicState(room.gameState)
+ : null,
+ inferiorSuit: isThreeSixNine ? room.gameState.inferiorSuit : null
+ });
+ }
+ if (isThreeSixNine) {
+ io.to(room.id).emit('three_six_nine_updated', {
+ trumpSuit: room.gameState.trumpSuit,
+ trumpRank: room.gameState.trumpRank,
+ inferiorSuit: room.gameState.inferiorSuit,
+ currentTrumpDeclaration: room.gameState.currentTrumpDeclaration
+ ? {
+ ...room.gameState.currentTrumpDeclaration,
+ cards: room.gameState.currentTrumpDeclaration.cards.map(card => card.toJSON())
+ }
+ : null,
+ currentInferiorDeclaration: room.gameState.currentInferiorDeclaration
+ ? {
+ ...room.gameState.currentInferiorDeclaration,
+ cards: room.gameState.currentInferiorDeclaration.cards.map(card => card.toJSON())
+ }
+ : null,
+ claimedSuits: Object.fromEntries(room.gameState.threeSixNineClaimedSuits)
+ });
+ }
- const action = isCounter ? '反主' : '亮主';
+ const action = declarationRole === 'inferior'
+ ? (isCounter ? '反劣' : '亮劣')
+ : (isCounter ? '反主' : '亮主');
logger.info(`玩家 ${player.name} ${action}: ${count === 2 ? '一对' : '单张'} ${suit}`);
// 只有在摸牌结束后才重置庄家倒计时
@@ -133,8 +269,8 @@ export function registerPlayerHandlers(io, socket, roomManager) {
const { deck, drawingIndex } = room.gameState;
// 检查发牌是否已完成
if (drawingIndex >= deck.length) {
- gameEngine.drawingManager.startDealerCountdown();
- logger.info(`房间 ${room.id} 重置庄家倒计时`);
+ const restarted = gameEngine.drawingManager.startDealerCountdown();
+ if (restarted) logger.info(`房间 ${room.id} 重置庄家倒计时`);
} else {
logger.info(`房间 ${room.id} 摸牌中,暂不触发倒计时`);
}
@@ -151,6 +287,9 @@ export function registerPlayerHandlers(io, socket, roomManager) {
*/
socket.on('update_score', ({ roomId, playerId, newScore }) => {
try {
+ // 快捷加减分使用同名事件的amount格式,由gameHandlers处理。
+ if (newScore === undefined) return;
+
const room = roomManager.getRoom(roomId);
if (!room) {
throw new Error('房间不存在');
@@ -196,6 +335,9 @@ export function registerPlayerHandlers(io, socket, roomManager) {
*/
socket.on('update_level', ({ roomId, playerId, newLevel }) => {
try {
+ // 快捷加减等级使用同名事件的amount格式,由gameHandlers处理。
+ if (newLevel === undefined) return;
+
const room = roomManager.getRoom(roomId);
if (!room) {
throw new Error('房间不存在');
diff --git a/tractor-game-simulator/server/src/socket/handlers/roomHandlers.js b/tractor-game-simulator/server/src/socket/handlers/roomHandlers.js
index fbaf740..d0fae5a 100644
--- a/tractor-game-simulator/server/src/socket/handlers/roomHandlers.js
+++ b/tractor-game-simulator/server/src/socket/handlers/roomHandlers.js
@@ -3,6 +3,20 @@ import logger from '../../utils/logger.js';
import { getGameEngines, getBotServices } from './gameHandlers.js';
import { BotTypes } from '../../utils/constants.js';
+export const DISCONNECT_GRACE_MS = 120_000;
+const disconnectTimers = new Map();
+
+function getDisconnectTimerKey(roomId, playerId) {
+ return `${roomId}:${playerId}`;
+}
+
+function clearDisconnectTimer(roomId, playerId) {
+ const key = getDisconnectTimerKey(roomId, playerId);
+ const timer = disconnectTimers.get(key);
+ if (timer) clearTimeout(timer);
+ disconnectTimers.delete(key);
+}
+
export function registerRoomHandlers(io, socket, roomManager) {
/**
@@ -22,7 +36,8 @@ export function registerRoomHandlers(io, socket, roomManager) {
// 返回房间信息
socket.emit('room_created', {
room: room.toJSON(),
- player: host.toJSON()
+ player: host.toJSON(),
+ resumeToken: host.resumeToken
});
logger.info(`玩家 ${host.name} 创建房间: ${room.id}`);
@@ -42,6 +57,12 @@ export function registerRoomHandlers(io, socket, roomManager) {
throw new Error('房间不存在');
}
+ // “等待准备”仍未开始发牌,允许空位由玩家重新加入;只有进入
+ // 摸牌及后续阶段后才禁止陌生玩家中途补位。
+ if (room.gameState.phase !== 'waiting') {
+ throw new Error('游戏已经开始,无法中途加入');
+ }
+
if (room.players.length >= room.config.maxPlayers) {
throw new Error('房间已满');
}
@@ -50,13 +71,31 @@ export function registerRoomHandlers(io, socket, roomManager) {
const player = new Player(socket.id, playerName || `玩家${room.players.length + 1}`, room.players.length);
room.addPlayer(player);
+ // 若离开的恰好是规则选择者,让补位玩家接管同一组选项,避免
+ // 准备阶段永远等待一个已经不存在的 playerId。
+ const chooserStillExists = room.findPlayerById(room.gameState.ruleChooserPlayerId);
+ if (
+ room.gameState.isWaitingForReady
+ && room.gameState.isRuleSelectionPending
+ && !chooserStillExists
+ ) {
+ room.gameState.ruleChooserPlayerId = player.id;
+ io.to(room.id).emit('rule_selection_started', {
+ chooserPlayerId: player.id,
+ chooserPlayerName: player.name,
+ selectionMode: room.gameState.ruleSelectionMode,
+ options: room.gameState.ruleOptions
+ });
+ }
+
// 加入Socket.IO房间
socket.join(room.id);
// 通知该玩家
socket.emit('room_joined', {
room: room.toJSON(),
- player: player.toJSON()
+ player: player.toJSON(),
+ resumeToken: player.resumeToken
});
// 广播给其他玩家
@@ -76,11 +115,50 @@ export function registerRoomHandlers(io, socket, roomManager) {
}
});
+ /**
+ * Reclaim an existing seat after refresh or a transient connection loss.
+ */
+ socket.on('resume_room', ({ roomId, playerId, resumeToken }) => {
+ try {
+ const room = roomManager.getRoom(roomId)
+ || roomManager.findRoomByResumeToken(resumeToken);
+ if (!room) throw new Error('原房间已不存在');
+
+ const player = room.findPlayerById(playerId);
+ if (!player || !resumeToken || player.resumeToken !== resumeToken) {
+ throw new Error('无法验证原座位');
+ }
+
+ const previousSocketId = player.socketId;
+ clearDisconnectTimer(room.id, player.id);
+ player.socketId = socket.id;
+ player.isOnline = true;
+ room.updatedAt = new Date();
+ if (room.hostId === previousSocketId) room.hostId = socket.id;
+ socket.join(room.id);
+
+ socket.emit('room_resumed', {
+ room: room.toJSON(),
+ player: player.toJSONWithCards(),
+ resumeToken: player.resumeToken
+ });
+ socket.to(room.id).emit('player_reconnected', {
+ playerId: player.id,
+ playerName: player.name
+ });
+ io.to(room.id).emit('room_updated', { room: room.toJSON() });
+ logger.info(`玩家 ${player.name} 已恢复房间 ${room.id} 的座位`);
+ } catch (error) {
+ socket.emit('resume_failed', { message: error.message });
+ logger.warn(`恢复房间失败: ${error.message}`);
+ }
+ });
+
/**
* 离开房间
*/
socket.on('leave_room', ({ roomId }) => {
- handlePlayerLeave(io, socket, roomManager, roomId);
+ handlePlayerLeave(io, socket, roomManager, roomId, { immediate: true });
});
/**
@@ -105,6 +183,10 @@ export function registerRoomHandlers(io, socket, roomManager) {
throw new Error('只有房主可以修改配置');
}
+ if (room.gameState.phase !== 'waiting' || room.gameState.isWaitingForReady) {
+ throw new Error('只能在游戏开始前修改配置');
+ }
+
room.updateConfig(config);
// 广播配置更新
@@ -133,7 +215,7 @@ export function registerRoomHandlers(io, socket, roomManager) {
throw new Error('只有房主可以设置Bot类型');
}
- if (room.gameState.phase !== 'waiting') {
+ if (room.gameState.phase !== 'waiting' || room.gameState.isWaitingForReady) {
throw new Error('游戏进行中无法更改Bot类型');
}
@@ -185,7 +267,7 @@ export function registerRoomHandlers(io, socket, roomManager) {
throw new Error('房间已满');
}
- if (room.gameState.phase !== 'waiting') {
+ if (room.gameState.phase !== 'waiting' || room.gameState.isWaitingForReady) {
throw new Error('游戏进行中无法添加bot');
}
@@ -240,7 +322,7 @@ export function registerRoomHandlers(io, socket, roomManager) {
throw new Error('该玩家不是bot');
}
- if (room.gameState.phase !== 'waiting') {
+ if (room.gameState.phase !== 'waiting' || room.gameState.isWaitingForReady) {
throw new Error('游戏进行中无法移除bot');
}
@@ -279,7 +361,7 @@ export function registerRoomHandlers(io, socket, roomManager) {
/**
* 处理玩家离开
*/
-function handlePlayerLeave(io, socket, roomManager, roomId) {
+function handlePlayerLeave(io, socket, roomManager, roomId, { immediate = false } = {}) {
try {
const room = roomManager.getRoom(roomId);
if (!room) return;
@@ -287,6 +369,54 @@ function handlePlayerLeave(io, socket, roomManager, roomId) {
const player = room.findPlayerBySocketId(socket.id);
if (!player) return;
+ if (!immediate) {
+ player.isOnline = false;
+ room.updatedAt = new Date();
+ io.to(room.id).emit('player_disconnected', {
+ playerId: player.id,
+ playerName: player.name,
+ graceMs: DISCONNECT_GRACE_MS
+ });
+ io.to(room.id).emit('room_updated', { room: room.toJSON() });
+
+ clearDisconnectTimer(room.id, player.id);
+ const timer = setTimeout(() => {
+ disconnectTimers.delete(getDisconnectTimerKey(room.id, player.id));
+ // A successful resume changes both fields, so a stale timer can never
+ // evict the newly connected player.
+ if (player.isOnline || player.socketId !== socket.id) return;
+ removePlayerPermanently(io, roomManager, room, player, socket);
+ }, DISCONNECT_GRACE_MS);
+ timer.unref?.();
+ disconnectTimers.set(getDisconnectTimerKey(room.id, player.id), timer);
+ logger.info(`玩家 ${player.name} 断线,座位保留 ${DISCONNECT_GRACE_MS / 1000} 秒`);
+ return;
+ }
+
+ clearDisconnectTimer(room.id, player.id);
+ removePlayerPermanently(io, roomManager, room, player, socket);
+ } catch (error) {
+ logger.error('处理玩家离开失败:', error);
+ }
+}
+
+function deleteRoomAndResources(roomManager, room) {
+ const gameEngines = getGameEngines();
+ const gameEngine = gameEngines.get(room.id);
+ try {
+ gameEngine?.cleanup?.();
+ } catch (error) {
+ logger.error(`清理房间 ${room.id} 的游戏引擎失败:`, error);
+ } finally {
+ gameEngines.delete(room.id);
+ getBotServices().delete(room.id);
+ roomManager.deleteRoom(room.id);
+ }
+}
+
+function removePlayerPermanently(io, roomManager, room, player, socket) {
+ try {
+
// 如果游戏正在进行,终止游戏
if (room.gameState.phase !== 'waiting' && room.gameState.phase !== 'finished') {
// 清理游戏引擎资源(停止发牌定时器等)
@@ -309,7 +439,7 @@ function handlePlayerLeave(io, socket, roomManager, roomId) {
room.removePlayer(player.id);
// 离开Socket.IO房间
- socket.leave(room.id);
+ socket?.leave?.(room.id);
// 广播玩家离开
io.to(room.id).emit('player_left', {
@@ -317,8 +447,16 @@ function handlePlayerLeave(io, socket, roomManager, roomId) {
playerName: player.name
});
+ // Bot-only rooms have no human who can continue or reclaim them. Delete
+ // the room immediately instead of transferring ownership to a bot.
+ if (!room.players.some(remainingPlayer => !remainingPlayer.isBot)) {
+ deleteRoomAndResources(roomManager, room);
+ logger.info(`玩家 ${player.name} 离开房间: ${room.id},仅剩Bot,房间已删除`);
+ return;
+ }
+
// 如果房主离开,转移房主权限或删除房间
- if (room.hostId === socket.id) {
+ if (room.hostId === player.socketId) {
if (room.players.length > 0) {
room.hostId = room.players[0].socketId;
io.to(room.id).emit('host_changed', {
@@ -327,12 +465,7 @@ function handlePlayerLeave(io, socket, roomManager, roomId) {
});
logger.info(`房间 ${room.id} 房主转移给: ${room.players[0].name}`);
} else {
- // 房间被删除时也清理游戏引擎和bot服务
- const gameEngines = getGameEngines();
- const botServices = getBotServices();
- gameEngines.delete(room.id);
- botServices.delete(room.id);
- roomManager.deleteRoom(room.id);
+ deleteRoomAndResources(roomManager, room);
logger.info(`玩家 ${player.name} 离开房间: ${room.id},房间已删除`);
return; // 房间已删除,不需要再广播
}
diff --git a/tractor-game-simulator/server/src/utils/cardCooldownUtils.js b/tractor-game-simulator/server/src/utils/cardCooldownUtils.js
new file mode 100644
index 0000000..ef95e14
--- /dev/null
+++ b/tractor-game-simulator/server/src/utils/cardCooldownUtils.js
@@ -0,0 +1,176 @@
+import {
+ isBirdsGoneBowHiddenRule,
+ isBushGateRule,
+ isCooldownTimeRule,
+ isTimeCoolingRule
+} from '../rules/ruleRegistry.js';
+import { getEffectiveSuit } from './cardPatternUtils.js';
+
+export const CardCooldownTypes = Object.freeze({
+ RANK: 'rank',
+ SUIT: 'suit'
+});
+
+const STANDARD_SUITS = new Set(['hearts', 'diamonds', 'clubs', 'spades']);
+const STANDARD_POINT_RANKS = new Set(['5', '10', 'K']);
+
+function getBirdPointCardTotalsByEffectiveSuit(gameState) {
+ const totals = new Map();
+ for (const suit of STANDARD_SUITS) {
+ for (const rank of STANDARD_POINT_RANKS) {
+ const effectiveSuit = getEffectiveSuit(
+ { suit, rank },
+ gameState?.trumpSuit,
+ gameState?.trumpRank
+ );
+ totals.set(effectiveSuit, (totals.get(effectiveSuit) || 0) + 2);
+ }
+ }
+ return totals;
+}
+
+export function getCardCooldownType(rule) {
+ if (isCooldownTimeRule(rule)) return CardCooldownTypes.RANK;
+ if (isTimeCoolingRule(rule)) return CardCooldownTypes.SUIT;
+ return null;
+}
+
+export function getCardCooldownValue(card, type, gameState = null) {
+ if (type === CardCooldownTypes.RANK) return card?.rank || null;
+ if (type === CardCooldownTypes.SUIT) {
+ return getEffectiveSuit(card, gameState?.trumpSuit, gameState?.trumpRank) || null;
+ }
+ return null;
+}
+
+export function getCardCooldownDisabledCards({
+ gameState,
+ playerId,
+ playerCards = [],
+ requiredCount = 1,
+ isLeading = false
+} = {}) {
+ const type = getCardCooldownType(gameState?.selectedRule);
+ if (!type || !playerId || !Array.isArray(playerCards) || playerCards.length === 0) return [];
+ const restrictedValues = gameState.cardCooldownValuesByPlayerId?.get(playerId) || [];
+ const restrictedSet = new Set(restrictedValues);
+ if (restrictedSet.size === 0) return [];
+
+ let restrictedCards = playerCards.filter(card => (
+ restrictedSet.has(getCardCooldownValue(card, type, gameState))
+ ));
+ const leadingSuit = !isLeading ? gameState?.leadingPattern?.suit : null;
+ if (leadingSuit) {
+ // 基本跟牌义务优先于冷却:首家要求的有效花色不能因冷却而被伪装成“缺门”。
+ // 同花色内全部解禁,也能覆盖对子、拖拉机等结构性跟牌要求。
+ restrictedCards = restrictedCards.filter(card => (
+ getEffectiveSuit(card, gameState?.trumpSuit, gameState?.trumpRank) !== leadingSuit
+ ));
+ }
+ const unrestrictedCount = playerCards.length - restrictedCards.length;
+ const normalizedRequiredCount = Number.isInteger(requiredCount) && requiredCount > 0
+ ? requiredCount
+ : 1;
+
+ // 冷却不能让玩家无牌可出;跟牌张数大于可用牌数时也应整体解除。
+ return unrestrictedCount >= normalizedRequiredCount ? restrictedCards : [];
+}
+
+export function getCardCooldownPlayableCards(options = {}) {
+ const disabledIds = new Set(
+ getCardCooldownDisabledCards(options).map(card => card.id)
+ );
+ return (options.playerCards || []).filter(card => !disabledIds.has(card.id));
+}
+
+export function recordBirdsGoneBowHiddenPointCards({ gameState, cards = [] } = {}) {
+ if (!isBirdsGoneBowHiddenRule(gameState?.selectedRule) || !Array.isArray(cards)) return [];
+
+ for (const card of cards) {
+ if (
+ card?.id
+ && STANDARD_SUITS.has(card.suit)
+ && STANDARD_POINT_RANKS.has(card.rank)
+ ) {
+ gameState.birdPlayedPointCardIds.add(card.id);
+ gameState.birdPlayedPointCardSuitsById.set(
+ card.id,
+ getEffectiveSuit(card, gameState.trumpSuit, gameState.trumpRank)
+ );
+ }
+ }
+
+ const newlyExhaustedSuits = [];
+ const totals = getBirdPointCardTotalsByEffectiveSuit(gameState);
+ for (const [effectiveSuit, totalCount] of totals) {
+ if (gameState.birdExhaustedSuits.has(effectiveSuit)) continue;
+ const playedCount = Array.from(gameState.birdPlayedPointCardSuitsById.values())
+ .filter(value => value === effectiveSuit)
+ .length;
+ if (playedCount >= totalCount) {
+ gameState.birdExhaustedSuits.add(effectiveSuit);
+ newlyExhaustedSuits.push(effectiveSuit);
+ }
+ }
+ return newlyExhaustedSuits;
+}
+
+export function getBirdsGoneBowHiddenDisabledCards({
+ gameState,
+ playerCards = [],
+ isLeading = false,
+ requiredCount = 1
+} = {}) {
+ if (
+ !isLeading
+ || !isBirdsGoneBowHiddenRule(gameState?.selectedRule)
+ || !Array.isArray(playerCards)
+ || playerCards.length === 0
+ ) {
+ return [];
+ }
+
+ const exhaustedSuits = gameState.birdExhaustedSuits || new Set();
+ if (exhaustedSuits.size === 0) return [];
+ const restrictedCards = playerCards.filter(card => exhaustedSuits.has(
+ getEffectiveSuit(card, gameState.trumpSuit, gameState.trumpRank)
+ ));
+ const normalizedRequiredCount = Number.isInteger(requiredCount) && requiredCount > 0
+ ? requiredCount
+ : 1;
+ const unrestrictedCount = playerCards.length - restrictedCards.length;
+ return unrestrictedCount >= normalizedRequiredCount ? restrictedCards : [];
+}
+
+export function getBushGateDisabledCards({
+ gameState,
+ playerId,
+ playerCards = [],
+ isLeading = false
+} = {}) {
+ const restriction = gameState?.bushGateRestriction;
+ if (
+ !isLeading
+ || !isBushGateRule(gameState?.selectedRule)
+ || !restriction
+ || restriction.round !== gameState.currentRound
+ || restriction.leaderPlayerId !== playerId
+ ) {
+ return [];
+ }
+ const forbiddenIds = new Set(restriction.forbiddenCardIds || []);
+ return playerCards.filter(card => forbiddenIds.has(card.id));
+}
+
+export function getRuleDisabledCards(options = {}) {
+ return [
+ ...getCardCooldownDisabledCards(options),
+ ...getBirdsGoneBowHiddenDisabledCards(options),
+ ...getBushGateDisabledCards(options)
+ ];
+}
+
+export function getRulePlayableCards(options = {}) {
+ const disabledIds = new Set(getRuleDisabledCards(options).map(card => card.id));
+ return (options.playerCards || []).filter(card => !disabledIds.has(card.id));
+}
diff --git a/tractor-game-simulator/server/src/utils/cardPatternUtils.js b/tractor-game-simulator/server/src/utils/cardPatternUtils.js
index 4780545..747bc45 100644
--- a/tractor-game-simulator/server/src/utils/cardPatternUtils.js
+++ b/tractor-game-simulator/server/src/utils/cardPatternUtils.js
@@ -1,4 +1,53 @@
-import { Suits, Ranks, RANK_ORDER, normalizeRank } from './constants.js';
+import {
+ Suits,
+ Ranks,
+ RANK_ORDER,
+ normalizeRank,
+ STANDARD_ORDINARY_RANKS,
+ EXTENDED_ORDINARY_RANKS,
+ PROMOTED_ORDINARY_RANKS
+} from './constants.js';
+import {
+ isReverseRankOrderRule,
+ isBeltAndRoadRule,
+ isDayNightRotationRule,
+ getDayNightRotatingRank,
+ isSixSixGreatSuccessRule,
+ isTaiChiFourSymbolsRule,
+ isSingleStepDebugRule,
+ isStrengthCompensationRule,
+ isAfterglowRule,
+ isAntinomyRule,
+ isTeammateCheerRule,
+ isThreeTigersRule,
+ isThreeSixNineGradesRule,
+ isUnarmedRule
+} from '../rules/ruleRegistry.js';
+
+const ORDINARY_RANKS = STANDARD_ORDINARY_RANKS;
+
+const STANDARD_SUITS = Object.freeze([
+ Suits.HEARTS,
+ Suits.DIAMONDS,
+ Suits.CLUBS,
+ Suits.SPADES
+]);
+const TAI_CHI_SUIT = 'tai_chi';
+
+export function getAntinomyFaceKey(cardOrSuit, rank = null) {
+ const suit = typeof cardOrSuit === 'object' ? cardOrSuit?.suit : cardOrSuit;
+ const faceRank = typeof cardOrSuit === 'object' ? cardOrSuit?.rank : rank;
+ return suit && faceRank ? `${suit}:${faceRank}` : null;
+}
+
+function canCardsFormPair(card1, card2, activeRule = null) {
+ if (!card1 || !card2 || card1.rank !== card2.rank || card1.suit !== card2.suit) {
+ return false;
+ }
+ if (!isAntinomyRule(activeRule)) return true;
+ const splitFaceKeys = activeRule?.antinomySplitFaceKeys || [];
+ return !splitFaceKeys.includes(getAntinomyFaceKey(card1));
+}
/**
* 牌型类型
@@ -7,6 +56,9 @@ export const PatternTypes = {
SINGLE: 'single', // 单牌
PAIR: 'pair', // 对子
TRACTOR: 'tractor', // 拖拉机(连对)
+ STRAIGHT_FLUSH: 'straight_flush', // 六六大顺:六张起步的同花顺
+ TAI_CHI_FOUR_SYMBOLS: 'tai_chi_four_symbols', // 四种花色的同点数牌
+ BELT_AND_ROAD: 'belt_and_road', // 一带一路:同一有效花色的两张非对子单牌
THROW: 'throw', // 甩牌(多种牌型组合)
INVALID: 'invalid' // 无效牌型
};
@@ -19,13 +71,25 @@ export const PatternTypes = {
* @returns {Boolean}
*/
export function isTrumpCard(card, trumpSuit, trumpRank) {
+ if (card?.isForbiddenMagicDemoted) {
+ return false;
+ }
+ if (card.isLastStandTrump) {
+ return true;
+ }
+ if (card.isThreeTigersTrump) {
+ return true;
+ }
+ if (card.rank === Ranks.NO_TRUMP_MINUS) {
+ return true;
+ }
const normTrumpRank = normalizeRank(trumpRank);
// 大小王永远是主牌
if (card.suit === Suits.JOKER) {
return true;
}
// 级牌是主牌 - 使用字符串比较确保类型一致
- if (String(card.rank) === String(normTrumpRank)) {
+ if (!card.isUnarmed && String(card.rank) === String(normTrumpRank)) {
return true;
}
// 主花色的牌是主牌
@@ -49,6 +113,14 @@ export function getEffectiveSuit(card, trumpSuit, trumpRank) {
return card.suit;
}
+function getThreeSixNineSuitTier(patternSuit, activeRule) {
+ if (!isThreeSixNineGradesRule(activeRule) || !activeRule?.inferiorSuit) return null;
+ if (patternSuit === 'trump') return 2;
+ if (patternSuit === activeRule.inferiorSuit) return 0;
+ if (STANDARD_SUITS.includes(patternSuit)) return 1;
+ return null;
+}
+
/**
* 获取牌的强度值(用于大小比较)
* @param {Object} card - 牌
@@ -56,8 +128,21 @@ export function getEffectiveSuit(card, trumpSuit, trumpRank) {
* @param {String} trumpRank - 级牌
* @returns {Number} 强度值,越大越强
*/
-export function getCardStrength(card, trumpSuit, trumpRank) {
+export function getCardStrength(card, trumpSuit, trumpRank, activeRule = null) {
+ if (card?.isForbiddenMagicDemoted) {
+ return RANK_ORDER[card.rank] || 0;
+ }
const normTrumpRank = normalizeRank(trumpRank);
+ const isUnarmed = isUnarmedRule(activeRule) || Boolean(card?.isUnarmed);
+ if (card.rank === Ranks.WHITE_JOKER) {
+ return 1003;
+ }
+ if (card.rank === Ranks.PRINCE_JOKER) {
+ return 1002;
+ }
+ if (card.rank === Ranks.COUNTY_PRINCE_JOKER) {
+ return 1001;
+ }
// 大王最大
if (card.rank === Ranks.BIG_JOKER) {
return 1000;
@@ -66,36 +151,101 @@ export function getCardStrength(card, trumpSuit, trumpRank) {
if (card.rank === Ranks.SMALL_JOKER) {
return 999;
}
+ // 取长补短:无主级牌降一级后仍是主牌,只比无主级牌低一档。
+ if (card.rank === Ranks.NO_TRUMP_MINUS) {
+ return 996;
+ }
+ // 三人成虎的降级牌视同当前主花色;若降到级牌点数,按主级牌处理。
+ if (card.isThreeTigersTrump && String(card.rank) === String(normTrumpRank)) {
+ return 998;
+ }
// 主花色的级牌
- if (String(card.rank) === String(normTrumpRank) && card.suit === trumpSuit) {
+ if (!isUnarmed && String(card.rank) === String(normTrumpRank) && card.suit === trumpSuit) {
return 998;
}
+ // 三六九等:劣花色级牌仍属于主牌,但排在其他副花色级牌之后。
+ if (
+ !isUnarmed
+ && String(card.rank) === String(normTrumpRank)
+ && isThreeSixNineGradesRule(activeRule)
+ && activeRule?.inferiorSuit
+ && card.suit === activeRule.inferiorSuit
+ ) {
+ return 996;
+ }
// 副花色的级牌
- if (String(card.rank) === String(normTrumpRank)) {
+ if (!isUnarmed && String(card.rank) === String(normTrumpRank)) {
return 997;
}
- // 基础点数值
- let baseValue = RANK_ORDER[card.rank] || 0;
+ const rotatingRank = isDayNightRotationRule(activeRule)
+ ? getDayNightRotatingRank(activeRule?.currentRound)
+ : null;
+ const boostedRank = rotatingRank && String(rotatingRank) !== String(normTrumpRank)
+ ? rotatingRank
+ : null;
+ const dayNightOrderedRanks = boostedRank
+ ? ORDINARY_RANKS
+ .filter(rank => String(rank) !== String(normTrumpRank) && rank !== boostedRank)
+ .concat(boostedRank)
+ : null;
+
+ const reverseRankOrder = isReverseRankOrderRule(activeRule);
+ const baseValue = RANK_ORDER[card.rank] || 0;
+ const usesExtendedRanks = isStrengthCompensationRule(activeRule)
+ || isAfterglowRule(activeRule)
+ || isTeammateCheerRule(activeRule)
+ || isThreeTigersRule(activeRule)
+ || Boolean(card?.isStrengthCompensated)
+ || Boolean(card?.isTeammateCheered)
+ || Boolean(card?.isAfterglowBoosted);
+
+ if (card.isThreeTigersTrump) {
+ const ordinaryTrumpRanks = EXTENDED_ORDINARY_RANKS
+ .filter(rank => (
+ !PROMOTED_ORDINARY_RANKS.includes(rank)
+ && String(rank) !== String(normTrumpRank)
+ ));
+ const lowestTrumpStrength = 997 - ordinaryTrumpRanks.length;
+ return lowestTrumpStrength + ordinaryTrumpRanks.indexOf(card.rank);
+ }
+
+ if (card.isLastStandTrump) {
+ const orderedRanks = reverseRankOrder
+ ? [...ORDINARY_RANKS].reverse()
+ : ORDINARY_RANKS;
+ const ordinaryTrumpRanks = isUnarmed
+ ? orderedRanks
+ : orderedRanks.filter(rank => String(rank) !== String(normTrumpRank));
+ return 985 + ordinaryTrumpRanks.indexOf(card.rank);
+ }
// 主花色的牌 - 需要连续排列在副花色级牌之下
- // 主牌顺序: 大王(1000) > 小王(999) > 主级牌(998) > 副级牌(997) > 主A(996) > 主K(995) > ... > 主2(984)
+ // 标准主牌段仍为:大王(1000) > 小王(999) > 主级牌(998) > 副级牌(997) >
+ // 主A(996) > 主K(995) > ...;郡王、亲王、白王另占1001至1003。
if (card.suit === trumpSuit) {
- // 计算在主花色中的位置(跳过级牌)
- // A=14, K=13, Q=12, J=11, 10=10, 9=9, 8=8, 7=7, 6=6, 5=5, 4=4, 3=3, 2=2
- const trumpRankValue = RANK_ORDER[normTrumpRank] || 0;
-
- // 计算这张牌在主花色序列中的排名(从A往下数,跳过级牌)
- let position = 14 - baseValue; // A=0, K=1, Q=2, ...
- if (baseValue < trumpRankValue) {
- position--; // 如果在级牌下面,不需要跳过
+ if (dayNightOrderedRanks) {
+ return 985 + dayNightOrderedRanks.indexOf(card.rank);
}
-
- return 996 - position;
+ const baseOrderedRanks = usesExtendedRanks
+ ? EXTENDED_ORDINARY_RANKS.filter(rank => !PROMOTED_ORDINARY_RANKS.includes(rank))
+ : ORDINARY_RANKS;
+ const orderedRanks = reverseRankOrder
+ ? [...ORDINARY_RANKS].reverse()
+ : baseOrderedRanks;
+ const ordinaryTrumpRanks = isUnarmed
+ ? orderedRanks
+ : orderedRanks.filter(rank => String(rank) !== String(normTrumpRank));
+ const lowestTrumpStrength = 997 - ordinaryTrumpRanks.length
+ - (isThreeSixNineGradesRule(activeRule) && activeRule?.inferiorSuit ? 1 : 0);
+ return lowestTrumpStrength + ordinaryTrumpRanks.indexOf(card.rank);
}
// 副牌
- return baseValue;
+ if (dayNightOrderedRanks) {
+ return 2 + dayNightOrderedRanks.indexOf(card.rank);
+ }
+ return reverseRankOrder ? 16 - baseValue : baseValue;
}
/**
@@ -113,6 +263,124 @@ function getNonTrumpRankValue(card, trumpRank) {
return RANK_ORDER[card.rank] || 0;
}
+function areStrengthsConsecutive(currentStrength, nextStrength, trumpSuit, trumpRank, activeRule) {
+ return arePairsConsecutive(
+ { strength: currentStrength },
+ { strength: nextStrength },
+ trumpSuit,
+ trumpRank,
+ activeRule
+ );
+}
+
+function detectStraightFlush(cards, trumpSuit, trumpRank, activeRule) {
+ if (!isSixSixGreatSuccessRule(activeRule) || cards.length < 6) {
+ return { valid: false };
+ }
+
+ const effectiveSuits = new Set(
+ cards.map(card => getEffectiveSuit(card, trumpSuit, trumpRank))
+ );
+ if (effectiveSuits.size !== 1) return { valid: false };
+
+ const strengths = cards
+ .map(card => getCardStrength(card, trumpSuit, trumpRank, activeRule))
+ .sort((a, b) => a - b);
+ if (new Set(strengths).size !== cards.length) return { valid: false };
+
+ for (let index = 1; index < strengths.length; index++) {
+ if (!areStrengthsConsecutive(
+ strengths[index - 1],
+ strengths[index],
+ trumpSuit,
+ trumpRank,
+ activeRule
+ )) {
+ return { valid: false };
+ }
+ }
+
+ return {
+ valid: true,
+ suit: [...effectiveSuits][0],
+ strength: strengths[strengths.length - 1],
+ strengths
+ };
+}
+
+export function findStraightFlushes(
+ cards,
+ requiredLength,
+ trumpSuit,
+ trumpRank,
+ activeRule = null
+) {
+ if (!isSixSixGreatSuccessRule(activeRule) || requiredLength < 6) return [];
+
+ const cardsByEffectiveSuit = new Map();
+ for (const card of cards) {
+ const suit = getEffectiveSuit(card, trumpSuit, trumpRank);
+ const suitCards = cardsByEffectiveSuit.get(suit) || [];
+ suitCards.push(card);
+ cardsByEffectiveSuit.set(suit, suitCards);
+ }
+
+ const results = [];
+ for (const [suit, suitCards] of cardsByEffectiveSuit) {
+ const cardsByStrength = new Map();
+ for (const card of suitCards) {
+ const strength = getCardStrength(card, trumpSuit, trumpRank, activeRule);
+ if (!cardsByStrength.has(strength)) cardsByStrength.set(strength, card);
+ }
+ const strengths = [...cardsByStrength.keys()].sort((a, b) => a - b);
+ for (let start = 0; start <= strengths.length - requiredLength; start++) {
+ const window = strengths.slice(start, start + requiredLength);
+ const isStraight = window.every((strength, index) =>
+ index === 0 || areStrengthsConsecutive(
+ window[index - 1],
+ strength,
+ trumpSuit,
+ trumpRank,
+ activeRule
+ )
+ );
+ if (isStraight) {
+ results.push({
+ suit,
+ cards: window.map(strength => cardsByStrength.get(strength)),
+ strength: window[window.length - 1],
+ length: requiredLength
+ });
+ }
+ }
+ }
+ return results;
+}
+
+export function findTaiChiFourSymbols(cards, activeRule = null) {
+ if (!isTaiChiFourSymbolsRule(activeRule)) return [];
+ const cardsByRank = new Map();
+ for (const card of cards) {
+ if (!STANDARD_SUITS.includes(card.suit)) continue;
+ const cardsBySuit = cardsByRank.get(card.rank) || new Map();
+ if (!cardsBySuit.has(card.suit)) cardsBySuit.set(card.suit, card);
+ cardsByRank.set(card.rank, cardsBySuit);
+ }
+
+ const results = [];
+ for (const [rank, cardsBySuit] of cardsByRank) {
+ if (STANDARD_SUITS.every(suit => cardsBySuit.has(suit))) {
+ results.push({
+ rank,
+ cards: STANDARD_SUITS.map(suit => cardsBySuit.get(suit)),
+ strength: RANK_ORDER[rank] || 0,
+ length: 4
+ });
+ }
+ }
+ return results;
+}
+
/**
* 检测牌型
* @param {Array} cards - 要检测的牌
@@ -120,13 +388,24 @@ function getNonTrumpRankValue(card, trumpRank) {
* @param {String} trumpRank - 级牌
* @returns {Object} { type, suit, strength, length }
*/
-export function detectPattern(cards, trumpSuit, trumpRank) {
+export function detectPattern(cards, trumpSuit, trumpRank, activeRule = null) {
if (!cards || cards.length === 0) {
return { type: PatternTypes.INVALID, suit: null, strength: 0, length: 0 };
}
const count = cards.length;
+ if (count === 4 && findTaiChiFourSymbols(cards, activeRule).length > 0) {
+ const rank = cards[0].rank;
+ return {
+ type: PatternTypes.TAI_CHI_FOUR_SYMBOLS,
+ suit: TAI_CHI_SUIT,
+ rank,
+ strength: RANK_ORDER[rank] || 0,
+ length: 4
+ };
+ }
+
// 获取所有牌的有效花色
const effectiveSuits = cards.map(c => getEffectiveSuit(c, trumpSuit, trumpRank));
const uniqueSuits = [...new Set(effectiveSuits)];
@@ -138,23 +417,34 @@ export function detectPattern(cards, trumpSuit, trumpRank) {
const suit = uniqueSuits[0];
+ const straightFlush = detectStraightFlush(cards, trumpSuit, trumpRank, activeRule);
+ if (straightFlush.valid) {
+ return {
+ type: PatternTypes.STRAIGHT_FLUSH,
+ suit: straightFlush.suit,
+ strength: straightFlush.strength,
+ length: count,
+ strengths: straightFlush.strengths
+ };
+ }
+
// 单牌
if (count === 1) {
return {
type: PatternTypes.SINGLE,
suit,
- strength: getCardStrength(cards[0], trumpSuit, trumpRank),
+ strength: getCardStrength(cards[0], trumpSuit, trumpRank, activeRule),
length: 1
};
}
// 对子
if (count === 2) {
- if (cards[0].rank === cards[1].rank && cards[0].suit === cards[1].suit) {
+ if (canCardsFormPair(cards[0], cards[1], activeRule)) {
return {
type: PatternTypes.PAIR,
suit,
- strength: getCardStrength(cards[0], trumpSuit, trumpRank),
+ strength: getCardStrength(cards[0], trumpSuit, trumpRank, activeRule),
length: 2
};
}
@@ -164,7 +454,19 @@ export function detectPattern(cards, trumpSuit, trumpRank) {
return {
type: PatternTypes.PAIR,
suit,
- strength: getCardStrength(cards[0], trumpSuit, trumpRank),
+ strength: getCardStrength(cards[0], trumpSuit, trumpRank, activeRule),
+ length: 2
+ };
+ }
+ if (isBeltAndRoadRule(activeRule) && activeRule?.beltAndRoadSkillActive === true) {
+ const strengths = cards
+ .map(card => getCardStrength(card, trumpSuit, trumpRank, activeRule))
+ .sort((a, b) => b - a);
+ return {
+ type: PatternTypes.BELT_AND_ROAD,
+ suit,
+ strength: strengths[0],
+ strengths,
length: 2
};
}
@@ -173,7 +475,7 @@ export function detectPattern(cards, trumpSuit, trumpRank) {
// 拖拉机检测(4张及以上,必须是偶数)
if (count >= 4 && count % 2 === 0) {
- const tractorResult = detectTractor(cards, trumpSuit, trumpRank);
+ const tractorResult = detectTractor(cards, trumpSuit, trumpRank, activeRule);
if (tractorResult.valid) {
return {
type: PatternTypes.TRACTOR,
@@ -195,16 +497,14 @@ export function detectPattern(cards, trumpSuit, trumpRank) {
* @param {String} trumpRank - 级牌
* @returns {Object} { valid, strength, pairs }
*/
-function detectTractor(cards, trumpSuit, trumpRank) {
+function detectTractor(cards, trumpSuit, trumpRank, activeRule) {
// 按强度分组成对子
const pairs = [];
const cardsCopy = [...cards];
while (cardsCopy.length >= 2) {
const card1 = cardsCopy.shift();
- const pairIndex = cardsCopy.findIndex(c =>
- c.rank === card1.rank && c.suit === card1.suit
- );
+ const pairIndex = cardsCopy.findIndex(c => canCardsFormPair(card1, c, activeRule));
if (pairIndex === -1) {
// 找不到配对
@@ -215,7 +515,7 @@ function detectTractor(cards, trumpSuit, trumpRank) {
pairs.push({
rank: card1.rank,
suit: card1.suit,
- strength: getCardStrength(card1, trumpSuit, trumpRank)
+ strength: getCardStrength(card1, trumpSuit, trumpRank, activeRule)
});
}
@@ -231,52 +531,22 @@ function detectTractor(cards, trumpSuit, trumpRank) {
if (effectiveSuit === 'trump') {
// 主牌拖拉机需要特殊处理
- return checkTrumpTractor(pairs, trumpSuit, trumpRank);
+ return checkTrumpTractor(pairs, trumpSuit, trumpRank, activeRule);
} else {
// 副牌拖拉机
- return checkNonTrumpTractor(pairs, trumpRank);
+ return checkNonTrumpTractor(pairs, trumpRank, activeRule);
}
}
/**
* 检查主牌拖拉机
*/
-function checkTrumpTractor(pairs, trumpSuit, trumpRank) {
- const normTrumpRank = normalizeRank(trumpRank);
- // 主牌顺序:2,3,4,...,A,级牌(副),级牌(主),小王,大王
- // 跳过级牌在正常序列中的位置
-
+function checkTrumpTractor(pairs, trumpSuit, trumpRank, activeRule) {
for (let i = 0; i < pairs.length - 1; i++) {
const current = pairs[i];
const next = pairs[i + 1];
-
- // 检查是否连续(强度值连续)
- // 这里简化处理:允许强度差在一定范围内
- // 实际应该根据主牌序列精确判断
- if (next.strength - current.strength !== 1 &&
- !(current.strength === 997 && next.strength === 998) &&
- !(current.strength === 998 && next.strength === 999) &&
- !(current.strength === 999 && next.strength === 1000)) {
-
- // 检查普通主牌连续性
- const currentRankValue = RANK_ORDER[current.rank] || 0;
- const nextRankValue = RANK_ORDER[next.rank] || 0;
-
- // 如果不是特殊的级牌/王连接,检查普通连续性
- if (current.strength < 500 || next.strength < 500) {
- return { valid: false, strength: 0, pairs: [] };
- }
-
- // 主花色牌的连续性检查,需要跳过级牌
- if (nextRankValue - currentRankValue !== 1) {
- // 检查是否因为跳过级牌
- if (RANK_ORDER[normTrumpRank] === currentRankValue + 1 &&
- nextRankValue === currentRankValue + 2) {
- // 跳过级牌,连续
- continue;
- }
- return { valid: false, strength: 0, pairs: [] };
- }
+ if (!arePairsConsecutive(current, next, trumpSuit, trumpRank, activeRule)) {
+ return { valid: false, strength: 0, pairs: [] };
}
}
@@ -290,19 +560,9 @@ function checkTrumpTractor(pairs, trumpSuit, trumpRank) {
/**
* 检查副牌拖拉机
*/
-function checkNonTrumpTractor(pairs, trumpRank) {
- const normTrumpRank = normalizeRank(trumpRank);
+function checkNonTrumpTractor(pairs, trumpRank, activeRule) {
for (let i = 0; i < pairs.length - 1; i++) {
- const currentRankValue = RANK_ORDER[pairs[i].rank] || 0;
- const nextRankValue = RANK_ORDER[pairs[i + 1].rank] || 0;
-
- // 检查连续性,需要跳过级牌
- let expectedNext = currentRankValue + 1;
- if (RANK_ORDER[normTrumpRank] === expectedNext) {
- expectedNext++; // 跳过级牌
- }
-
- if (nextRankValue !== expectedNext) {
+ if (!arePairsConsecutive(pairs[i], pairs[i + 1], null, trumpRank, activeRule)) {
return { valid: false, strength: 0, pairs: [] };
}
}
@@ -314,6 +574,31 @@ function checkNonTrumpTractor(pairs, trumpRank) {
};
}
+/**
+ * 判断两个对子在当前主牌规则下是否严格相邻。
+ * - 副牌序列会跳过级牌;
+ * - 有主局的主牌强度已经压缩为连续序列;
+ * - 无主局所有级牌同级,并且任意一对级牌都与小王相邻。
+ */
+function arePairsConsecutive(current, next, trumpSuit, trumpRank, activeRule) {
+ const diff = next.strength - current.strength;
+ const isTrumpSequence = current.strength >= 900 || next.strength >= 900;
+
+ if (isTrumpSequence) {
+ if (diff === 1) return true;
+ const isNoTrump = !trumpSuit || trumpSuit === Suits.NO_TRUMP;
+ return isNoTrump && current.strength === 997 && next.strength === 999;
+ }
+
+ if (diff === 1) return true;
+ if (isUnarmedRule(activeRule)) return false;
+ const normalTrumpRankValue = RANK_ORDER[normalizeRank(trumpRank)] || 0;
+ const trumpRankValue = isReverseRankOrderRule(activeRule)
+ ? 16 - normalTrumpRankValue
+ : normalTrumpRankValue;
+ return diff === 2 && current.strength + 1 === trumpRankValue;
+}
+
/**
* 比较两组牌的大小
* @param {Object} play1 - 第一组牌 { cards, pattern, playerIndex }
@@ -321,40 +606,136 @@ function checkNonTrumpTractor(pairs, trumpRank) {
* @param {String} leadingSuit - 首发花色
* @param {String} trumpSuit - 主花色
* @param {String} trumpRank - 级牌
+ * @param {Object|null} leadingPattern - 本轮首家实际牌型
* @returns {Number} 1表示play1大,-1表示play2大,0表示相等
*/
-export function compareCards(play1, play2, leadingSuit, trumpSuit, trumpRank) {
+export function compareCards(
+ play1,
+ play2,
+ leadingSuit,
+ trumpSuit,
+ trumpRank,
+ activeRule = null,
+ leadingPattern = null
+) {
const pattern1 = play1.pattern;
const pattern2 = play2.pattern;
+ if (leadingSuit === TAI_CHI_SUIT) {
+ const taiChi1 = pattern1.type === PatternTypes.TAI_CHI_FOUR_SYMBOLS;
+ const taiChi2 = pattern2.type === PatternTypes.TAI_CHI_FOUR_SYMBOLS;
+ const trumped1 = Boolean(pattern1.canTrumpTaiChi);
+ const trumped2 = Boolean(pattern2.canTrumpTaiChi);
+
+ if (trumped1 || trumped2) {
+ if (trumped1 && !trumped2) return 1;
+ if (!trumped1 && trumped2) return -1;
+ if (pattern1.strength > pattern2.strength) return 1;
+ if (pattern1.strength < pattern2.strength) return -1;
+ return 0;
+ }
+ if (taiChi1 && taiChi2) {
+ if (pattern1.strength > pattern2.strength) return 1;
+ if (pattern1.strength < pattern2.strength) return -1;
+ return 0;
+ }
+ if (taiChi1 && !taiChi2) return 1;
+ if (!taiChi1 && taiChi2) return -1;
+ return 0;
+ }
+
+ const straightFlush1 = pattern1.type === PatternTypes.STRAIGHT_FLUSH;
+ const straightFlush2 = pattern2.type === PatternTypes.STRAIGHT_FLUSH;
+ if (straightFlush1 !== straightFlush2) {
+ return straightFlush1 ? 0 : -1;
+ }
+
+ const beltAndRoad1 = pattern1.type === PatternTypes.BELT_AND_ROAD;
+ const beltAndRoad2 = pattern2.type === PatternTypes.BELT_AND_ROAD;
+ if (beltAndRoad1 !== beltAndRoad2) {
+ // 当前最大牌型与挑战牌型结构不一致时,后出者不能改变胜者。
+ return beltAndRoad1 ? 0 : -1;
+ }
+ if (beltAndRoad1 && beltAndRoad2) {
+ const isTrump1 = pattern1.suit === 'trump';
+ const isTrump2 = pattern2.suit === 'trump';
+ if (isTrump1 && !isTrump2) return 1;
+ if (!isTrump1 && isTrump2) return -1;
+
+ if (!isTrump1 && !isTrump2) {
+ const isLeading1 = pattern1.suit === leadingSuit;
+ const isLeading2 = pattern2.suit === leadingSuit;
+ if (isLeading1 && !isLeading2) return 1;
+ if (!isLeading1 && isLeading2) return -1;
+ if (!isLeading1 && !isLeading2) return 0;
+ }
+
+ const strengths1 = pattern1.strengths || [];
+ const strengths2 = pattern2.strengths || [];
+ for (let index = 0; index < 2; index += 1) {
+ if (strengths1[index] > strengths2[index]) return 1;
+ if (strengths1[index] < strengths2[index]) return -1;
+ }
+ return 0;
+ }
+
// 无效牌型最小
if (pattern1.type === PatternTypes.INVALID) return -1;
if (pattern2.type === PatternTypes.INVALID) return 1;
// 如果是甩牌,使用特殊的比较逻辑
if (pattern1.type === PatternTypes.THROW || pattern2.type === PatternTypes.THROW) {
- return compareThrowCards(play1, play2, leadingSuit, trumpSuit, trumpRank);
+ return compareThrowCards(
+ play1,
+ play2,
+ leadingSuit,
+ trumpSuit,
+ trumpRank,
+ activeRule,
+ leadingPattern
+ );
}
// 主牌大于副牌
const isTrump1 = pattern1.suit === 'trump';
const isTrump2 = pattern2.suit === 'trump';
+ const threeSixNineTier1 = getThreeSixNineSuitTier(pattern1.suit, activeRule);
+ const threeSixNineTier2 = getThreeSixNineSuitTier(pattern2.suit, activeRule);
+ const isInferiorLead = isThreeSixNineGradesRule(activeRule)
+ && leadingSuit === activeRule?.inferiorSuit;
+
+ if (
+ isInferiorLead
+ && threeSixNineTier1 !== null
+ && threeSixNineTier2 !== null
+ && threeSixNineTier1 !== threeSixNineTier2
+ ) {
+ return threeSixNineTier1 > threeSixNineTier2 ? 1 : -1;
+ }
if (isTrump1 && !isTrump2) return 1;
if (!isTrump1 && isTrump2) return -1;
// 都是副牌时,只有同花色才能比较
if (!isTrump1 && !isTrump2) {
- // 首发花色优先
- const isLeading1 = pattern1.suit === leadingSuit;
- const isLeading2 = pattern2.suit === leadingSuit;
-
- if (isLeading1 && !isLeading2) return 1;
- if (!isLeading1 && isLeading2) return -1;
-
- // 都不是首发花色,无法比较大小
- if (!isLeading1 && !isLeading2) {
- return 0; // 先出的大
+ const bothOrdinaryRuffInferior = isInferiorLead
+ && threeSixNineTier1 === 1
+ && threeSixNineTier2 === 1;
+ if (bothOrdinaryRuffInferior) {
+ // 不同普通副花色彼此不可比较;同一花色继续按牌型与点数比较。
+ if (pattern1.suit !== pattern2.suit) return 0;
+ } else {
+ // 首发花色优先
+ const isLeading1 = pattern1.suit === leadingSuit;
+ const isLeading2 = pattern2.suit === leadingSuit;
+
+ if (isLeading1 && !isLeading2) return 1;
+ if (!isLeading1 && isLeading2) return -1;
+
+ // 都不是首发花色,无法比较大小
+ if (!isLeading1 && !isLeading2) {
+ return 0; // 先出的大
+ }
}
}
@@ -378,6 +759,228 @@ export function compareCards(play1, play2, leadingSuit, trumpSuit, trumpRank) {
return 0;
}
+/**
+ * 「尊老爱幼」和「力争上游」共用的整轮全序。
+ * 同首家牌型时沿用正常牌力;牌型不一致时,先比首花色张数和结构贴合度,
+ * 最后才从各自最小的牌开始按字典序比较。
+ * 返回 1 表示 play1 更大,-1 表示 play1 更小,0 表示完全相同。
+ */
+export function compareCardsForRespectElders(
+ play1,
+ play2,
+ leadingPlay,
+ trumpSuit,
+ trumpRank,
+ activeRule = null
+) {
+ const leadingSuit = leadingPlay.pattern.suit;
+ const category1 = getRespectPlayCategory(
+ play1,
+ leadingPlay,
+ leadingSuit,
+ trumpSuit,
+ trumpRank,
+ activeRule
+ );
+ const category2 = getRespectPlayCategory(
+ play2,
+ leadingPlay,
+ leadingSuit,
+ trumpSuit,
+ trumpRank,
+ activeRule
+ );
+
+ if (category1.matchesLeadingPattern !== category2.matchesLeadingPattern) {
+ return category1.matchesLeadingPattern ? 1 : -1;
+ }
+
+ // 能与首家正常比大小的同牌型跟牌(包括完整毙牌)直接沿用常规牌力。
+ if (category1.matchesLeadingPattern && category2.matchesLeadingPattern) {
+ return compareCards(
+ play1,
+ play2,
+ leadingSuit,
+ trumpSuit,
+ trumpRank,
+ activeRule,
+ leadingPlay.pattern
+ );
+ }
+
+ // 牌型不一致时,离开首家花色的牌越多越小;主牌同样计作异花色。
+ if (category1.offSuitCount !== category2.offSuitCount) {
+ return category1.offSuitCount < category2.offSuitCount ? 1 : -1;
+ }
+
+ // 四张都跟首花色时,两个散对 > 一个对子加两张单牌 > 四张单牌。
+ // 若首家是更长的拖拉机,还会先比能跟出的最长连对。
+ if (category1.offSuitCount === 0) {
+ const structureComparison = compareRespectStructureProfiles(
+ category1.structureProfile,
+ category2.structureProfile
+ );
+ if (structureComparison !== 0) return structureComparison;
+ }
+
+ return compareRespectLexicographically(
+ play1.cards,
+ play2.cards,
+ trumpSuit,
+ trumpRank,
+ activeRule
+ );
+}
+
+/** 由大到小排列一轮的四手牌;完全相同时保持先出者在前。 */
+export function rankRoundPlaysByRespectOrder(
+ plays,
+ trumpSuit,
+ trumpRank,
+ activeRule = null
+) {
+ if (!Array.isArray(plays) || plays.length === 0) return [];
+ const leadingPlay = plays[0];
+ const originalPosition = new Map(plays.map((play, index) => [play, index]));
+ return [...plays].sort((play1, play2) => {
+ const comparison = compareCardsForRespectElders(
+ play1,
+ play2,
+ leadingPlay,
+ trumpSuit,
+ trumpRank,
+ activeRule
+ );
+ if (comparison !== 0) return -comparison;
+ return originalPosition.get(play1) - originalPosition.get(play2);
+ });
+}
+
+/**
+ * 在完整一轮的出牌记录中寻找最小者。完全同点数时后出者更小。
+ */
+export function findSmallestPlayForRespectElders(
+ plays,
+ trumpSuit,
+ trumpRank,
+ activeRule = null
+) {
+ if (!Array.isArray(plays) || plays.length === 0) return null;
+ return rankRoundPlaysByRespectOrder(
+ plays,
+ trumpSuit,
+ trumpRank,
+ activeRule
+ ).at(-1) || null;
+}
+
+function getRespectPlayCategory(
+ play,
+ leadingPlay,
+ leadingSuit,
+ trumpSuit,
+ trumpRank,
+ activeRule
+) {
+ const matchesLeadingPattern = haveSameRespectPattern(
+ play,
+ leadingPlay,
+ trumpSuit,
+ trumpRank,
+ activeRule
+ );
+ const comparisonCards = Array.isArray(play.cards) ? play.cards : [];
+ const leadingSuitCardCount = comparisonCards.filter(card =>
+ getEffectiveSuit(card, trumpSuit, trumpRank) === leadingSuit
+ ).length;
+ const isComparableSuit = play.pattern?.suit === leadingSuit
+ || (leadingSuit !== 'trump' && play.pattern?.suit === 'trump');
+
+ return {
+ matchesLeadingPattern: matchesLeadingPattern && isComparableSuit,
+ offSuitCount: comparisonCards.length - leadingSuitCardCount,
+ structureProfile: getRespectStructureProfile(
+ comparisonCards,
+ leadingPlay.cards || [],
+ trumpSuit,
+ trumpRank,
+ activeRule
+ )
+ };
+}
+
+function haveSameRespectPattern(play, leadingPlay, trumpSuit, trumpRank, activeRule) {
+ if (play.pattern?.type !== leadingPlay.pattern?.type) return false;
+ if ((play.cards?.length || 0) !== (leadingPlay.cards?.length || 0)) return false;
+
+ if (leadingPlay.pattern?.type === PatternTypes.THROW) {
+ return haveSameThrowStructure(
+ getThrowComponents(play, trumpSuit, trumpRank, activeRule),
+ getThrowComponents(leadingPlay, trumpSuit, trumpRank, activeRule)
+ );
+ }
+ return true;
+}
+
+function getRespectStructureProfile(cards, leadingCards, trumpSuit, trumpRank, activeRule) {
+ const leadingPairs = findPairsInCards(leadingCards, trumpSuit, trumpRank, activeRule);
+ const playedPairs = findPairsInCards(cards, trumpSuit, trumpRank, activeRule);
+ const requiredLongestTractor = getLongestTractorPairCount(
+ leadingPairs,
+ leadingPairs.length,
+ trumpSuit,
+ trumpRank,
+ activeRule
+ );
+ const playedLongestTractor = getLongestTractorPairCount(
+ playedPairs,
+ playedPairs.length,
+ trumpSuit,
+ trumpRank,
+ activeRule
+ );
+
+ return [
+ Math.min(playedLongestTractor, requiredLongestTractor),
+ Math.min(playedPairs.length, leadingPairs.length)
+ ];
+}
+
+function compareRespectStructureProfiles(profile1, profile2) {
+ const length = Math.max(profile1?.length || 0, profile2?.length || 0);
+ for (let index = 0; index < length; index += 1) {
+ const value1 = profile1?.[index] || 0;
+ const value2 = profile2?.[index] || 0;
+ if (value1 > value2) return 1;
+ if (value1 < value2) return -1;
+ }
+ return 0;
+}
+
+function compareRespectLexicographically(
+ cards1,
+ cards2,
+ trumpSuit,
+ trumpRank,
+ activeRule
+) {
+ const strengths1 = (cards1 || [])
+ .map(card => getCardStrength(card, trumpSuit, trumpRank, activeRule))
+ .sort((a, b) => a - b);
+ const strengths2 = (cards2 || [])
+ .map(card => getCardStrength(card, trumpSuit, trumpRank, activeRule))
+ .sort((a, b) => a - b);
+ const length = Math.max(strengths1.length, strengths2.length);
+
+ for (let index = 0; index < length; index += 1) {
+ const strength1 = strengths1[index] ?? Number.NEGATIVE_INFINITY;
+ const strength2 = strengths2[index] ?? Number.NEGATIVE_INFINITY;
+ if (strength1 > strength2) return 1;
+ if (strength1 < strength2) return -1;
+ }
+ return 0;
+}
+
/**
* 比较甩牌的大小
* @param {Object} play1 - 第一组牌
@@ -385,29 +988,81 @@ export function compareCards(play1, play2, leadingSuit, trumpSuit, trumpRank) {
* @param {String} leadingSuit - 首发花色
* @param {String} trumpSuit - 主花色
* @param {String} trumpRank - 级牌
+ * @param {Object|null} leadingPattern - 本轮首家实际牌型
* @returns {Number} 1表示play1大,-1表示play2大,0表示相等
*/
-function compareThrowCards(play1, play2, leadingSuit, trumpSuit, trumpRank) {
+function compareThrowCards(
+ play1,
+ play2,
+ leadingSuit,
+ trumpSuit,
+ trumpRank,
+ activeRule,
+ leadingPattern
+) {
const pattern1 = play1.pattern;
const pattern2 = play2.pattern;
// 主牌大于副牌
const isTrump1 = pattern1.suit === 'trump';
const isTrump2 = pattern2.suit === 'trump';
+ const threeSixNineTier1 = getThreeSixNineSuitTier(pattern1.suit, activeRule);
+ const threeSixNineTier2 = getThreeSixNineSuitTier(pattern2.suit, activeRule);
+ const isInferiorLead = isThreeSixNineGradesRule(activeRule)
+ && leadingSuit === activeRule?.inferiorSuit;
+
+ // 普通副牌毙劣牌时沿用主牌毙副牌的甩牌结构要求。
+ if (
+ isInferiorLead
+ && threeSixNineTier1 !== null
+ && threeSixNineTier2 !== null
+ && threeSixNineTier1 !== threeSixNineTier2
+ && Math.max(threeSixNineTier1, threeSixNineTier2) === 1
+ ) {
+ const firstIsHigher = threeSixNineTier1 > threeSixNineTier2;
+ const higherPlay = firstIsHigher ? play1 : play2;
+ const lowerPlay = firstIsHigher ? play2 : play1;
+ const higherComponents = higherPlay.pattern.components
+ || parseThrowCombination(
+ higherPlay.cards,
+ trumpSuit,
+ trumpRank,
+ activeRule
+ ).components
+ || [];
+ const lowerComponents = getLeadingThrowComponents(
+ leadingPattern,
+ lowerPlay,
+ trumpSuit,
+ trumpRank,
+ activeRule
+ );
+ if (!canTrumpThrow(higherComponents, lowerComponents)) {
+ return firstIsHigher ? -1 : 1;
+ }
+ return firstIsHigher ? 1 : -1;
+ }
if (isTrump1 && !isTrump2) {
// play1是主牌,play2是副牌
// 主牌必须匹配相同的牌型组合才能毙掉
- if (pattern2.type === PatternTypes.THROW && pattern2.components) {
+ const sideComponents = getLeadingThrowComponents(
+ leadingPattern,
+ play2,
+ trumpSuit,
+ trumpRank,
+ activeRule
+ );
+ if (sideComponents.length > 0) {
// 获取主牌的组件(如果不是THROW类型,先解析为组件)
let trumpComponents = pattern1.components;
if (!trumpComponents) {
- const parsed = parseThrowCombination(play1.cards, trumpSuit, trumpRank);
+ const parsed = parseThrowCombination(play1.cards, trumpSuit, trumpRank, activeRule);
trumpComponents = parsed.components || [];
}
// 检查主牌是否匹配了副牌的牌型组合(支持向下兼容)
- if (canTrumpThrow(trumpComponents, pattern2.components)) {
+ if (canTrumpThrow(trumpComponents, sideComponents)) {
return 1; // 主牌毙掉副牌
} else {
return -1; // 主牌牌型不匹配,无法毙掉
@@ -418,16 +1073,23 @@ function compareThrowCards(play1, play2, leadingSuit, trumpSuit, trumpRank) {
if (!isTrump1 && isTrump2) {
// play2是主牌,play1是副牌
- if (pattern1.type === PatternTypes.THROW && pattern1.components) {
+ const sideComponents = getLeadingThrowComponents(
+ leadingPattern,
+ play1,
+ trumpSuit,
+ trumpRank,
+ activeRule
+ );
+ if (sideComponents.length > 0) {
// 获取主牌的组件(如果不是THROW类型,先解析为组件)
let trumpComponents = pattern2.components;
if (!trumpComponents) {
- const parsed = parseThrowCombination(play2.cards, trumpSuit, trumpRank);
+ const parsed = parseThrowCombination(play2.cards, trumpSuit, trumpRank, activeRule);
trumpComponents = parsed.components || [];
}
// 检查主牌是否匹配了副牌的牌型组合(支持向下兼容)
- if (canTrumpThrow(trumpComponents, pattern1.components)) {
+ if (canTrumpThrow(trumpComponents, sideComponents)) {
return -1; // 主牌毙掉副牌
} else {
return 1; // 主牌牌型不匹配,无法毙掉
@@ -438,22 +1100,54 @@ function compareThrowCards(play1, play2, leadingSuit, trumpSuit, trumpRank) {
// 都是副牌时,只有同花色才能比较
if (!isTrump1 && !isTrump2) {
- const isLeading1 = pattern1.suit === leadingSuit;
- const isLeading2 = pattern2.suit === leadingSuit;
-
- if (isLeading1 && !isLeading2) return 1;
- if (!isLeading1 && isLeading2) return -1;
+ const bothOrdinaryRuffInferior = isInferiorLead
+ && threeSixNineTier1 === 1
+ && threeSixNineTier2 === 1;
+ if (bothOrdinaryRuffInferior) {
+ if (pattern1.suit !== pattern2.suit) return 0;
+ } else {
+ const isLeading1 = pattern1.suit === leadingSuit;
+ const isLeading2 = pattern2.suit === leadingSuit;
+
+ if (isLeading1 && !isLeading2) return 1;
+ if (!isLeading1 && isLeading2) return -1;
+
+ // 都不是首发花色
+ if (!isLeading1 && !isLeading2) {
+ return 0; // 先出的大
+ }
+ }
+ }
- // 都不是首发花色
- if (!isLeading1 && !isLeading2) {
- return 0; // 先出的大
+ // 首家甩出的全部是散牌时,后家的对子/拖拉机可以向下拆成散牌通道;
+ // 此时整组只由最大单张决定。比如“小王 + 对7”可以压过三张较小的散主,
+ // 而“对10”拆开后仍压不过“A、K”。
+ if (isAllSingleThrowPattern(leadingPattern)) {
+ const expectedLength = leadingPattern.length;
+ if (
+ play1.cards?.length !== expectedLength
+ || play2.cards?.length !== expectedLength
+ ) {
+ return 0;
}
+ const max1 = Math.max(...play1.cards.map(card =>
+ getCardStrength(card, trumpSuit, trumpRank, activeRule)
+ ));
+ const max2 = Math.max(...play2.cards.map(card =>
+ getCardStrength(card, trumpSuit, trumpRank, activeRule)
+ ));
+ if (max1 > max2) return 1;
+ if (max1 < max2) return -1;
+ return 0;
}
- // 同花色比较(都是主牌或都是同一副牌花色)
- // 比较对应的组件
- const components1 = pattern1.components || [];
- const components2 = pattern2.components || [];
+ // 首家含对子或拖拉机组件时,争大仍须匹配首家的组件结构。
+ // 主牌毙副牌是否允许向下拆分,仍由上面的 canTrumpThrow 单独处理。
+ const components1 = getThrowComponents(play1, trumpSuit, trumpRank, activeRule);
+ const components2 = getThrowComponents(play2, trumpSuit, trumpRank, activeRule);
+ if (!haveSameThrowStructure(components1, components2)) {
+ return 0;
+ }
// 按类型分组组件
const grouped1 = groupComponentsByType(components1);
@@ -481,6 +1175,198 @@ function compareThrowCards(play1, play2, leadingSuit, trumpSuit, trumpRank) {
return 0; // 完全相同,先出的大
}
+function getThrowComponents(play, trumpSuit, trumpRank, activeRule) {
+ if (Array.isArray(play.pattern?.components) && play.pattern.components.length > 0) {
+ return play.pattern.components;
+ }
+
+ const parsed = parseThrowCombination(play.cards, trumpSuit, trumpRank, activeRule);
+ return parsed.valid ? parsed.components : [];
+}
+
+function getLeadingThrowComponents(
+ leadingPattern,
+ fallbackPlay,
+ trumpSuit,
+ trumpRank,
+ activeRule
+) {
+ if (
+ leadingPattern?.type === PatternTypes.THROW
+ && Array.isArray(leadingPattern.components)
+ && leadingPattern.components.length > 0
+ ) {
+ return leadingPattern.components;
+ }
+ if (fallbackPlay.pattern?.type !== PatternTypes.THROW) return [];
+ return getThrowComponents(fallbackPlay, trumpSuit, trumpRank, activeRule);
+}
+
+function isAllSingleThrowPattern(pattern) {
+ if (
+ pattern?.type !== PatternTypes.THROW
+ || !Array.isArray(pattern.components)
+ || pattern.components.length < 2
+ ) {
+ return false;
+ }
+
+ const representedCards = pattern.components.reduce(
+ (total, component) => total + (component.length ?? component.cards?.length ?? 0),
+ 0
+ );
+ return pattern.components.every(component => component.type === PatternTypes.SINGLE)
+ && representedCards === pattern.length;
+}
+
+function haveSameThrowStructure(components1, components2) {
+ if (components1.length !== components2.length) return false;
+
+ const signature = components => components
+ .map(component => `${component.type}:${component.length ?? component.cards?.length ?? 0}`)
+ .sort();
+ const signature1 = signature(components1);
+ const signature2 = signature(components2);
+ return signature1.every((value, index) => value === signature2[index]);
+}
+
+const ENDURING_PATTERN_TYPES = new Set([
+ PatternTypes.SINGLE,
+ PatternTypes.PAIR,
+ PatternTypes.TRACTOR,
+ PatternTypes.THROW
+]);
+
+function clonePatternForComparison(pattern) {
+ if (!pattern) return null;
+ return {
+ ...pattern,
+ ...(Array.isArray(pattern.strengths) ? { strengths: [...pattern.strengths] } : {}),
+ ...(Array.isArray(pattern.pairs)
+ ? { pairs: pattern.pairs.map(pair => ({ ...pair })) }
+ : {}),
+ ...(Array.isArray(pattern.components)
+ ? {
+ components: pattern.components.map(component => ({
+ ...component,
+ cards: Array.isArray(component.cards) ? [...component.cards] : component.cards
+ }))
+ }
+ : {})
+ };
+}
+
+function getEnduringComponentKey(component) {
+ return `${component.type}:${component.length ?? component.cards?.length ?? 0}`;
+}
+
+function groupEnduringComponentsWithIndexes(components) {
+ const grouped = new Map();
+ components.forEach((component, index) => {
+ const key = getEnduringComponentKey(component);
+ const entries = grouped.get(key) || [];
+ entries.push({ component, index });
+ grouped.set(key, entries);
+ });
+ for (const entries of grouped.values()) {
+ entries.sort((left, right) => left.component.strength - right.component.strength);
+ }
+ return grouped;
+}
+
+/**
+ * “经久不衰”只生成一份用于比大小的牌力副本。
+ * 实际牌面、跟牌义务、得分与扣底倍数均仍使用 currentPlay.pattern。
+ */
+export function resolveEnduringComparison(currentPlay, previousPlay, trumpSuit, trumpRank, activeRule = null) {
+ const currentPattern = currentPlay?.pattern;
+ const previousPattern = previousPlay?.pattern;
+ const unchanged = {
+ inherited: false,
+ comparisonPattern: currentPattern
+ };
+
+ if (
+ !currentPattern
+ || !previousPattern
+ || !ENDURING_PATTERN_TYPES.has(currentPattern.type)
+ || currentPattern.type !== previousPattern.type
+ || currentPattern.suit !== previousPattern.suit
+ || (currentPattern.length ?? currentPlay?.cards?.length ?? 0)
+ !== (previousPattern.length ?? previousPlay?.cards?.length ?? 0)
+ ) {
+ return unchanged;
+ }
+
+ const previousComparisonPattern = previousPlay.comparisonPattern || previousPattern;
+ let comparisonPattern = clonePatternForComparison(currentPattern);
+ let inheritedComponentCount = 0;
+
+ if (currentPattern.type === PatternTypes.THROW) {
+ const currentComponents = getThrowComponents(
+ currentPlay,
+ trumpSuit,
+ trumpRank,
+ activeRule
+ );
+ const previousStructureComponents = getThrowComponents(
+ { ...previousPlay, pattern: previousPattern },
+ trumpSuit,
+ trumpRank,
+ activeRule
+ );
+ const previousComparisonComponents = getThrowComponents(
+ { ...previousPlay, pattern: previousComparisonPattern },
+ trumpSuit,
+ trumpRank,
+ activeRule
+ );
+ if (
+ !haveSameThrowStructure(currentComponents, previousStructureComponents)
+ || !haveSameThrowStructure(currentComponents, previousComparisonComponents)
+ ) {
+ return unchanged;
+ }
+
+ comparisonPattern.components = currentComponents.map(component => ({
+ ...component,
+ cards: Array.isArray(component.cards) ? [...component.cards] : component.cards
+ }));
+ const currentGroups = groupEnduringComponentsWithIndexes(comparisonPattern.components);
+ const previousGroups = groupEnduringComponentsWithIndexes(previousComparisonComponents);
+ for (const [key, currentEntries] of currentGroups.entries()) {
+ const previousEntries = previousGroups.get(key) || [];
+ currentEntries.forEach((entry, index) => {
+ const previousStrength = previousEntries[index]?.component?.strength;
+ if (Number.isFinite(previousStrength) && previousStrength > entry.component.strength) {
+ comparisonPattern.components[entry.index].strength = previousStrength;
+ inheritedComponentCount += 1;
+ }
+ });
+ }
+ comparisonPattern.strength = Math.max(
+ ...comparisonPattern.components.map(component => component.strength)
+ );
+ } else {
+ const currentStrength = Number(currentPattern.strength);
+ const previousStrength = Number(previousComparisonPattern.strength);
+ if (Number.isFinite(previousStrength) && previousStrength > currentStrength) {
+ comparisonPattern.strength = previousStrength;
+ inheritedComponentCount = 1;
+ }
+ }
+
+ if (inheritedComponentCount === 0) return unchanged;
+
+ return {
+ inherited: true,
+ comparisonPattern,
+ sourceCards: previousPlay.displaySourceCards || previousPlay.cards || [],
+ sourcePattern: previousComparisonPattern,
+ inheritedComponentCount
+ };
+}
+
/**
* 检查主牌是否能毙掉副牌甩牌(支持向下兼容)
* 规则:
@@ -552,16 +1438,8 @@ function canTrumpThrow(trumpComponents, sideComponents) {
}
}
- if (needed > 0) {
- // 还需要更多拖拉机,尝试用对子拼凑
- const pairsNeeded = requiredLength / 2 * needed;
- if (trumpResources.pairs >= pairsNeeded) {
- trumpResources.pairs -= pairsNeeded;
- needed = 0;
- } else {
- return false; // 对子不够
- }
- }
+ // 不连续的对子不能拼成拖拉机;毙拖拉机必须有真实主拖拉机。
+ if (needed > 0) return false;
}
// 2. 将剩余的拖拉机全部转化为对子
@@ -615,11 +1493,16 @@ function groupComponentsByType(components) {
* @param {String} trumpRank - 级牌
* @returns {Object} { valid, message, pattern }
*/
-export function validateLeadingPlay(cards, trumpSuit, trumpRank) {
+export function validateLeadingPlay(cards, trumpSuit, trumpRank, activeRule = null) {
if (!cards || cards.length === 0) {
return { valid: false, message: '请选择要出的牌', pattern: null };
}
+ const pattern = detectPattern(cards, trumpSuit, trumpRank, activeRule);
+ if (pattern.type === PatternTypes.TAI_CHI_FOUR_SYMBOLS) {
+ return { valid: true, message: '出牌合法', pattern };
+ }
+
// 首发出牌时,只需要检查是否同花色
// 允许甩牌(多种牌型的组合),具体验证在后续流程中处理
const effectiveSuits = cards.map(c => getEffectiveSuit(c, trumpSuit, trumpRank));
@@ -633,8 +1516,14 @@ export function validateLeadingPlay(cards, trumpSuit, trumpRank) {
};
}
- // 同花色的牌就允许出,后续流程会判断是否是有效的甩牌
- const pattern = detectPattern(cards, trumpSuit, trumpRank);
+ // 同花色的牌通常允许作为甩牌;单步调试只保留完整的单张、对子和拖拉机。
+ if (isSingleStepDebugRule(activeRule) && pattern.type === PatternTypes.INVALID) {
+ return {
+ valid: false,
+ message: '单步调试规则下不能甩牌',
+ pattern: null
+ };
+ }
return { valid: true, message: '出牌合法', pattern };
}
@@ -647,7 +1536,14 @@ export function validateLeadingPlay(cards, trumpSuit, trumpRank) {
* @param {String} trumpRank - 级牌
* @returns {Object} { valid, message, pattern }
*/
-export function validateFollowingPlay(cards, handCards, leadingPattern, trumpSuit, trumpRank) {
+export function validateFollowingPlay(
+ cards,
+ handCards,
+ leadingPattern,
+ trumpSuit,
+ trumpRank,
+ activeRule = null
+) {
if (!cards || cards.length === 0) {
return { valid: false, message: '请选择要出的牌', pattern: null };
}
@@ -662,10 +1558,30 @@ export function validateFollowingPlay(cards, handCards, leadingPattern, trumpSui
}
const leadingSuit = leadingPattern.suit;
+ const patternRuleContext = leadingPattern.type === PatternTypes.BELT_AND_ROAD
+ ? { ...activeRule, beltAndRoadSkillActive: true }
+ : activeRule;
+
+ if (leadingPattern.type === PatternTypes.TAI_CHI_FOUR_SYMBOLS) {
+ return validateFollowingTaiChi(
+ cards,
+ handCards,
+ trumpSuit,
+ trumpRank,
+ activeRule
+ );
+ }
// 如果首发是甩牌,使用特殊的跟牌验证
if (leadingPattern.type === PatternTypes.THROW) {
- return validateFollowingThrow(cards, handCards, leadingPattern, trumpSuit, trumpRank);
+ return validateFollowingThrow(
+ cards,
+ handCards,
+ leadingPattern,
+ trumpSuit,
+ trumpRank,
+ activeRule
+ );
}
// 统计手牌中同花色的牌
@@ -691,15 +1607,28 @@ export function validateFollowingPlay(cards, handCards, leadingPattern, trumpSui
}
// 检测出牌的牌型
- const pattern = detectPattern(cards, trumpSuit, trumpRank);
+ const pattern = detectPattern(cards, trumpSuit, trumpRank, patternRuleContext);
// 检查是否需要匹配牌型
if (playedSameSuit.length >= leadingPattern.length) {
// 全部是同花色,需要匹配牌型
- if (!matchPatternRequirement(cards, handCards, leadingPattern, trumpSuit, trumpRank)) {
+ if (!matchPatternRequirement(
+ cards,
+ handCards,
+ leadingPattern,
+ trumpSuit,
+ trumpRank,
+ activeRule
+ )) {
return {
valid: false,
- message: getPatternMismatchMessage(leadingPattern, handCards, trumpSuit, trumpRank),
+ message: getPatternMismatchMessage(
+ leadingPattern,
+ handCards,
+ trumpSuit,
+ trumpRank,
+ activeRule
+ ),
pattern
};
}
@@ -708,10 +1637,595 @@ export function validateFollowingPlay(cards, handCards, leadingPattern, trumpSui
return { valid: true, message: '跟牌合法', pattern };
}
+const SUBSTITUTION_SUITS = Object.freeze([
+ Suits.HEARTS,
+ Suits.DIAMONDS,
+ Suits.CLUBS,
+ Suits.SPADES
+]);
+const SUBSTITUTION_RANKS = Object.freeze([
+ Ranks.TWO,
+ Ranks.THREE,
+ Ranks.FOUR,
+ Ranks.FIVE,
+ Ranks.SIX,
+ Ranks.SEVEN,
+ Ranks.EIGHT,
+ Ranks.NINE,
+ Ranks.TEN,
+ Ranks.JACK,
+ Ranks.QUEEN,
+ Ranks.KING,
+ Ranks.ACE
+]);
+
+function createForbiddenMagicDemotedCard(card, targetSuit = card.suit, targetRank = card.rank) {
+ return {
+ ...card,
+ suit: targetSuit,
+ rank: targetRank,
+ originalSuit: card.originalSuit || card.suit,
+ originalRank: card.originalRank || card.rank,
+ isForbiddenMagicDemoted: true,
+ isForbiddenMagicTransformed: targetSuit !== card.suit || targetRank !== card.rank
+ };
+}
+
+/**
+ * 禁术秘法确认发动后,原主牌只在完成显式转化后才能打出。
+ * 此函数仍用于枚举这些牌潜在的副牌身份;实际出牌由 resolveForbiddenMagicPlay
+ * 要求每张被选中的原主牌都提交明确转化。
+ */
+export function demoteForbiddenMagicHand(cards, trumpSuit, trumpRank) {
+ const list = Array.isArray(cards) ? cards : [];
+ return list.map(card => isTrumpCard(card, trumpSuit, trumpRank)
+ ? createForbiddenMagicDemotedCard(card)
+ : card);
+}
+
+export function resolveForbiddenMagicPlay({
+ selectedCards,
+ handCards,
+ substitutions = [],
+ leadingPattern = null,
+ trumpSuit = null,
+ trumpRank = null,
+ activeRule = null
+}) {
+ const cards = Array.isArray(selectedCards) ? selectedCards : [];
+ const hand = Array.isArray(handCards) ? handCards : [];
+ const submitted = Array.isArray(substitutions) ? substitutions : [];
+ if (new Set(submitted.map(item => item?.cardId)).size !== submitted.length) {
+ return { valid: false, message: '同一张牌不能重复设置禁术转化' };
+ }
+
+ const selectedById = new Map(cards.map(card => [card.id, card]));
+ const originalTrumpIds = new Set(
+ hand.filter(card => isTrumpCard(card, trumpSuit, trumpRank)).map(card => card.id)
+ );
+ const replacementById = new Map();
+ const normalizedSubstitutions = [];
+
+ for (const substitution of submitted) {
+ const sourceCard = selectedById.get(substitution?.cardId);
+ if (!sourceCard) {
+ return { valid: false, message: '所有已转化的牌都必须包含在本次出牌中' };
+ }
+ if (!originalTrumpIds.has(sourceCard.id)) {
+ return { valid: false, message: '禁术秘法只能转化发动前属于主牌的牌' };
+ }
+ if (!SUBSTITUTION_SUITS.includes(substitution.suit)) {
+ return { valid: false, message: '禁术秘法的目标花色无效' };
+ }
+
+ const isJoker = sourceCard.suit === Suits.JOKER;
+ const targetRank = isJoker ? substitution.rank : sourceCard.rank;
+ if (!SUBSTITUTION_RANKS.includes(targetRank)) {
+ return { valid: false, message: '禁术秘法的目标点数无效' };
+ }
+ if (!isJoker && String(substitution.rank || sourceCard.rank) !== String(sourceCard.rank)) {
+ return { valid: false, message: '非王牌只能改变花色,不能改变原点数' };
+ }
+ if (
+ trumpSuit
+ && trumpSuit !== Suits.NO_TRUMP
+ && substitution.suit === trumpSuit
+ ) {
+ return { valid: false, message: '禁术秘法只能转化为副牌花色,不能选择当前主花色' };
+ }
+
+ replacementById.set(
+ sourceCard.id,
+ createForbiddenMagicDemotedCard(sourceCard, substitution.suit, targetRank)
+ );
+ normalizedSubstitutions.push({
+ cardId: sourceCard.id,
+ fromSuit: sourceCard.suit,
+ fromRank: sourceCard.rank,
+ suit: substitution.suit,
+ rank: targetRank
+ });
+ }
+
+ const selectedTrumpsWithoutTarget = cards.filter(card => (
+ originalTrumpIds.has(card.id)
+ && !replacementById.has(card.id)
+ ));
+ if (selectedTrumpsWithoutTarget.length > 0) {
+ return {
+ valid: false,
+ message: '禁术秘法生效后,主牌不能直接打出;请先为每张要出的主牌选择一种副花色'
+ };
+ }
+
+ const effectiveCards = demoteForbiddenMagicHand(cards, trumpSuit, trumpRank)
+ .map(card => replacementById.get(card.id) || card);
+ const effectiveHandCards = demoteForbiddenMagicHand(hand, trumpSuit, trumpRank)
+ .map(card => replacementById.get(card.id) || card);
+ const validation = leadingPattern
+ ? validateFollowingPlay(
+ effectiveCards,
+ effectiveHandCards,
+ leadingPattern,
+ trumpSuit,
+ trumpRank,
+ activeRule
+ )
+ : validateLeadingPlay(effectiveCards, trumpSuit, trumpRank, activeRule);
+
+ return {
+ ...validation,
+ usesSkill: true,
+ effectiveCards,
+ effectiveHandCards,
+ substitutions: normalizedSubstitutions
+ };
+}
+
+/**
+ * 偷梁换柱:只采用玩家明确提交的王牌转换,不再自动猜测匹配方案。
+ * 实体王牌不会被修改;返回的 effectiveCards 只用于牌型与大小判断。
+ */
+export function resolveJokerSubstitutionPlay({
+ selectedCards,
+ handCards,
+ substitutions = [],
+ leadingPattern = null,
+ trumpSuit = null,
+ trumpRank = null,
+ activeRule = null
+}) {
+ const cards = Array.isArray(selectedCards) ? selectedCards : [];
+ const hand = Array.isArray(handCards) ? handCards : [];
+ const jokers = cards.filter(card => card.suit === Suits.JOKER);
+ const submitted = Array.isArray(substitutions) ? substitutions : [];
+ const validate = (effectiveCards, effectiveHand) => leadingPattern
+ ? validateFollowingPlay(
+ effectiveCards,
+ effectiveHand,
+ leadingPattern,
+ trumpSuit,
+ trumpRank,
+ activeRule
+ )
+ : validateLeadingPlay(effectiveCards, trumpSuit, trumpRank, activeRule);
+
+ if (submitted.length === 0 && cards.length === 1 && jokers.length === 1) {
+ const validation = validate(cards, hand);
+ return { ...validation, effectiveCards: cards, effectiveHandCards: hand, usesSkill: false, substitutions: [] };
+ }
+ if (submitted.length === 0) {
+ return { valid: false, message: '请先明确选择至少一张王牌要转换成的牌面' };
+ }
+ if (new Set(submitted.map(item => item?.cardId)).size !== submitted.length) {
+ return { valid: false, message: '同一张王牌不能重复设置转换' };
+ }
+
+ const selectedById = new Map(cards.map(card => [card.id, card]));
+ const replacementById = new Map();
+ for (const substitution of submitted) {
+ const sourceCard = selectedById.get(substitution?.cardId);
+ if (!sourceCard) return { valid: false, message: '所有已转换的王牌都必须包含在本次出牌中' };
+ if (sourceCard.suit !== Suits.JOKER) return { valid: false, message: '偷梁换柱只能转换王牌' };
+ if (!SUBSTITUTION_SUITS.includes(substitution.suit)) {
+ return { valid: false, message: '偷梁换柱的目标花色无效' };
+ }
+ if (!SUBSTITUTION_RANKS.includes(substitution.rank)) {
+ return { valid: false, message: '偷梁换柱的目标点数无效' };
+ }
+ replacementById.set(sourceCard.id, {
+ ...sourceCard,
+ suit: substitution.suit,
+ rank: substitution.rank,
+ originalSuit: Suits.JOKER,
+ originalRank: sourceCard.rank,
+ isJokerSubstitution: true
+ });
+ }
+
+ const effectiveCards = cards.map(card => replacementById.get(card.id) || card);
+ const effectiveHandCards = hand.map(card => replacementById.get(card.id) || card);
+ const validation = validate(effectiveCards, effectiveHandCards);
+ return validation.valid
+ ? { ...validation, effectiveCards, effectiveHandCards, usesSkill: true, substitutions: submitted }
+ : { ...validation, effectiveCards, effectiveHandCards, usesSkill: true, substitutions: submitted };
+}
+
+const CLUSTER_ANALYSIS_RANKS = Object.freeze([
+ Ranks.TWO,
+ Ranks.THREE,
+ Ranks.FOUR,
+ Ranks.FIVE,
+ Ranks.SIX,
+ Ranks.SEVEN,
+ Ranks.EIGHT,
+ Ranks.NINE,
+ Ranks.TEN,
+ Ranks.JACK,
+ Ranks.QUEEN,
+ Ranks.KING,
+ Ranks.ACE
+]);
+
+export function getClusterAnalysisTargetRanks(card, trumpRank) {
+ if (!card || card.suit === Suits.JOKER) return [];
+ const sourceIndex = CLUSTER_ANALYSIS_RANKS.findIndex(rank => String(rank) === String(card.rank));
+ if (sourceIndex < 0 || [Ranks.FIVE, Ranks.TEN, Ranks.KING].includes(card.rank)) return [];
+ if (String(card.rank) === String(trumpRank)) return [];
+ return [sourceIndex - 1, sourceIndex + 1]
+ .filter(index => index >= 0 && index < CLUSTER_ANALYSIS_RANKS.length)
+ .map(index => CLUSTER_ANALYSIS_RANKS[index])
+ .filter(rank => ![Ranks.FIVE, Ranks.TEN, Ranks.KING].includes(rank))
+ .filter(rank => String(rank) !== String(trumpRank));
+}
+
+function createClusterCard(card, targetRank) {
+ return {
+ ...card,
+ rank: targetRank,
+ originalRank: card.rank,
+ isClusterAnalysisTransformed: true,
+ clusterAnalysisSourceRank: card.rank
+ };
+}
+
+/**
+ * 聚类分析:采用玩家明确提交的一张或多张牌及各自的相邻目标点数。
+ * 转化只存在于本次比较视图,实体手牌及牌的原始分值均不改变。
+ */
+export function resolveClusterAnalysisPlay({
+ selectedCards,
+ handCards,
+ substitutions = [],
+ leadingPattern = null,
+ trumpSuit = null,
+ trumpRank = null,
+ activeRule = null
+}) {
+ const cards = Array.isArray(selectedCards) ? selectedCards : [];
+ const hand = Array.isArray(handCards) ? handCards : [];
+ const submitted = Array.isArray(substitutions) ? substitutions : [];
+ const validate = (effectiveCards, effectiveHandCards) => leadingPattern
+ ? validateFollowingPlay(
+ effectiveCards,
+ effectiveHandCards,
+ leadingPattern,
+ trumpSuit,
+ trumpRank,
+ activeRule
+ )
+ : validateLeadingPlay(effectiveCards, trumpSuit, trumpRank, activeRule);
+
+ if (submitted.length === 0) {
+ return {
+ valid: false,
+ usesSkill: false,
+ effectiveCards: cards,
+ effectiveHandCards: hand,
+ substitutions: [],
+ message: '聚类分析必须先明确转换至少一张牌'
+ };
+ }
+ if (new Set(submitted.map(item => item?.cardId)).size !== submitted.length) {
+ return { valid: false, message: '同一张牌不能重复设置聚类转换' };
+ }
+
+ const selectedById = new Map(cards.map(card => [card.id, card]));
+ const replacementById = new Map();
+ const normalizedSubstitutions = [];
+ for (const substitution of submitted) {
+ const sourceCard = selectedById.get(substitution?.cardId);
+ if (!sourceCard) {
+ return { valid: false, message: '所有已转换的牌都必须包含在本次出牌中' };
+ }
+ const targetRanks = getClusterAnalysisTargetRanks(sourceCard, trumpRank);
+ if (!targetRanks.includes(substitution.toRank)) {
+ return { valid: false, message: `牌 ${sourceCard.rank} 不能转换成所选点数` };
+ }
+ normalizedSubstitutions.push({
+ cardId: sourceCard.id,
+ suit: sourceCard.suit,
+ fromRank: sourceCard.rank,
+ toRank: substitution.toRank
+ });
+ replacementById.set(sourceCard.id, createClusterCard(sourceCard, substitution.toRank));
+ }
+ const effectiveCards = cards.map(card => replacementById.get(card.id) || card);
+ const effectiveHandCards = hand.map(card => replacementById.get(card.id) || card);
+ const validation = validate(effectiveCards, effectiveHandCards);
+ return {
+ ...validation,
+ usesSkill: true,
+ effectiveCards,
+ effectiveHandCards,
+ substitutions: normalizedSubstitutions
+ };
+}
+
+/** 保留单张转换变体供调试使用;甩牌威胁判定使用下方的多张匹配搜索。 */
+export function getClusterAnalysisHandVariants(cards, trumpRank) {
+ const hand = Array.isArray(cards) ? cards : [];
+ const variants = [hand];
+ for (const sourceCard of hand) {
+ for (const targetRank of getClusterAnalysisTargetRanks(sourceCard, trumpRank)) {
+ const transformedCard = createClusterCard(sourceCard, targetRank);
+ variants.push(hand.map(card => card.id === sourceCard.id ? transformedCard : card));
+ }
+ }
+ return variants;
+}
+
+function getClusterAnalysisCardOptions(card, trumpRank) {
+ const options = [
+ card,
+ ...getClusterAnalysisTargetRanks(card, trumpRank).map(rank => createClusterCard(card, rank))
+ ];
+ return [...new Map(options.map(option => [
+ `${option.suit}:${option.rank}`,
+ option
+ ])).values()];
+}
+
+function getClusterAnalysisPairCandidates(
+ cards,
+ componentSuit,
+ trumpSuit,
+ trumpRank,
+ activeRule
+) {
+ const optionSets = cards.map(card => getClusterAnalysisCardOptions(card, trumpRank));
+ const candidates = [];
+ for (let firstIndex = 0; firstIndex < cards.length; firstIndex += 1) {
+ for (let secondIndex = firstIndex + 1; secondIndex < cards.length; secondIndex += 1) {
+ for (const firstOption of optionSets[firstIndex]) {
+ if (getEffectiveSuit(firstOption, trumpSuit, trumpRank) !== componentSuit) continue;
+ for (const secondOption of optionSets[secondIndex]) {
+ if (getEffectiveSuit(secondOption, trumpSuit, trumpRank) !== componentSuit) continue;
+ const pair = findPairsInCards(
+ [firstOption, secondOption],
+ trumpSuit,
+ trumpRank,
+ activeRule
+ )[0];
+ if (!pair) continue;
+ candidates.push({
+ ...pair,
+ sourceCardIds: [cards[firstIndex].id, cards[secondIndex].id]
+ });
+ }
+ }
+ }
+ }
+ return candidates.sort((left, right) => left.strength - right.strength);
+}
+
+function canClusterAnalysisBeatComponent(
+ cards,
+ component,
+ trumpSuit,
+ trumpRank,
+ activeRule
+) {
+ const componentSuit = getEffectiveSuit(component.cards[0], trumpSuit, trumpRank);
+ if (component.type === PatternTypes.SINGLE) {
+ return cards.some(card => getClusterAnalysisCardOptions(card, trumpRank).some(option =>
+ getEffectiveSuit(option, trumpSuit, trumpRank) === componentSuit
+ && getCardStrength(option, trumpSuit, trumpRank, activeRule) > component.strength
+ ));
+ }
+
+ const pairCandidates = getClusterAnalysisPairCandidates(
+ cards,
+ componentSuit,
+ trumpSuit,
+ trumpRank,
+ activeRule
+ );
+ if (component.type === PatternTypes.PAIR) {
+ return pairCandidates.some(pair => pair.strength > component.strength);
+ }
+ if (component.type !== PatternTypes.TRACTOR) return false;
+
+ const requiredPairs = component.length / 2;
+ const search = (chain, usedCardIds) => {
+ if (chain.length === requiredPairs) {
+ return chain[chain.length - 1].strength > component.strength;
+ }
+ for (const candidate of pairCandidates) {
+ if (candidate.sourceCardIds.some(cardId => usedCardIds.has(cardId))) continue;
+ if (chain.length > 0) {
+ const previous = chain[chain.length - 1];
+ if (candidate.strength <= previous.strength) continue;
+ if (!arePairsConsecutive(
+ previous,
+ candidate,
+ trumpSuit,
+ trumpRank,
+ activeRule
+ )) continue;
+ }
+ const nextUsed = new Set(usedCardIds);
+ candidate.sourceCardIds.forEach(cardId => nextUsed.add(cardId));
+ if (search([...chain, candidate], nextUsed)) return true;
+ }
+ return false;
+ };
+ return search([], new Set());
+}
+
+function getForbiddenMagicCardOptions(card, trumpSuit) {
+ if (!card?.isForbiddenMagicDemoted) return [card];
+ const originalSuit = card.originalSuit || card.suit;
+ const originalRank = card.originalRank || card.rank;
+ if (originalSuit === Suits.JOKER) {
+ return SUBSTITUTION_SUITS
+ .filter(suit => !(
+ trumpSuit
+ && trumpSuit !== Suits.NO_TRUMP
+ && suit === trumpSuit
+ ))
+ .flatMap(suit => SUBSTITUTION_RANKS.map(rank => (
+ createForbiddenMagicDemotedCard(card, suit, rank)
+ )));
+ }
+ return SUBSTITUTION_SUITS.map(suit => (
+ createForbiddenMagicDemotedCard(card, suit, originalRank)
+ ));
+}
+
+function getForbiddenMagicPairCandidates(
+ cards,
+ componentSuit,
+ trumpSuit,
+ trumpRank,
+ activeRule
+) {
+ const optionSets = cards.map(card => getForbiddenMagicCardOptions(card, trumpSuit));
+ const candidates = [];
+ for (let firstIndex = 0; firstIndex < cards.length; firstIndex += 1) {
+ for (let secondIndex = firstIndex + 1; secondIndex < cards.length; secondIndex += 1) {
+ for (const firstOption of optionSets[firstIndex]) {
+ if (getEffectiveSuit(firstOption, trumpSuit, trumpRank) !== componentSuit) continue;
+ for (const secondOption of optionSets[secondIndex]) {
+ if (getEffectiveSuit(secondOption, trumpSuit, trumpRank) !== componentSuit) continue;
+ const pair = findPairsInCards(
+ [firstOption, secondOption],
+ trumpSuit,
+ trumpRank,
+ activeRule
+ )[0];
+ if (!pair) continue;
+ candidates.push({
+ ...pair,
+ sourceCardIds: [cards[firstIndex].id, cards[secondIndex].id]
+ });
+ }
+ }
+ }
+ }
+ return candidates.sort((left, right) => left.strength - right.strength);
+}
+
+function canForbiddenMagicBeatComponent(
+ cards,
+ component,
+ trumpSuit,
+ trumpRank,
+ activeRule
+) {
+ const componentSuit = getEffectiveSuit(component.cards[0], trumpSuit, trumpRank);
+ if (component.type === PatternTypes.SINGLE) {
+ return cards.some(card => getForbiddenMagicCardOptions(card, trumpSuit).some(option => (
+ getEffectiveSuit(option, trumpSuit, trumpRank) === componentSuit
+ && getCardStrength(option, trumpSuit, trumpRank, activeRule) > component.strength
+ )));
+ }
+
+ const pairCandidates = getForbiddenMagicPairCandidates(
+ cards,
+ componentSuit,
+ trumpSuit,
+ trumpRank,
+ activeRule
+ );
+ if (component.type === PatternTypes.PAIR) {
+ return pairCandidates.some(pair => pair.strength > component.strength);
+ }
+ if (component.type !== PatternTypes.TRACTOR) return false;
+
+ const requiredPairs = component.length / 2;
+ const search = (chain, usedCardIds) => {
+ if (chain.length === requiredPairs) {
+ return chain[chain.length - 1].strength > component.strength;
+ }
+ for (const candidate of pairCandidates) {
+ if (candidate.sourceCardIds.some(cardId => usedCardIds.has(cardId))) continue;
+ if (chain.length > 0) {
+ const previous = chain[chain.length - 1];
+ if (candidate.strength <= previous.strength) continue;
+ if (!arePairsConsecutive(
+ previous,
+ candidate,
+ trumpSuit,
+ trumpRank,
+ activeRule
+ )) continue;
+ }
+ const nextUsed = new Set(usedCardIds);
+ candidate.sourceCardIds.forEach(cardId => nextUsed.add(cardId));
+ if (search([...chain, candidate], nextUsed)) return true;
+ }
+ return false;
+ };
+ return search([], new Set());
+}
+
+function validateFollowingTaiChi(cards, handCards, trumpSuit, trumpRank, activeRule) {
+ const pattern = detectPattern(cards, trumpSuit, trumpRank, activeRule);
+ const availableTaiChi = findTaiChiFourSymbols(handCards, activeRule);
+ if (
+ availableTaiChi.length > 0 &&
+ pattern.type !== PatternTypes.TAI_CHI_FOUR_SYMBOLS
+ ) {
+ return {
+ valid: false,
+ message: '手牌中有太极四象,必须优先跟出太极四象',
+ pattern
+ };
+ }
+
+ if (pattern.type === PatternTypes.TAI_CHI_FOUR_SYMBOLS) {
+ return { valid: true, message: '跟牌合法', pattern };
+ }
+
+ const hasNoSideCards = handCards.length > 0 && handCards.every(card =>
+ isTrumpCard(card, trumpSuit, trumpRank)
+ );
+ const playedOnlyTrump = cards.every(card => isTrumpCard(card, trumpSuit, trumpRank));
+ if (hasNoSideCards && playedOnlyTrump) {
+ return {
+ valid: true,
+ message: '四张主牌毙太极四象',
+ pattern: {
+ ...pattern,
+ type: pattern.type === PatternTypes.INVALID ? PatternTypes.THROW : pattern.type,
+ suit: 'trump',
+ length: 4,
+ strength: Math.max(...cards.map(card =>
+ getCardStrength(card, trumpSuit, trumpRank, activeRule)
+ )),
+ canTrumpTaiChi: true
+ }
+ };
+ }
+
+ return { valid: true, message: '跟牌合法', pattern };
+}
+
/**
* 检查是否满足牌型匹配要求
*/
-function matchPatternRequirement(cards, handCards, leadingPattern, trumpSuit, trumpRank) {
+function matchPatternRequirement(cards, handCards, leadingPattern, trumpSuit, trumpRank, activeRule) {
const leadingSuit = leadingPattern.suit;
// 获取手牌中同花色的牌
@@ -719,37 +2233,71 @@ function matchPatternRequirement(cards, handCards, leadingPattern, trumpSuit, tr
getEffectiveSuit(c, trumpSuit, trumpRank) === leadingSuit
);
+ if (leadingPattern.type === PatternTypes.STRAIGHT_FLUSH) {
+ const availableStraightFlushes = findStraightFlushes(
+ sameSuitHand,
+ leadingPattern.length,
+ trumpSuit,
+ trumpRank,
+ activeRule
+ );
+ if (availableStraightFlushes.length > 0) {
+ const playedPattern = detectPattern(cards, trumpSuit, trumpRank, activeRule);
+ return playedPattern.type === PatternTypes.STRAIGHT_FLUSH &&
+ playedPattern.suit === leadingSuit &&
+ playedPattern.length === leadingPattern.length;
+ }
+ }
+
if (leadingPattern.type === PatternTypes.PAIR) {
// 首发是对子,检查手牌中是否有同花色对子
- const hasPair = findPairsInCards(sameSuitHand, trumpSuit, trumpRank).length > 0;
+ const hasPair = findPairsInCards(sameSuitHand, trumpSuit, trumpRank, activeRule).length > 0;
if (hasPair) {
// 有对子就必须出对子
- const playedPattern = detectPattern(cards, trumpSuit, trumpRank);
+ const playedPattern = detectPattern(cards, trumpSuit, trumpRank, activeRule);
return playedPattern.type === PatternTypes.PAIR;
}
}
if (leadingPattern.type === PatternTypes.TRACTOR) {
// 首发是拖拉机
- const pairs = findPairsInCards(sameSuitHand, trumpSuit, trumpRank);
+ const pairs = findPairsInCards(sameSuitHand, trumpSuit, trumpRank, activeRule);
- // 检查是否有拖拉机
const tractorPairs = leadingPattern.length / 2;
- const hasTractor = findTractorInPairs(pairs, tractorPairs, trumpSuit, trumpRank);
+ const longestTractor = getLongestTractorPairCount(
+ pairs,
+ tractorPairs,
+ trumpSuit,
+ trumpRank,
+ activeRule
+ );
- if (hasTractor) {
+ if (longestTractor === tractorPairs) {
// 有拖拉机就必须出拖拉机
- const playedPattern = detectPattern(cards, trumpSuit, trumpRank);
+ const playedPattern = detectPattern(cards, trumpSuit, trumpRank, activeRule);
return playedPattern.type === PatternTypes.TRACTOR &&
playedPattern.length === leadingPattern.length;
}
- // 没有完整拖拉机,检查是否有对子
+ // 没有完整拖拉机时,仍必须优先跟出手中能组成的最长拖拉机,
+ // 然后再尽量跟足对子。
if (pairs.length > 0) {
- // 必须把对子打下来
- const playedPairs = findPairsInCards(cards, trumpSuit, trumpRank);
+ const playedPairs = findPairsInCards(cards, trumpSuit, trumpRank, activeRule);
const requiredPairs = Math.min(pairs.length, tractorPairs);
- return playedPairs.length >= requiredPairs;
+ if (playedPairs.length < requiredPairs) return false;
+
+ if (longestTractor >= 2) {
+ const playedLongest = getLongestTractorPairCount(
+ playedPairs,
+ longestTractor,
+ trumpSuit,
+ trumpRank,
+ activeRule
+ );
+ return playedLongest >= longestTractor;
+ }
+
+ return true;
}
}
@@ -759,21 +2307,34 @@ function matchPatternRequirement(cards, handCards, leadingPattern, trumpSuit, tr
/**
* 获取牌型不匹配的错误信息
*/
-function getPatternMismatchMessage(leadingPattern, handCards, trumpSuit, trumpRank) {
+function getPatternMismatchMessage(leadingPattern, handCards, trumpSuit, trumpRank, activeRule) {
const leadingSuit = leadingPattern.suit;
const sameSuitHand = handCards.filter(c =>
getEffectiveSuit(c, trumpSuit, trumpRank) === leadingSuit
);
+ if (leadingPattern.type === PatternTypes.STRAIGHT_FLUSH) {
+ const straightFlushes = findStraightFlushes(
+ sameSuitHand,
+ leadingPattern.length,
+ trumpSuit,
+ trumpRank,
+ activeRule
+ );
+ if (straightFlushes.length > 0) {
+ return `手牌中有同花色${leadingPattern.length}张同花顺,必须出同花顺`;
+ }
+ }
+
if (leadingPattern.type === PatternTypes.PAIR) {
- const pairs = findPairsInCards(sameSuitHand, trumpSuit, trumpRank);
+ const pairs = findPairsInCards(sameSuitHand, trumpSuit, trumpRank, activeRule);
if (pairs.length > 0) {
return '手牌中有同花色对子,必须出对子';
}
}
if (leadingPattern.type === PatternTypes.TRACTOR) {
- const pairs = findPairsInCards(sameSuitHand, trumpSuit, trumpRank);
+ const pairs = findPairsInCards(sameSuitHand, trumpSuit, trumpRank, activeRule);
if (pairs.length > 0) {
return '手牌中有同花色对子,必须优先把对子打下来';
}
@@ -785,7 +2346,7 @@ function getPatternMismatchMessage(leadingPattern, handCards, trumpSuit, trumpRa
/**
* 在牌组中找出所有对子
*/
-function findPairsInCards(cards, trumpSuit, trumpRank) {
+function findPairsInCards(cards, trumpSuit, trumpRank, activeRule) {
const pairs = [];
const used = new Set();
@@ -795,10 +2356,10 @@ function findPairsInCards(cards, trumpSuit, trumpRank) {
for (let j = i + 1; j < cards.length; j++) {
if (used.has(j)) continue;
- if (cards[i].rank === cards[j].rank && cards[i].suit === cards[j].suit) {
+ if (canCardsFormPair(cards[i], cards[j], activeRule)) {
pairs.push({
cards: [cards[i], cards[j]],
- strength: getCardStrength(cards[i], trumpSuit, trumpRank)
+ strength: getCardStrength(cards[i], trumpSuit, trumpRank, activeRule)
});
used.add(i);
used.add(j);
@@ -813,26 +2374,39 @@ function findPairsInCards(cards, trumpSuit, trumpRank) {
/**
* 在对子中找拖拉机
*/
-function findTractorInPairs(pairs, requiredLength, trumpSuit, trumpRank) {
- if (pairs.length < requiredLength) return false;
+function findTractorInPairs(pairs, requiredLength, trumpSuit, trumpRank, activeRule) {
+ return getLongestTractorPairCount(
+ pairs,
+ requiredLength,
+ trumpSuit,
+ trumpRank,
+ activeRule
+ ) >= requiredLength;
+}
- // 按强度排序
- const sortedPairs = [...pairs].sort((a, b) => a.strength - b.strength);
+function getLongestTractorPairCount(pairs, maxLength, trumpSuit, trumpRank, activeRule) {
+ if (!pairs || pairs.length === 0) return 0;
- // 滑动窗口查找连续对子
- for (let i = 0; i <= sortedPairs.length - requiredLength; i++) {
- let isConsecutive = true;
- for (let j = 0; j < requiredLength - 1; j++) {
- // 简化检查:强度差应该在合理范围内
- if (sortedPairs[i + j + 1].strength - sortedPairs[i + j].strength > 2) {
- isConsecutive = false;
- break;
- }
+ const sortedPairs = [...pairs].sort((a, b) => a.strength - b.strength);
+ let longest = 1;
+ let currentLength = 1;
+
+ for (let i = 1; i < sortedPairs.length; i++) {
+ if (arePairsConsecutive(
+ sortedPairs[i - 1],
+ sortedPairs[i],
+ trumpSuit,
+ trumpRank,
+ activeRule
+ )) {
+ currentLength++;
+ longest = Math.max(longest, currentLength);
+ } else {
+ currentLength = 1;
}
- if (isConsecutive) return true;
}
- return false;
+ return Math.min(longest, maxLength);
}
/**
@@ -851,11 +2425,29 @@ export function countSuitCards(handCards, suit, trumpSuit, trumpRank) {
* @param {String} trumpRank - 级牌
* @returns {Object} { valid, suit, components } components是组件数组,每个组件包含 { type, cards, strength }
*/
-export function parseThrowCombination(cards, trumpSuit, trumpRank) {
+export function parseThrowCombination(cards, trumpSuit, trumpRank, activeRule = null) {
if (!cards || cards.length === 0) {
return { valid: false, suit: null, components: [] };
}
+ const specialPattern = detectPattern(cards, trumpSuit, trumpRank, activeRule);
+ if (
+ specialPattern.type === PatternTypes.STRAIGHT_FLUSH ||
+ specialPattern.type === PatternTypes.TAI_CHI_FOUR_SYMBOLS
+ ) {
+ return {
+ valid: true,
+ suit: specialPattern.suit,
+ components: [{
+ type: specialPattern.type,
+ cards: [...cards],
+ strength: specialPattern.strength,
+ length: cards.length
+ }],
+ totalCards: cards.length
+ };
+ }
+
// 检查是否同花色
const effectiveSuits = cards.map(c => getEffectiveSuit(c, trumpSuit, trumpRank));
const uniqueSuits = [...new Set(effectiveSuits)];
@@ -871,7 +2463,8 @@ export function parseThrowCombination(cards, trumpSuit, trumpRank) {
// 按强度排序(从大到小),方便后续处理
remainingCards.sort((a, b) =>
- getCardStrength(b, trumpSuit, trumpRank) - getCardStrength(a, trumpSuit, trumpRank)
+ getCardStrength(b, trumpSuit, trumpRank, activeRule) -
+ getCardStrength(a, trumpSuit, trumpRank, activeRule)
);
// 第一步:找出所有拖拉机
@@ -895,7 +2488,7 @@ export function parseThrowCombination(cards, trumpSuit, trumpRank) {
}
if (candidateCards.length === len) {
- const pattern = detectPattern(candidateCards, trumpSuit, trumpRank);
+ const pattern = detectPattern(candidateCards, trumpSuit, trumpRank, activeRule);
if (pattern.type === PatternTypes.TRACTOR && pattern.length === len) {
// 找到一个拖拉机
components.push({
@@ -923,11 +2516,11 @@ export function parseThrowCombination(cards, trumpSuit, trumpRank) {
const card1 = remainingCards[i];
const card2 = remainingCards[j];
- if (card1.rank === card2.rank && card1.suit === card2.suit) {
+ if (canCardsFormPair(card1, card2, activeRule)) {
components.push({
type: PatternTypes.PAIR,
cards: [card1, card2],
- strength: getCardStrength(card1, trumpSuit, trumpRank),
+ strength: getCardStrength(card1, trumpSuit, trumpRank, activeRule),
length: 2
});
used.add(i);
@@ -945,7 +2538,7 @@ export function parseThrowCombination(cards, trumpSuit, trumpRank) {
components.push({
type: PatternTypes.SINGLE,
cards: [card],
- strength: getCardStrength(card, trumpSuit, trumpRank),
+ strength: getCardStrength(card, trumpSuit, trumpRank, activeRule),
length: 1
});
used.add(i);
@@ -959,6 +2552,105 @@ export function parseThrowCombination(cards, trumpSuit, trumpRank) {
};
}
+const SUITLESS_PATTERN_NAMES = Object.freeze({
+ [PatternTypes.SINGLE]: '单牌',
+ [PatternTypes.PAIR]: '对子',
+ [PatternTypes.TRACTOR]: '拖拉机',
+ [PatternTypes.STRAIGHT_FLUSH]: '同花顺',
+ [PatternTypes.TAI_CHI_FOUR_SYMBOLS]: '太极四象',
+ [PatternTypes.BELT_AND_ROAD]: '一带一路'
+});
+
+function normalizeSuitlessComponent(component) {
+ return {
+ type: component?.type || PatternTypes.SINGLE,
+ length: Number(component?.length ?? component?.cards?.length) || 1
+ };
+}
+
+function decomposeSuitlessPatternCards(cards, trumpSuit, trumpRank, activeRule) {
+ const cardsByEffectiveSuit = new Map();
+ for (const card of cards || []) {
+ const suit = getEffectiveSuit(card, trumpSuit, trumpRank);
+ const groupedCards = cardsByEffectiveSuit.get(suit) || [];
+ groupedCards.push(card);
+ cardsByEffectiveSuit.set(suit, groupedCards);
+ }
+
+ const components = [];
+ for (const groupedCards of cardsByEffectiveSuit.values()) {
+ const parsed = parseThrowCombination(groupedCards, trumpSuit, trumpRank, activeRule);
+ if (parsed.valid && parsed.components.length > 0) {
+ components.push(...parsed.components.map(normalizeSuitlessComponent));
+ } else {
+ components.push(...groupedCards.map(() => ({ type: PatternTypes.SINGLE, length: 1 })));
+ }
+ }
+ return components;
+}
+
+/**
+ * 将一次出牌归一化为忽略花色、点数和牌力的牌型结构。
+ * 甩牌与不成标准牌型的跟牌会拆成拖拉机、对子和单牌组件,确保
+ * “两个对子”“一对加两单”“四张单牌”不会被误判为同一牌型。
+ */
+export function getSuitlessPatternProfile(
+ play,
+ trumpSuit,
+ trumpRank,
+ activeRule = null
+) {
+ const cards = play?.cards || [];
+ const pattern = play?.pattern || detectPattern(cards, trumpSuit, trumpRank, activeRule);
+ const directlyComparableTypes = new Set([
+ PatternTypes.SINGLE,
+ PatternTypes.PAIR,
+ PatternTypes.TRACTOR,
+ PatternTypes.STRAIGHT_FLUSH,
+ PatternTypes.TAI_CHI_FOUR_SYMBOLS,
+ PatternTypes.BELT_AND_ROAD
+ ]);
+
+ let components;
+ if (directlyComparableTypes.has(pattern?.type)) {
+ components = [normalizeSuitlessComponent(pattern)];
+ } else if (
+ pattern?.type === PatternTypes.THROW
+ && Array.isArray(pattern.components)
+ && pattern.components.length > 0
+ ) {
+ components = pattern.components.map(normalizeSuitlessComponent);
+ } else {
+ components = decomposeSuitlessPatternCards(cards, trumpSuit, trumpRank, activeRule);
+ }
+
+ components.sort((left, right) => {
+ const leftKey = `${left.type}:${left.length}`;
+ const rightKey = `${right.type}:${right.length}`;
+ return leftKey.localeCompare(rightKey);
+ });
+ const key = components.map(component => `${component.type}:${component.length}`).join('|');
+ const groupedLabels = new Map();
+ for (const component of components) {
+ const componentKey = `${component.type}:${component.length}`;
+ const baseName = SUITLESS_PATTERN_NAMES[component.type] || '单牌';
+ const label = component.type === PatternTypes.TRACTOR
+ ? `${component.length}张${baseName}`
+ : baseName;
+ const group = groupedLabels.get(componentKey) || { label, count: 0 };
+ group.count += 1;
+ groupedLabels.set(componentKey, group);
+ }
+
+ return {
+ key,
+ label: Array.from(groupedLabels.values())
+ .map(group => group.count > 1 ? `${group.label}×${group.count}` : group.label)
+ .join('+') || '无牌型',
+ components
+ };
+}
+
/**
* 检查某个组件是否是最大的(其他玩家手牌中没有能压过它的牌)
* @param {Object} component - 组件 { type, cards, strength, length }
@@ -967,11 +2659,47 @@ export function parseThrowCombination(cards, trumpSuit, trumpRank) {
* @param {String} trumpRank - 级牌
* @returns {Boolean} true表示是最大的,false表示不是
*/
-export function isComponentLargest(component, otherPlayersCards, trumpSuit, trumpRank) {
+export function isComponentLargest(
+ component,
+ otherPlayersCards,
+ trumpSuit,
+ trumpRank,
+ activeRule = null
+) {
const componentSuit = getEffectiveSuit(component.cards[0], trumpSuit, trumpRank);
+ const handsToCheck = otherPlayersCards;
// 遍历所有其他玩家
- for (const playerCards of otherPlayersCards) {
+ for (const playerCards of handsToCheck) {
+ if (
+ activeRule?.id === 'cluster_analysis'
+ && canClusterAnalysisBeatComponent(
+ playerCards,
+ component,
+ trumpSuit,
+ trumpRank,
+ activeRule
+ )
+ ) {
+ return false;
+ }
+ if (activeRule?.id === 'cluster_analysis') continue;
+ if (
+ activeRule?.id === 'forbidden_magic'
+ && playerCards.some(card => card?.isForbiddenMagicDemoted)
+ ) {
+ if (canForbiddenMagicBeatComponent(
+ playerCards,
+ component,
+ trumpSuit,
+ trumpRank,
+ activeRule
+ )) {
+ return false;
+ }
+ continue;
+ }
+
// 获取该玩家同花色的牌
const sameSuitCards = playerCards.filter(c =>
getEffectiveSuit(c, trumpSuit, trumpRank) === componentSuit
@@ -983,13 +2711,13 @@ export function isComponentLargest(component, otherPlayersCards, trumpSuit, trum
if (component.type === PatternTypes.SINGLE) {
// 单牌:检查是否有更大的单牌
for (const card of sameSuitCards) {
- if (getCardStrength(card, trumpSuit, trumpRank) > component.strength) {
+ if (getCardStrength(card, trumpSuit, trumpRank, activeRule) > component.strength) {
return false; // 有更大的单牌
}
}
} else if (component.type === PatternTypes.PAIR) {
// 对子:检查是否有更大的对子
- const pairs = findPairsInCards(sameSuitCards, trumpSuit, trumpRank);
+ const pairs = findPairsInCards(sameSuitCards, trumpSuit, trumpRank, activeRule);
for (const pair of pairs) {
if (pair.strength > component.strength) {
return false; // 有更大的对子
@@ -1000,7 +2728,14 @@ export function isComponentLargest(component, otherPlayersCards, trumpSuit, trum
const requiredPairs = component.length / 2;
// 找出所有可能的拖拉机组合
- if (hasLargerTractor(sameSuitCards, requiredPairs, component.strength, trumpSuit, trumpRank)) {
+ if (hasLargerTractor(
+ sameSuitCards,
+ requiredPairs,
+ component.strength,
+ trumpSuit,
+ trumpRank,
+ activeRule
+ )) {
return false; // 有更大的拖拉机
}
}
@@ -1012,9 +2747,16 @@ export function isComponentLargest(component, otherPlayersCards, trumpSuit, trum
/**
* 检查是否有更大的拖拉机
*/
-function hasLargerTractor(cards, requiredPairs, targetStrength, trumpSuit, trumpRank) {
+function hasLargerTractor(
+ cards,
+ requiredPairs,
+ targetStrength,
+ trumpSuit,
+ trumpRank,
+ activeRule
+) {
// 找出所有对子
- const pairs = findPairsInCards(cards, trumpSuit, trumpRank);
+ const pairs = findPairsInCards(cards, trumpSuit, trumpRank, activeRule);
if (pairs.length < requiredPairs) return false;
@@ -1028,8 +2770,13 @@ function hasLargerTractor(cards, requiredPairs, targetStrength, trumpSuit, trump
// 检查是否连续
let isConsecutive = true;
for (let j = 0; j < candidatePairs.length - 1; j++) {
- const diff = candidatePairs[j + 1].strength - candidatePairs[j].strength;
- if (diff > 2) { // 允许一定的容差
+ if (!arePairsConsecutive(
+ candidatePairs[j],
+ candidatePairs[j + 1],
+ trumpSuit,
+ trumpRank,
+ activeRule
+ )) {
isConsecutive = false;
break;
}
@@ -1055,9 +2802,15 @@ function hasLargerTractor(cards, requiredPairs, targetStrength, trumpSuit, trump
* @param {String} trumpRank - 级牌
* @returns {Object} { success, components, failedComponents, forcedCards }
*/
-export function validateThrow(cards, otherPlayersCards, trumpSuit, trumpRank) {
+export function validateThrow(
+ cards,
+ otherPlayersCards,
+ trumpSuit,
+ trumpRank,
+ activeRule = null
+) {
// 解析甩牌组合
- const parsed = parseThrowCombination(cards, trumpSuit, trumpRank);
+ const parsed = parseThrowCombination(cards, trumpSuit, trumpRank, activeRule);
if (!parsed.valid) {
return {
@@ -1075,7 +2828,13 @@ export function validateThrow(cards, otherPlayersCards, trumpSuit, trumpRank) {
const failedComponents = [];
for (const component of components) {
- if (!isComponentLargest(component, otherPlayersCards, trumpSuit, trumpRank)) {
+ if (!isComponentLargest(
+ component,
+ otherPlayersCards,
+ trumpSuit,
+ trumpRank,
+ activeRule
+ )) {
failedComponents.push(component);
}
}
@@ -1134,7 +2893,14 @@ function findSmallestComponent(components) {
* @param {String} trumpRank - 级牌
* @returns {Object} { valid, message, pattern }
*/
-function validateFollowingThrow(cards, handCards, leadingPattern, trumpSuit, trumpRank) {
+function validateFollowingThrow(
+ cards,
+ handCards,
+ leadingPattern,
+ trumpSuit,
+ trumpRank,
+ activeRule
+) {
const leadingSuit = leadingPattern.suit;
const leadingComponents = leadingPattern.components;
@@ -1161,7 +2927,7 @@ function validateFollowingThrow(cards, handCards, leadingPattern, trumpSuit, tru
}
// 解析跟牌的组合
- const followParsed = parseThrowCombination(cards, trumpSuit, trumpRank);
+ const followParsed = parseThrowCombination(cards, trumpSuit, trumpRank, activeRule);
// 如果全部是同花色,需要检查牌型匹配
if (playedSameSuit.length >= leadingPattern.length && sameSuitCards.length >= leadingPattern.length) {
@@ -1177,46 +2943,44 @@ function validateFollowingThrow(cards, handCards, leadingPattern, trumpSuit, tru
leadingRequirements[component.type].push(component);
}
- // 检查手牌中是否有对应的牌型
- const sameSuitParsed = parseThrowCombination(sameSuitCards, trumpSuit, trumpRank);
- const availableComponents = {
- [PatternTypes.TRACTOR]: [],
- [PatternTypes.PAIR]: [],
- [PatternTypes.SINGLE]: []
- };
-
- for (const component of sameSuitParsed.components) {
- availableComponents[component.type].push(component);
- }
-
// 验证跟牌是否匹配首发的牌型要求
const errors = [];
+ const availablePairs = findPairsInCards(sameSuitCards, trumpSuit, trumpRank, activeRule);
+ const followPairs = findPairsInCards(cards, trumpSuit, trumpRank, activeRule);
- // 检查拖拉机
+ // 有完整拖跟完整拖;没有完整拖时,仍必须优先跟能组成的最长短拖。
for (const leadTractor of leadingRequirements[PatternTypes.TRACTOR]) {
- const matchingTractors = availableComponents[PatternTypes.TRACTOR].filter(
- t => t.length === leadTractor.length
+ const requiredPairCount = leadTractor.length / 2;
+ const availableLongest = getLongestTractorPairCount(
+ availablePairs,
+ requiredPairCount,
+ trumpSuit,
+ trumpRank,
+ activeRule
);
- if (matchingTractors.length > 0) {
- // 手牌中有相同长度的拖拉机,必须出
- const followTractors = followParsed.components.filter(
- c => c.type === PatternTypes.TRACTOR && c.length === leadTractor.length
- );
- if (followTractors.length === 0) {
- errors.push(`手牌中有同花色${leadTractor.length}张的拖拉机,必须出`);
- }
+ const followedLongest = getLongestTractorPairCount(
+ followPairs,
+ requiredPairCount,
+ trumpSuit,
+ trumpRank,
+ activeRule
+ );
+
+ if (availableLongest >= 2 && followedLongest < availableLongest) {
+ errors.push(`手牌中有同花色${availableLongest * 2}张的拖拉机,必须优先出`);
}
}
- // 检查对子
- const leadPairsCount = leadingRequirements[PatternTypes.PAIR].length;
- const availablePairsCount = availableComponents[PatternTypes.PAIR].length;
- if (leadPairsCount > 0 && availablePairsCount > 0) {
- const followPairsCount = followParsed.components.filter(c => c.type === PatternTypes.PAIR).length;
- const requiredPairs = Math.min(leadPairsCount, availablePairsCount);
- if (followPairsCount < requiredPairs) {
- errors.push(`手牌中有${availablePairsCount}个同花色对子,必须至少出${requiredPairs}个`);
- }
+ // 拖拉机中的对子和独立对子都计入对子义务。
+ const totalRequiredPairs =
+ leadingRequirements[PatternTypes.PAIR].length +
+ leadingRequirements[PatternTypes.TRACTOR].reduce(
+ (total, tractor) => total + tractor.length / 2,
+ 0
+ );
+ const requiredPairs = Math.min(availablePairs.length, totalRequiredPairs);
+ if (followPairs.length < requiredPairs) {
+ errors.push(`手牌中有${availablePairs.length}个同花色对子,必须至少出${requiredPairs}个`);
}
if (errors.length > 0) {
diff --git a/tractor-game-simulator/server/src/utils/constants.js b/tractor-game-simulator/server/src/utils/constants.js
index b907e53..ed25b0b 100644
--- a/tractor-game-simulator/server/src/utils/constants.js
+++ b/tractor-game-simulator/server/src/utils/constants.js
@@ -16,6 +16,7 @@ export const PlayModes = {
// 出牌顺序
export const TurnOrders = {
+ CLOCKWISE: 'clockwise',
COUNTER_CLOCKWISE: 'counter-clockwise',
CUSTOM: 'custom'
};
@@ -32,6 +33,10 @@ export const Suits = {
// 牌面值
export const Ranks = {
+ MINUS_TWO: '-2',
+ MINUS_ONE: '-1',
+ ZERO: '0',
+ ONE: '1',
TWO: '2',
THREE: '3',
FOUR: '4',
@@ -45,10 +50,35 @@ export const Ranks = {
QUEEN: 'Q',
KING: 'K',
ACE: 'A',
+ BONUS_ONE: 'B',
+ BONUS_TWO: 'C',
+ BONUS_THREE: 'D',
+ NO_TRUMP_MINUS: 'M',
SMALL_JOKER: 'small_joker',
- BIG_JOKER: 'big_joker'
+ BIG_JOKER: 'big_joker',
+ COUNTY_PRINCE_JOKER: 'county_prince_joker',
+ PRINCE_JOKER: 'prince_joker',
+ WHITE_JOKER: 'white_joker'
};
+export const STANDARD_ORDINARY_RANKS = Object.freeze([
+ Ranks.TWO, Ranks.THREE, Ranks.FOUR, Ranks.FIVE, Ranks.SIX,
+ Ranks.SEVEN, Ranks.EIGHT, Ranks.NINE, Ranks.TEN,
+ Ranks.JACK, Ranks.QUEEN, Ranks.KING, Ranks.ACE
+]);
+
+export const PROMOTED_ORDINARY_RANKS = Object.freeze([
+ Ranks.BONUS_ONE,
+ Ranks.BONUS_TWO,
+ Ranks.BONUS_THREE
+]);
+
+export const EXTENDED_ORDINARY_RANKS = Object.freeze([
+ Ranks.MINUS_TWO, Ranks.MINUS_ONE, Ranks.ZERO, Ranks.ONE,
+ ...STANDARD_ORDINARY_RANKS,
+ ...PROMOTED_ORDINARY_RANKS
+]);
+
// 花色排序
export const SUIT_ORDER = {
[Suits.HEARTS]: 0,
@@ -60,6 +90,10 @@ export const SUIT_ORDER = {
// 牌面值排序
export const RANK_ORDER = {
+ [Ranks.MINUS_TWO]: -2,
+ [Ranks.MINUS_ONE]: -1,
+ [Ranks.ZERO]: 0,
+ [Ranks.ONE]: 1,
[Ranks.TWO]: 2,
[Ranks.THREE]: 3,
[Ranks.FOUR]: 4,
@@ -73,8 +107,15 @@ export const RANK_ORDER = {
[Ranks.QUEEN]: 12,
[Ranks.KING]: 13,
[Ranks.ACE]: 14,
+ [Ranks.BONUS_ONE]: 15,
+ [Ranks.BONUS_TWO]: 16,
+ [Ranks.BONUS_THREE]: 17,
+ [Ranks.NO_TRUMP_MINUS]: 18,
[Ranks.SMALL_JOKER]: 100,
- [Ranks.BIG_JOKER]: 101
+ [Ranks.BIG_JOKER]: 101,
+ [Ranks.COUNTY_PRINCE_JOKER]: 102,
+ [Ranks.PRINCE_JOKER]: 103,
+ [Ranks.WHITE_JOKER]: 104
};
// Bot类型
@@ -90,9 +131,11 @@ export const DEFAULT_CONFIG = {
minDealInterval: 10,
turnOrder: TurnOrders.COUNTER_CLOCKWISE,
customTurnOrder: [0, 1, 2, 3],
- minPlayers: 2,
+ minPlayers: 4,
maxPlayers: 4,
- botType: BotTypes.SIMPLE // 默认使用简单bot
+ botType: BotTypes.WHO_DESIGNED,
+ testMode: false,
+ testRuleId: null
};
// 默认玩家属性
diff --git a/tractor-game-simulator/server/src/utils/ironEvidenceUtils.js b/tractor-game-simulator/server/src/utils/ironEvidenceUtils.js
new file mode 100644
index 0000000..37f1f59
--- /dev/null
+++ b/tractor-game-simulator/server/src/utils/ironEvidenceUtils.js
@@ -0,0 +1,43 @@
+export const IronEvidenceModes = Object.freeze({
+ MULTIPLY: 'multiply',
+ ZERO: 'zero'
+});
+
+export function isIronEvidenceSpecialCard(card) {
+ return Boolean(card) && (
+ (card.suit === 'joker' && card.rank === 'small_joker')
+ || (card.suit === 'hearts' && card.rank === 'Q')
+ || (card.suit === 'spades' && card.rank === 'J')
+ || (card.suit === 'clubs' && card.rank === 'J')
+ );
+}
+
+export function isIronEvidenceBigJoker(card) {
+ return card?.suit === 'joker' && card?.rank === 'big_joker';
+}
+
+export function getIronEvidenceRoundMode(bigJokersPlayedCount = 0) {
+ return Number(bigJokersPlayedCount) >= 2
+ ? IronEvidenceModes.ZERO
+ : IronEvidenceModes.MULTIPLY;
+}
+
+export function calculateIronEvidenceRoundScoring(
+ cards,
+ baseRoundPoints,
+ roundMode = IronEvidenceModes.MULTIPLY
+) {
+ const specialCardCount = (cards || []).filter(isIronEvidenceSpecialCard).length;
+ const multiplier = specialCardCount === 0
+ ? 1
+ : roundMode === IronEvidenceModes.ZERO
+ ? 0
+ : 1 + specialCardCount;
+ return {
+ mode: roundMode,
+ specialCardCount,
+ multiplier,
+ baseRoundPoints,
+ roundPoints: baseRoundPoints * multiplier
+ };
+}
diff --git a/tractor-game-simulator/server/src/utils/oneCountryTwoSystemsUtils.js b/tractor-game-simulator/server/src/utils/oneCountryTwoSystemsUtils.js
new file mode 100644
index 0000000..97a541b
--- /dev/null
+++ b/tractor-game-simulator/server/src/utils/oneCountryTwoSystemsUtils.js
@@ -0,0 +1,148 @@
+import { Suits } from './constants.js';
+
+const PLAIN_SUITS = new Set([
+ Suits.HEARTS,
+ Suits.DIAMONDS,
+ Suits.CLUBS,
+ Suits.SPADES
+]);
+
+export function getOneCountryTeamIndex(playerIndex) {
+ if (!Number.isInteger(playerIndex) || playerIndex < 0) return null;
+ return playerIndex % 2;
+}
+
+export function isOneCountryJokerDeclaration(declaration) {
+ return declaration?.suit === Suits.JOKER || declaration?.suit === 'joker';
+}
+
+function getTeamDeclaration(declarationsByTeam, teamIndex) {
+ if (declarationsByTeam instanceof Map) {
+ return declarationsByTeam.get(teamIndex) || declarationsByTeam.get(String(teamIndex)) || null;
+ }
+ return declarationsByTeam?.[teamIndex] || declarationsByTeam?.[String(teamIndex)] || null;
+}
+
+export function resolveOneCountryTwoSystems(declarationsByTeam, dealerPlayerIndex) {
+ const dealerTeamIndex = getOneCountryTeamIndex(dealerPlayerIndex);
+ if (dealerTeamIndex === null) return null;
+
+ const declarations = {
+ 0: getTeamDeclaration(declarationsByTeam, 0),
+ 1: getTeamDeclaration(declarationsByTeam, 1)
+ };
+ const hasJokerDeclaration = Object.values(declarations).some(isOneCountryJokerDeclaration);
+ const declaredSuits = Object.values(declarations)
+ .map(declaration => declaration?.suit)
+ .filter(suit => PLAIN_SUITS.has(suit));
+ const uniqueSuits = [...new Set(declaredSuits)];
+ const attackerTeamIndex = 1 - dealerTeamIndex;
+
+ if (hasJokerDeclaration || uniqueSuits.length === 0) {
+ return {
+ dealerTeamIndex,
+ attackerTeamIndex,
+ dealerSuit: null,
+ attackerSuit: null,
+ canonicalTrumpSuit: Suits.NO_TRUMP,
+ teamTrumpSuits: { 0: null, 1: null },
+ hasJokerDeclaration,
+ hasDistinctTeamSuits: false,
+ isNoTrump: true
+ };
+ }
+
+ if (uniqueSuits.length === 1) {
+ const sharedSuit = uniqueSuits[0];
+ return {
+ dealerTeamIndex,
+ attackerTeamIndex,
+ dealerSuit: sharedSuit,
+ attackerSuit: sharedSuit,
+ canonicalTrumpSuit: sharedSuit,
+ teamTrumpSuits: { 0: sharedSuit, 1: sharedSuit },
+ hasJokerDeclaration: false,
+ hasDistinctTeamSuits: false,
+ isNoTrump: false
+ };
+ }
+
+ const dealerSuit = declarations[dealerTeamIndex]?.suit;
+ const attackerSuit = declarations[attackerTeamIndex]?.suit;
+ return {
+ dealerTeamIndex,
+ attackerTeamIndex,
+ dealerSuit,
+ attackerSuit,
+ canonicalTrumpSuit: dealerSuit,
+ teamTrumpSuits: {
+ 0: declarations[0]?.suit || null,
+ 1: declarations[1]?.suit || null
+ },
+ hasJokerDeclaration: false,
+ hasDistinctTeamSuits: dealerSuit !== attackerSuit,
+ isNoTrump: false
+ };
+}
+
+function cloneCardWithSuit(card, suit) {
+ const prototype = Object.getPrototypeOf(card) || Object.prototype;
+ return Object.assign(Object.create(prototype), card, {
+ suit,
+ oneCountryOriginalSuit: card.suit
+ });
+}
+
+/**
+ * 将某名玩家的实体牌投影到庄家方的规范花色坐标。
+ * 只有双方亮出不同花色时,闲家方才互换两种花色。
+ */
+export function mapOneCountryCard(card, playerIndex, resolution) {
+ if (!card || !resolution?.hasDistinctTeamSuits) return card;
+ const playerTeamIndex = getOneCountryTeamIndex(playerIndex);
+ if (playerTeamIndex === null || playerTeamIndex === resolution.dealerTeamIndex) return card;
+
+ if (card.suit === resolution.attackerSuit) {
+ return cloneCardWithSuit(card, resolution.dealerSuit);
+ }
+ if (card.suit === resolution.dealerSuit) {
+ return cloneCardWithSuit(card, resolution.attackerSuit);
+ }
+ return card;
+}
+
+export function mapOneCountryCards(cards, playerIndex, resolution) {
+ return (cards || []).map(card => mapOneCountryCard(card, playerIndex, resolution));
+}
+
+export function serializeOneCountryDeclaration(declaration) {
+ if (!declaration) return null;
+ return {
+ ...declaration,
+ cards: (declaration.cards || []).map(card => card?.toJSON ? card.toJSON() : card)
+ };
+}
+
+export function getOneCountryPublicState(gameState) {
+ if (!gameState) return null;
+ return {
+ declarationsByTeam: Object.fromEntries(
+ [0, 1]
+ .map(teamIndex => [
+ teamIndex,
+ serializeOneCountryDeclaration(getTeamDeclaration(
+ gameState.oneCountryDeclarationsByTeam,
+ teamIndex
+ ))
+ ])
+ .filter(([, declaration]) => declaration)
+ ),
+ resolved: gameState.oneCountryResolved ? {
+ ...gameState.oneCountryResolved,
+ teamTrumpSuits: { ...gameState.oneCountryResolved.teamTrumpSuits }
+ } : null,
+ hasJokerDeclaration: [0, 1].some(teamIndex => isOneCountryJokerDeclaration(
+ getTeamDeclaration(gameState.oneCountryDeclarationsByTeam, teamIndex)
+ ))
+ };
+}
diff --git a/tractor-game-simulator/server/src/utils/pokerUtils.js b/tractor-game-simulator/server/src/utils/pokerUtils.js
new file mode 100644
index 0000000..31194c0
--- /dev/null
+++ b/tractor-game-simulator/server/src/utils/pokerUtils.js
@@ -0,0 +1,226 @@
+import { Ranks, Suits, STANDARD_ORDINARY_RANKS } from './constants.js';
+
+const POKER_SUITS = new Set([
+ Suits.HEARTS,
+ Suits.DIAMONDS,
+ Suits.CLUBS,
+ Suits.SPADES
+]);
+
+const POKER_RANK_VALUES = Object.freeze({
+ [Ranks.TWO]: 2,
+ [Ranks.THREE]: 3,
+ [Ranks.FOUR]: 4,
+ [Ranks.FIVE]: 5,
+ [Ranks.SIX]: 6,
+ [Ranks.SEVEN]: 7,
+ [Ranks.EIGHT]: 8,
+ [Ranks.NINE]: 9,
+ [Ranks.TEN]: 10,
+ [Ranks.JACK]: 11,
+ [Ranks.QUEEN]: 12,
+ [Ranks.KING]: 13,
+ [Ranks.ACE]: 14
+});
+
+export const POKER_CATEGORY_NAMES = Object.freeze([
+ '高牌',
+ '一对',
+ '两对',
+ '三条',
+ '顺子',
+ '同花',
+ '葫芦',
+ '四条',
+ '同花顺',
+ '五条'
+]);
+
+export function comparePokerScores(left, right) {
+ const length = Math.max(left?.length || 0, right?.length || 0);
+ for (let index = 0; index < length; index++) {
+ const difference = (left?.[index] || 0) - (right?.[index] || 0);
+ if (difference !== 0) return Math.sign(difference);
+ }
+ return 0;
+}
+
+function getStraightHighCard(rankValues) {
+ const uniqueRanks = [...new Set(rankValues)].sort((left, right) => right - left);
+ if (uniqueRanks.length !== 5) return null;
+ if (
+ uniqueRanks[0] === 14
+ && uniqueRanks[1] === 5
+ && uniqueRanks[2] === 4
+ && uniqueRanks[3] === 3
+ && uniqueRanks[4] === 2
+ ) {
+ return 5;
+ }
+ return uniqueRanks[0] - uniqueRanks[4] === 4 ? uniqueRanks[0] : null;
+}
+
+function evaluateResolvedFiveCardPokerHand(cards) {
+ if (!Array.isArray(cards) || cards.length !== 5) return null;
+ const rankValues = cards.map(card => POKER_RANK_VALUES[card?.rank]);
+ if (rankValues.some(value => !value) || cards.some(card => !POKER_SUITS.has(card?.suit))) {
+ return null;
+ }
+
+ const rankCounts = new Map();
+ rankValues.forEach(value => rankCounts.set(value, (rankCounts.get(value) || 0) + 1));
+ const groups = [...rankCounts.entries()]
+ .map(([rank, count]) => ({ rank, count }))
+ .sort((left, right) => right.count - left.count || right.rank - left.rank);
+ const sortedRanks = [...rankValues].sort((left, right) => right - left);
+ const isFlush = cards.every(card => card.suit === cards[0].suit);
+ const straightHighCard = getStraightHighCard(rankValues);
+
+ let category = 0;
+ let tieBreak = sortedRanks;
+ if (groups[0].count === 5) {
+ category = 9;
+ tieBreak = [groups[0].rank];
+ } else if (isFlush && straightHighCard) {
+ category = 8;
+ tieBreak = [straightHighCard];
+ } else if (groups[0].count === 4) {
+ category = 7;
+ tieBreak = [groups[0].rank, groups[1].rank];
+ } else if (groups[0].count === 3 && groups[1].count === 2) {
+ category = 6;
+ tieBreak = [groups[0].rank, groups[1].rank];
+ } else if (isFlush) {
+ category = 5;
+ } else if (straightHighCard) {
+ category = 4;
+ tieBreak = [straightHighCard];
+ } else if (groups[0].count === 3) {
+ category = 3;
+ tieBreak = [
+ groups[0].rank,
+ ...groups.filter(group => group.count === 1).map(group => group.rank).sort((a, b) => b - a)
+ ];
+ } else if (groups[0].count === 2 && groups[1].count === 2) {
+ category = 2;
+ const pairRanks = groups
+ .filter(group => group.count === 2)
+ .map(group => group.rank)
+ .sort((a, b) => b - a);
+ tieBreak = [pairRanks[0], pairRanks[1], groups.find(group => group.count === 1).rank];
+ } else if (groups[0].count === 2) {
+ category = 1;
+ tieBreak = [
+ groups[0].rank,
+ ...groups.filter(group => group.count === 1).map(group => group.rank).sort((a, b) => b - a)
+ ];
+ }
+
+ return {
+ category,
+ categoryName: POKER_CATEGORY_NAMES[category],
+ score: [category, ...tieBreak],
+ cards: [...cards]
+ };
+}
+
+function resolvePokerWildcards(cards) {
+ const jokers = cards.filter(card => card?.suit === Suits.JOKER);
+ if (jokers.length === 0) return evaluateResolvedFiveCardPokerHand(cards);
+
+ const ordinaryCards = cards.filter(card => card?.suit !== Suits.JOKER);
+ if (ordinaryCards.some(card => !POKER_SUITS.has(card?.suit) || !POKER_RANK_VALUES[card?.rank])) {
+ return null;
+ }
+
+ // 花色只会影响同花与同花顺。普通牌同花时,让所有王补成该花色即可覆盖最优解;
+ // 普通牌花色不一致时无论怎样转换王都无法组成同花,使用固定花色即可。
+ const wildcardSuit = ordinaryCards.length > 0
+ && ordinaryCards.every(card => card.suit === ordinaryCards[0].suit)
+ ? ordinaryCards[0].suit
+ : Suits.HEARTS;
+ const jokerIndexes = cards
+ .map((card, index) => card?.suit === Suits.JOKER ? index : -1)
+ .filter(index => index >= 0);
+ const candidateCards = [...cards];
+ let bestHand = null;
+
+ // 四张王已经可以把唯一普通牌补成五条;五张全是王时直接取最高的五条 A。
+ // 这是牌型的理论上限,也避免特殊牌堆下对 13^5 种等价替代做无谓枚举。
+ if (jokers.length >= 4) {
+ const targetRank = ordinaryCards[0]?.rank || Ranks.ACE;
+ const resolvedCards = cards.map(card => card?.suit === Suits.JOKER ? {
+ ...card,
+ suit: wildcardSuit,
+ rank: targetRank,
+ originalSuit: card.originalSuit || card.suit,
+ originalRank: card.originalRank || card.rank,
+ secondBattlefieldWildcard: true
+ } : card);
+ return evaluateResolvedFiveCardPokerHand(resolvedCards);
+ }
+
+ const visit = jokerIndex => {
+ if (jokerIndex === jokerIndexes.length) {
+ const candidate = evaluateResolvedFiveCardPokerHand(candidateCards);
+ if (!bestHand || comparePokerScores(candidate.score, bestHand.score) > 0) {
+ bestHand = candidate;
+ }
+ return;
+ }
+
+ const cardIndex = jokerIndexes[jokerIndex];
+ const sourceCard = cards[cardIndex];
+ for (const rank of STANDARD_ORDINARY_RANKS) {
+ candidateCards[cardIndex] = {
+ ...sourceCard,
+ suit: wildcardSuit,
+ rank,
+ originalSuit: sourceCard.originalSuit || sourceCard.suit,
+ originalRank: sourceCard.originalRank || sourceCard.rank,
+ secondBattlefieldWildcard: true
+ };
+ visit(jokerIndex + 1);
+ }
+ candidateCards[cardIndex] = sourceCard;
+ };
+
+ visit(0);
+ return bestHand;
+}
+
+export function evaluateFiveCardPokerHand(cards) {
+ if (!Array.isArray(cards) || cards.length !== 5) return null;
+ return resolvePokerWildcards(cards);
+}
+
+export function evaluateBestPokerHand(cards) {
+ // 原牌局使用双副牌,因此同花色同点数的两个实体仍分别参与牌型;所有王均作万能牌。
+ const eligibleCards = (cards || []).filter(
+ card => (
+ (POKER_SUITS.has(card?.suit) && POKER_RANK_VALUES[card?.rank])
+ || card?.suit === Suits.JOKER
+ )
+ );
+ if (eligibleCards.length < 5) return null;
+
+ let bestHand = null;
+ const selected = [];
+ const visit = startIndex => {
+ if (selected.length === 5) {
+ const candidate = evaluateFiveCardPokerHand(selected);
+ if (!bestHand || comparePokerScores(candidate.score, bestHand.score) > 0) {
+ bestHand = candidate;
+ }
+ return;
+ }
+ const cardsNeeded = 5 - selected.length;
+ for (let index = startIndex; index <= eligibleCards.length - cardsNeeded; index++) {
+ selected.push(eligibleCards[index]);
+ visit(index + 1);
+ selected.pop();
+ }
+ };
+ visit(0);
+ return bestHand;
+}
diff --git a/tractor-game-simulator/server/src/utils/scoringUtils.js b/tractor-game-simulator/server/src/utils/scoringUtils.js
index f32e0d5..dda9e2e 100644
--- a/tractor-game-simulator/server/src/utils/scoringUtils.js
+++ b/tractor-game-simulator/server/src/utils/scoringUtils.js
@@ -1,37 +1,74 @@
import { Ranks, levelToRank } from './constants.js';
import { PatternTypes } from './cardPatternUtils.js';
+const METICULOUS_ACCOUNTING_POINT_VALUES = Object.freeze({
+ A: 1,
+ '2': 2,
+ '3': 3,
+ '4': 4,
+ '5': 5,
+ '6': 6,
+ '7': 7
+});
+
+function getScoringRank(card) {
+ if (card?.isNinePrincesPromoted && card?.ninePrincesScoringRank) {
+ return card.ninePrincesScoringRank;
+ }
+ // 临时转化只改变牌面与牌力;分值始终来自被转化的实体牌。
+ const usesOriginalRank = card?.isDivineWeaponTransformed
+ || card?.isJokerSubstitution
+ || card?.isClusterAnalysisTransformed
+ || card?.isForbiddenMagicDemoted
+ || card?.isStrengthCompensated
+ || card?.isDefenseAsOffenseBoosted
+ || card?.isTeammateCheered
+ || card?.isAfterglowBoosted
+ || card?.isThreeTigersTransformed
+ || card?.isNinePrincesPromoted;
+ return usesOriginalRank && card?.originalRank
+ ? card.originalRank
+ : card?.rank;
+}
+
/**
* 获取单张牌的分数
* @param {Object} card - 牌对象
* @returns {Number} 分数 (0, 5, 或 10)
*/
export function getCardPoints(card) {
+ if (card?.isRiceToMulberryTransformed) return 0;
+ const scoringRank = getScoringRank(card);
// 5 = 5分
- if (card.rank === Ranks.FIVE || card.rank === '5') {
+ if (scoringRank === Ranks.FIVE || scoringRank === '5') {
return 5;
}
// 10 = 10分
- if (card.rank === Ranks.TEN || card.rank === '10') {
+ if (scoringRank === Ranks.TEN || scoringRank === '10') {
return 10;
}
// K = 10分
- if (card.rank === Ranks.KING || card.rank === 'K') {
+ if (scoringRank === Ranks.KING || scoringRank === 'K') {
return 10;
}
return 0;
}
+/** “锱铢必较”中 A、2、3、4、5、6、7 分别计 1 至 7 分。 */
+export function getMeticulousAccountingCardPoints(card) {
+ return METICULOUS_ACCOUNTING_POINT_VALUES[String(getScoringRank(card))] || 0;
+}
+
/**
* 计算一组牌的总分
* @param {Array} cards - 牌数组
* @returns {Number} 总分
*/
-export function calculateRoundPoints(cards) {
+export function calculateRoundPoints(cards, pointResolver = getCardPoints) {
if (!cards || cards.length === 0) {
return 0;
}
- return cards.reduce((total, card) => total + getCardPoints(card), 0);
+ return cards.reduce((total, card) => total + pointResolver(card), 0);
}
/**
@@ -39,11 +76,11 @@ export function calculateRoundPoints(cards) {
* @param {Array} cards - 牌数组
* @returns {Array} 分数牌数组
*/
-export function extractPointCards(cards) {
+export function extractPointCards(cards, pointResolver = getCardPoints) {
if (!cards || cards.length === 0) {
return [];
}
- return cards.filter(card => getCardPoints(card) > 0);
+ return cards.filter(card => pointResolver(card) > 0);
}
/**
@@ -167,8 +204,8 @@ function calculateThrowMultiplier(leadingPattern) {
* @param {Array} bottomCards - 底牌数组
* @returns {Number} 底牌总分
*/
-export function calculateBottomPoints(bottomCards) {
- return calculateRoundPoints(bottomCards);
+export function calculateBottomPoints(bottomCards, pointResolver = getCardPoints) {
+ return calculateRoundPoints(bottomCards, pointResolver);
}
/**
@@ -189,10 +226,20 @@ export function generateScoringSummary(params) {
bottomCards,
attackerWonLastRound,
bottomMultiplier,
- bottomPoints
+ bottomPoints,
+ bottomScoreGained: suppliedBottomScoreGained = null,
+ ambushRank = null,
+ ambushCardCount = 0,
+ ambushPoints = 0,
+ ambushScoreDelta = 0,
+ ambushAttackerNetCardDelta = 0,
+ ambushAttackerNetCardCount = 0,
+ ambushRevealedFromBottom = false
} = params;
- const bottomScoreGained = attackerWonLastRound ? bottomPoints * bottomMultiplier : 0;
+ const bottomScoreGained = Number.isFinite(suppliedBottomScoreGained)
+ ? suppliedBottomScoreGained
+ : (attackerWonLastRound ? bottomPoints * bottomMultiplier : 0);
return {
// 闲家收集的分数牌
@@ -205,6 +252,14 @@ export function generateScoringSummary(params) {
bottomMultiplier,
// 从底牌获得的分数
bottomScoreGained,
+ // “十面埋伏”底牌中的反向五分牌结算。
+ ambushRank,
+ ambushCardCount,
+ ambushPoints,
+ ambushScoreDelta,
+ ambushAttackerNetCardDelta,
+ ambushAttackerNetCardCount,
+ ambushRevealedFromBottom,
// 闲家总得分
totalScore: attackerScore,
// 是否闲家拿底
diff --git a/tractor-game-simulator/server/src/utils/strengthCompensationUtils.js b/tractor-game-simulator/server/src/utils/strengthCompensationUtils.js
new file mode 100644
index 0000000..846dd59
--- /dev/null
+++ b/tractor-game-simulator/server/src/utils/strengthCompensationUtils.js
@@ -0,0 +1,131 @@
+import {
+ EXTENDED_ORDINARY_RANKS,
+ PROMOTED_ORDINARY_RANKS,
+ Ranks,
+ Suits
+} from './constants.js';
+
+const STANDARD_SUITS = Object.freeze([
+ Suits.HEARTS,
+ Suits.DIAMONDS,
+ Suits.CLUBS,
+ Suits.SPADES
+]);
+
+function getDelta(delta) {
+ return Number.isFinite(Number(delta)) ? Math.trunc(Number(delta)) : 0;
+}
+
+function shiftWithin(rank, direction, ranks) {
+ const index = ranks.indexOf(rank);
+ if (index === -1) return rank;
+ return ranks[Math.max(0, Math.min(ranks.length - 1, index + direction))];
+}
+
+function getOrdinaryRanks(trumpRank, includePromotedRanks) {
+ return EXTENDED_ORDINARY_RANKS.filter(rank => (
+ String(rank) !== String(trumpRank)
+ && (includePromotedRanks || !PROMOTED_ORDINARY_RANKS.includes(rank))
+ ));
+}
+
+function hasSuitTrump(trumpSuit) {
+ return STANDARD_SUITS.includes(trumpSuit);
+}
+
+function getViceSuit(trumpSuit) {
+ return STANDARD_SUITS.find(suit => suit !== trumpSuit) || Suits.HEARTS;
+}
+
+function shiftFaceOnce(face, trumpSuit, trumpRank, direction) {
+ const suitedTrump = hasSuitTrump(trumpSuit);
+
+ if (face.rank === Ranks.NO_TRUMP_MINUS) {
+ return direction > 0
+ ? { suit: face.suit, rank: trumpRank }
+ : face;
+ }
+
+ if (face.rank === Ranks.WHITE_JOKER) {
+ return direction < 0
+ ? { suit: Suits.JOKER, rank: Ranks.PRINCE_JOKER }
+ : face;
+ }
+ if (face.rank === Ranks.PRINCE_JOKER) {
+ return {
+ suit: Suits.JOKER,
+ rank: direction > 0 ? Ranks.WHITE_JOKER : Ranks.COUNTY_PRINCE_JOKER
+ };
+ }
+ if (face.rank === Ranks.COUNTY_PRINCE_JOKER) {
+ return {
+ suit: Suits.JOKER,
+ rank: direction > 0 ? Ranks.PRINCE_JOKER : Ranks.BIG_JOKER
+ };
+ }
+ if (face.rank === Ranks.BIG_JOKER) {
+ return {
+ suit: Suits.JOKER,
+ rank: direction > 0 ? Ranks.COUNTY_PRINCE_JOKER : Ranks.SMALL_JOKER
+ };
+ }
+ if (face.rank === Ranks.SMALL_JOKER) {
+ if (direction > 0) return { suit: Suits.JOKER, rank: Ranks.BIG_JOKER };
+ return {
+ suit: suitedTrump ? trumpSuit : Suits.HEARTS,
+ rank: trumpRank
+ };
+ }
+
+ const isLevelCard = String(face.rank) === String(trumpRank);
+ if (isLevelCard) {
+ const isMainLevel = suitedTrump && face.suit === trumpSuit;
+ if (direction > 0) {
+ if (isMainLevel || !suitedTrump) {
+ return { suit: Suits.JOKER, rank: Ranks.SMALL_JOKER };
+ }
+ return { suit: trumpSuit, rank: trumpRank };
+ }
+
+ if (isMainLevel) {
+ return { suit: getViceSuit(trumpSuit), rank: trumpRank };
+ }
+ if (!suitedTrump) {
+ return { suit: face.suit, rank: Ranks.NO_TRUMP_MINUS };
+ }
+ const targetSuit = trumpSuit;
+ const targetRanks = getOrdinaryRanks(trumpRank, false);
+ return { suit: targetSuit, rank: targetRanks[targetRanks.length - 1] };
+ }
+
+ if (suitedTrump && face.suit === trumpSuit) {
+ const mainRanks = getOrdinaryRanks(trumpRank, false);
+ const nextRank = shiftWithin(face.rank, direction, mainRanks);
+ if (direction > 0 && face.rank === mainRanks[mainRanks.length - 1]) {
+ return { suit: getViceSuit(trumpSuit), rank: trumpRank };
+ }
+ return { suit: face.suit, rank: nextRank };
+ }
+
+ const sideRanks = getOrdinaryRanks(trumpRank, true);
+ return {
+ suit: face.suit,
+ rank: shiftWithin(face.rank, direction, sideRanks)
+ };
+}
+
+/**
+ * 沿当前牌局的真实牌力序列移动牌面。主牌链为:
+ * 有花色主牌链:主普通牌 < 副级牌 < 主级牌 < 小王 < 大王 < 郡王 < 亲王 < 白王(皇)。
+ * 无主主牌链:M(Minus)< 无主级牌 < 小王 < 大王 < 郡王 < 亲王 < 白王(皇)。
+ * 副牌始终留在副牌类别内,跳过级牌点数,A 之上依次使用 B、C、D,绝不跨入主牌链。
+ */
+export function shiftStrengthCompensationCardFace(card, trumpSuit, trumpRank, delta) {
+ const numericDelta = getDelta(delta);
+ let face = { suit: card.suit, rank: card.rank };
+ const direction = Math.sign(numericDelta);
+ for (let step = 0; step < Math.abs(numericDelta); step++) {
+ face = shiftFaceOnce(face, trumpSuit, trumpRank, direction);
+ }
+ return face;
+}
diff --git a/tractor-game-simulator/server/src/utils/threeTigersUtils.js b/tractor-game-simulator/server/src/utils/threeTigersUtils.js
new file mode 100644
index 0000000..5726d9e
--- /dev/null
+++ b/tractor-game-simulator/server/src/utils/threeTigersUtils.js
@@ -0,0 +1,64 @@
+import {
+ EXTENDED_ORDINARY_RANKS,
+ PROMOTED_ORDINARY_RANKS,
+ Ranks,
+ STANDARD_ORDINARY_RANKS
+} from './constants.js';
+
+const SHIFT = 4;
+const SHIFTABLE_RANKS = Object.freeze(
+ EXTENDED_ORDINARY_RANKS.filter(rank => !PROMOTED_ORDINARY_RANKS.includes(rank))
+);
+
+/**
+ * “降四级”按本局普通牌序列计算,而不是把牌面数字机械减四。
+ * 级牌已从普通牌序列抽走;若原牌恰为级牌,则从级牌下方的普通牌开始向下数。
+ * 例如4为级牌时,4下方是3,再降四级得到−1。
+ */
+export function shiftThreeTigersRank(rank, trumpRank = null) {
+ if (!STANDARD_ORDINARY_RANKS.includes(rank)) return rank;
+ const ordinaryRanks = trumpRank && STANDARD_ORDINARY_RANKS.includes(String(trumpRank))
+ ? SHIFTABLE_RANKS.filter(candidate => String(candidate) !== String(trumpRank))
+ : SHIFTABLE_RANKS;
+ const sourceIndex = String(rank) === String(trumpRank)
+ ? Math.max(0, SHIFTABLE_RANKS.indexOf(rank) - 1)
+ : ordinaryRanks.indexOf(rank);
+ return ordinaryRanks[Math.max(0, sourceIndex - SHIFT)] || Ranks.MINUS_TWO;
+}
+
+/**
+ * 生成仅用于本轮展示与比较的牌面;实体牌和实体分值保持不变。
+ */
+export function transformThreeTigersCard(
+ card,
+ tigerSuit,
+ trumpRank = null,
+ trumpSuit = null
+) {
+ const source = card?.toJSON ? card.toJSON() : { ...card };
+ const isOriginalTrump = source?.suit === 'joker'
+ || source?.suit === trumpSuit
+ || (trumpRank != null && String(source?.rank) === String(trumpRank));
+ if (!source || source.suit !== tigerSuit || isOriginalTrump) return source;
+ return {
+ ...source,
+ originalSuit: source.originalSuit || source.suit,
+ originalRank: source.originalRank || source.rank,
+ rank: shiftThreeTigersRank(source.rank, trumpRank),
+ isThreeTigersTransformed: true,
+ isThreeTigersTrump: true,
+ threeTigersSourceSuit: tigerSuit,
+ threeTigersShift: -SHIFT
+ };
+}
+
+export function transformThreeTigersCards(
+ cards,
+ tigerSuit,
+ trumpRank = null,
+ trumpSuit = null
+) {
+ return (cards || []).map(card => (
+ transformThreeTigersCard(card, tigerSuit, trumpRank, trumpSuit)
+ ));
+}
diff --git a/tractor-game-simulator/server/src/utils/trumpUtils.js b/tractor-game-simulator/server/src/utils/trumpUtils.js
index f66d175..69ae2a7 100644
--- a/tractor-game-simulator/server/src/utils/trumpUtils.js
+++ b/tractor-game-simulator/server/src/utils/trumpUtils.js
@@ -7,7 +7,9 @@ export const DeclarationTypes = {
SINGLE_RANK: 'single_rank', // 单张级牌
PAIR_RANK: 'pair_rank', // 一对级牌
PAIR_SMALL_JOKER: 'pair_small_joker', // 一对小王
- PAIR_BIG_JOKER: 'pair_big_joker' // 一对大王
+ PAIR_BIG_JOKER: 'pair_big_joker', // 一对大王
+ PAIR_COUNTY_PRINCE_JOKER: 'pair_county_prince_joker', // 一对郡王
+ PAIR_PRINCE_JOKER: 'pair_prince_joker' // 一对亲王
};
/**
@@ -17,7 +19,9 @@ export const DeclarationStrength = {
[DeclarationTypes.SINGLE_RANK]: 1,
[DeclarationTypes.PAIR_RANK]: 2,
[DeclarationTypes.PAIR_SMALL_JOKER]: 3,
- [DeclarationTypes.PAIR_BIG_JOKER]: 4
+ [DeclarationTypes.PAIR_BIG_JOKER]: 4,
+ [DeclarationTypes.PAIR_COUNTY_PRINCE_JOKER]: 5,
+ [DeclarationTypes.PAIR_PRINCE_JOKER]: 6
};
/**
@@ -65,8 +69,58 @@ export function validateDeclaration(cards, suit, count, trumpRank, currentTrump
// 统计大王和小王的数量
const smallJokers = cards.filter(c => c.suit === Suits.JOKER && c.rank === Ranks.SMALL_JOKER);
const bigJokers = cards.filter(c => c.suit === Suits.JOKER && c.rank === Ranks.BIG_JOKER);
+ const countyPrinceJokers = cards.filter(
+ c => c.suit === Suits.JOKER && c.rank === Ranks.COUNTY_PRINCE_JOKER
+ );
+ const princeJokers = cards.filter(
+ c => c.suit === Suits.JOKER && c.rank === Ranks.PRINCE_JOKER
+ );
- if (bigJokers.length >= 2) {
+ if (princeJokers.length >= 2) {
+ matchingCards = princeJokers.slice(0, 2);
+ const declarationType = DeclarationTypes.PAIR_PRINCE_JOKER;
+ const strength = DeclarationStrength[declarationType];
+
+ if (currentTrump && strength <= currentTrump.strength) {
+ return {
+ valid: false,
+ message: '无法反主:需要更强的牌',
+ declarationType: null,
+ strength: 0
+ };
+ }
+
+ return {
+ valid: true,
+ message: '亮一对亲王成功',
+ declarationType,
+ strength,
+ jokerType: 'prince',
+ cards: matchingCards
+ };
+ } else if (countyPrinceJokers.length >= 2) {
+ matchingCards = countyPrinceJokers.slice(0, 2);
+ const declarationType = DeclarationTypes.PAIR_COUNTY_PRINCE_JOKER;
+ const strength = DeclarationStrength[declarationType];
+
+ if (currentTrump && strength <= currentTrump.strength) {
+ return {
+ valid: false,
+ message: '无法反主:需要更强的牌',
+ declarationType: null,
+ strength: 0
+ };
+ }
+
+ return {
+ valid: true,
+ message: '亮一对郡王成功',
+ declarationType,
+ strength,
+ jokerType: 'county_prince',
+ cards: matchingCards
+ };
+ } else if (bigJokers.length >= 2) {
// 有一对大王
matchingCards = bigJokers.slice(0, 2);
const declarationType = DeclarationTypes.PAIR_BIG_JOKER;
diff --git a/tractor-game-simulator/server/test/botIntegration.mjs b/tractor-game-simulator/server/test/botIntegration.mjs
new file mode 100644
index 0000000..b23d585
--- /dev/null
+++ b/tractor-game-simulator/server/test/botIntegration.mjs
@@ -0,0 +1,106 @@
+import assert from 'node:assert/strict';
+import { Room } from '../src/models/Room.js';
+import { Player } from '../src/models/Player.js';
+import { Card } from '../src/models/Card.js';
+import { GameEngine } from '../src/services/GameEngine.js';
+import { DeckService } from '../src/services/DeckService.js';
+import BotService from '../src/services/BotService.js';
+import { BotTypes, GamePhases } from '../src/utils/constants.js';
+import { detectPattern } from '../src/utils/cardPatternUtils.js';
+import logger from '../src/utils/logger.js';
+
+const originalInfo = logger.info;
+logger.info = () => {};
+
+const io = { to: () => ({ emit: () => {} }) };
+
+async function playBotGame(trumpSuit) {
+ const room = new Room('bot-integration', 'host', { botType: BotTypes.WHO_DESIGNED });
+ for (let index = 0; index < 4; index++) {
+ room.addPlayer(new Player(`bot-${index}`, `Bot ${index}`, index, true));
+ }
+
+ const deck = DeckService.shuffle(DeckService.createDeck());
+ room.gameState.bottomCards = deck.slice(0, 8);
+ deck.slice(8).forEach((card, index) => room.players[index % 4].addCard(card));
+ room.gameState.trumpSuit = trumpSuit;
+ room.gameState.trumpRank = '6';
+ room.gameState.phase = GamePhases.BURYING;
+
+ const engine = new GameEngine(room, io);
+ const dealer = engine.setBuryingPlayer(room.players[0].id);
+ engine.buryCards(dealer.id, dealer.cards.slice(0, 8).map(card => card.id));
+
+ const bot = new BotService(BotTypes.WHO_DESIGNED);
+ assert.equal(await bot.checkBotAvailability(), true, 'WhoDesigned files should be available');
+
+ let actions = 0;
+ let fallbacks = 0;
+ while (room.gameState.phase === GamePhases.PLAYING && actions < 200) {
+ const index = room.gameState.currentPlayerIndex;
+ const player = room.players[index];
+ let cardIds = await bot.getBotAction(room.gameState, player.cards, index, room);
+ try {
+ engine.playCards(player.id, cardIds);
+ } catch {
+ fallbacks++;
+ cardIds = bot.getFallbackAction(room.gameState, player.cards);
+ engine.playCards(player.id, cardIds);
+ }
+ actions++;
+ }
+
+ assert.ok(actions < 200, 'Bot game should not stall');
+ assert.ok(room.players.every(player => player.cards.length === 0), 'Every bot should finish its hand');
+ return { trumpSuit, actions, fallbacks, phase: room.gameState.phase };
+}
+
+async function verifyKnownVoidPointProtection() {
+ const room = new Room('bot-point-protection', 'host', { botType: BotTypes.WHO_DESIGNED });
+ for (let index = 0; index < 4; index++) {
+ room.addPlayer(new Player(`bot-${index}`, `Bot ${index}`, index, true));
+ }
+
+ const completedTrick = [
+ { playerIndex: 0, cards: [new Card('diamonds', 'A')] },
+ { playerIndex: 1, cards: [new Card('diamonds', 'K')] },
+ { playerIndex: 2, cards: [new Card('diamonds', 'Q')] },
+ { playerIndex: 3, cards: [new Card('clubs', '3')] }
+ ];
+ const currentTrick = [
+ { playerIndex: 0, cards: [new Card('diamonds', '10')] },
+ { playerIndex: 1, cards: [new Card('diamonds', '4')] }
+ ];
+ room.gameState.trumpSuit = 'hearts';
+ room.gameState.trumpRank = '2';
+ room.gameState.phase = GamePhases.PLAYING;
+ room.gameState.currentPlayerIndex = 2;
+ room.gameState.currentRoundPlays = currentTrick;
+ room.gameState.leadingPattern = detectPattern(
+ currentTrick[0].cards,
+ room.gameState.trumpSuit,
+ room.gameState.trumpRank
+ );
+ room.gameState.playHistory = [...completedTrick, ...currentTrick].map(play => ({
+ ...play,
+ playerId: room.players[play.playerIndex].id
+ }));
+
+ const protectingTrump = new Card('hearts', '3');
+ room.players[2].cards = [protectingTrump, new Card('clubs', '4')];
+ const bot = new BotService(BotTypes.WHO_DESIGNED);
+ const action = await bot.getBotAction(
+ room.gameState,
+ room.players[2].cards,
+ 2,
+ room
+ );
+ assert.deepEqual(action, [protectingTrump.id], 'Bot should trump the exposed 10 before a known-void opponent');
+}
+
+const results = [];
+await verifyKnownVoidPointProtection();
+results.push(await playBotGame('hearts'));
+results.push(await playBotGame('no_trump'));
+logger.info = originalInfo;
+console.log(JSON.stringify(results));
diff --git a/tractor-game-simulator/server/test/privateStateSync.test.mjs b/tractor-game-simulator/server/test/privateStateSync.test.mjs
new file mode 100644
index 0000000..394f3e3
--- /dev/null
+++ b/tractor-game-simulator/server/test/privateStateSync.test.mjs
@@ -0,0 +1,308 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+
+import { Card } from '../src/models/Card.js';
+import { Player } from '../src/models/Player.js';
+import { Room } from '../src/models/Room.js';
+import { GameEngine } from '../src/services/GameEngine.js';
+import { getRuleById, RuleIds } from '../src/rules/ruleRegistry.js';
+import { GamePhases } from '../src/utils/constants.js';
+
+function createRoom() {
+ const room = new Room('私密状态恢复测试', 'socket-0', { dealInterval: 10 });
+ for (let index = 0; index < 4; index++) {
+ room.addPlayer(new Player(`socket-${index}`, `玩家${index}`, index));
+ }
+ return room;
+}
+
+function createIo() {
+ return {
+ to() {
+ return { emit() {} };
+ }
+ };
+}
+
+function eventMap(events) {
+ return new Map(events.map(entry => [entry.event, entry.payload]));
+}
+
+test('统一私密同步恢复所有会冻结牌局的个人待办', () => {
+ const room = createRoom();
+ const engine = new GameEngine(room, createIo());
+ const player = room.players[0];
+ const teammate = room.players[2];
+ const opponent = room.players[1];
+ player.cards = [
+ new Card('hearts', '5', 0),
+ new Card('clubs', '7', 0),
+ new Card('diamonds', '9', 0)
+ ];
+
+ const state = room.gameState;
+ state.selectedRule = getRuleById(RuleIds.NORMAL_GAME);
+ state.phase = GamePhases.PLAYING;
+ state.trumpSuit = 'spades';
+ state.trumpRank = '2';
+ state.currentRound = 3;
+
+ state.removeFirewoodCurrentDecision = {
+ counteredPlayerId: player.id,
+ counteringPlayerId: opponent.id
+ };
+ state.mainstayCurrentAction = {
+ id: 'mainstay-1',
+ stage: 'decision',
+ chooserPlayerId: player.id,
+ trumpCount: 2
+ };
+ state.woodenOxMulesByTeam.set(0, {
+ teamIndex: 0,
+ initialHolderPlayerId: player.id,
+ holderPlayerId: player.id,
+ storedCard: new Card('clubs', 'A', 1),
+ transfersUsed: 1,
+ maxTransfers: 4
+ });
+ state.woodenOxRoundWindow = {
+ round: 3,
+ pendingPlayerIds: new Set([player.id]),
+ requiredTransferPlayerIds: new Set([player.id])
+ };
+ state.politicalReviewPending = {
+ id: 'political-pending',
+ round: 3,
+ reviewerPlayerId: player.id,
+ reviewerPlayerName: player.name,
+ teammatePlayerId: teammate.id,
+ teammatePlayerName: teammate.name,
+ requestingPlayerId: teammate.id,
+ controlledPlayerId: null,
+ activeSkillId: null,
+ cardIds: ['spades-Q-0'],
+ playOptions: {},
+ cards: [{ id: 'spades-Q-0', suit: 'spades', rank: 'Q' }]
+ };
+ state.timeReversalDecisionState = 'awaiting_response';
+ state.timeReversalWindowRound = 3;
+ state.timeReversalReservations.set(player.id, {
+ playerId: player.id,
+ playerName: player.name,
+ round: 3
+ });
+ state.lastStandPendingPlayerIds.add(player.id);
+ state.teammateCheerPending = {
+ playerId: player.id,
+ playerName: player.name,
+ teammatePlayerId: teammate.id,
+ teammatePlayerName: teammate.name,
+ triggerRound: 3
+ };
+ state.afterglowPending = {
+ playerId: player.id,
+ playerName: player.name,
+ trumpCount: 2,
+ triggerRound: 3
+ };
+ state.forbiddenMagicCurrentDecisionPlayerId = player.id;
+ state.forbiddenMagicDecisionRound = 3;
+ state.forbiddenMagicDecisionQueue = [opponent.id];
+ state.lureTigerCurrentDecision = {
+ round: 3,
+ playerId: player.id,
+ playerName: player.name,
+ stage: 'target',
+ eligibleTargetIds: [opponent.id]
+ };
+ state.icebergPendingPlayerIds.add(player.id);
+ engine.icebergSelectionRequests.set(player.id, {
+ playerId: player.id,
+ reason: 'replenish',
+ requiredCount: 1,
+ targetCount: 2,
+ currentlyRevealedCardIds: [player.cards[0].id]
+ });
+ state.tenSidedAmbushSelectorPlayerId = player.id;
+ state.isTenSidedAmbushSelectionPending = true;
+ state.threePowersSlots = [{
+ sourceRank: '5',
+ pointValue: 5,
+ selectorPosition: 3,
+ selectorPlayerId: player.id,
+ selectedRank: null,
+ isRevealed: false
+ }];
+ state.waitingRabbitPendingSelectionPlayerIds.add(player.id);
+ state.gentlemanPromisePendingPlayerIds.add(player.id);
+ state.hiddenDragonPendingPlayerIds.add(player.id);
+ state.antinomyPendingPlayerIds.add(player.id);
+ state.antinomySelectionStage = 'opening';
+ state.riceToMulberryPendingPlayerIds.add(player.id);
+ state.destroyDykeDecision = {
+ round: 3,
+ dealerPlayerId: player.id,
+ dealerPlayerName: player.name,
+ roundPoints: 10
+ };
+ state.administrativeReview = {
+ suitSelectorPlayerId: player.id,
+ rankSelectorPlayerId: opponent.id,
+ suit: null,
+ rank: null
+ };
+ state.isFocusFigureVotingStarted = true;
+ state.focusFigureTeams = [{
+ team: 1,
+ playerIds: [player.id, teammate.id],
+ nomineePlayerId: teammate.id,
+ finalPlayerId: null,
+ attempt: 1,
+ votes: new Map(),
+ isFinalized: false
+ }];
+ state.equivalentReciprocityChallenge = {
+ id: 'equivalent-1',
+ initiatorPlayerId: player.id,
+ targetPlayerId: opponent.id,
+ selectedCardsByPlayerId: new Map()
+ };
+ state.ambiguousRoundDecision = {
+ round: 3,
+ currentPlayerId: player.id,
+ queuePlayerIds: [],
+ selections: [{
+ playerId: player.id,
+ playerName: player.name,
+ position: 2,
+ options: [
+ { index: 0, cards: [player.cards[0]] },
+ { index: 1, cards: [player.cards[1]] }
+ ]
+ }]
+ };
+
+ const events = engine.getPrivateGameStateSyncEvents(player.id);
+ const names = new Set(events.map(entry => entry.event));
+ const expected = [
+ 'remove_firewood_decision_required',
+ 'mainstay_decision_required',
+ 'wooden_ox_private_state',
+ 'wooden_ox_decision_required',
+ 'political_review_decision_required',
+ 'time_reversal_decision_required',
+ 'last_stand_decision_required',
+ 'teammate_cheer_decision_required',
+ 'afterglow_decision_required',
+ 'forbidden_magic_decision_required',
+ 'lure_tiger_target_required',
+ 'iceberg_reveal_selection_required',
+ 'ten_sided_ambush_selection_required',
+ 'three_powers_selection_required',
+ 'waiting_rabbit_selection_required',
+ 'gentleman_promise_selection_required',
+ 'hidden_dragon_selection_required',
+ 'antinomy_selection_required',
+ 'rice_to_mulberry_selection_required',
+ 'destroy_dyke_decision_required',
+ 'administrative_review_selection_required',
+ 'focus_figure_vote_required',
+ 'equivalent_reciprocity_card_required',
+ 'ambiguous_choice_required'
+ ];
+
+ expected.forEach(event => assert.ok(names.has(event), `缺少恢复事件 ${event}`));
+ assert.ok(events.every(entry => entry.payload.recovered === true));
+});
+
+test('私密同步重放暗选结果和政治审查放行凭证且不泄露给其他玩家', () => {
+ const room = createRoom();
+ const engine = new GameEngine(room, createIo());
+ const player = room.players[0];
+ const teammate = room.players[2];
+ const otherPlayer = room.players[1];
+ const state = room.gameState;
+ state.selectedRule = getRuleById(RuleIds.NORMAL_GAME);
+ state.phase = GamePhases.PLAYING;
+ state.trumpSuit = 'spades';
+ state.trumpRank = '2';
+ state.currentRound = 4;
+
+ state.politicalReviewApproval = {
+ id: 'political-approved',
+ requestingPlayerId: player.id,
+ controlledPlayerId: null,
+ teammatePlayerId: player.id,
+ cardIds: ['hearts-A-0'],
+ activeSkillId: null,
+ playOptions: {
+ jokerSubstitutions: [],
+ clusterAnalysisSubstitutions: [],
+ forbiddenMagicSubstitutions: [],
+ ambiguousAlternativeCardIds: ['hearts-K-0']
+ }
+ };
+ state.tenSidedAmbushSelectorPlayerId = player.id;
+ state.tenSidedAmbushRank = '7';
+ state.isTenSidedAmbushSelectionPending = false;
+ state.isTenSidedAmbushRevealed = false;
+ state.threePowersSlots = [{
+ sourceRank: '10',
+ pointValue: 10,
+ selectorPosition: 2,
+ selectorPlayerId: player.id,
+ selectedRank: 'Q',
+ isRevealed: false
+ }];
+ state.waitingRabbitDeclarationsByPlayerId.set(player.id, {
+ suit: 'clubs',
+ rank: 'K'
+ });
+ state.focusFigureTeams = [{
+ team: 1,
+ playerIds: [player.id, teammate.id],
+ nomineePlayerId: teammate.id,
+ finalPlayerId: teammate.id,
+ attempt: 1,
+ votes: new Map([
+ [player.id, true],
+ [teammate.id, true]
+ ]),
+ isFinalized: true
+ }];
+ state.magicTrickSelection = {
+ round: 4,
+ playerId: player.id,
+ targetPlayerIds: [otherPlayer.id, teammate.id]
+ };
+
+ const ownEvents = eventMap(engine.getPrivateGameStateSyncEvents(player.id));
+ assert.deepEqual(
+ ownEvents.get('political_review_play_approved').ambiguousAlternativeCardIds,
+ ['hearts-K-0']
+ );
+ assert.equal(ownEvents.get('ten_sided_ambush_rank_selected').rank, '7');
+ assert.equal(ownEvents.get('three_powers_rank_selected').rank, 'Q');
+ assert.deepEqual(
+ {
+ suit: ownEvents.get('waiting_rabbit_target_selected').suit,
+ rank: ownEvents.get('waiting_rabbit_target_selected').rank
+ },
+ { suit: 'clubs', rank: 'K' }
+ );
+ assert.equal(ownEvents.get('focus_figure_team_finalized').focusPlayerId, teammate.id);
+ assert.deepEqual(
+ ownEvents.get('magic_trick_prepared').targetPlayerIds,
+ [otherPlayer.id, teammate.id]
+ );
+
+ const otherEvents = eventMap(engine.getPrivateGameStateSyncEvents(otherPlayer.id));
+ [
+ 'political_review_play_approved',
+ 'ten_sided_ambush_rank_selected',
+ 'three_powers_rank_selected',
+ 'waiting_rabbit_target_selected',
+ 'focus_figure_team_finalized',
+ 'magic_trick_prepared'
+ ].forEach(event => assert.equal(otherEvents.has(event), false, `${event} 不得泄露`));
+});
diff --git a/tractor-game-simulator/server/test/roomReconnect.test.mjs b/tractor-game-simulator/server/test/roomReconnect.test.mjs
new file mode 100644
index 0000000..7d52c73
--- /dev/null
+++ b/tractor-game-simulator/server/test/roomReconnect.test.mjs
@@ -0,0 +1,330 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import { RoomManager } from '../src/services/RoomManager.js';
+import { Player } from '../src/models/Player.js';
+import { registerRoomHandlers } from '../src/socket/handlers/roomHandlers.js';
+import {
+ getBotServices,
+ getGameEngines,
+ registerGameHandlers
+} from '../src/socket/handlers/gameHandlers.js';
+
+class FakeSocket {
+ constructor(id) {
+ this.id = id;
+ this.handlers = new Map();
+ this.emitted = [];
+ this.joinedRooms = new Set();
+ }
+
+ on(event, handler) {
+ this.handlers.set(event, handler);
+ }
+
+ emit(event, payload) {
+ this.emitted.push({ event, payload });
+ }
+
+ join(roomId) {
+ this.joinedRooms.add(roomId);
+ }
+
+ leave(roomId) {
+ this.joinedRooms.delete(roomId);
+ }
+
+ to() {
+ return { emit: () => {} };
+ }
+
+ trigger(event, payload) {
+ return this.handlers.get(event)?.(payload);
+ }
+
+ last(event) {
+ return this.emitted.findLast(entry => entry.event === event)?.payload;
+ }
+}
+
+function createIo() {
+ return {
+ broadcasts: [],
+ to(roomId) {
+ return {
+ emit: (event, payload) => {
+ this.broadcasts.push({ roomId, event, payload });
+ }
+ };
+ }
+ };
+}
+
+test('断线后保留座位,并可用私密令牌恢复同一玩家、手牌与房主身份', () => {
+ const io = createIo();
+ const roomManager = new RoomManager();
+ const originalSocket = new FakeSocket('socket-old');
+ registerRoomHandlers(io, originalSocket, roomManager);
+
+ originalSocket.trigger('create_room', {
+ name: '重连测试',
+ playerName: '玩家A',
+ config: {}
+ });
+ const created = originalSocket.last('room_created');
+ const room = roomManager.getRoom(created.room.id);
+ const player = room.findPlayerById(created.player.id);
+ player.cards.push({ toJSON: () => ({ id: 'hearts-A-0', suit: 'hearts', rank: 'A' }) });
+ const politicalReviewPending = {
+ id: 'political-review-reconnect',
+ round: 3,
+ reviewerPlayerId: player.id,
+ reviewerPlayerName: player.name,
+ teammatePlayerId: 'teammate-player',
+ teammatePlayerName: '队友',
+ cards: [
+ { id: 'spades-Q-0', suit: 'spades', rank: 'Q' },
+ { id: 'spades-K-0', suit: 'spades', rank: 'K' }
+ ]
+ };
+ room.gameState.selectedRule = { id: 'political_review', name: '政治审查' };
+ room.gameState.politicalReviewPending = {
+ ...politicalReviewPending,
+ requestingPlayerId: 'teammate-player',
+ controlledPlayerId: null,
+ activeSkillId: null,
+ cardIds: politicalReviewPending.cards.map(card => card.id),
+ playOptions: {}
+ };
+ room.gameState.phase = 'playing';
+ room.gameState.currentRound = 3;
+ room.gameState.currentRoundPlays = [{
+ playerIndex: 0,
+ playerId: player.id,
+ cards: [{ toJSON: () => ({ id: 'clubs-K-0', suit: 'clubs', rank: 'K' }) }],
+ concealed: false,
+ treatedAsSmall: false
+ }, {
+ playerIndex: 1,
+ playerId: 'teammate-player',
+ cards: [
+ { toJSON: () => ({ id: 'hearts-9-0', suit: 'hearts', rank: '9' }) },
+ { toJSON: () => ({ id: 'hearts-9-1', suit: 'hearts', rank: '9' }) }
+ ],
+ concealed: true,
+ treatedAsSmall: true
+ }];
+ room.gameState.playHistory = [{
+ round: 3,
+ playerId: player.id,
+ playerName: player.name,
+ controllerPlayerId: player.id,
+ controllerPlayerName: player.name,
+ isProxy: false
+ }, {
+ round: 3,
+ playerId: 'teammate-player',
+ playerName: '队友',
+ controllerPlayerId: 'controller-player',
+ controllerPlayerName: '代打玩家',
+ isProxy: true
+ }];
+
+ assert.ok(created.resumeToken);
+ assert.equal(created.room.players[0].resumeToken, undefined, '令牌不得出现在公开房间数据中');
+
+ originalSocket.trigger('disconnect');
+ assert.equal(room.players.length, 1, '宽限期内不能释放座位');
+ assert.equal(player.isOnline, false);
+
+ const replacementSocket = new FakeSocket('socket-new');
+ registerRoomHandlers(io, replacementSocket, roomManager);
+ replacementSocket.trigger('resume_room', {
+ roomId: room.id,
+ playerId: player.id,
+ resumeToken: created.resumeToken
+ });
+
+ const resumed = replacementSocket.last('room_resumed');
+ assert.equal(resumed.player.id, player.id);
+ assert.deepEqual(resumed.player.cards, [
+ { id: 'hearts-A-0', suit: 'hearts', rank: 'A' }
+ ]);
+ assert.deepEqual(
+ resumed.room.gameState.politicalReview.pending,
+ politicalReviewPending,
+ '恢复房间时必须携带尚未处理的政治审查,供审查者重建询问框'
+ );
+ assert.equal(resumed.room.gameState.currentRoundPlays, 2);
+ assert.deepEqual(resumed.room.gameState.currentRoundTable[0].cards, [
+ { id: 'clubs-K-0', suit: 'clubs', rank: 'K' }
+ ]);
+ assert.equal(resumed.room.gameState.currentRoundTable[1].cardsCount, 2);
+ assert.deepEqual(
+ resumed.room.gameState.currentRoundTable[1].cards,
+ [],
+ '暗置牌在重连快照中只能公开张数,不能泄露牌面'
+ );
+ assert.equal(resumed.room.gameState.currentRoundTable[1].controllerPlayerId, 'controller-player');
+ assert.equal(player.socketId, replacementSocket.id);
+ assert.equal(player.isOnline, true);
+ assert.equal(room.hostId, replacementSocket.id);
+ assert.ok(replacementSocket.joinedRooms.has(room.id));
+});
+
+test('错误的恢复令牌不能认领已有座位', () => {
+ const io = createIo();
+ const roomManager = new RoomManager();
+ const ownerSocket = new FakeSocket('socket-owner');
+ registerRoomHandlers(io, ownerSocket, roomManager);
+ ownerSocket.trigger('create_room', {
+ name: '安全测试',
+ playerName: '玩家A',
+ config: {}
+ });
+ const created = ownerSocket.last('room_created');
+
+ const attackerSocket = new FakeSocket('socket-attacker');
+ registerRoomHandlers(io, attackerSocket, roomManager);
+ attackerSocket.trigger('resume_room', {
+ roomId: created.room.id,
+ playerId: created.player.id,
+ resumeToken: 'wrong-token'
+ });
+
+ assert.match(attackerSocket.last('resume_failed').message, /无法验证/);
+ assert.equal(roomManager.getRoom(created.room.id).players[0].socketId, ownerSocket.id);
+});
+
+test('牌桌监听器挂载后可按当前连接同步个人私密待办', () => {
+ const io = createIo();
+ const roomManager = new RoomManager();
+ const socket = new FakeSocket('socket-private-sync');
+ registerRoomHandlers(io, socket, roomManager);
+ registerGameHandlers(io, socket, roomManager);
+ socket.trigger('create_room', {
+ name: '私密待办同步测试',
+ playerName: '玩家A',
+ config: {}
+ });
+ const created = socket.last('room_created');
+ const room = roomManager.getRoom(created.room.id);
+ const expectedPayload = {
+ round: 6,
+ playerId: created.player.id,
+ recovered: true
+ };
+ getGameEngines().set(room.id, {
+ getPrivateGameStateSyncEvents(playerId) {
+ assert.equal(playerId, created.player.id);
+ return [{
+ event: 'time_reversal_decision_required',
+ payload: expectedPayload
+ }];
+ }
+ });
+
+ socket.trigger('request_private_game_state_sync', { roomId: room.id });
+
+ assert.deepEqual(socket.last('time_reversal_decision_required'), expectedPayload);
+ assert.deepEqual(socket.last('private_game_state_synced'), {
+ roomId: room.id,
+ eventCount: 1
+ });
+ getGameEngines().delete(room.id);
+});
+
+test('等待准备阶段允许空位重新加入,并接管已离线的规则选择职责', () => {
+ const io = createIo();
+ const roomManager = new RoomManager();
+ const ownerSocket = new FakeSocket('socket-owner');
+ registerRoomHandlers(io, ownerSocket, roomManager);
+ ownerSocket.trigger('create_room', {
+ name: '准备补位测试',
+ playerName: '房主',
+ config: {}
+ });
+ const created = ownerSocket.last('room_created');
+ const room = roomManager.getRoom(created.room.id);
+ room.gameState.isWaitingForReady = true;
+ room.gameState.isRuleSelectionPending = true;
+ room.gameState.ruleSelectionMode = 'single';
+ room.gameState.ruleOptions = [{ id: 'normal_game', name: '世事无常' }];
+ room.gameState.ruleChooserPlayerId = 'removed-player';
+
+ const replacementSocket = new FakeSocket('socket-replacement');
+ registerRoomHandlers(io, replacementSocket, roomManager);
+ replacementSocket.trigger('join_room', {
+ roomId: room.id,
+ playerName: '补位玩家'
+ });
+
+ const joined = replacementSocket.last('room_joined');
+ assert.ok(joined, '准备阶段的空位应允许加入');
+ assert.equal(room.gameState.ruleChooserPlayerId, joined.player.id);
+ assert.equal(replacementSocket.last('error'), undefined);
+ assert.ok(
+ io.broadcasts.some(entry =>
+ entry.event === 'rule_selection_started'
+ && entry.payload.chooserPlayerId === joined.player.id
+ )
+ );
+});
+
+test('主动离开后房间只剩Bot时立即清理房间和服务资源', () => {
+ const io = createIo();
+ const roomManager = new RoomManager();
+ const ownerSocket = new FakeSocket('socket-owner-bot-room');
+ registerRoomHandlers(io, ownerSocket, roomManager);
+ ownerSocket.trigger('create_room', {
+ name: 'Bot资源清理测试',
+ playerName: '唯一真人',
+ config: {}
+ });
+
+ const created = ownerSocket.last('room_created');
+ const room = roomManager.getRoom(created.room.id);
+ room.addPlayer(new Player('bot-only', 'Bot 1', 1, true));
+
+ let cleanupCalls = 0;
+ getGameEngines().set(room.id, {
+ cleanup() {
+ cleanupCalls += 1;
+ }
+ });
+ getBotServices().set(room.id, { type: 'test-bot-service' });
+
+ ownerSocket.trigger('leave_room', { roomId: room.id });
+
+ assert.equal(roomManager.getRoom(room.id), undefined);
+ assert.equal(getGameEngines().has(room.id), false);
+ assert.equal(getBotServices().has(room.id), false);
+ assert.equal(cleanupCalls, 1);
+});
+
+test('真人退出后仍有其他真人时保留房间', () => {
+ const io = createIo();
+ const roomManager = new RoomManager();
+ const ownerSocket = new FakeSocket('socket-owner-shared-room');
+ registerRoomHandlers(io, ownerSocket, roomManager);
+ ownerSocket.trigger('create_room', {
+ name: '真人保留测试',
+ playerName: '房主',
+ config: {}
+ });
+
+ const created = ownerSocket.last('room_created');
+ const room = roomManager.getRoom(created.room.id);
+ const guestSocket = new FakeSocket('socket-guest-shared-room');
+ registerRoomHandlers(io, guestSocket, roomManager);
+ guestSocket.trigger('join_room', {
+ roomId: room.id,
+ playerName: '仍在房间的真人'
+ });
+ room.addPlayer(new Player('bot-shared', 'Bot 1', 2, true));
+
+ ownerSocket.trigger('leave_room', { roomId: room.id });
+
+ assert.ok(roomManager.getRoom(room.id));
+ assert.ok(room.players.some(remainingPlayer => !remainingPlayer.isBot));
+ assert.equal(room.hostId, guestSocket.id);
+});
diff --git a/tractor-game-simulator/server/test/specialRules.test.mjs b/tractor-game-simulator/server/test/specialRules.test.mjs
new file mode 100644
index 0000000..343b369
--- /dev/null
+++ b/tractor-game-simulator/server/test/specialRules.test.mjs
@@ -0,0 +1,12268 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+
+import { Room } from '../src/models/Room.js';
+import { Player } from '../src/models/Player.js';
+import { Card } from '../src/models/Card.js';
+import { GameEngine } from '../src/services/GameEngine.js';
+import { DrawingPhaseManager } from '../src/services/DrawingPhaseManager.js';
+import { DeckService } from '../src/services/DeckService.js';
+import { BotService } from '../src/services/BotService.js';
+import { registerPlayerHandlers } from '../src/socket/handlers/playerHandlers.js';
+import {
+ emitCardsPlayed,
+ registerGameHandlers
+} from '../src/socket/handlers/gameHandlers.js';
+import {
+ compareCards,
+ detectPattern,
+ findSmallestPlayForRespectElders,
+ getSuitlessPatternProfile,
+ rankRoundPlaysByRespectOrder,
+ getCardStrength,
+ isTrumpCard,
+ parseThrowCombination,
+ PatternTypes,
+ resolveClusterAnalysisPlay,
+ resolveEnduringComparison,
+ resolveForbiddenMagicPlay,
+ resolveJokerSubstitutionPlay,
+ validateFollowingPlay,
+ validateLeadingPlay
+} from '../src/utils/cardPatternUtils.js';
+import {
+ ActiveSkillIds,
+ getImplementedRules,
+ getOpeningCardExchangeOffset,
+ getDayNightHighestRank,
+ getOddEvenRoundMultiplier,
+ getRuleById,
+ getRuleSetup,
+ isReverseRankOrderRule,
+ isSingleStepDebugRule,
+ RuleIds
+} from '../src/rules/ruleRegistry.js';
+import { GamePhases, Ranks, TurnOrders } from '../src/utils/constants.js';
+import {
+ calculateRoundPoints,
+ getCardPoints,
+ getMeticulousAccountingCardPoints
+} from '../src/utils/scoringUtils.js';
+import { comparePokerScores, evaluateBestPokerHand } from '../src/utils/pokerUtils.js';
+import {
+ getBirdsGoneBowHiddenDisabledCards,
+ getCardCooldownDisabledCards,
+ recordBirdsGoneBowHiddenPointCards
+} from '../src/utils/cardCooldownUtils.js';
+import {
+ mapOneCountryCards,
+ resolveOneCountryTwoSystems
+} from '../src/utils/oneCountryTwoSystemsUtils.js';
+import { shiftStrengthCompensationCardFace } from '../src/utils/strengthCompensationUtils.js';
+import {
+ shiftThreeTigersRank,
+ transformThreeTigersCard
+} from '../src/utils/threeTigersUtils.js';
+import { validateDeclaration } from '../src/utils/trumpUtils.js';
+import {
+ calculateIronEvidenceRoundScoring,
+ IronEvidenceModes,
+ isIronEvidenceSpecialCard
+} from '../src/utils/ironEvidenceUtils.js';
+
+const NORMAL_RULE = getRuleById(RuleIds.NORMAL_GAME);
+const DOUBLE_HAPPINESS_RULE = getRuleById(RuleIds.DOUBLE_HAPPINESS);
+const REVERSE_RULE = getRuleById(RuleIds.REVERSE_RANK_ORDER);
+const IRRESISTIBLE_FORCE_RULE = getRuleById(RuleIds.IRRESISTIBLE_FORCE);
+const SINGLE_STEP_DEBUG_RULE = getRuleById(RuleIds.SINGLE_STEP_DEBUG);
+const REFORM_AND_OPENING_UP_RULE = getRuleById(RuleIds.REFORM_AND_OPENING_UP);
+const SUBSTITUTE_SACRIFICE_RULE = getRuleById(RuleIds.SUBSTITUTE_SACRIFICE);
+const SIX_SIX_GREAT_SUCCESS_RULE = getRuleById(RuleIds.SIX_SIX_GREAT_SUCCESS);
+const TAI_CHI_FOUR_SYMBOLS_RULE = getRuleById(RuleIds.TAI_CHI_FOUR_SYMBOLS);
+const ONE_HORSE_LEADS_RULE = getRuleById(RuleIds.ONE_HORSE_LEADS);
+const HEAVY_FOG_RULE = getRuleById(RuleIds.HEAVY_FOG);
+const ROUTE_SWING_RULE = getRuleById(RuleIds.ROUTE_SWING);
+const BELT_AND_ROAD_RULE = getRuleById(RuleIds.BELT_AND_ROAD);
+const DAY_NIGHT_ROTATION_RULE = getRuleById(RuleIds.DAY_NIGHT_ROTATION);
+const RESPECT_ELDERS_AND_CHILDREN_RULE = getRuleById(RuleIds.RESPECT_ELDERS_AND_CHILDREN);
+const RITES_COLLAPSE_RULE = getRuleById(RuleIds.RITES_COLLAPSE);
+const RECOMMEND_TALENT_RULE = getRuleById(RuleIds.RECOMMEND_TALENT);
+const ACCIDENT_INSURANCE_RULE = getRuleById(RuleIds.ACCIDENT_INSURANCE);
+const THREE_POWERS_RULE = getRuleById(RuleIds.THREE_POWERS);
+const GENTLEMAN_PROMISE_RULE = getRuleById(RuleIds.GENTLEMAN_PROMISE);
+const REPEATED_EXHAUSTION_RULE = getRuleById(RuleIds.REPEATED_EXHAUSTION);
+const FOCUS_FIGURE_RULE = getRuleById(RuleIds.FOCUS_FIGURE);
+const COOLDOWN_TIME_RULE = getRuleById(RuleIds.COOLDOWN_TIME);
+const TIME_COOLING_RULE = getRuleById(RuleIds.TIME_COOLING);
+const PLANNED_ECONOMY_RULE = getRuleById(RuleIds.PLANNED_ECONOMY);
+const EQUIVALENT_RECIPROCITY_RULE = getRuleById(RuleIds.EQUIVALENT_RECIPROCITY);
+const ENDURING_RULE = getRuleById(RuleIds.ENDURING);
+const AVERAGE_POOLING_RULE = getRuleById(RuleIds.AVERAGE_POOLING);
+const DREAM_KILLING_RULE = getRuleById(RuleIds.DREAM_KILLING);
+const JOINT_HARMONY_RULE = getRuleById(RuleIds.JOINT_HARMONY);
+const DIVINE_WEAPON_RULE = getRuleById(RuleIds.DIVINE_WEAPON);
+const MAGIC_TRICK_RULE = getRuleById(RuleIds.MAGIC_TRICK);
+const ABRUPT_STOP_RULE = getRuleById(RuleIds.ABRUPT_STOP);
+const CLUSTER_ANALYSIS_RULE = getRuleById(RuleIds.CLUSTER_ANALYSIS);
+const FORBIDDEN_MAGIC_RULE = getRuleById(RuleIds.FORBIDDEN_MAGIC);
+const METICULOUS_ACCOUNTING_RULE = getRuleById(RuleIds.METICULOUS_ACCOUNTING);
+const LOST_IN_FOG_RULE = getRuleById(RuleIds.LOST_IN_FOG);
+const BIRDS_GONE_BOW_HIDDEN_RULE = getRuleById(RuleIds.BIRDS_GONE_BOW_HIDDEN);
+const ODD_EVEN_SCORING_RULE = getRuleById(RuleIds.ODD_EVEN_SCORING);
+const SECOND_BATTLEFIELD_RULE = getRuleById(RuleIds.SECOND_BATTLEFIELD);
+const ONE_COUNTRY_TWO_SYSTEMS_RULE = getRuleById(RuleIds.ONE_COUNTRY_TWO_SYSTEMS);
+const WOODEN_OX_FLOWING_HORSE_RULE = getRuleById(RuleIds.WOODEN_OX_FLOWING_HORSE);
+const STRENGTH_COMPENSATION_RULE = getRuleById(RuleIds.STRENGTH_COMPENSATION);
+const UNARMED_RULE = getRuleById(RuleIds.UNARMED);
+const MUTUAL_SUPPORT_RULE = getRuleById(RuleIds.MUTUAL_SUPPORT);
+const CANDLE_TO_DAWN_RULE = getRuleById(RuleIds.CANDLE_TO_DAWN);
+const CULTURAL_REVOLUTION_RULE = getRuleById(RuleIds.CULTURAL_REVOLUTION);
+const THREE_TIGERS_RULE = getRuleById(RuleIds.THREE_TIGERS);
+const INVITE_INTO_URN_RULE = getRuleById(RuleIds.INVITE_INTO_URN);
+const OLD_HORSE_RULE = getRuleById(RuleIds.OLD_HORSE_STILL_HAS_STRENGTH);
+const TRUMP_WINS_RULE = getRuleById(RuleIds.TRUMP_WINS);
+const OPENLY_REVEALED_RULE = getRuleById(RuleIds.OPENLY_REVEALED);
+const STRAW_BOAT_BORROWING_ARROWS_RULE = getRuleById(RuleIds.STRAW_BOAT_BORROWING_ARROWS);
+const BUSH_GATE_RULE = getRuleById(RuleIds.BUSH_GATE);
+const TEAMMATE_CHEER_RULE = getRuleById(RuleIds.TEAMMATE_CHEER);
+const ILLUSION_AND_REALITY_RULE = getRuleById(RuleIds.ILLUSION_AND_REALITY);
+const STRIVE_UPSTREAM_RULE = getRuleById(RuleIds.STRIVE_UPSTREAM);
+const AFTERGLOW_RULE = getRuleById(RuleIds.AFTERGLOW);
+const OUTWARD_HARMONY_INNER_DIVISION_RULE = getRuleById(
+ RuleIds.OUTWARD_HARMONY_INNER_DIVISION
+);
+const AMBIGUOUS_RULE = getRuleById(RuleIds.AMBIGUOUS);
+const TWO_GHOSTS_KNOCK_DOOR_RULE = getRuleById(RuleIds.TWO_GHOSTS_KNOCK_DOOR);
+const PEOPLE_COMMUNE_RULE = getRuleById(RuleIds.PEOPLE_COMMUNE);
+const REMOVE_FIREWOOD_RULE = getRuleById(RuleIds.REMOVE_FIREWOOD_FROM_UNDER_CAULDRON);
+const MAINSTAY_RULE = getRuleById(RuleIds.MAINSTAY);
+const HAPPY_TWINS_RULE = getRuleById(RuleIds.HAPPY_TWINS);
+const ENCIRCLE_THREE_MISSING_ONE_RULE = getRuleById(RuleIds.ENCIRCLE_THREE_MISSING_ONE);
+const THREE_SIX_NINE_GRADES_RULE = getRuleById(RuleIds.THREE_SIX_NINE_GRADES);
+const IRON_EVIDENCE_RULE = getRuleById(RuleIds.IRON_EVIDENCE);
+const WAITING_RABBIT_RULE = getRuleById(RuleIds.WAITING_RABBIT);
+const HIDDEN_DRAGON_RULE = getRuleById(RuleIds.HIDDEN_DRAGON_IN_ABYSS);
+const ADMINISTRATIVE_REVIEW_RULE = getRuleById(RuleIds.ADMINISTRATIVE_REVIEW);
+const POLITICAL_REVIEW_RULE = getRuleById(RuleIds.POLITICAL_REVIEW);
+const NO_ONE_SURVIVES_RULE = getRuleById(RuleIds.NO_ONE_SURVIVES);
+const LURE_TIGER_RULE = getRuleById(RuleIds.LURE_TIGER_FROM_MOUNTAIN);
+const DEFENSE_AS_OFFENSE_RULE = getRuleById(RuleIds.DEFENSE_AS_OFFENSE);
+const ANTINOMY_RULE = getRuleById(RuleIds.ANTINOMY);
+const CHANGE_RICE_TO_MULBERRY_RULE = getRuleById(RuleIds.CHANGE_RICE_TO_MULBERRY);
+const DESTROY_DYKE_RULE = getRuleById(RuleIds.DESTROY_DYKE_FLOOD_FIELDS);
+const RECORD_ON_FILE_RULE = getRuleById(RuleIds.RECORD_ON_FILE);
+const WEIGHING_THOUSAND_JIN_RULE = getRuleById(RuleIds.WEIGHING_THOUSAND_JIN);
+const KING_OVER_WHITE_RULE = getRuleById(RuleIds.KING_OVER_WHITE);
+const FEAR_OF_BREAKING_VASE_RULE = getRuleById(RuleIds.FEAR_OF_BREAKING_VASE);
+const EIGHT_KINGS_COUNCIL_RULE = getRuleById(RuleIds.EIGHT_KINGS_COUNCIL);
+const NINE_PRINCES_SUCCESSION_RULE = getRuleById(RuleIds.NINE_PRINCES_SUCCESSION);
+
+function createRoom(config = {}) {
+ const room = new Room('规则测试房', 'socket-0', { dealInterval: 10, ...config });
+ for (let index = 0; index < 4; index++) {
+ room.addPlayer(new Player(`socket-${index}`, `玩家${index}`, index));
+ }
+ return room;
+}
+
+function createIo() {
+ const events = [];
+ return {
+ events,
+ to(target) {
+ return {
+ emit(event, payload) {
+ events.push({ target, event, payload });
+ }
+ };
+ }
+ };
+}
+
+function sequenceRandom(values) {
+ let index = 0;
+ return () => values[Math.min(index++, values.length - 1)];
+}
+
+function card(suit, rank, copyIndex = 0) {
+ return new Card(suit, rank, copyIndex);
+}
+
+function singlePlay(cardValue, trumpSuit, trumpRank, rule) {
+ return {
+ cards: [cardValue],
+ pattern: detectPattern([cardValue], trumpSuit, trumpRank, rule)
+ };
+}
+
+test('首家甩散牌时对子可拆成单牌通道,仍由最大单张决定', () => {
+ const trumpSuit = 'spades';
+ const trumpRank = '2';
+ const leadingCards = [card('hearts', 'A'), card('hearts', 'K')];
+ const leadingThrow = parseThrowCombination(
+ leadingCards,
+ trumpSuit,
+ trumpRank,
+ NORMAL_RULE
+ );
+ const leadingPattern = {
+ type: PatternTypes.THROW,
+ suit: leadingThrow.suit,
+ components: leadingThrow.components,
+ length: leadingCards.length,
+ strength: Math.max(...leadingThrow.components.map(component => component.strength))
+ };
+ const pairTens = [card('hearts', '10', 0), card('hearts', '10', 1)];
+ const following = validateFollowingPlay(
+ pairTens,
+ pairTens,
+ leadingPattern,
+ trumpSuit,
+ trumpRank,
+ NORMAL_RULE
+ );
+
+ assert.equal(following.valid, true);
+ assert.deepEqual(
+ leadingPattern.components.map(component => component.type),
+ [PatternTypes.SINGLE, PatternTypes.SINGLE]
+ );
+ assert.deepEqual(
+ following.pattern.components.map(component => component.type),
+ [PatternTypes.PAIR]
+ );
+ assert.ok(
+ compareCards(
+ { cards: pairTens, pattern: following.pattern },
+ { cards: leadingCards, pattern: leadingPattern },
+ leadingPattern.suit,
+ trumpSuit,
+ trumpRank,
+ NORMAL_RULE,
+ leadingPattern
+ ) <= 0
+ );
+
+ const room = createRoom();
+ const engine = new GameEngine(room, createIo());
+ leadingCards.forEach(value => room.players[0].addCard(value));
+ room.players[0].addCard(card('clubs', '3'));
+ pairTens.forEach(value => room.players[1].addCard(value));
+ room.players[1].addCard(card('clubs', '4'));
+ [card('hearts', '9'), card('hearts', '8'), card('clubs', '5')]
+ .forEach(value => room.players[2].addCard(value));
+ [card('hearts', '7'), card('hearts', '6'), card('clubs', '6')]
+ .forEach(value => room.players[3].addCard(value));
+ room.gameState.phase = GamePhases.PLAYING;
+ room.gameState.trumpSuit = trumpSuit;
+ room.gameState.trumpRank = trumpRank;
+ room.gameState.selectedRule = NORMAL_RULE;
+ engine.setFirstPlayer(room.players[0].id);
+
+ engine.playCards(room.players[0].id, leadingCards.map(value => value.id));
+ engine.playCards(room.players[1].id, pairTens.map(value => value.id));
+
+ assert.equal(room.gameState.currentWinnerIndex, 0);
+});
+
+test('首家甩三张散主时,小王加对子按最大单张取得牌权', () => {
+ const trumpSuit = 'hearts';
+ const trumpRank = '2';
+ const leadingCards = [
+ card('clubs', trumpRank, 10),
+ card(trumpSuit, 'A', 10),
+ card(trumpSuit, 'Q', 10)
+ ];
+ const leadingThrow = parseThrowCombination(
+ leadingCards,
+ trumpSuit,
+ trumpRank,
+ NORMAL_RULE
+ );
+ const leadingPattern = {
+ type: PatternTypes.THROW,
+ suit: leadingThrow.suit,
+ components: leadingThrow.components,
+ length: leadingCards.length,
+ strength: Math.max(...leadingThrow.components.map(component => component.strength))
+ };
+ const challengerCards = [
+ card('joker', 'small_joker', 10),
+ card(trumpSuit, '7', 10),
+ card(trumpSuit, '7', 11)
+ ];
+ const challengerThrow = parseThrowCombination(
+ challengerCards,
+ trumpSuit,
+ trumpRank,
+ NORMAL_RULE
+ );
+ const challengerPattern = {
+ type: PatternTypes.THROW,
+ suit: challengerThrow.suit,
+ components: challengerThrow.components,
+ length: challengerCards.length,
+ strength: Math.max(...challengerThrow.components.map(component => component.strength))
+ };
+
+ assert.deepEqual(
+ leadingPattern.components.map(component => component.type),
+ [PatternTypes.SINGLE, PatternTypes.SINGLE, PatternTypes.SINGLE]
+ );
+ assert.deepEqual(
+ challengerPattern.components.map(component => component.type).sort(),
+ [PatternTypes.PAIR, PatternTypes.SINGLE].sort()
+ );
+ assert.equal(
+ compareCards(
+ { cards: challengerCards, pattern: challengerPattern },
+ { cards: leadingCards, pattern: leadingPattern },
+ leadingPattern.suit,
+ trumpSuit,
+ trumpRank,
+ NORMAL_RULE,
+ leadingPattern
+ ),
+ 1
+ );
+});
+
+test('已按首家散牌结构完成毙牌后,异门对子加单牌不能反压主牌', () => {
+ const trumpSuit = 'spades';
+ const trumpRank = '2';
+ const makeThrowPattern = cards => {
+ const parsed = parseThrowCombination(cards, trumpSuit, trumpRank, NORMAL_RULE);
+ return {
+ type: PatternTypes.THROW,
+ suit: parsed.suit,
+ components: parsed.components,
+ length: cards.length,
+ strength: Math.max(...parsed.components.map(component => component.strength))
+ };
+ };
+ const leadingCards = [
+ card('hearts', 'Q', 20),
+ card('hearts', '8', 20),
+ card('hearts', '7', 20)
+ ];
+ const trumpCards = [
+ card(trumpSuit, 'K', 20),
+ card(trumpSuit, 'J', 20),
+ card(trumpSuit, '5', 20)
+ ];
+ const offSuitCards = [
+ card('diamonds', '8', 20),
+ card('diamonds', '8', 21),
+ card('diamonds', '7', 20)
+ ];
+ const leadingPattern = makeThrowPattern(leadingCards);
+
+ assert.equal(
+ compareCards(
+ { cards: offSuitCards, pattern: makeThrowPattern(offSuitCards) },
+ { cards: trumpCards, pattern: makeThrowPattern(trumpCards) },
+ leadingPattern.suit,
+ trumpSuit,
+ trumpRank,
+ NORMAL_RULE,
+ leadingPattern
+ ),
+ -1
+ );
+});
+
+test('毙了和盖毙结果会记录攻击者与被压牌玩家,供局部动画定位', () => {
+ const room = createRoom();
+ const engine = new GameEngine(room, createIo());
+ const lead = card('hearts', 'A', 90);
+ const ruff = card('spades', '3', 90);
+ const overruff = card('spades', 'A', 90);
+ const last = card('clubs', '4', 90);
+ [lead, ruff, overruff, last].forEach((value, index) => {
+ room.players[index].addCard(value);
+ });
+ room.gameState.phase = GamePhases.PLAYING;
+ room.gameState.trumpSuit = 'spades';
+ room.gameState.trumpRank = '2';
+ room.gameState.selectedRule = NORMAL_RULE;
+ engine.setFirstPlayer(room.players[0].id);
+
+ engine.playCards(room.players[0].id, [lead.id]);
+ const ruffResult = engine.playCards(room.players[1].id, [ruff.id]);
+ assert.deepEqual(
+ ruffResult.trumpAction,
+ {
+ type: 'trump',
+ playerId: room.players[1].id,
+ playerName: room.players[1].name,
+ targetPlayerId: room.players[0].id,
+ targetPlayerName: room.players[0].name
+ }
+ );
+
+ const overruffResult = engine.playCards(room.players[2].id, [overruff.id]);
+ assert.deepEqual(
+ overruffResult.trumpAction,
+ {
+ type: 'overtrump',
+ playerId: room.players[2].id,
+ playerName: room.players[2].name,
+ targetPlayerId: room.players[1].id,
+ targetPlayerName: room.players[1].name
+ }
+ );
+});
+
+test('首局随机玩家获得二选一权限,其他玩家和伪造规则都会被拒绝', () => {
+ const room = createRoom();
+ const io = createIo();
+ // 0.6 -> 玩家2;0.2 -> 仅改变两个候选的展示顺序。
+ const engine = new GameEngine(room, io, sequenceRandom([0.6, 0.2]));
+
+ engine.startGame();
+
+ assert.equal(room.gameState.ruleChooserPlayerId, room.players[2].id);
+ assert.equal(room.gameState.isRuleSelectionPending, true);
+ assert.equal(room.gameState.ruleOptions.length, 2);
+ assert.equal(new Set(room.gameState.ruleOptions.map(rule => rule.id)).size, 2);
+ const implementedIds = new Set(getImplementedRules().map(rule => rule.id));
+ assert.ok(room.gameState.ruleOptions.every(rule => implementedIds.has(rule.id)));
+ assert.throws(
+ () => engine.selectRule(room.players[1].id, room.gameState.ruleOptions[0]),
+ /不是本局的规则选择者/
+ );
+ assert.throws(
+ () => engine.selectRule(room.players[2].id, { id: 'unimplemented_rule' }),
+ /只能从本局提供的两条规则中选择/
+ );
+});
+
+test('准备和规则选择两个条件都完成后才开始发牌,且规则不能二次更换', () => {
+ const room = createRoom();
+ const engine = new GameEngine(room, createIo(), sequenceRandom([0.1, 0.8]));
+ let drawingStarts = 0;
+ engine.startDrawing = () => { drawingStarts++; };
+
+ engine.startGame();
+ const chooser = room.findPlayerById(room.gameState.ruleChooserPlayerId);
+
+ room.players.forEach(player => engine.playerReady(player.id));
+ assert.equal(room.gameState.isWaitingForReady, false);
+ assert.equal(room.gameState.isRuleSelectionPending, true);
+ assert.equal(drawingStarts, 0);
+
+ const selectedOption = room.gameState.ruleOptions[0];
+ engine.selectRule(chooser.id, { id: selectedOption.id, content: '客户端伪造描述' });
+ assert.equal(room.gameState.selectedRule.id, selectedOption.id);
+ assert.equal(room.gameState.selectedRule.content, getRuleById(selectedOption.id).content);
+ assert.equal(drawingStarts, 1);
+ assert.throws(
+ () => engine.selectRule(chooser.id, { id: RuleIds.NORMAL_GAME }),
+ /当前不在规则选择阶段/
+ );
+});
+
+test('双喜临门改为三选二,房主可逐条刷新且两条子规则同时生效', () => {
+ const room = createRoom();
+ const io = createIo();
+ const engine = new GameEngine(room, io, sequenceRandom([0.1, 0.8, 0.3, 0.6]));
+ const chooser = room.players[1];
+ room.gameState.isWaitingForReady = true;
+ room.gameState.isRuleSelectionPending = true;
+ room.gameState.ruleSelectionMode = 'single';
+ room.gameState.ruleChooserPlayerId = chooser.id;
+ room.gameState.ruleOptions = [DOUBLE_HAPPINESS_RULE, NORMAL_RULE];
+
+ const enteredSecondStage = engine.selectRule(chooser.id, DOUBLE_HAPPINESS_RULE);
+ assert.equal(enteredSecondStage, false);
+ assert.equal(room.gameState.isRuleSelectionPending, true);
+ assert.equal(room.gameState.ruleSelectionMode, 'double_happiness');
+ assert.equal(room.gameState.ruleOptions.length, 3);
+ assert.equal(new Set(room.gameState.ruleOptions.map(rule => rule.id)).size, 3);
+ assert.ok(
+ room.gameState.ruleOptions.every(rule => rule.id !== RuleIds.DOUBLE_HAPPINESS)
+ );
+
+ assert.throws(
+ () => engine.refreshDoubleHappinessOption('socket-2', 0),
+ /只有房主/
+ );
+ const idsBeforeRefresh = room.gameState.ruleOptions.map(rule => rule.id);
+ const refreshed = engine.refreshDoubleHappinessOption('socket-0', 0);
+ assert.equal(refreshed.oldRule.id, idsBeforeRefresh[0]);
+ assert.notEqual(refreshed.rule.id, refreshed.oldRule.id);
+ assert.equal(new Set(room.gameState.ruleOptions.map(rule => rule.id)).size, 3);
+ assert.ok(!idsBeforeRefresh.includes(refreshed.rule.id));
+
+ room.gameState.ruleOptions = [
+ REVERSE_RULE,
+ SINGLE_STEP_DEBUG_RULE,
+ NORMAL_RULE
+ ];
+ engine.selectRule(chooser.id, {
+ ids: [RuleIds.REVERSE_RANK_ORDER, RuleIds.SINGLE_STEP_DEBUG]
+ });
+
+ assert.equal(room.gameState.isRuleSelectionPending, false);
+ assert.equal(room.gameState.selectedRule.id, RuleIds.DOUBLE_HAPPINESS);
+ assert.deepEqual(
+ room.gameState.selectedRule.rules.map(rule => rule.id),
+ [RuleIds.REVERSE_RANK_ORDER, RuleIds.SINGLE_STEP_DEBUG]
+ );
+ assert.equal(isReverseRankOrderRule(room.gameState.selectedRule), true);
+ assert.equal(isSingleStepDebugRule(room.gameState.selectedRule), true);
+ assert.ok(
+ getCardStrength(
+ card('hearts', '2'),
+ 'spades',
+ '7',
+ room.gameState.selectedRule
+ ) > getCardStrength(
+ card('hearts', 'A'),
+ 'spades',
+ '7',
+ room.gameState.selectedRule
+ )
+ );
+ assert.deepEqual(getRuleSetup(room.gameState.selectedRule), {
+ bottomCardsCount: 8,
+ attackerStartingScore: 0
+ });
+ assert.equal(
+ io.events.some(({ event }) => event === 'double_happiness_option_refreshed'),
+ true
+ );
+});
+
+test('规则测试模式跳过随机二选一并在准备完成后直接发牌', () => {
+ const room = createRoom({
+ testMode: true,
+ testRuleId: RuleIds.PERFECT_STRATEGY
+ });
+ const io = createIo();
+ const engine = new GameEngine(room, io, sequenceRandom([0.1, 0.8]));
+ let drawingStarts = 0;
+ engine.startDrawing = () => { drawingStarts++; };
+
+ engine.startGame();
+
+ assert.equal(room.gameState.selectedRule.id, RuleIds.PERFECT_STRATEGY);
+ assert.equal(room.gameState.isRuleSelectionPending, false);
+ assert.equal(room.gameState.ruleChooserPlayerId, null);
+ assert.equal(room.gameState.bottomCardsCount, 8);
+ assert.equal(room.gameState.attackerScore, 10);
+ assert.equal(io.events.some(({ event }) => event === 'rule_selection_started'), false);
+ assert.equal(io.events.find(({ event }) => event === 'rule_selected')?.payload.testMode, true);
+
+ room.players.forEach(player => engine.playerReady(player.id));
+ assert.equal(drawingStarts, 1);
+});
+
+test('规则测试模式选择双喜临门时由房主直接进入三选二', () => {
+ const room = createRoom({
+ testMode: true,
+ testRuleId: RuleIds.DOUBLE_HAPPINESS
+ });
+ const io = createIo();
+ const engine = new GameEngine(room, io, sequenceRandom([0.9, 0.2, 0.7]));
+
+ engine.startGame();
+
+ assert.equal(room.gameState.selectedRule, null);
+ assert.equal(room.gameState.isRuleSelectionPending, true);
+ assert.equal(room.gameState.ruleSelectionMode, 'double_happiness');
+ assert.equal(room.gameState.ruleChooserPlayerId, room.players[0].id);
+ assert.equal(room.gameState.ruleOptions.length, 3);
+ assert.ok(
+ room.gameState.ruleOptions.every(rule => rule.id !== RuleIds.DOUBLE_HAPPINESS)
+ );
+ assert.equal(
+ io.events.some(({ event, payload }) => (
+ event === 'rule_selected' && payload.rule?.id === RuleIds.NORMAL_GAME
+ )),
+ false
+ );
+});
+
+test('规则测试模式拒绝不存在的规则', () => {
+ assert.throws(
+ () => createRoom({ testMode: true, testRuleId: 'not-implemented' }),
+ /必须选择一条已实现规则/
+ );
+});
+
+test('下一局由上一局最后一轮赢家选择规则', () => {
+ const room = createRoom();
+ const engine = new GameEngine(room, createIo(), sequenceRandom([0.4]));
+ engine.startDrawing = () => {};
+
+ room.gameState.buryingPlayerId = room.players[0].id;
+ room.gameState.lastRoundWinnerIndex = 3;
+ room.gameState.lastRoundLeadingPattern = { type: PatternTypes.SINGLE, length: 1 };
+ engine.finishGame();
+
+ assert.equal(room.gameState.nextRuleChooserIndex, 3);
+ engine.startNextGame();
+ assert.equal(room.gameState.ruleChooserPlayerId, room.players[3].id);
+ assert.equal(room.gameState.isRuleSelectionPending, true);
+});
+
+test('倒反天罡颠倒普通牌大小,但级牌和王保持最高层级', () => {
+ const trumpSuit = 'spades';
+ const trumpRank = '7';
+ const two = card('hearts', '2');
+ const ace = card('hearts', 'A');
+ const offSuitLevel = card('hearts', '7');
+ const mainLevel = card('spades', '7');
+ const smallJoker = card('joker', 'small_joker');
+ const bigJoker = card('joker', 'big_joker');
+
+ assert.ok(getCardStrength(ace, trumpSuit, trumpRank, NORMAL_RULE) >
+ getCardStrength(two, trumpSuit, trumpRank, NORMAL_RULE));
+ assert.ok(getCardStrength(two, trumpSuit, trumpRank, REVERSE_RULE) >
+ getCardStrength(ace, trumpSuit, trumpRank, REVERSE_RULE));
+
+ const ordered = [two, offSuitLevel, mainLevel, smallJoker, bigJoker]
+ .map(value => getCardStrength(value, trumpSuit, trumpRank, REVERSE_RULE));
+ assert.deepEqual(ordered, [...ordered].sort((a, b) => a - b));
+
+ const twoPlay = singlePlay(two, trumpSuit, trumpRank, REVERSE_RULE);
+ const acePlay = singlePlay(ace, trumpSuit, trumpRank, REVERSE_RULE);
+ assert.equal(
+ compareCards(twoPlay, acePlay, 'hearts', trumpSuit, trumpRank, REVERSE_RULE),
+ 1
+ );
+});
+
+test('倒反天罡下拖拉机仍按颠倒后的相邻顺序并跳过级牌', () => {
+ const cardsAcrossLevel = [
+ card('hearts', '8', 0),
+ card('hearts', '8', 1),
+ card('hearts', '6', 0),
+ card('hearts', '6', 1)
+ ];
+ const cardsWithGap = [
+ card('hearts', '8', 0),
+ card('hearts', '8', 1),
+ card('hearts', '5', 0),
+ card('hearts', '5', 1)
+ ];
+
+ assert.equal(
+ detectPattern(cardsAcrossLevel, 'spades', '7', REVERSE_RULE).type,
+ PatternTypes.TRACTOR
+ );
+ assert.equal(
+ detectPattern(cardsWithGap, 'spades', '7', REVERSE_RULE).type,
+ PatternTypes.INVALID
+ );
+});
+
+test('特殊开局规则具有正确的底牌数量与闲家初始分', () => {
+ const expectedSetups = [
+ [RuleIds.ABUNDANT_HARVEST, '五谷丰登', 12, 30],
+ [RuleIds.EXTREME_CHALLENGE, '极限挑战', 16, 60],
+ [RuleIds.HALF_REALM, '江山半壁', 4, -10],
+ [RuleIds.SHARED_PROSPERITY, '与民同乐', 0, -20],
+ [RuleIds.PERFECT_STRATEGY, '算无遗策', 8, 10],
+ [RuleIds.PEOPLE_COMMUNE, '人民公社', 0, -20],
+ [RuleIds.ADMINISTRATIVE_REVIEW, '行政审查', 12, 0],
+ [RuleIds.POLITICAL_REVIEW, '政治审查', 8, 0],
+ [RuleIds.NO_ONE_SURVIVES, '无人生还', 8, 0],
+ [RuleIds.LURE_TIGER_FROM_MOUNTAIN, '调虎离山', 8, 0],
+ [RuleIds.DEFENSE_AS_OFFENSE, '以守为攻', 8, 0],
+ [RuleIds.ANTINOMY, '二律背反', 8, 0],
+ [RuleIds.CHANGE_RICE_TO_MULBERRY, '改稻为桑', 8, 0],
+ [RuleIds.DESTROY_DYKE_FLOOD_FIELDS, '毁堤淹田', 8, 0],
+ [RuleIds.RECORD_ON_FILE, '记录在案', 8, 0],
+ [RuleIds.WEIGHING_THOUSAND_JIN, '上称千斤', 8, 0],
+ [RuleIds.KING_OVER_WHITE, '王上加白', 8, 0],
+ [RuleIds.FEAR_OF_BREAKING_VASE, '投鼠忌器', 8, 0],
+ [RuleIds.EIGHT_KINGS_COUNCIL, '八王议政', 8, 0],
+ [RuleIds.NINE_PRINCES_SUCCESSION, '九子夺嫡', 8, 0]
+ ];
+
+ assert.equal(getImplementedRules().length, 108);
+ for (const [id, name, bottomCardsCount, attackerStartingScore] of expectedSetups) {
+ assert.equal(getRuleById(id).name, name);
+ assert.deepEqual(getRuleSetup({ id }), { bottomCardsCount, attackerStartingScore });
+ }
+});
+
+test('王上加白在洗牌后随机将且仅将一张普通王变为全局最大的白王', () => {
+ assert.equal(KING_OVER_WHITE_RULE.name, '王上加白');
+ assert.deepEqual(getRuleSetup(KING_OVER_WHITE_RULE), {
+ bottomCardsCount: 8,
+ attackerStartingScore: 0
+ });
+
+ const room = createRoom();
+ const manager = new DrawingPhaseManager(
+ room,
+ createIo(),
+ null,
+ null,
+ null,
+ () => 0.5
+ );
+ const originalShuffle = DeckService.shuffle;
+ room.gameState.selectedRule = KING_OVER_WHITE_RULE;
+
+ try {
+ DeckService.shuffle = deck => [...deck];
+ manager.start();
+ manager.stop();
+ } finally {
+ DeckService.shuffle = originalShuffle;
+ }
+
+ const fullDeck = [
+ ...room.gameState.bottomCards,
+ ...room.gameState.deck
+ ];
+ const jokers = fullDeck.filter(value => value.suit === 'joker');
+ const whiteJokers = jokers.filter(value => value.rank === Ranks.WHITE_JOKER);
+ const ordinaryJokers = jokers.filter(value => (
+ [Ranks.SMALL_JOKER, Ranks.BIG_JOKER].includes(value.rank)
+ ));
+
+ assert.equal(fullDeck.length, 108);
+ assert.equal(jokers.length, 4);
+ assert.equal(whiteJokers.length, 1);
+ assert.equal(ordinaryJokers.length, 3);
+ assert.equal(whiteJokers[0].id, 'joker-small_joker-1');
+ assert.equal(whiteJokers[0].value, whiteJokers[0].calculateValue());
+ assert.equal(whiteJokers[0].toJSON().rank, Ranks.WHITE_JOKER);
+ assert.ok(
+ getCardStrength(whiteJokers[0], 'hearts', '2', KING_OVER_WHITE_RULE)
+ > getCardStrength(card('joker', Ranks.BIG_JOKER), 'hearts', '2', KING_OVER_WHITE_RULE)
+ );
+ assert.equal(
+ DeckService.createDeck().filter(value => value.rank === Ranks.WHITE_JOKER).length,
+ 0
+ );
+});
+
+test('八王议政加入两张郡王和两张亲王,正常配置下四家各摸26张', () => {
+ assert.equal(EIGHT_KINGS_COUNCIL_RULE.name, '八王议政');
+ assert.deepEqual(getRuleSetup(EIGHT_KINGS_COUNCIL_RULE), {
+ bottomCardsCount: 8,
+ attackerStartingScore: 0
+ });
+
+ const room = createRoom();
+ const io = createIo();
+ const manager = new DrawingPhaseManager(room, io);
+ const originalShuffle = DeckService.shuffle;
+ room.gameState.selectedRule = EIGHT_KINGS_COUNCIL_RULE;
+
+ try {
+ DeckService.shuffle = deck => [...deck];
+ manager.start();
+ manager.stop();
+ } finally {
+ DeckService.shuffle = originalShuffle;
+ }
+
+ const fullDeck = [
+ ...room.gameState.bottomCards,
+ ...room.gameState.deck
+ ];
+ assert.equal(fullDeck.length, 112);
+ assert.equal(room.gameState.bottomCards.length, 8);
+ assert.equal(room.gameState.deck.length, 104);
+ assert.equal(
+ fullDeck.filter(value => value.rank === Ranks.COUNTY_PRINCE_JOKER).length,
+ 2
+ );
+ assert.equal(
+ fullDeck.filter(value => value.rank === Ranks.PRINCE_JOKER).length,
+ 2
+ );
+ assert.equal(
+ new Set(fullDeck.map(value => value.id)).size,
+ 112,
+ '新增王必须拥有唯一实体牌 ID'
+ );
+
+ while (room.gameState.drawingIndex < room.gameState.deck.length) {
+ manager.dealOneCard();
+ }
+ assert.deepEqual(room.players.map(player => player.cards.length), [26, 26, 26, 26]);
+ assert.ok(
+ getCardStrength(
+ card('joker', Ranks.PRINCE_JOKER),
+ 'hearts',
+ '2',
+ EIGHT_KINGS_COUNCIL_RULE
+ ) > getCardStrength(
+ card('joker', Ranks.COUNTY_PRINCE_JOKER),
+ 'hearts',
+ '2',
+ EIGHT_KINGS_COUNCIL_RULE
+ )
+ );
+});
+
+test('九子夺嫡只由赢家收下的对方分牌触发,并私下永久晋升一张手牌', () => {
+ const room = createRoom();
+ const io = createIo();
+ const engine = new GameEngine(room, io);
+ const winner = room.players[1];
+ room.gameState.phase = GamePhases.PLAYING;
+ room.gameState.selectedRule = NINE_PRINCES_SUCCESSION_RULE;
+ room.gameState.trumpSuit = 'spades';
+ room.gameState.trumpRank = '2';
+ room.gameState.buryingPlayerId = room.players[0].id;
+ winner.cards = [card('hearts', '4', 880)];
+
+ assert.equal(
+ engine.prepareNinePrincesDecisionAtRoundEnd({
+ winner,
+ completedRound: 1,
+ roundPlays: [{
+ playerId: room.players[3].id,
+ originalCards: [card('clubs', 'K', 879)]
+ }]
+ }),
+ null,
+ '只收下队友打出的分牌不能触发'
+ );
+
+ const pending = engine.prepareNinePrincesDecisionAtRoundEnd({
+ winner,
+ completedRound: 1,
+ roundPlays: [
+ {
+ playerId: room.players[0].id,
+ originalCards: [card('clubs', 'K', 881)]
+ },
+ {
+ playerId: winner.id,
+ originalCards: [card('hearts', 'A', 882)]
+ }
+ ]
+ });
+
+ assert.equal(pending.playerId, winner.id);
+ assert.equal(room.gameState.toJSON().ninePrinces.pending.playerId, winner.id);
+ assert.equal(
+ Object.hasOwn(room.gameState.toJSON().ninePrinces.pending, 'eligibleCardIds'),
+ false,
+ '公共快照不得泄露候选手牌'
+ );
+ const reconnectRequest = engine.getPrivateGameStateSyncEvents(winner.id)
+ .find(event => event.event === 'nine_princes_selection_required');
+ assert.equal(reconnectRequest.payload.candidates.length, 1);
+ assert.equal(reconnectRequest.payload.candidates[0].card.id, winner.cards[0].id);
+ assert.throws(
+ () => engine.assertNinePrincesDecisionComplete(),
+ /九子夺嫡/
+ );
+
+ engine.beginNinePrincesDecision();
+ assert.equal(
+ io.events.filter(event => event.event === 'nine_princes_selection_required').length,
+ 1
+ );
+ const result = engine.respondNinePrincesDecision(winner.id, winner.cards[0].id);
+ assert.equal(result.promoted, true);
+ assert.equal(result.becameWhite, false);
+ assert.equal(winner.cards[0].rank, '5');
+ assert.equal(winner.cards[0].ninePrincesScoringRank, '4');
+ assert.equal(winner.cards[0].isNinePrincesPromoted, true);
+ assert.equal(winner.cards[0].ninePrincesPromotionCount, 1);
+ assert.equal(getCardPoints(winner.cards[0]), 0, '晋升只改牌面,不改变实体分值');
+ assert.equal(room.gameState.ninePrincesDecision, null);
+ assert.equal(room.gameState.ninePrincesResolved, false);
+
+ room.gameState.selectedRule = {
+ id: 'double_happiness',
+ rules: [NINE_PRINCES_SUCCESSION_RULE, STRENGTH_COMPENSATION_RULE]
+ };
+ room.gameState.currentRound = 1;
+ engine.applyStrengthCompensationForRound({ emit: false });
+ assert.equal(winner.cards[0].rank, '6');
+ room.gameState.currentRound = 2;
+ engine.applyStrengthCompensationForRound({ emit: false });
+ assert.equal(winner.cards[0].rank, '5', '临时牌面轮换后必须恢复到永久晋升结果');
+ assert.equal(getCardPoints(winner.cards[0]), 0);
+});
+
+test('九子夺嫡在真实轮末冻结下一轮,完成选择后恢复赢家牌权', () => {
+ const room = createRoom();
+ const engine = new GameEngine(room, createIo());
+ room.gameState.phase = GamePhases.PLAYING;
+ room.gameState.selectedRule = NINE_PRINCES_SUCCESSION_RULE;
+ room.gameState.trumpSuit = 'spades';
+ room.gameState.trumpRank = '2';
+ room.gameState.buryingPlayerId = room.players[0].id;
+ const hands = [
+ [card('hearts', '3', 883), card('clubs', '3', 884)],
+ [card('hearts', 'A', 885), card('clubs', '4', 886)],
+ [card('hearts', '5', 887), card('clubs', '5', 888)],
+ [card('hearts', '4', 889), card('clubs', '6', 893)]
+ ];
+ hands.forEach((cards, playerIndex) => {
+ room.players[playerIndex].cards = cards;
+ });
+ engine.setFirstPlayer(room.players[0].id);
+
+ engine.playCards(room.players[0].id, [hands[0][0].id]);
+ engine.playCards(room.players[1].id, [hands[1][0].id]);
+ engine.playCards(room.players[2].id, [hands[2][0].id]);
+ const roundResult = engine.playCards(room.players[3].id, [hands[3][0].id]);
+
+ assert.equal(roundResult.roundWinner.playerId, room.players[1].id);
+ assert.equal(roundResult.roundUpdate.ninePrinces.pending, true);
+ assert.equal(room.gameState.ninePrincesDecision.playerId, room.players[1].id);
+ assert.equal(room.gameState.currentPlayerIndex, 1);
+ assert.throws(
+ () => engine.playCards(room.players[1].id, [room.players[1].cards[0].id]),
+ /九子夺嫡/
+ );
+
+ engine.respondNinePrincesDecision(
+ room.players[1].id,
+ room.players[1].cards[0].id
+ );
+ assert.equal(room.gameState.ninePrincesDecision, null);
+});
+
+test('九子夺嫡升出白王后给所在阵营加10分,并永久停止后续触发', () => {
+ const room = createRoom();
+ const engine = new GameEngine(room, createIo());
+ const attackerWinner = room.players[1];
+ room.gameState.phase = GamePhases.PLAYING;
+ room.gameState.selectedRule = NINE_PRINCES_SUCCESSION_RULE;
+ room.gameState.trumpSuit = 'hearts';
+ room.gameState.trumpRank = '2';
+ room.gameState.buryingPlayerId = room.players[0].id;
+ attackerWinner.cards = [card('joker', Ranks.PRINCE_JOKER, 890)];
+
+ engine.prepareNinePrincesDecisionAtRoundEnd({
+ winner: attackerWinner,
+ completedRound: 3,
+ roundPlays: [{
+ playerId: room.players[0].id,
+ originalCards: [card('diamonds', '10', 891)]
+ }]
+ });
+ const result = engine.respondNinePrincesDecision(
+ attackerWinner.id,
+ attackerWinner.cards[0].id
+ );
+
+ assert.equal(attackerWinner.cards[0].rank, Ranks.WHITE_JOKER);
+ assert.equal(result.becameWhite, true);
+ assert.equal(result.teamBonus, 10);
+ assert.equal(result.attackerScoreDelta, 10);
+ assert.equal(room.gameState.attackerScore, 10);
+ assert.equal(room.gameState.ninePrincesResolved, true);
+ assert.equal(
+ engine.prepareNinePrincesDecisionAtRoundEnd({
+ winner: attackerWinner,
+ completedRound: 4,
+ roundPlays: [{
+ playerId: room.players[0].id,
+ originalCards: [card('clubs', '5', 892)]
+ }]
+ }),
+ null
+ );
+});
+
+test('九子夺嫡由Bot获胜时自动选择最接近白王的可晋升手牌', () => {
+ const room = createRoom();
+ const engine = new GameEngine(room, createIo());
+ const botWinner = room.players[1];
+ botWinner.isBot = true;
+ room.gameState.phase = GamePhases.PLAYING;
+ room.gameState.selectedRule = NINE_PRINCES_SUCCESSION_RULE;
+ room.gameState.trumpSuit = 'spades';
+ room.gameState.trumpRank = '2';
+ room.gameState.buryingPlayerId = room.players[0].id;
+ botWinner.cards = [
+ card('hearts', '4', 895),
+ card('joker', Ranks.PRINCE_JOKER, 896)
+ ];
+
+ engine.prepareNinePrincesDecisionAtRoundEnd({
+ winner: botWinner,
+ completedRound: 2,
+ roundPlays: [{
+ playerId: room.players[0].id,
+ originalCards: [card('hearts', '5', 897)]
+ }]
+ });
+ const result = engine.beginNinePrincesDecision();
+
+ assert.equal(result.automatic, true);
+ assert.equal(result.becameWhite, true);
+ assert.equal(Object.hasOwn(result, 'privateResult'), false);
+ assert.equal(
+ botWinner.cards.some(value => value.rank === Ranks.WHITE_JOKER),
+ true
+ );
+ assert.equal(room.gameState.ninePrincesDecision, null);
+});
+
+test('九子夺嫡与时间倒流同局时先等待回溯窗口,确认保留本轮后才询问', () => {
+ const room = createRoom();
+ const io = createIo();
+ const engine = new GameEngine(room, io);
+ const winner = room.players[1];
+ room.gameState.phase = GamePhases.PLAYING;
+ room.gameState.selectedRule = {
+ id: 'double_happiness',
+ rules: [
+ NINE_PRINCES_SUCCESSION_RULE,
+ getRuleById(RuleIds.TIME_REVERSAL)
+ ]
+ };
+ room.gameState.trumpSuit = 'spades';
+ room.gameState.trumpRank = '2';
+ room.gameState.buryingPlayerId = room.players[0].id;
+ room.gameState.timeReversalDecisionState = 'holding';
+ room.gameState.timeReversalWindowRound = 1;
+ winner.cards = [card('clubs', '4', 898)];
+
+ engine.prepareNinePrincesDecisionAtRoundEnd({
+ winner,
+ completedRound: 1,
+ roundPlays: [{
+ playerId: room.players[0].id,
+ originalCards: [card('clubs', 'K', 899)]
+ }]
+ });
+ const deferred = engine.beginNinePrincesDecision();
+ assert.equal(deferred.deferred, true);
+ assert.equal(
+ io.events.some(event => event.event === 'nine_princes_selection_required'),
+ false
+ );
+
+ const closed = engine.closeTimeReversalWindow('no_reservations');
+ assert.equal(closed.ninePrinces.pending, true);
+ assert.equal(
+ io.events.some(event => event.event === 'nine_princes_selection_required'),
+ true
+ );
+});
+
+test('八王议政的一对郡王和一对亲王可依次压过普通对王亮成无主', () => {
+ const countyPair = [
+ card('joker', Ranks.COUNTY_PRINCE_JOKER, 0),
+ card('joker', Ranks.COUNTY_PRINCE_JOKER, 1)
+ ];
+ const countyDeclaration = validateDeclaration(
+ countyPair,
+ 'joker',
+ 2,
+ '2',
+ {
+ playerId: 'other-player',
+ suit: 'joker',
+ declarationType: 'pair_big_joker',
+ strength: 4
+ },
+ 'current-player'
+ );
+ assert.equal(countyDeclaration.valid, true);
+ assert.equal(countyDeclaration.declarationType, 'pair_county_prince_joker');
+ assert.equal(countyDeclaration.strength, 5);
+ assert.deepEqual(
+ countyDeclaration.cards.map(value => value.rank),
+ [Ranks.COUNTY_PRINCE_JOKER, Ranks.COUNTY_PRINCE_JOKER]
+ );
+
+ const princePair = [
+ card('joker', Ranks.PRINCE_JOKER, 0),
+ card('joker', Ranks.PRINCE_JOKER, 1)
+ ];
+ const princeDeclaration = validateDeclaration(
+ princePair,
+ 'joker',
+ 2,
+ '2',
+ {
+ playerId: 'other-player',
+ suit: 'joker',
+ declarationType: 'pair_county_prince_joker',
+ strength: 5
+ },
+ 'current-player'
+ );
+ assert.equal(princeDeclaration.valid, true);
+ assert.equal(princeDeclaration.declarationType, 'pair_prince_joker');
+ assert.equal(princeDeclaration.strength, 6);
+});
+
+test('八王议政的扩展王不会作为空牌传给外部Bot', async () => {
+ const room = createRoom();
+ const bot = new BotService('simple');
+ const countyPrince = card('joker', Ranks.COUNTY_PRINCE_JOKER, 0);
+ let externalBotCalled = false;
+ bot._callPythonBot = async () => {
+ externalBotCalled = true;
+ return { player: 0, action: [] };
+ };
+ room.gameState.selectedRule = EIGHT_KINGS_COUNCIL_RULE;
+ room.gameState.leadingPattern = null;
+ room.gameState.currentRoundPlays = [];
+ room.gameState.playHistory = [];
+
+ const action = await bot.getBotAction(
+ room.gameState,
+ [countyPrince],
+ 0,
+ room
+ );
+ assert.equal(externalBotCalled, false);
+ assert.deepEqual(action, [countyPrince.id]);
+});
+
+test('二鬼拍门在摸到第二张王时立即公开,后摸到的王也加入明置手牌', () => {
+ const room = createRoom();
+ const io = createIo();
+ const engine = new GameEngine(room, io);
+ const firstJoker = card('joker', 'small_joker', 1900);
+ const secondJoker = card('joker', 'big_joker', 1901);
+ const thirdJoker = card('joker', 'small_joker', 1902);
+ room.gameState.selectedRule = TWO_GHOSTS_KNOCK_DOOR_RULE;
+ room.gameState.phase = GamePhases.DRAWING;
+ room.gameState.deck = [
+ firstJoker,
+ card('hearts', '3', 1903),
+ card('clubs', '4', 1904),
+ card('diamonds', '5', 1905),
+ secondJoker,
+ card('spades', '6', 1906),
+ card('hearts', '7', 1907),
+ card('clubs', '8', 1908),
+ thirdJoker
+ ];
+
+ const manager = new DrawingPhaseManager(
+ room,
+ io,
+ null,
+ null,
+ null,
+ Math.random,
+ player => engine.handleRuleCardDealt(player)
+ );
+
+ manager.dealOneCard();
+ assert.equal(room.gameState.twoGhostsRevealedPlayerIds.size, 0);
+ assert.equal(io.events.some(({ event }) => event === 'rule_visible_hands_updated'), false);
+
+ for (let index = 0; index < 4; index++) manager.dealOneCard();
+ assert.deepEqual(
+ [...room.gameState.twoGhostsRevealedPlayerIds],
+ [room.players[0].id]
+ );
+ let updates = io.events.filter(({ event }) => event === 'rule_visible_hands_updated');
+ assert.equal(updates.length, 4, '四名玩家应同时收到公开手牌');
+ updates.forEach(({ payload }) => {
+ assert.equal(payload.hands.length, 1);
+ assert.equal(payload.hands[0].kind, 'jokers');
+ assert.deepEqual(
+ payload.hands[0].cards.map(value => value.id),
+ [firstJoker.id, secondJoker.id]
+ );
+ });
+
+ for (let index = 0; index < 4; index++) manager.dealOneCard();
+ updates = io.events.filter(({ event }) => event === 'rule_visible_hands_updated');
+ assert.deepEqual(
+ updates.at(-1).payload.hands[0].cards.map(value => value.id).sort(),
+ [firstJoker.id, secondJoker.id, thirdJoker.id].sort()
+ );
+ assert.deepEqual(
+ room.toJSON().gameState.twoGhosts.revealedHands[0].cards.map(value => value.id).sort(),
+ [firstJoker.id, secondJoker.id, thirdJoker.id].sort()
+ );
+});
+
+test('二鬼拍门只展示仍在手中的王,降到一张不会重新藏起,出尽后不留历史牌', () => {
+ const room = createRoom();
+ const io = createIo();
+ const engine = new GameEngine(room, io);
+ const jokers = [
+ card('joker', 'small_joker', 1910),
+ card('joker', 'big_joker', 1911)
+ ];
+ room.gameState.selectedRule = TWO_GHOSTS_KNOCK_DOOR_RULE;
+ room.gameState.phase = GamePhases.DRAWING;
+ jokers.forEach(value => room.players[0].addCard(value));
+ engine.handleRuleCardDealt(room.players[0]);
+
+ room.gameState.phase = GamePhases.PLAYING;
+ room.players[0].removeCards([jokers[0].id]);
+ engine.updateRuleHandVisibilityAfterCardChange(room.players[0]);
+ let latest = io.events.filter(({ event }) => event === 'rule_visible_hands_updated').at(-1);
+ assert.deepEqual(
+ latest.payload.hands[0].cards.map(value => value.id),
+ [jokers[1].id],
+ '已经明置的一张余王必须继续公开'
+ );
+
+ room.players[0].removeCards([jokers[1].id]);
+ engine.updateRuleHandVisibilityAfterCardChange(room.players[0]);
+ latest = io.events.filter(({ event }) => event === 'rule_visible_hands_updated').at(-1);
+ assert.deepEqual(latest.payload.hands, []);
+ assert.deepEqual(room.toJSON().gameState.twoGhosts.revealedHands, []);
+ assert.equal(JSON.stringify(room.toJSON()).includes(jokers[0].id), false);
+ assert.equal(JSON.stringify(room.toJSON()).includes(jokers[1].id), false);
+});
+
+test('人民公社把108张牌全部摸完,四家各27张后再依次各埋两张', () => {
+ const room = createRoom();
+ const io = createIo();
+ const engine = new GameEngine(room, io);
+ room.gameState.selectedRule = PEOPLE_COMMUNE_RULE;
+ room.gameState.bottomCardsCount = 0;
+ room.gameState.attackerScore = -20;
+
+ const manager = new DrawingPhaseManager(
+ room,
+ io,
+ dealer => engine.handleDealerAssigned(dealer)
+ );
+ engine.drawingManager = manager;
+ manager.start();
+ manager.stop();
+ for (let index = 0; index < 108; index++) manager.dealOneCard();
+
+ assert.equal(room.gameState.bottomCards.length, 0);
+ assert.deepEqual(room.players.map(player => player.cards.length), [27, 27, 27, 27]);
+
+ const dealer = room.players[1];
+ manager.completeDealerAssignment(dealer);
+ assert.equal(room.gameState.phase, GamePhases.BURYING);
+ assert.deepEqual(
+ room.gameState.peopleCommuneBuryingOrder,
+ [room.players[1].id, room.players[2].id, room.players[3].id, room.players[0].id]
+ );
+ assert.equal(room.gameState.peopleCommuneCurrentBuryingPlayerId, dealer.id);
+ assert.equal(io.events.some(({ event }) => event === 'bottom_cards_received'), false);
+
+ assert.throws(
+ () => engine.buryCards(room.players[2].id, room.players[2].cards.slice(0, 2).map(value => value.id)),
+ /还没有轮到你/
+ );
+
+ for (const [orderIndex, playerId] of room.gameState.peopleCommuneBuryingOrder.entries()) {
+ const player = room.findPlayerById(playerId);
+ const buriedIds = player.cards.slice(0, 2).map(value => value.id);
+ engine.buryCards(player.id, buriedIds);
+ if (orderIndex < 3) {
+ buriedIds.forEach(cardId => {
+ assert.equal(JSON.stringify(room.toJSON()).includes(cardId), false);
+ });
+ }
+ }
+
+ assert.equal(room.gameState.phase, GamePhases.PLAYING);
+ assert.equal(room.gameState.firstPlayerId, dealer.id);
+ assert.equal(room.gameState.bottomCards.length, 8);
+ assert.deepEqual(room.players.map(player => player.cards.length), [25, 25, 25, 25]);
+ assert.equal(room.gameState.peopleCommuneBuriedCardsByPlayerId.size, 4);
+});
+
+test('人民公社每名玩家只能查看自己埋下的两张牌', () => {
+ const room = createRoom();
+ room.gameState.selectedRule = PEOPLE_COMMUNE_RULE;
+ const buriedCardsByPlayer = room.players.map((player, playerIndex) => [
+ card('hearts', playerIndex === 2 ? '5' : '3', 1930 + playerIndex * 2),
+ card('clubs', playerIndex === 2 ? 'K' : '4', 1931 + playerIndex * 2)
+ ]);
+ buriedCardsByPlayer.forEach((cards, playerIndex) => {
+ room.gameState.peopleCommuneBuriedCardsByPlayerId.set(
+ room.players[playerIndex].id,
+ cards
+ );
+ });
+ room.gameState.bottomCards = buriedCardsByPlayer.flat();
+
+ const handlers = new Map();
+ const emitted = [];
+ const viewer = room.players[2];
+ const socket = {
+ id: viewer.socketId,
+ on(event, handler) {
+ handlers.set(event, handler);
+ },
+ emit(event, payload) {
+ emitted.push({ event, payload });
+ }
+ };
+
+ registerGameHandlers(createIo(), socket, {
+ getRoom: roomId => roomId === room.id ? room : null
+ });
+ handlers.get('view_my_bottom_cards')({ roomId: room.id });
+
+ const response = emitted.find(({ event }) => event === 'my_bottom_cards');
+ assert.equal(response.payload.isPeopleCommune, true);
+ assert.equal(response.payload.isPublic, false);
+ assert.deepEqual(
+ response.payload.bottomCards,
+ buriedCardsByPlayer[2].map(value => value.toJSON())
+ );
+ assert.equal(calculateRoundPoints(response.payload.bottomCards), 15);
+ buriedCardsByPlayer
+ .filter((_, playerIndex) => playerIndex !== 2)
+ .flat()
+ .forEach(otherCard => {
+ assert.equal(JSON.stringify(response.payload).includes(otherCard.id), false);
+ });
+ assert.equal(emitted.some(({ event }) => event === 'error'), false);
+});
+
+test('人民公社只抄对方埋分:闲家拿底加分,庄家方拿底令闲家扣分', () => {
+ const calculate = lastRoundWinnerIndex => {
+ const room = createRoom();
+ const engine = new GameEngine(room, createIo());
+ room.gameState.selectedRule = PEOPLE_COMMUNE_RULE;
+ room.gameState.buryingPlayerId = room.players[0].id;
+ room.gameState.attackerScore = -20;
+ room.gameState.lastRoundWinnerIndex = lastRoundWinnerIndex;
+
+ const buriedByPlayer = [
+ [card('hearts', '5', 1920), card('hearts', '3', 1921)],
+ [card('clubs', '10', 1922), card('clubs', '5', 1923)],
+ [card('diamonds', 'K', 1924), card('diamonds', '4', 1925)],
+ [card('spades', 'K', 1926), card('spades', '10', 1927)]
+ ];
+ buriedByPlayer.forEach((cards, playerIndex) => {
+ room.gameState.peopleCommuneBuriedCardsByPlayerId.set(
+ room.players[playerIndex].id,
+ cards
+ );
+ });
+ room.gameState.bottomCards = buriedByPlayer.flat();
+ return { room, result: engine.calculateBottomScore() };
+ };
+
+ const attackerBottom = calculate(1);
+ assert.equal(attackerBottom.result.peopleCommune.dealerBuriedPoints, 15);
+ assert.equal(attackerBottom.result.peopleCommune.attackerBuriedPoints, 35);
+ assert.equal(attackerBottom.result.bottomPoints, 15);
+ assert.equal(attackerBottom.result.bottomScoreGained, 30);
+ assert.equal(attackerBottom.room.gameState.attackerScore, 10);
+ assert.equal(attackerBottom.result.resultText, '闲家抄庄家底');
+
+ const dealerBottom = calculate(0);
+ assert.equal(dealerBottom.result.bottomPoints, 35);
+ assert.equal(dealerBottom.result.bottomScoreGained, -70);
+ assert.equal(dealerBottom.room.gameState.attackerScore, -90);
+ assert.equal(dealerBottom.result.resultText, '庄家方抄闲家底');
+});
+
+test('釜底抽薪记录每次反主,并由后向前让被反主者自由决定是否交换整手', () => {
+ const room = createRoom();
+ const io = createIo();
+ room.gameState.phase = GamePhases.DRAWING;
+ room.gameState.selectedRule = REMOVE_FIREWOOD_RULE;
+ room.gameState.trumpRank = '2';
+ room.players[0].cards = [card('hearts', '2', 1940), card('clubs', '3', 1941)];
+ room.players[1].cards = [
+ card('spades', '2', 1942), card('spades', '2', 1943), card('clubs', '4', 1944)
+ ];
+ room.players[2].cards = [
+ card('joker', 'small_joker', 1945), card('joker', 'small_joker', 1946), card('clubs', '5', 1947)
+ ];
+ room.players[3].cards = [
+ card('joker', 'big_joker', 1948), card('joker', 'big_joker', 1949), card('clubs', '6', 1950)
+ ];
+
+ const declare = (player, suit, count) => {
+ const handlers = new Map();
+ const emitted = [];
+ const socket = {
+ id: player.socketId,
+ on(event, handler) {
+ handlers.set(event, handler);
+ },
+ emit(event, payload) {
+ emitted.push({ event, payload });
+ }
+ };
+ registerPlayerHandlers(io, socket, {
+ getRoom: roomId => roomId === room.id ? room : null
+ });
+ handlers.get('declare_trump')({ roomId: room.id, suit, count });
+ assert.equal(emitted.some(({ event }) => event === 'error'), false);
+ };
+
+ declare(room.players[0], 'hearts', 1);
+ declare(room.players[1], 'spades', 2);
+ declare(room.players[2], 'joker', 2);
+ declare(room.players[3], 'joker', 2);
+ assert.deepEqual(
+ room.gameState.removeFirewoodCounterPairs.map(pair => [
+ pair.counteredPlayerId,
+ pair.counteringPlayerId
+ ]),
+ [
+ [room.players[0].id, room.players[1].id],
+ [room.players[1].id, room.players[2].id],
+ [room.players[2].id, room.players[3].id]
+ ]
+ );
+
+ const engine = new GameEngine(room, io, () => 0.75);
+ let completedDealerId = null;
+ engine.drawingManager = {
+ completeDealerAssignment(playerId) {
+ completedDealerId = playerId;
+ }
+ };
+ room.gameState.isTrumpDeclarationLocked = true;
+ room.gameState.pendingDealerPlayerId = room.players[3].id;
+
+ assert.equal(engine.handleDealerSelected(room.players[3]), true);
+ assert.equal(room.gameState.removeFirewoodCurrentDecision.counteredPlayerId, room.players[2].id);
+ const playerTwoHand = room.players[1].cards.map(value => value.id);
+ const playerThreeHand = room.players[2].cards.map(value => value.id);
+
+ const latestDecline = engine.respondRemoveFirewood(room.players[2].id, false);
+ assert.equal(latestDecline.accepted, false);
+ assert.deepEqual(room.players[2].cards.map(value => value.id), playerThreeHand);
+ assert.equal(room.gameState.removeFirewoodCurrentDecision.counteredPlayerId, room.players[1].id);
+
+ const middleExchange = engine.respondRemoveFirewood(room.players[1].id, true);
+ assert.equal(middleExchange.accepted, true);
+ assert.deepEqual(room.players[1].cards.map(value => value.id).sort(), [...playerThreeHand].sort());
+ assert.deepEqual(room.players[2].cards.map(value => value.id).sort(), [...playerTwoHand].sort());
+ assert.equal(room.gameState.removeFirewoodCurrentDecision.counteredPlayerId, room.players[0].id);
+
+ const earliestDecline = engine.respondRemoveFirewood(room.players[0].id, false);
+ assert.equal(earliestDecline.accepted, false);
+ assert.equal(completedDealerId, room.players[3].id, '最终反主者仍是庄家,不随换手改变');
+ assert.equal(room.gameState.removeFirewoodCurrentDecision, null);
+ assert.equal(room.gameState.postDrawStage, null);
+ assert.deepEqual(
+ room.gameState.removeFirewoodExchangeResults.map(result => result.accepted),
+ [false, true, false]
+ );
+ const exchangeEvents = io.events.filter(({ event }) => event === 'whole_hand_exchange_resolved');
+ assert.equal(exchangeEvents.length, 1, '只有明确同意的一次才交换');
+ assert.equal(exchangeEvents[0].payload.exchangeKind, 'pair');
+});
+
+test('一国两制按无声明、一对王、单方声明和双方声明结算主花色', () => {
+ assert.equal(ONE_COUNTRY_TWO_SYSTEMS_RULE.name, '一国两制');
+
+ const noDeclarations = resolveOneCountryTwoSystems(new Map(), 0);
+ assert.equal(noDeclarations.isNoTrump, true);
+ assert.equal(noDeclarations.canonicalTrumpSuit, 'no_trump');
+
+ const jokerDeclaration = resolveOneCountryTwoSystems(new Map([
+ [0, { suit: 'hearts' }],
+ [1, { suit: 'joker', declarationType: 'pair_big_joker' }]
+ ]), 0);
+ assert.equal(jokerDeclaration.isNoTrump, true);
+ assert.deepEqual(jokerDeclaration.teamTrumpSuits, { 0: null, 1: null });
+
+ const singleSide = resolveOneCountryTwoSystems(new Map([
+ [1, { suit: 'spades' }]
+ ]), 0);
+ assert.equal(singleSide.canonicalTrumpSuit, 'spades');
+ assert.deepEqual(singleSide.teamTrumpSuits, { 0: 'spades', 1: 'spades' });
+
+ const twoSides = resolveOneCountryTwoSystems(new Map([
+ [0, { suit: 'hearts' }],
+ [1, { suit: 'spades' }]
+ ]), 0);
+ assert.equal(twoSides.canonicalTrumpSuit, 'hearts');
+ assert.equal(twoSides.hasDistinctTeamSuits, true);
+ assert.deepEqual(twoSides.teamTrumpSuits, { 0: 'hearts', 1: 'spades' });
+
+ const attackerCards = mapOneCountryCards([
+ card('spades', '9', 900),
+ card('hearts', '10', 901),
+ card('clubs', 'J', 902)
+ ], 1, twoSides);
+ assert.deepEqual(
+ attackerCards.map(value => value.suit),
+ ['hearts', 'spades', 'clubs'],
+ '闲家方在规范坐标中互换双方声明花色'
+ );
+ assert.deepEqual(
+ attackerCards.map(value => value.id),
+ ['spades-9-900', 'hearts-10-901', 'clubs-J-902'],
+ '花色投影不得改变实体牌ID'
+ );
+});
+
+test('一国两制双方在本方内反主,异方同强度声明互不覆盖', () => {
+ const room = createRoom();
+ const io = createIo();
+ room.gameState.phase = GamePhases.DRAWING;
+ room.gameState.selectedRule = ONE_COUNTRY_TWO_SYSTEMS_RULE;
+ room.gameState.trumpRank = '2';
+ room.players[0].addCard(card('hearts', '2', 910));
+ room.players[1].addCard(card('spades', '2', 911));
+ room.players[2].addCard(card('diamonds', '2', 912));
+ room.players[3].addCard(card('joker', 'big_joker', 913));
+ room.players[3].addCard(card('joker', 'big_joker', 914));
+
+ const register = playerIndex => {
+ const handlers = new Map();
+ const socketEvents = [];
+ const socket = {
+ id: room.players[playerIndex].socketId,
+ on(event, handler) {
+ handlers.set(event, handler);
+ },
+ emit(event, payload) {
+ socketEvents.push({ event, payload });
+ }
+ };
+ registerPlayerHandlers(io, socket, {
+ getRoom: roomId => roomId === room.id ? room : null
+ });
+ return { handlers, socketEvents };
+ };
+
+ const team0 = register(0);
+ const team1 = register(1);
+ team0.handlers.get('declare_trump')({ roomId: room.id, suit: 'hearts', count: 1 });
+ team1.handlers.get('declare_trump')({ roomId: room.id, suit: 'spades', count: 1 });
+
+ assert.equal(team0.socketEvents.length, 0);
+ assert.equal(team1.socketEvents.length, 0);
+ assert.equal(room.gameState.oneCountryDeclarationsByTeam.get(0).suit, 'hearts');
+ assert.equal(room.gameState.oneCountryDeclarationsByTeam.get(1).suit, 'spades');
+ assert.equal(room.gameState.currentTrumpDeclaration.playerId, room.players[1].id);
+ assert.ok(io.events.some(({ event, payload }) =>
+ event === 'trump_declared' && payload.teamIndex === 0
+ ));
+ assert.ok(io.events.some(({ event, payload }) =>
+ event === 'trump_declared' && payload.teamIndex === 1
+ ));
+
+ const jokerPlayer = register(3);
+ jokerPlayer.handlers.get('declare_trump')({
+ roomId: room.id,
+ suit: 'joker',
+ count: 2
+ });
+ assert.equal(room.gameState.oneCountryDeclarationsByTeam.get(1).suit, 'joker');
+ assert.equal(room.gameState.trumpSuit, 'no_trump');
+
+ const blockedAfterJoker = register(2);
+ blockedAfterJoker.handlers.get('declare_trump')({
+ roomId: room.id,
+ suit: 'diamonds',
+ count: 1
+ });
+ assert.match(
+ blockedAfterJoker.socketEvents.at(-1).payload.message,
+ /一对王.*无主/
+ );
+
+ const manager = new DrawingPhaseManager(room, io);
+ manager.assignDealer();
+ assert.equal(room.gameState.dealerPlayerIndex, 3);
+ assert.equal(room.gameState.oneCountryResolved.isNoTrump, true);
+ assert.equal(room.gameState.trumpSuit, 'no_trump');
+});
+
+test('一国两制在花色互换后统一执行双向跟牌和大小判定', () => {
+ const room = createRoom();
+ const engine = new GameEngine(room, createIo());
+ const hands = [
+ [card('hearts', '9', 920), card('spades', 'A', 921), card('clubs', 'Q', 922)],
+ [card('spades', '10', 923), card('hearts', '6', 924)],
+ [card('hearts', '8', 925), card('spades', 'K', 926)],
+ [card('spades', 'J', 927), card('hearts', '7', 928)]
+ ];
+ room.players.forEach((player, index) => hands[index].forEach(value => player.addCard(value)));
+ room.gameState.phase = GamePhases.PLAYING;
+ room.gameState.selectedRule = ONE_COUNTRY_TWO_SYSTEMS_RULE;
+ room.gameState.trumpRank = '2';
+ room.gameState.trumpSuit = 'hearts';
+ room.gameState.dealerPlayerIndex = 0;
+ room.gameState.buryingPlayerId = room.players[0].id;
+ room.gameState.oneCountryResolved = resolveOneCountryTwoSystems(new Map([
+ [0, { suit: 'hearts' }],
+ [1, { suit: 'spades' }]
+ ]), 0);
+ engine.setFirstPlayer(room.players[0].id);
+
+ engine.playCards(room.players[0].id, [hands[0][0].id]);
+ assert.throws(
+ () => engine.playCards(room.players[1].id, [hands[1][1].id]),
+ /must|follow|suit|跟|花色|张数/i,
+ '庄家方红桃主牌首发时,闲家方必须跟自己的黑桃主牌'
+ );
+ engine.playCards(room.players[1].id, [hands[1][0].id]);
+ engine.playCards(room.players[2].id, [hands[2][0].id]);
+ const firstRound = engine.playCards(room.players[3].id, [hands[3][0].id]);
+ assert.equal(firstRound.roundUpdate.roundWinner.playerIndex, 3);
+
+ engine.playCards(room.players[3].id, [hands[3][1].id]);
+ assert.throws(
+ () => engine.playCards(room.players[0].id, [hands[0][2].id]),
+ /must|follow|suit|跟|花色|张数/i,
+ '闲家方红桃相当于庄家方黑桃,庄家方必须跟黑桃'
+ );
+ engine.playCards(room.players[0].id, [hands[0][1].id]);
+ engine.playCards(room.players[1].id, [hands[1][1].id]);
+ const secondRound = engine.playCards(room.players[2].id, [hands[2][1].id]);
+ assert.equal(secondRound.roundUpdate.roundWinner.playerIndex, 0);
+});
+
+test('一国两制使用本方主花色毙对方的副牌', () => {
+ const room = createRoom();
+ const engine = new GameEngine(room, createIo());
+ const trick = [
+ card('clubs', 'A', 930),
+ card('spades', '5', 931),
+ card('clubs', 'K', 932),
+ card('diamonds', 'A', 933)
+ ];
+ room.players.forEach((player, index) => {
+ player.addCard(trick[index]);
+ player.addCard(card('diamonds', String(index + 3), 940 + index));
+ });
+ room.gameState.phase = GamePhases.PLAYING;
+ room.gameState.selectedRule = ONE_COUNTRY_TWO_SYSTEMS_RULE;
+ room.gameState.trumpRank = '2';
+ room.gameState.trumpSuit = 'hearts';
+ room.gameState.dealerPlayerIndex = 0;
+ room.gameState.buryingPlayerId = room.players[0].id;
+ room.gameState.oneCountryResolved = resolveOneCountryTwoSystems(new Map([
+ [0, { suit: 'hearts' }],
+ [1, { suit: 'spades' }]
+ ]), 0);
+ engine.setFirstPlayer(room.players[0].id);
+
+ engine.playCards(room.players[0].id, [trick[0].id]);
+ engine.playCards(room.players[1].id, [trick[1].id]);
+ engine.playCards(room.players[2].id, [trick[2].id]);
+ const result = engine.playCards(room.players[3].id, [trick[3].id]);
+ assert.equal(result.roundUpdate.roundWinner.playerIndex, 1);
+});
+
+test('锱铢必较已注册,闲家以-10分开局且A至7分别计1至7分', () => {
+ assert.equal(METICULOUS_ACCOUNTING_RULE.name, '锱铢必较');
+ assert.deepEqual(getRuleSetup(METICULOUS_ACCOUNTING_RULE), {
+ bottomCardsCount: 8,
+ attackerStartingScore: -10
+ });
+ assert.deepEqual(
+ ['A', '2', '3', '4', '5', '6', '7', '8', '10', 'K']
+ .map(rank => getMeticulousAccountingCardPoints(card('hearts', rank))),
+ [1, 2, 3, 4, 5, 6, 7, 0, 0, 0]
+ );
+});
+
+test('锱铢必较按新分值结算每墩和底牌,原10与K不再计分', () => {
+ const room = createRoom();
+ const engine = new GameEngine(room, createIo());
+ const trickCards = [
+ card('hearts', '2', 400),
+ card('hearts', 'A', 401),
+ card('hearts', '3', 402),
+ card('hearts', '4', 403)
+ ];
+ room.players.forEach((player, index) => {
+ player.addCard(trickCards[index]);
+ player.addCard(card('clubs', ['8', '9', 'J', 'Q'][index], 410 + index));
+ });
+ room.gameState.phase = GamePhases.PLAYING;
+ room.gameState.selectedRule = METICULOUS_ACCOUNTING_RULE;
+ room.gameState.attackerScore = -10;
+ room.gameState.trumpSuit = 'spades';
+ room.gameState.trumpRank = 'K';
+ room.gameState.buryingPlayerId = room.players[0].id;
+ engine.setFirstPlayer(room.players[0].id);
+
+ engine.playCards(room.players[0].id, [trickCards[0].id]);
+ engine.playCards(room.players[1].id, [trickCards[1].id]);
+ engine.playCards(room.players[2].id, [trickCards[2].id]);
+ const roundResult = engine.playCards(room.players[3].id, [trickCards[3].id]);
+
+ assert.equal(roundResult.roundUpdate.scoreInfo.winnerIsAttacker, true);
+ assert.equal(roundResult.roundUpdate.scoreInfo.roundPoints, 10);
+ assert.equal(roundResult.roundUpdate.scoreInfo.roundPointCards.length, 4);
+ assert.equal(room.gameState.attackerScore, 0);
+
+ room.gameState.bottomCards = [
+ card('diamonds', 'A', 420),
+ card('diamonds', '2', 421),
+ card('diamonds', '7', 422),
+ card('diamonds', '10', 423),
+ card('diamonds', 'K', 424)
+ ];
+ room.gameState.lastRoundWinnerIndex = 1;
+ room.gameState.lastRoundLeadingPattern = { type: PatternTypes.SINGLE, length: 1 };
+ const bottomResult = engine.calculateBottomScore();
+
+ assert.equal(bottomResult.bottomPoints, 10);
+ assert.equal(bottomResult.bottomScoreGained, 20);
+ assert.equal(bottomResult.totalScore, 20);
+});
+
+test('如堕云雾已注册,并在出牌阶段从公共快照隐藏得分牌和分值', () => {
+ assert.equal(LOST_IN_FOG_RULE.name, '如堕云雾');
+ assert.deepEqual(getRuleSetup(LOST_IN_FOG_RULE), {
+ bottomCardsCount: 8,
+ attackerStartingScore: 0
+ });
+
+ const room = createRoom();
+ room.gameState.selectedRule = LOST_IN_FOG_RULE;
+ room.gameState.phase = GamePhases.PLAYING;
+ room.gameState.attackerScore = 15;
+ room.gameState.collectedPointCards = [card('hearts', '5', 430)];
+ const hiddenState = room.gameState.toJSON();
+ assert.equal(hiddenState.attackerScore, null);
+ assert.equal(hiddenState.collectedPointCards, null);
+
+ room.gameState.phase = GamePhases.REVEALING;
+ const revealedState = room.gameState.toJSON();
+ assert.equal(revealedState.attackerScore, 15);
+ assert.equal(revealedState.collectedPointCards.length, 1);
+});
+
+test('如堕云雾正常结算但轮末事件不泄露本轮分值、总分或得分牌', () => {
+ const room = createRoom();
+ const engine = new GameEngine(room, createIo());
+ const trickCards = [
+ card('hearts', '2', 440),
+ card('hearts', 'A', 441),
+ card('hearts', '3', 442),
+ card('hearts', '5', 443)
+ ];
+ room.players.forEach((player, index) => {
+ player.addCard(trickCards[index]);
+ player.addCard(card('clubs', ['6', '7', '8', '9'][index], 450 + index));
+ });
+ room.gameState.phase = GamePhases.PLAYING;
+ room.gameState.selectedRule = LOST_IN_FOG_RULE;
+ room.gameState.trumpSuit = 'spades';
+ room.gameState.trumpRank = 'K';
+ room.gameState.buryingPlayerId = room.players[0].id;
+ engine.setFirstPlayer(room.players[0].id);
+
+ engine.playCards(room.players[0].id, [trickCards[0].id]);
+ engine.playCards(room.players[1].id, [trickCards[1].id]);
+ engine.playCards(room.players[2].id, [trickCards[2].id]);
+ const result = engine.playCards(room.players[3].id, [trickCards[3].id]);
+
+ assert.deepEqual(result.roundUpdate.scoreInfo, { hidden: true });
+ assert.equal(room.gameState.attackerScore, 5);
+ assert.deepEqual(room.gameState.collectedPointCards.map(value => value.id), [trickCards[3].id]);
+});
+
+test('鸟尽弓藏已注册,并在同花色六张传统分牌全部打出后公开弓藏花色', () => {
+ assert.equal(BIRDS_GONE_BOW_HIDDEN_RULE.name, '鸟尽弓藏');
+ assert.deepEqual(getRuleSetup(BIRDS_GONE_BOW_HIDDEN_RULE), {
+ bottomCardsCount: 8,
+ attackerStartingScore: 0
+ });
+
+ const room = createRoom();
+ room.gameState.selectedRule = BIRDS_GONE_BOW_HIDDEN_RULE;
+ const firstFive = [
+ card('hearts', '5', 0),
+ card('hearts', '5', 1),
+ card('hearts', '10', 0),
+ card('hearts', '10', 1),
+ card('hearts', 'K', 0),
+ card('hearts', 'A', 0)
+ ];
+ assert.deepEqual(recordBirdsGoneBowHiddenPointCards({
+ gameState: room.gameState,
+ cards: firstFive
+ }), []);
+ assert.deepEqual(room.gameState.toJSON().birdsGoneBowHidden.exhaustedSuits, []);
+
+ assert.deepEqual(recordBirdsGoneBowHiddenPointCards({
+ gameState: room.gameState,
+ cards: [card('hearts', 'K', 1)]
+ }), ['hearts']);
+ assert.deepEqual(room.gameState.toJSON().birdsGoneBowHidden.exhaustedSuits, ['hearts']);
+});
+
+test('鸟尽弓藏把带分级牌统一计入主花色,并从原牌面副花色中排除', () => {
+ const room = createRoom();
+ room.gameState.selectedRule = BIRDS_GONE_BOW_HIDDEN_RULE;
+ room.gameState.trumpSuit = 'spades';
+ room.gameState.trumpRank = '5';
+
+ const heartLevelCards = [card('hearts', '5', 0), card('hearts', '5', 1)];
+ const firstHeartSidePoints = [
+ ...heartLevelCards,
+ card('hearts', '10', 0),
+ card('hearts', '10', 1),
+ card('hearts', 'K', 0)
+ ];
+ assert.deepEqual(recordBirdsGoneBowHiddenPointCards({
+ gameState: room.gameState,
+ cards: firstHeartSidePoints
+ }), []);
+ assert.ok(heartLevelCards.every(value => (
+ room.gameState.birdPlayedPointCardSuitsById.get(value.id) === 'trump'
+ )));
+
+ assert.deepEqual(recordBirdsGoneBowHiddenPointCards({
+ gameState: room.gameState,
+ cards: [card('hearts', 'K', 1)]
+ }), ['hearts'], '红桃副牌只需等待两张10和两张K,红桃5级牌不计入其中');
+
+ const remainingTrumpPoints = [
+ ...['diamonds', 'clubs', 'spades'].flatMap(suit => [
+ card(suit, '5', 0),
+ card(suit, '5', 1)
+ ]),
+ card('spades', '10', 0),
+ card('spades', '10', 1),
+ card('spades', 'K', 0),
+ card('spades', 'K', 1)
+ ];
+ assert.deepEqual(recordBirdsGoneBowHiddenPointCards({
+ gameState: room.gameState,
+ cards: remainingTrumpPoints
+ }), ['trump'], '主花色应等待八张5级牌及两张主10、两张主K全部打出');
+
+ const hand = [
+ card('diamonds', '5', 600),
+ card('spades', 'Q', 601),
+ card('clubs', '8', 602)
+ ];
+ assert.deepEqual(getBirdsGoneBowHiddenDisabledCards({
+ gameState: room.gameState,
+ playerCards: hand,
+ isLeading: true
+ }).map(value => value.id), hand.slice(0, 2).map(value => value.id));
+});
+
+test('鸟尽弓藏只限制主动首发,跟牌放行且无其他花色可出时自动解除', () => {
+ const room = createRoom();
+ const engine = new GameEngine(room, createIo());
+ const plays = [
+ [card('hearts', '5', 0), card('hearts', '5', 1)],
+ [card('hearts', '10', 0), card('hearts', '10', 1)],
+ [card('hearts', 'K', 0), card('hearts', 'K', 1)],
+ [card('hearts', '7', 0), card('hearts', '7', 1)]
+ ];
+ const nextCards = [
+ card('clubs', '3', 500),
+ card('clubs', '4', 501),
+ card('hearts', 'A', 502),
+ card('clubs', 'A', 503)
+ ];
+ room.players.forEach((player, index) => {
+ plays[index].forEach(value => player.addCard(value));
+ player.addCard(nextCards[index]);
+ if (index === 2) player.addCard(card('clubs', 'Q', 504));
+ });
+ room.gameState.phase = GamePhases.PLAYING;
+ room.gameState.selectedRule = BIRDS_GONE_BOW_HIDDEN_RULE;
+ room.gameState.trumpSuit = 'spades';
+ room.gameState.trumpRank = '2';
+ room.gameState.buryingPlayerId = room.players[0].id;
+ engine.setFirstPlayer(room.players[0].id);
+
+ engine.playCards(room.players[0].id, plays[0].map(value => value.id));
+ assert.equal(room.gameState.birdPlayedPointCardIds.size, 0, '未完成的墩不能提前固化分牌');
+ engine.undoLastPlay(room.players[0].id);
+ assert.equal(room.gameState.birdPlayedPointCardIds.size, 0, '撤回的分牌不能计入鸟尽弓藏');
+ engine.playCards(room.players[0].id, plays[0].map(value => value.id));
+ engine.playCards(room.players[1].id, plays[1].map(value => value.id));
+ engine.playCards(room.players[2].id, plays[2].map(value => value.id));
+ assert.doesNotThrow(
+ () => engine.playCards(room.players[3].id, plays[3].map(value => value.id)),
+ '第六张分牌出现后,本墩尚未出牌的玩家仍可正常跟牌'
+ );
+
+ assert.deepEqual([...room.gameState.birdExhaustedSuits], ['hearts']);
+ assert.equal(room.gameState.currentPlayerIndex, 2, '红桃K对子应赢得本墩并成为下一墩一号位');
+ assert.throws(
+ () => engine.playCards(room.players[2].id, [nextCards[2].id]),
+ /鸟尽弓藏/
+ );
+ assert.doesNotThrow(() => engine.playCards(room.players[2].id, [card('clubs', 'Q', 504).id]));
+
+ const onlyExhaustedSuit = [card('hearts', '8', 510), card('hearts', '9', 511)];
+ assert.deepEqual(getBirdsGoneBowHiddenDisabledCards({
+ gameState: room.gameState,
+ playerCards: onlyExhaustedSuit,
+ isLeading: true
+ }), [], '只剩弓藏花色时必须解除限制');
+
+ const bot = new BotService('simple');
+ assert.deepEqual(
+ bot.getFallbackAction({
+ selectedRule: BIRDS_GONE_BOW_HIDDEN_RULE,
+ currentRound: 2,
+ leadingPattern: null,
+ trumpSuit: 'spades',
+ trumpRank: '2',
+ birdExhaustedSuits: new Set(['hearts'])
+ }, [card('hearts', '8', 520), card('clubs', '8', 521)], room.players[0].id),
+ [card('clubs', '8', 521).id]
+ );
+});
+
+test('无独有偶奇数轮不收分牌且计0分,偶数轮只收当轮分牌并计双倍', () => {
+ assert.equal(ODD_EVEN_SCORING_RULE.name, '无独有偶');
+ assert.deepEqual(getRuleSetup(ODD_EVEN_SCORING_RULE), {
+ bottomCardsCount: 8,
+ attackerStartingScore: 0
+ });
+ assert.equal(getOddEvenRoundMultiplier(ODD_EVEN_SCORING_RULE, 1), 0);
+ assert.equal(getOddEvenRoundMultiplier(ODD_EVEN_SCORING_RULE, 2), 2);
+
+ const room = createRoom();
+ const engine = new GameEngine(room, createIo());
+ const firstRoundCards = [
+ card('hearts', '9', 700),
+ card('hearts', 'A', 701),
+ card('hearts', 'Q', 702),
+ card('hearts', '10', 703)
+ ];
+ const secondRoundCards = [
+ card('clubs', '9', 710),
+ card('clubs', 'A', 711),
+ card('clubs', 'Q', 712),
+ card('clubs', '10', 713)
+ ];
+ room.players.forEach((player, index) => {
+ player.addCard(firstRoundCards[index]);
+ player.addCard(secondRoundCards[index]);
+ });
+ room.gameState.phase = GamePhases.PLAYING;
+ room.gameState.selectedRule = ODD_EVEN_SCORING_RULE;
+ room.gameState.trumpSuit = 'spades';
+ room.gameState.trumpRank = '2';
+ room.gameState.buryingPlayerId = room.players[0].id;
+ engine.setFirstPlayer(room.players[1].id);
+
+ engine.playCards(room.players[1].id, [firstRoundCards[1].id]);
+ engine.playCards(room.players[2].id, [firstRoundCards[2].id]);
+ engine.playCards(room.players[3].id, [firstRoundCards[3].id]);
+ const oddResult = engine.playCards(room.players[0].id, [firstRoundCards[0].id]);
+ assert.equal(oddResult.roundUpdate.scoreInfo.baseRoundPoints, 10);
+ assert.equal(oddResult.roundUpdate.scoreInfo.roundPointMultiplier, 0);
+ assert.equal(oddResult.roundUpdate.scoreInfo.roundPoints, 0);
+ assert.equal(oddResult.roundUpdate.scoreInfo.attackerRoundPointsAwarded, 0);
+ assert.equal(room.gameState.attackerScore, 0);
+ assert.deepEqual(room.gameState.collectedPointCards, [], '奇数轮分牌不能进入左上角计分区');
+
+ engine.playCards(room.players[1].id, [secondRoundCards[1].id]);
+ engine.playCards(room.players[2].id, [secondRoundCards[2].id]);
+ engine.playCards(room.players[3].id, [secondRoundCards[3].id]);
+ const evenResult = engine.playCards(room.players[0].id, [secondRoundCards[0].id]);
+ assert.equal(evenResult.roundUpdate.scoreInfo.baseRoundPoints, 10);
+ assert.equal(evenResult.roundUpdate.scoreInfo.roundPointMultiplier, 2);
+ assert.equal(evenResult.roundUpdate.scoreInfo.roundPoints, 20);
+ assert.equal(evenResult.roundUpdate.scoreInfo.attackerRoundPointsAwarded, 20);
+ assert.equal(room.gameState.attackerScore, 20);
+ assert.deepEqual(
+ room.gameState.collectedPointCards.map(value => value.id),
+ [secondRoundCards[3].id],
+ '计分区只能放入偶数轮赢得的分牌'
+ );
+});
+
+test('无独有偶不改变底牌的常规抠底计分', () => {
+ const room = createRoom();
+ const engine = new GameEngine(room, createIo());
+ room.gameState.selectedRule = ODD_EVEN_SCORING_RULE;
+ room.gameState.buryingPlayerId = room.players[0].id;
+ room.gameState.bottomCards = [card('hearts', '5', 720)];
+ room.gameState.lastRoundWinnerIndex = 1;
+ room.gameState.lastRoundLeadingPattern = { type: PatternTypes.SINGLE, length: 1 };
+
+ const result = engine.calculateBottomScore();
+ assert.equal(result.bottomPoints, 5);
+ assert.equal(result.bottomScoreGained, 10);
+});
+
+test('第二战场由系统把王转换成最优牌面,并支持双副牌五条', () => {
+ const straightFlush = evaluateBestPokerHand([
+ card('clubs', '2', 798),
+ card('diamonds', '7', 799),
+ card('hearts', '10', 800),
+ card('hearts', 'J', 801),
+ card('hearts', 'Q', 802),
+ card('hearts', 'K', 803),
+ card('joker', 'big_joker', 804)
+ ]);
+ const fourOfAKind = evaluateBestPokerHand([
+ card('hearts', 'A', 810),
+ card('diamonds', 'A', 811),
+ card('clubs', 'A', 812),
+ card('spades', 'A', 813),
+ card('clubs', 'K', 814)
+ ]);
+ const fiveOfAKind = evaluateBestPokerHand([
+ card('hearts', 'A', 815),
+ card('diamonds', 'A', 816),
+ card('clubs', 'A', 817),
+ card('spades', 'A', 818),
+ card('joker', 'small_joker', 819)
+ ]);
+
+ assert.equal(straightFlush.categoryName, '同花顺');
+ const resolvedBigJoker = straightFlush.cards.find(value => value.secondBattlefieldWildcard);
+ assert.equal(resolvedBigJoker.rank, 'A');
+ assert.equal(resolvedBigJoker.suit, 'hearts');
+ assert.equal(resolvedBigJoker.originalRank, 'big_joker');
+ assert.equal(resolvedBigJoker.secondBattlefieldWildcard, true);
+ assert.equal(fourOfAKind.categoryName, '四条');
+ assert.equal(comparePokerScores(straightFlush.score, fourOfAKind.score), 1);
+ assert.equal(fiveOfAKind.categoryName, '五条');
+ assert.equal(fiveOfAKind.cards.find(value => value.secondBattlefieldWildcard).rank, 'A');
+ assert.equal(comparePokerScores(fiveOfAKind.score, straightFlush.score), 1);
+});
+
+test('第二战场只比较各家累计牌,四家满5张即开牌,残局不足下一场时延至终局', () => {
+ assert.equal(SECOND_BATTLEFIELD_RULE.name, '第二战场');
+ const room = createRoom();
+ const engine = new GameEngine(room, createIo(), () => 0.5);
+ room.gameState.selectedRule = SECOND_BATTLEFIELD_RULE;
+ room.gameState.buryingPlayerId = room.players[0].id;
+ room.gameState.phase = GamePhases.PLAYING;
+ const initialized = engine.initializeSecondBattlefield();
+ assert.equal(initialized.initialized, true);
+ assert.equal('communityCards' in room.gameState.toJSON().secondBattlefield, false);
+ room.players.forEach((player, playerIndex) => {
+ player.cards = Array.from({ length: 12 }, (_, index) => card('spades', '2', 900 + playerIndex * 20 + index));
+ });
+ const accumulatedCards = [
+ [card('hearts', '10', 830), card('hearts', 'J', 831), card('hearts', 'Q', 832), card('hearts', 'K', 833), card('joker', 'big_joker', 834)],
+ [card('hearts', 'A', 840), card('diamonds', 'A', 841), card('clubs', 'A', 842), card('spades', 'A', 843), card('diamonds', '10', 844)],
+ [card('clubs', '5', 850), card('spades', '7', 851), card('diamonds', '8', 852), card('clubs', 'J', 853), card('spades', 'Q', 854)],
+ [card('diamonds', '5', 860), card('clubs', '6', 861), card('spades', '9', 862), card('diamonds', 'J', 863), card('clubs', 'Q', 864)]
+ ];
+
+ for (let roundIndex = 0; roundIndex < 5; roundIndex++) {
+ room.gameState.currentRoundPlays = room.players.map((player, playerIndex) => ({
+ playerId: player.id,
+ originalCards: [accumulatedCards[playerIndex][roundIndex]],
+ cards: [accumulatedCards[playerIndex][roundIndex]]
+ }));
+ const result = engine.applySecondBattlefieldAtRoundEnd();
+ if (roundIndex < 4) {
+ assert.equal(result.triggered, false, '任一家累计不足5张时不能提前开牌');
+ const publicBattlefield = room.gameState.toJSON().secondBattlefield;
+ room.players.forEach((player, playerIndex) => {
+ assert.deepEqual(
+ publicBattlefield.accumulatedCardsByPlayerId[player.id].map(value => value.id),
+ accumulatedCards[playerIndex].slice(0, roundIndex + 1).map(value => value.id),
+ '尚未参与德州的出牌必须作为牌面公开留在玩家桌前'
+ );
+ });
+ } else {
+ assert.equal(result.triggered, true);
+ assert.equal(result.triggerRound, 0);
+ assert.deepEqual(result.winnerPlayerIds, [room.players[0].id]);
+ assert.equal(result.winningCategoryName, '同花顺');
+ assert.equal(result.scoreDelta, -5, '庄家阵营胜出应使闲家总分减少5分');
+ assert.ok(result.players.every(player => player.accumulatedCards.length === 5));
+ const resolvedJoker = result.players[0].bestFive.find(value => value.id === accumulatedCards[0][4].id);
+ assert.equal(resolvedJoker.rank, 'A');
+ assert.equal(resolvedJoker.originalRank, 'big_joker');
+ }
+ }
+ assert.equal(room.gameState.secondBattlefieldShowdownCount, 1);
+ assert.ok(
+ [...room.gameState.secondBattlefieldAccumulatedCardsByPlayerId.values()].every(cards => cards.length === 0),
+ '正常开牌后应重新累计'
+ );
+ assert.ok(
+ Object.values(room.gameState.toJSON().secondBattlefield.accumulatedCardsByPlayerId)
+ .every(cards => cards.length === 0),
+ '已经参与德州的牌必须从公开桌面收走'
+ );
+
+ room.players.forEach(player => {
+ player.cards = Array(5).fill(null);
+ });
+ room.gameState.currentRoundPlays = room.players.map((player, playerIndex) => ({
+ playerId: player.id,
+ originalCards: [
+ card('hearts', '7', 870 + playerIndex * 10),
+ card('diamonds', '8', 871 + playerIndex * 10),
+ card('clubs', '9', 872 + playerIndex * 10),
+ card('spades', '10', 873 + playerIndex * 10),
+ card('hearts', 'J', 874 + playerIndex * 10)
+ ]
+ }));
+ const exactFiveRemainingResult = engine.applySecondBattlefieldAtRoundEnd();
+ assert.equal(exactFiveRemainingResult.triggered, true, '正好剩5张手牌时仍须正常开牌');
+ assert.equal(exactFiveRemainingResult.isFinal, false);
+ assert.equal(room.gameState.secondBattlefieldFinalStage, false);
+
+ room.players.forEach(player => {
+ player.cards = Array(4).fill(null);
+ });
+ room.gameState.currentRoundPlays = room.players.map((player, playerIndex) => ({
+ playerId: player.id,
+ originalCards: [
+ card('hearts', '2', 920 + playerIndex * 10),
+ card('diamonds', '3', 921 + playerIndex * 10),
+ card('clubs', '4', 922 + playerIndex * 10),
+ card('spades', '5', 923 + playerIndex * 10),
+ card('hearts', '6', 924 + playerIndex * 10)
+ ]
+ }));
+ const deferredResult = engine.applySecondBattlefieldAtRoundEnd();
+ assert.equal(deferredResult.triggered, false);
+ assert.equal(deferredResult.isFinalStage, true, '四家剩余手牌严格少于5张后才延至最终开牌');
+
+ room.players.forEach(player => {
+ player.cards = [];
+ });
+ room.gameState.currentRoundPlays = room.players.map((player, playerIndex) => ({
+ playerId: player.id,
+ originalCards: [card('clubs', String(2 + playerIndex), 880 + playerIndex)]
+ }));
+ const finalResult = engine.applySecondBattlefieldAtRoundEnd();
+ assert.equal(finalResult.triggered, true);
+ assert.equal(finalResult.isFinal, true);
+ assert.equal(finalResult.showdownNumber, 3);
+ assert.ok(
+ finalResult.players.every(player => player.accumulatedCardCount === 6),
+ '残局必须把先前累计的5张和最后打出的牌一起用于最佳五张判定'
+ );
+});
+
+test('三条明牌规则已注册并保持默认底牌与初始分', () => {
+ const cases = [
+ [RuleIds.ICEBERG_TIP, '冰山一角'],
+ [RuleIds.MUTUAL_VISIBILITY, '互通有无'],
+ [RuleIds.OPEN_AND_HONEST, '为人坦荡']
+ ];
+ for (const [ruleId, name] of cases) {
+ assert.equal(getRuleById(ruleId).name, name);
+ assert.deepEqual(getRuleSetup({ id: ruleId }), {
+ bottomCardsCount: 8,
+ attackerStartingScore: 0
+ });
+ }
+});
+
+test('十面埋伏已注册并保持默认底牌与初始分', () => {
+ assert.equal(getRuleById(RuleIds.TEN_SIDED_AMBUSH).name, '十面埋伏');
+ assert.deepEqual(getRuleSetup({ id: RuleIds.TEN_SIDED_AMBUSH }), {
+ bottomCardsCount: 8,
+ attackerStartingScore: 0
+ });
+});
+
+test('三权分立已注册并保持默认底牌与初始分', () => {
+ assert.equal(THREE_POWERS_RULE.name, '三权分立');
+ assert.deepEqual(getRuleSetup(THREE_POWERS_RULE), {
+ bottomCardsCount: 8,
+ attackerStartingScore: 0
+ });
+});
+
+test('君子一言和再衰三竭已注册并保持默认牌局配置', () => {
+ for (const [rule, name] of [
+ [GENTLEMAN_PROMISE_RULE, '君子一言'],
+ [REPEATED_EXHAUSTION_RULE, '再衰三竭']
+ ]) {
+ assert.equal(rule.name, name);
+ assert.deepEqual(getRuleSetup(rule), {
+ bottomCardsCount: 8,
+ attackerStartingScore: 0
+ });
+ }
+});
+
+test('焦点人物已注册并保持默认底牌与初始分', () => {
+ assert.equal(FOCUS_FIGURE_RULE.name, '焦点人物');
+ assert.deepEqual(getRuleSetup(FOCUS_FIGURE_RULE), {
+ bottomCardsCount: 8,
+ attackerStartingScore: 0
+ });
+});
+
+test('冷却时间与时间冷却已注册并保持默认底牌与初始分', () => {
+ for (const [rule, name] of [
+ [COOLDOWN_TIME_RULE, '冷却时间'],
+ [TIME_COOLING_RULE, '时间冷却']
+ ]) {
+ assert.equal(rule.name, name);
+ assert.deepEqual(getRuleSetup(rule), {
+ bottomCardsCount: 8,
+ attackerStartingScore: 0
+ });
+ }
+});
+
+test('李代桃僵以通用主动技能元数据注册', () => {
+ assert.equal(SUBSTITUTE_SACRIFICE_RULE.name, '李代桃僵');
+ assert.deepEqual(SUBSTITUTE_SACRIFICE_RULE.activeSkill, {
+ id: ActiveSkillIds.SUBSTITUTE_SACRIFICE,
+ name: '李代桃僵',
+ usageLimit: 1,
+ timing: 'following_play',
+ effect: 'free_discard_treated_small'
+ });
+});
+
+test('势如破竹和单步调试已注册并保持默认底牌与初始分', () => {
+ const cases = [
+ [RuleIds.IRRESISTIBLE_FORCE, '势如破竹'],
+ [RuleIds.SINGLE_STEP_DEBUG, '单步调试']
+ ];
+ for (const [ruleId, name] of cases) {
+ assert.equal(getRuleById(ruleId).name, name);
+ assert.deepEqual(getRuleSetup({ id: ruleId }), {
+ bottomCardsCount: 8,
+ attackerStartingScore: 0
+ });
+ }
+});
+
+test('改革开放已注册,使用八张底牌且闲家以40分开局', () => {
+ assert.equal(getRuleById(RuleIds.REFORM_AND_OPENING_UP).name, '改革开放');
+ assert.deepEqual(getRuleSetup({ id: RuleIds.REFORM_AND_OPENING_UP }), {
+ bottomCardsCount: 8,
+ attackerStartingScore: 40
+ });
+});
+
+test('六六大顺和太极四象已注册并使用默认牌局配置', () => {
+ const cases = [
+ [RuleIds.SIX_SIX_GREAT_SUCCESS, '六六大顺'],
+ [RuleIds.TAI_CHI_FOUR_SYMBOLS, '太极四象']
+ ];
+ for (const [ruleId, name] of cases) {
+ assert.equal(getRuleById(ruleId).name, name);
+ assert.deepEqual(getRuleSetup({ id: ruleId }), {
+ bottomCardsCount: 8,
+ attackerStartingScore: 0
+ });
+ }
+});
+
+test('一马当先和迷雾重重已注册并使用默认牌局配置', () => {
+ const cases = [
+ [RuleIds.ONE_HORSE_LEADS, '一马当先'],
+ [RuleIds.HEAVY_FOG, '迷雾重重']
+ ];
+ for (const [ruleId, name] of cases) {
+ assert.equal(getRuleById(ruleId).name, name);
+ assert.deepEqual(getRuleSetup({ id: ruleId }), {
+ bottomCardsCount: 8,
+ attackerStartingScore: 0
+ });
+ }
+});
+
+test('路线摇摆、一带一路和昼夜轮转已注册,昼夜轮转以20分开局', () => {
+ assert.deepEqual(getRuleSetup(ROUTE_SWING_RULE), {
+ bottomCardsCount: 8,
+ attackerStartingScore: 0
+ });
+ assert.deepEqual(getRuleSetup(BELT_AND_ROAD_RULE), {
+ bottomCardsCount: 8,
+ attackerStartingScore: 0
+ });
+ assert.deepEqual(BELT_AND_ROAD_RULE.activeSkill, {
+ id: ActiveSkillIds.BELT_AND_ROAD,
+ name: '一带一路',
+ usageLimit: 1,
+ timing: 'leading_play',
+ effect: 'belt_and_road_lead'
+ });
+ assert.deepEqual(getRuleSetup(DAY_NIGHT_ROTATION_RULE), {
+ bottomCardsCount: 8,
+ attackerStartingScore: 20
+ });
+});
+
+test('尊老爱幼已注册并使用默认牌局配置', () => {
+ assert.equal(RESPECT_ELDERS_AND_CHILDREN_RULE.name, '尊老爱幼');
+ assert.deepEqual(getRuleSetup(RESPECT_ELDERS_AND_CHILDREN_RULE), {
+ bottomCardsCount: 8,
+ attackerStartingScore: 0
+ });
+});
+
+test('尊老爱幼按异花色、牌型、点数与后出顺序判定最小牌', () => {
+ const trumpSuit = 'spades';
+ const makePlay = (cards, playerIndex, trumpRank = '2') => ({
+ cards,
+ playerIndex,
+ pattern: detectPattern(cards, trumpSuit, trumpRank, RESPECT_ELDERS_AND_CHILDREN_RULE)
+ });
+ const makeThrowPlay = (cards, playerIndex, trumpRank = '6') => {
+ const parsed = parseThrowCombination(
+ cards,
+ trumpSuit,
+ trumpRank,
+ RESPECT_ELDERS_AND_CHILDREN_RULE
+ );
+ return {
+ cards,
+ playerIndex,
+ pattern: {
+ type: PatternTypes.THROW,
+ suit: parsed.suit,
+ components: parsed.components,
+ length: cards.length,
+ strength: Math.max(...parsed.components.map(component => component.strength))
+ }
+ };
+ };
+ const makeFollowingThrowPlay = (
+ cards,
+ playerIndex,
+ leadingPlay,
+ trumpRank = '7'
+ ) => {
+ const validation = validateFollowingPlay(
+ cards,
+ cards,
+ leadingPlay.pattern,
+ trumpSuit,
+ trumpRank,
+ RESPECT_ELDERS_AND_CHILDREN_RULE
+ );
+ assert.equal(validation.valid, true, validation.message);
+ return {
+ cards,
+ playerIndex,
+ pattern: validation.pattern
+ };
+ };
+ const find = (plays, trumpRank = '2') => findSmallestPlayForRespectElders(
+ plays,
+ trumpSuit,
+ trumpRank,
+ RESPECT_ELDERS_AND_CHILDREN_RULE
+ ).playerIndex;
+
+ const differentSuitBeatsLeadingMismatch = [
+ makePlay([card('hearts', '10', 0), card('hearts', '10', 1)], 0),
+ makePlay([card('hearts', '3', 0), card('hearts', '4', 0)], 1),
+ makePlay([card('diamonds', '3', 0), card('diamonds', '3', 1)], 2),
+ makePlay([card('spades', '5', 0), card('spades', '5', 1)], 3)
+ ];
+ assert.equal(find(differentSuitBeatsLeadingMismatch), 2, '异花色的方块对3最小');
+
+ const invalidTrumpHasNoPrivilege = [
+ makePlay([card('hearts', '10', 2), card('hearts', '10', 3)], 0),
+ makePlay([card('spades', '3', 2), card('spades', '4', 2)], 1),
+ makePlay([card('diamonds', '3', 2), card('diamonds', '3', 3)], 2),
+ makePlay([card('hearts', '9', 2), card('hearts', '9', 3)], 3)
+ ];
+ assert.equal(find(invalidTrumpHasNoPrivilege), 2, '无法毙牌的主3+4只按自然点数比较');
+
+ const partialLeadingSuitIsLargerThanPureDiscard = [
+ makePlay([card('hearts', 'A', 30), card('hearts', 'A', 31)], 0, '7'),
+ makePlay([card('hearts', '9', 30), card('clubs', '2', 30)], 1, '7'),
+ makePlay([card('diamonds', '6', 30), card('diamonds', '6', 31)], 2, '7'),
+ makePlay([card('hearts', 'K', 30), card('hearts', 'Q', 30)], 3, '7')
+ ];
+ assert.equal(
+ find(partialLeadingSuitIsLargerThanPureDiscard, '7'),
+ 2,
+ '红桃9加梅花2含一张首家花色,因此纯异花色的方块对6更小'
+ );
+
+ const partialLeadingSuitBeatsRawRankCounterexample = [
+ makePlay([card('hearts', 'A', 32), card('hearts', 'A', 33)], 0, '7'),
+ makePlay([card('hearts', '3', 32), card('clubs', '2', 32)], 1, '7'),
+ makePlay([card('diamonds', '6', 32), card('diamonds', '6', 33)], 2, '7'),
+ makePlay([card('hearts', 'K', 32), card('hearts', 'Q', 32)], 3, '7')
+ ];
+ assert.equal(
+ find(partialLeadingSuitBeatsRawRankCounterexample, '7'),
+ 2,
+ '即使混合垫牌的自然点数更低,纯异花色仍优先判小'
+ );
+
+ const leadingFourCardThrow = makeThrowPlay([
+ card('hearts', 'A', 40),
+ card('hearts', 'K', 40),
+ card('hearts', 'Q', 40),
+ card('hearts', 'J', 40)
+ ], 0, '7');
+ const throwFollowersWithDifferentSuitCounts = [
+ leadingFourCardThrow,
+ makeFollowingThrowPlay([
+ card('hearts', '3', 40),
+ card('clubs', '2', 40),
+ card('clubs', '4', 40),
+ card('clubs', '5', 40)
+ ], 1, leadingFourCardThrow),
+ makeFollowingThrowPlay([
+ card('diamonds', '10', 40),
+ card('diamonds', 'J', 40),
+ card('diamonds', 'Q', 40),
+ card('diamonds', 'K', 40)
+ ], 2, leadingFourCardThrow),
+ makeFollowingThrowPlay([
+ card('hearts', '4', 40),
+ card('hearts', '5', 40),
+ card('clubs', '8', 40),
+ card('clubs', '9', 40)
+ ], 3, leadingFourCardThrow)
+ ];
+ assert.equal(
+ find(throwFollowersWithDifferentSuitCounts, '7'),
+ 2,
+ '跟四张甩牌时,四张均为异花色者最小,即使其自然点数最高'
+ );
+
+ const throwFollowersWithEqualSuitCounts = [
+ leadingFourCardThrow,
+ makeFollowingThrowPlay([
+ card('hearts', '9', 41),
+ card('clubs', '2', 41),
+ card('clubs', '3', 41),
+ card('clubs', '4', 41)
+ ], 1, leadingFourCardThrow),
+ makeFollowingThrowPlay([
+ card('hearts', '8', 41),
+ card('diamonds', '2', 41),
+ card('diamonds', '3', 41),
+ card('diamonds', '4', 41)
+ ], 2, leadingFourCardThrow),
+ makeFollowingThrowPlay([
+ card('hearts', '5', 41),
+ card('hearts', '6', 41),
+ card('clubs', '2', 42),
+ card('clubs', '3', 42)
+ ], 3, leadingFourCardThrow)
+ ];
+ assert.equal(
+ find(throwFollowersWithEqualSuitCounts, '7'),
+ 2,
+ '异花色张数相同时才继续比较整手点数'
+ );
+
+ const offSuitSingles = [
+ makePlay([card('hearts', 'A', 4)], 0, '6'),
+ makePlay([card('diamonds', 'K', 4)], 1, '6'),
+ makePlay([card('clubs', '2', 4)], 2, '6'),
+ makePlay([card('diamonds', '5', 4)], 3, '6')
+ ];
+ assert.equal(find(offSuitSingles, '6'), 2, '异花色单牌中2最小');
+
+ const throwAndTrump = [
+ makeThrowPlay([card('hearts', 'A', 5), card('hearts', 'K', 5)], 0),
+ makeThrowPlay([card('hearts', 'Q', 5), card('hearts', 'J', 5)], 1),
+ makePlay([card('diamonds', '2', 5), card('diamonds', '2', 6)], 2, '6'),
+ makeThrowPlay([card('spades', '3', 5), card('spades', '4', 5)], 3)
+ ];
+ assert.equal(find(throwAndTrump, '6'), 2, '异花色对2比同花色和有效毙牌都小');
+
+ const laterEqualPlayIsSmaller = [
+ makePlay([card('hearts', '10', 7), card('hearts', '10', 8)], 0),
+ makePlay([card('hearts', '5', 7), card('hearts', '5', 8)], 1),
+ makePlay([card('hearts', '5', 9), card('hearts', '5', 10)], 2),
+ makePlay([card('hearts', '6', 7), card('hearts', '6', 8)], 3)
+ ];
+ assert.equal(find(laterEqualPlayIsSmaller), 2, '同点数时后出的红桃5更小');
+});
+
+test('尊老爱幼不改变本轮获胜与计分,只把下轮首发权交给最小牌', () => {
+ const room = createRoom();
+ const engine = new GameEngine(room, createIo());
+ const trickCards = [
+ card('hearts', 'A', 20),
+ card('hearts', 'K', 20),
+ card('clubs', '3', 20),
+ card('diamonds', '5', 20)
+ ];
+ room.players.forEach((player, index) => {
+ player.addCard(trickCards[index]);
+ player.addCard(card('clubs', String(index + 7), index + 30));
+ });
+ room.gameState.phase = GamePhases.PLAYING;
+ room.gameState.selectedRule = RESPECT_ELDERS_AND_CHILDREN_RULE;
+ room.gameState.trumpSuit = 'spades';
+ room.gameState.trumpRank = '2';
+ room.gameState.buryingPlayerId = room.players[0].id;
+ engine.setFirstPlayer(room.players[0].id);
+
+ engine.playCards(room.players[0].id, [trickCards[0].id]);
+ engine.playCards(room.players[1].id, [trickCards[1].id]);
+ engine.playCards(room.players[2].id, [trickCards[2].id]);
+ const result = engine.playCards(room.players[3].id, [trickCards[3].id]);
+
+ assert.equal(result.roundUpdate.roundWinner.playerIndex, 0, '红桃A仍是本轮获胜牌');
+ assert.equal(result.roundUpdate.smallestPlayer.playerIndex, 2, '异花色的草花3是本轮最小牌');
+ assert.equal(result.roundUpdate.nextRoundLeader.playerIndex, 2);
+ assert.equal(room.gameState.currentPlayerIndex, 2);
+ assert.equal(room.gameState.roundStartPlayerIndex, 2);
+ assert.equal(room.gameState.lastRoundWinnerIndex, 0, '最后一轮真正胜者记录不得被改写');
+});
+
+test('尊老爱幼与力争上游把同花色的牌型不完整程度纳入全序', () => {
+ const trumpSuit = 'spades';
+ const trumpRank = '2';
+ const makePlay = (cards, playerIndex) => ({
+ cards,
+ playerIndex,
+ pattern: detectPattern(cards, trumpSuit, trumpRank, STRIVE_UPSTREAM_RULE)
+ });
+ const leadingTractor = makePlay([
+ card('hearts', '5', 60),
+ card('hearts', '5', 61),
+ card('hearts', '6', 60),
+ card('hearts', '6', 61)
+ ], 0);
+ assert.equal(leadingTractor.pattern.type, PatternTypes.TRACTOR);
+
+ const twoLoosePairs = makePlay([
+ card('hearts', '3', 60),
+ card('hearts', '3', 61),
+ card('hearts', '8', 60),
+ card('hearts', '8', 61)
+ ], 1);
+ const onePairAndSingles = makePlay([
+ card('hearts', '4', 60),
+ card('hearts', '4', 61),
+ card('hearts', '9', 60),
+ card('hearts', '10', 60)
+ ], 2);
+ const fourSingles = makePlay([
+ card('hearts', 'J', 60),
+ card('hearts', 'Q', 60),
+ card('hearts', 'K', 60),
+ card('hearts', 'A', 60)
+ ], 3);
+
+ const ranked = rankRoundPlaysByRespectOrder(
+ [leadingTractor, fourSingles, onePairAndSingles, twoLoosePairs],
+ trumpSuit,
+ trumpRank,
+ STRIVE_UPSTREAM_RULE
+ );
+ assert.deepEqual(
+ ranked.map(play => play.playerIndex),
+ [0, 1, 2, 3],
+ '同首花色的牌型不完整时,散对越多越大,不能被单牌点数反超'
+ );
+});
+
+test('力争上游把上轮四手牌由大到小设为下轮完整出牌顺序', () => {
+ const room = createRoom();
+ const engine = new GameEngine(room, createIo());
+ const firstTrick = [
+ card('hearts', '10', 70),
+ card('hearts', 'A', 70),
+ card('hearts', '3', 70),
+ card('hearts', '3', 71)
+ ];
+ const secondTrick = [
+ card('clubs', '6', 70),
+ card('clubs', '5', 70),
+ card('clubs', '7', 70),
+ card('clubs', '8', 70)
+ ];
+ room.players.forEach((player, index) => {
+ player.addCard(firstTrick[index]);
+ player.addCard(secondTrick[index]);
+ });
+ room.gameState.phase = GamePhases.PLAYING;
+ room.gameState.selectedRule = STRIVE_UPSTREAM_RULE;
+ room.gameState.trumpSuit = 'spades';
+ room.gameState.trumpRank = '2';
+ room.gameState.buryingPlayerId = room.players[0].id;
+ engine.setFirstPlayer(room.players[0].id);
+
+ engine.playCards(room.players[0].id, [firstTrick[0].id]);
+ engine.playCards(room.players[1].id, [firstTrick[1].id]);
+ engine.playCards(room.players[2].id, [firstTrick[2].id]);
+ const firstResult = engine.playCards(room.players[3].id, [firstTrick[3].id]);
+
+ assert.deepEqual(room.gameState.striveUpstreamPlayOrder, [1, 0, 2, 3]);
+ assert.deepEqual(firstResult.roundUpdate.striveUpstreamOrder.playerIndexes, [1, 0, 2, 3]);
+ assert.equal(
+ firstResult.currentWinningPlayerId,
+ room.players[1].id,
+ '桌面“大”应跟随力争上游第一名,也就是实际取得下轮牌权的玩家'
+ );
+ assert.equal(room.gameState.currentPlayerIndex, 1, '上轮最大的玩家1先出');
+
+ engine.playCards(room.players[1].id, [secondTrick[1].id]);
+ assert.equal(room.gameState.currentPlayerIndex, 0, '玩家1之后应轮到上轮第二大的玩家0');
+ engine.playCards(room.players[0].id, [secondTrick[0].id]);
+ assert.equal(room.gameState.currentPlayerIndex, 2);
+ engine.playCards(room.players[2].id, [secondTrick[2].id]);
+ assert.equal(room.gameState.currentPlayerIndex, 3);
+});
+
+test('路线摇摆只在出现单张10分牌的整轮结束后翻转下一轮方向', () => {
+ const room = createRoom();
+ const engine = new GameEngine(room, createIo());
+ const firstTrick = [
+ card('hearts', 'A', 10),
+ card('hearts', 'Q', 11),
+ card('hearts', 'J', 12),
+ card('hearts', '10', 13)
+ ];
+ const secondTrick = [
+ card('clubs', 'A', 20),
+ card('clubs', '6', 21),
+ card('clubs', '5', 22),
+ card('clubs', '5', 23)
+ ];
+ room.players.forEach((player, index) => {
+ player.addCard(firstTrick[index]);
+ player.addCard(secondTrick[index]);
+ });
+ room.gameState.phase = GamePhases.PLAYING;
+ room.gameState.selectedRule = ROUTE_SWING_RULE;
+ room.gameState.trumpSuit = 'spades';
+ room.gameState.trumpRank = '2';
+ room.gameState.buryingPlayerId = room.players[0].id;
+ engine.setFirstPlayer(room.players[0].id);
+
+ assert.equal(room.gameState.turnDirection, TurnOrders.COUNTER_CLOCKWISE);
+ let result;
+ for (const playerIndex of [0, 1, 2, 3]) {
+ result = engine.playCards(room.players[playerIndex].id, [firstTrick[playerIndex].id]);
+ }
+ assert.deepEqual(result.roundUpdate.turnDirectionChange, {
+ previousDirection: TurnOrders.COUNTER_CLOCKWISE,
+ nextDirection: TurnOrders.CLOCKWISE,
+ triggerRound: 1
+ });
+ assert.equal(room.gameState.turnDirection, TurnOrders.CLOCKWISE);
+ assert.equal(room.gameState.currentPlayerIndex, 0);
+
+ engine.playCards(room.players[0].id, [secondTrick[0].id]);
+ assert.equal(room.gameState.currentPlayerIndex, 3);
+ for (const playerIndex of [3, 2]) {
+ engine.playCards(room.players[playerIndex].id, [secondTrick[playerIndex].id]);
+ }
+ result = engine.playCards(room.players[1].id, [secondTrick[1].id]);
+ // 本轮两张5合计10分,但没有任何单张10分牌,因此不会再次翻转。
+ assert.equal(result.roundUpdate.turnDirectionChange, null);
+ assert.equal(room.gameState.turnDirection, TurnOrders.CLOCKWISE);
+});
+
+test('一带一路未发动时仍按普通甩牌,发动后小甩牌无需牌面必大', () => {
+ const trumpSuit = 'spades';
+ const trumpRank = '2';
+ const lower = [card('hearts', 'A', 30), card('hearts', '5', 31)];
+ const higher = [card('hearts', 'A', 32), card('hearts', '6', 33)];
+ const activeBeltAndRoadRule = {
+ ...BELT_AND_ROAD_RULE,
+ beltAndRoadSkillActive: true
+ };
+ const lowerPattern = detectPattern(lower, trumpSuit, trumpRank, activeBeltAndRoadRule);
+ const higherPattern = detectPattern(higher, trumpSuit, trumpRank, activeBeltAndRoadRule);
+ assert.equal(lowerPattern.type, PatternTypes.BELT_AND_ROAD);
+ assert.equal(higherPattern.type, PatternTypes.BELT_AND_ROAD);
+ assert.equal(compareCards(
+ { cards: higher, pattern: higherPattern },
+ { cards: lower, pattern: lowerPattern },
+ 'hearts',
+ trumpSuit,
+ trumpRank,
+ BELT_AND_ROAD_RULE
+ ), 1);
+ assert.equal(
+ detectPattern(lower, trumpSuit, trumpRank, BELT_AND_ROAD_RULE).type,
+ PatternTypes.INVALID
+ );
+
+ const room = createRoom();
+ const engine = new GameEngine(room, createIo());
+ const plays = [
+ [card('hearts', 'K', 40), card('hearts', '3', 41)],
+ [card('hearts', 'A', 42), card('hearts', 'Q', 43)],
+ [card('hearts', 'J', 44), card('hearts', '9', 45)],
+ [card('hearts', '8', 46), card('hearts', '7', 47)]
+ ];
+ room.players.forEach((player, index) => plays[index].forEach(value => player.addCard(value)));
+ room.gameState.phase = GamePhases.PLAYING;
+ room.gameState.selectedRule = BELT_AND_ROAD_RULE;
+ room.gameState.trumpSuit = trumpSuit;
+ room.gameState.trumpRank = trumpRank;
+ room.gameState.buryingPlayerId = room.players[0].id;
+ engine.setFirstPlayer(room.players[0].id);
+
+ const ordinaryThrowResult = engine.playCards(
+ room.players[0].id,
+ plays[0].map(value => value.id)
+ );
+ assert.ok(ordinaryThrowResult.throwFailed);
+ assert.equal(ordinaryThrowResult.playedCards.length, 1);
+ assert.equal(engine.hasUsedActiveSkill(room.players[0].id, ActiveSkillIds.BELT_AND_ROAD), false);
+ engine.undoLastPlay(room.players[0].id);
+
+ let leadResult = engine.playCards(
+ room.players[0].id,
+ plays[0].map(value => value.id),
+ null,
+ ActiveSkillIds.BELT_AND_ROAD
+ );
+ assert.equal(leadResult.throwFailed, null);
+ assert.equal(leadResult.activeSkillActivation.id, ActiveSkillIds.BELT_AND_ROAD);
+ assert.equal(room.gameState.leadingPattern.type, PatternTypes.BELT_AND_ROAD);
+ assert.equal(engine.hasUsedActiveSkill(room.players[0].id, ActiveSkillIds.BELT_AND_ROAD), true);
+
+ const undoResult = engine.undoLastPlay(room.players[0].id);
+ assert.equal(undoResult.restoredActiveSkillId, ActiveSkillIds.BELT_AND_ROAD);
+ assert.equal(engine.hasUsedActiveSkill(room.players[0].id, ActiveSkillIds.BELT_AND_ROAD), false);
+ leadResult = engine.playCards(
+ room.players[0].id,
+ plays[0].map(value => value.id),
+ null,
+ ActiveSkillIds.BELT_AND_ROAD
+ );
+ assert.equal(leadResult.activeSkillActivation.id, ActiveSkillIds.BELT_AND_ROAD);
+ engine.playCards(room.players[1].id, plays[1].map(value => value.id));
+ assert.equal(engine.hasUsedActiveSkill(room.players[1].id, ActiveSkillIds.BELT_AND_ROAD), false);
+
+ const blockedRoom = createRoom();
+ const blockedEngine = new GameEngine(blockedRoom, createIo());
+ const blockedCards = [card('clubs', '9', 50), card('clubs', '4', 51)];
+ blockedCards.forEach(value => blockedRoom.players[0].addCard(value));
+ blockedRoom.gameState.phase = GamePhases.PLAYING;
+ blockedRoom.gameState.selectedRule = BELT_AND_ROAD_RULE;
+ blockedRoom.gameState.trumpSuit = trumpSuit;
+ blockedRoom.gameState.trumpRank = trumpRank;
+ blockedEngine.recordActiveSkillUse(blockedRoom.players[0].id, ActiveSkillIds.BELT_AND_ROAD);
+ blockedEngine.setFirstPlayer(blockedRoom.players[0].id);
+ assert.throws(
+ () => blockedEngine.playCards(
+ blockedRoom.players[0].id,
+ blockedCards.map(value => value.id),
+ null,
+ ActiveSkillIds.BELT_AND_ROAD
+ ),
+ /每名玩家每局只能发动一次/
+ );
+
+ const successfulThrowRoom = createRoom();
+ const successfulThrowEngine = new GameEngine(successfulThrowRoom, createIo());
+ const successfulThrowPlays = [
+ [card('diamonds', 'A', 60), card('diamonds', 'K', 61)],
+ [card('diamonds', 'Q', 62), card('diamonds', 'J', 63)],
+ [card('diamonds', '10', 64), card('diamonds', '9', 65)],
+ [card('diamonds', '8', 66), card('diamonds', '7', 67)]
+ ];
+ successfulThrowRoom.players.forEach((player, index) => {
+ successfulThrowPlays[index].forEach(value => player.addCard(value));
+ });
+ successfulThrowRoom.gameState.phase = GamePhases.PLAYING;
+ successfulThrowRoom.gameState.selectedRule = BELT_AND_ROAD_RULE;
+ successfulThrowRoom.gameState.trumpSuit = trumpSuit;
+ successfulThrowRoom.gameState.trumpRank = trumpRank;
+ successfulThrowRoom.gameState.buryingPlayerId = successfulThrowRoom.players[0].id;
+ successfulThrowEngine.setFirstPlayer(successfulThrowRoom.players[0].id);
+ const successfulThrow = successfulThrowEngine.playCards(
+ successfulThrowRoom.players[0].id,
+ successfulThrowPlays[0].map(value => value.id)
+ );
+ assert.equal(successfulThrow.throwFailed, null);
+ assert.equal(successfulThrowRoom.gameState.leadingPattern.type, PatternTypes.THROW);
+ assert.equal(
+ successfulThrowEngine.hasUsedActiveSkill(
+ successfulThrowRoom.players[0].id,
+ ActiveSkillIds.BELT_AND_ROAD
+ ),
+ false
+ );
+});
+
+test('昼夜轮转从第1轮的2开始,只提升目标点数并在目标为级牌时跳过', () => {
+ const trumpSuit = 'spades';
+ const trumpRank = '3';
+ const roundOneRule = { ...DAY_NIGHT_ROTATION_RULE, currentRound: 1 };
+ const roundTwoRule = { ...DAY_NIGHT_ROTATION_RULE, currentRound: 2 };
+ const two = card('hearts', '2', 60);
+ const ace = card('hearts', 'A', 61);
+ const level = card('hearts', '3', 62);
+
+ assert.ok(getCardStrength(two, trumpSuit, trumpRank, roundOneRule) >
+ getCardStrength(ace, trumpSuit, trumpRank, roundOneRule));
+ assert.ok(getCardStrength(level, trumpSuit, trumpRank, roundOneRule) >
+ getCardStrength(two, trumpSuit, trumpRank, roundOneRule));
+ assert.ok(getCardStrength(ace, trumpSuit, trumpRank, roundTwoRule) >
+ getCardStrength(two, trumpSuit, trumpRank, roundTwoRule));
+ assert.equal(getDayNightHighestRank(1, trumpRank), '2');
+ assert.equal(getDayNightHighestRank(2, trumpRank), 'A');
+
+ const topTractor = [
+ card('hearts', 'A', 63), card('hearts', 'A', 64),
+ card('hearts', '2', 65), card('hearts', '2', 66)
+ ];
+ assert.equal(
+ detectPattern(topTractor, trumpSuit, trumpRank, roundOneRule).type,
+ PatternTypes.TRACTOR
+ );
+
+ const room = createRoom();
+ const engine = new GameEngine(room, createIo());
+ const trickCards = [
+ card('hearts', 'A', 70),
+ card('hearts', '2', 71),
+ card('hearts', 'K', 72),
+ card('hearts', 'Q', 73)
+ ];
+ room.players.forEach((player, index) => {
+ player.addCard(trickCards[index]);
+ player.addCard(card('clubs', String(4 + index), 80 + index));
+ });
+ room.gameState.phase = GamePhases.PLAYING;
+ room.gameState.selectedRule = DAY_NIGHT_ROTATION_RULE;
+ room.gameState.trumpSuit = trumpSuit;
+ room.gameState.trumpRank = trumpRank;
+ room.gameState.buryingPlayerId = room.players[0].id;
+ engine.setFirstPlayer(room.players[0].id);
+ engine.playCards(room.players[0].id, [trickCards[0].id]);
+ engine.playCards(room.players[1].id, [trickCards[1].id]);
+ assert.equal(room.gameState.currentWinnerIndex, 1);
+ engine.playCards(room.players[2].id, [trickCards[2].id]);
+ engine.playCards(room.players[3].id, [trickCards[3].id]);
+ assert.equal(room.gameState.lastRoundWinnerIndex, 1);
+ assert.equal(room.gameState.toJSON().dayNightHighestRank, 'A');
+});
+
+test('六六大顺识别六张起步的同花顺,副牌序列跳过级牌', () => {
+ const acrossLevel = ['3', '4', '5', '6', '8', '9']
+ .map((rank, index) => card('hearts', rank, index));
+ const fiveCards = acrossLevel.slice(0, 5);
+ const withGap = ['3', '4', '5', '6', '9', '10']
+ .map((rank, index) => card('hearts', rank, index + 10));
+ const withDuplicateRank = [
+ card('hearts', '3', 20),
+ card('hearts', '4', 20),
+ card('hearts', '5', 20),
+ card('hearts', '6', 20),
+ card('hearts', '8', 20),
+ card('hearts', '8', 21)
+ ];
+
+ const pattern = detectPattern(
+ acrossLevel,
+ 'spades',
+ '7',
+ SIX_SIX_GREAT_SUCCESS_RULE
+ );
+ assert.equal(pattern.type, PatternTypes.STRAIGHT_FLUSH);
+ assert.equal(pattern.suit, 'hearts');
+ assert.equal(pattern.length, 6);
+ assert.equal(
+ detectPattern(acrossLevel, 'spades', '7', NORMAL_RULE).type,
+ PatternTypes.INVALID
+ );
+ assert.equal(
+ detectPattern(fiveCards, 'spades', '7', SIX_SIX_GREAT_SUCCESS_RULE).type,
+ PatternTypes.INVALID
+ );
+ assert.equal(
+ detectPattern(withGap, 'spades', '7', SIX_SIX_GREAT_SUCCESS_RULE).type,
+ PatternTypes.INVALID
+ );
+ assert.equal(
+ detectPattern(withDuplicateRank, 'spades', '7', SIX_SIX_GREAT_SUCCESS_RULE).type,
+ PatternTypes.INVALID
+ );
+});
+
+test('六六大顺的主同花顺可从普通主牌连续到级牌和大小王', () => {
+ const trumpStraight = [
+ card('spades', 'Q'),
+ card('spades', 'K'),
+ card('spades', 'A'),
+ card('hearts', '7'),
+ card('spades', '7'),
+ card('joker', 'small_joker'),
+ card('joker', 'big_joker')
+ ];
+
+ const pattern = detectPattern(
+ trumpStraight,
+ 'spades',
+ '7',
+ SIX_SIX_GREAT_SUCCESS_RULE
+ );
+ assert.equal(pattern.type, PatternTypes.STRAIGHT_FLUSH);
+ assert.equal(pattern.suit, 'trump');
+ assert.equal(pattern.length, 7);
+ assert.deepEqual(pattern.strengths, [994, 995, 996, 997, 998, 999, 1000]);
+});
+
+test('六六大顺按同花顺高张比较,主同花顺可毙副同花顺', () => {
+ const lowCards = ['2', '3', '4', '5', '6', '7']
+ .map((rank, index) => card('hearts', rank, index));
+ const highCards = ['3', '4', '5', '6', '7', '8']
+ .map((rank, index) => card('hearts', rank, index + 10));
+ const trumpCards = ['2', '3', '4', '5', '6', '7']
+ .map((rank, index) => card('spades', rank, index + 20));
+ const play = cards => ({
+ cards,
+ pattern: detectPattern(cards, 'spades', 'A', SIX_SIX_GREAT_SUCCESS_RULE)
+ });
+
+ assert.equal(
+ compareCards(
+ play(highCards),
+ play(lowCards),
+ 'hearts',
+ 'spades',
+ 'A',
+ SIX_SIX_GREAT_SUCCESS_RULE
+ ),
+ 1
+ );
+ assert.equal(
+ compareCards(
+ play(trumpCards),
+ play(lowCards),
+ 'hearts',
+ 'spades',
+ 'A',
+ SIX_SIX_GREAT_SUCCESS_RULE
+ ),
+ 1
+ );
+});
+
+test('跟六六大顺时有同花顺必须跟同花顺,无首花色时可用主同花顺毙牌', () => {
+ const leadingCards = ['2', '3', '4', '5', '6', '7']
+ .map((rank, index) => card('hearts', rank, index));
+ const leadingPattern = detectPattern(
+ leadingCards,
+ 'spades',
+ 'A',
+ SIX_SIX_GREAT_SUCCESS_RULE
+ );
+ const sameSuitHand = [
+ ...['3', '4', '5', '6', '7', '8']
+ .map((rank, index) => card('hearts', rank, index + 10)),
+ card('hearts', '10', 30)
+ ];
+ const brokenStraight = [
+ ...sameSuitHand.slice(0, 5),
+ sameSuitHand[6]
+ ];
+ assert.equal(
+ validateFollowingPlay(
+ brokenStraight,
+ sameSuitHand,
+ leadingPattern,
+ 'spades',
+ 'A',
+ SIX_SIX_GREAT_SUCCESS_RULE
+ ).valid,
+ false
+ );
+ assert.equal(
+ validateFollowingPlay(
+ sameSuitHand.slice(0, 6),
+ sameSuitHand,
+ leadingPattern,
+ 'spades',
+ 'A',
+ SIX_SIX_GREAT_SUCCESS_RULE
+ ).valid,
+ true
+ );
+
+ const trumpStraight = ['2', '3', '4', '5', '6', '7']
+ .map((rank, index) => card('spades', rank, index + 40));
+ const trumpResponse = validateFollowingPlay(
+ trumpStraight,
+ trumpStraight,
+ leadingPattern,
+ 'spades',
+ 'A',
+ SIX_SIX_GREAT_SUCCESS_RULE
+ );
+ assert.equal(trumpResponse.valid, true);
+ assert.equal(
+ compareCards(
+ { cards: trumpStraight, pattern: trumpResponse.pattern },
+ { cards: leadingCards, pattern: leadingPattern },
+ leadingPattern.suit,
+ 'spades',
+ 'A',
+ SIX_SIX_GREAT_SUCCESS_RULE
+ ),
+ 1
+ );
+});
+
+test('六六大顺在牌局中作为完整牌型出牌,不会进入甩牌失败流程', () => {
+ const room = createRoom();
+ const engine = new GameEngine(room, createIo());
+ const player = room.players[0];
+ const straight = ['2', '3', '4', '5', '6', '7']
+ .map((rank, index) => card('hearts', rank, index));
+ [...straight, card('clubs', '9')].forEach(value => player.addCard(value));
+ room.gameState.phase = GamePhases.PLAYING;
+ room.gameState.trumpSuit = 'spades';
+ room.gameState.trumpRank = 'A';
+ room.gameState.selectedRule = SIX_SIX_GREAT_SUCCESS_RULE;
+ engine.setFirstPlayer(player.id);
+
+ const result = engine.playCards(player.id, straight.map(value => value.id));
+
+ assert.equal(result.throwFailed, null);
+ assert.equal(room.gameState.currentRoundPlays[0].pattern.type, PatternTypes.STRAIGHT_FLUSH);
+ assert.equal(room.gameState.currentRoundPlays[0].cards.length, 6);
+ assert.equal(player.cards.length, 1);
+});
+
+test('太极四象要求四种普通花色同点数,且大小只按点数比较', () => {
+ const taiChiNines = ['hearts', 'diamonds', 'clubs', 'spades']
+ .map((suit, index) => card(suit, '9', index));
+ const taiChiJacks = ['hearts', 'diamonds', 'clubs', 'spades']
+ .map((suit, index) => card(suit, 'J', index + 10));
+ const duplicatedSuit = [
+ card('hearts', '9', 20),
+ card('hearts', '9', 21),
+ card('clubs', '9', 20),
+ card('spades', '9', 20)
+ ];
+
+ const ninesPattern = detectPattern(
+ taiChiNines,
+ 'spades',
+ '9',
+ TAI_CHI_FOUR_SYMBOLS_RULE
+ );
+ const jacksPattern = detectPattern(
+ taiChiJacks,
+ 'spades',
+ '9',
+ TAI_CHI_FOUR_SYMBOLS_RULE
+ );
+ assert.equal(ninesPattern.type, PatternTypes.TAI_CHI_FOUR_SYMBOLS);
+ assert.equal(ninesPattern.suit, 'tai_chi');
+ assert.equal(ninesPattern.strength, 9);
+ assert.equal(
+ detectPattern(taiChiNines, 'spades', '9', NORMAL_RULE).type,
+ PatternTypes.INVALID
+ );
+ assert.equal(
+ detectPattern(duplicatedSuit, 'spades', '9', TAI_CHI_FOUR_SYMBOLS_RULE).type,
+ PatternTypes.INVALID
+ );
+ assert.equal(
+ compareCards(
+ { cards: taiChiJacks, pattern: jacksPattern },
+ { cards: taiChiNines, pattern: ninesPattern },
+ 'tai_chi',
+ 'spades',
+ '9',
+ TAI_CHI_FOUR_SYMBOLS_RULE
+ ),
+ 1
+ );
+});
+
+test('跟太极四象时有四象必须跟四象;全手主牌时可用任意四张主牌毙之', () => {
+ const leadingCards = ['hearts', 'diamonds', 'clubs', 'spades']
+ .map((suit, index) => card(suit, '9', index));
+ const leadingPattern = detectPattern(
+ leadingCards,
+ 'spades',
+ '2',
+ TAI_CHI_FOUR_SYMBOLS_RULE
+ );
+ const followerTaiChi = ['hearts', 'diamonds', 'clubs', 'spades']
+ .map((suit, index) => card(suit, 'Q', index + 10));
+ const followerHand = [...followerTaiChi, card('hearts', '3', 30)];
+ assert.equal(
+ validateFollowingPlay(
+ followerHand.slice(1, 5),
+ followerHand,
+ leadingPattern,
+ 'spades',
+ '2',
+ TAI_CHI_FOUR_SYMBOLS_RULE
+ ).valid,
+ false
+ );
+ assert.equal(
+ validateFollowingPlay(
+ followerTaiChi,
+ followerHand,
+ leadingPattern,
+ 'spades',
+ '2',
+ TAI_CHI_FOUR_SYMBOLS_RULE
+ ).valid,
+ true
+ );
+
+ const fourTrumpCards = ['3', '4', '5', '6']
+ .map((rank, index) => card('spades', rank, index + 40));
+ const allTrumpHand = [...fourTrumpCards, card('joker', 'small_joker', 50)];
+ const trumpResponse = validateFollowingPlay(
+ fourTrumpCards,
+ allTrumpHand,
+ leadingPattern,
+ 'spades',
+ '2',
+ TAI_CHI_FOUR_SYMBOLS_RULE
+ );
+ assert.equal(trumpResponse.valid, true);
+ assert.equal(trumpResponse.pattern.canTrumpTaiChi, true);
+ assert.equal(
+ compareCards(
+ { cards: fourTrumpCards, pattern: trumpResponse.pattern },
+ { cards: leadingCards, pattern: leadingPattern },
+ 'tai_chi',
+ 'spades',
+ '2',
+ TAI_CHI_FOUR_SYMBOLS_RULE
+ ),
+ 1
+ );
+
+ const handWithSideCard = [...fourTrumpCards, card('hearts', '8', 60)];
+ const ordinaryDiscard = validateFollowingPlay(
+ fourTrumpCards,
+ handWithSideCard,
+ leadingPattern,
+ 'spades',
+ '2',
+ TAI_CHI_FOUR_SYMBOLS_RULE
+ );
+ assert.equal(ordinaryDiscard.valid, true);
+ assert.notEqual(ordinaryDiscard.pattern.canTrumpTaiChi, true);
+ assert.equal(
+ compareCards(
+ { cards: fourTrumpCards, pattern: ordinaryDiscard.pattern },
+ { cards: leadingCards, pattern: leadingPattern },
+ 'tai_chi',
+ 'spades',
+ '2',
+ TAI_CHI_FOUR_SYMBOLS_RULE
+ ),
+ -1
+ );
+});
+
+test('太极四象可在牌局中首发并由更高点数的四象压过', () => {
+ const room = createRoom();
+ const engine = new GameEngine(room, createIo());
+ const nines = ['hearts', 'diamonds', 'clubs', 'spades']
+ .map((suit, index) => card(suit, '9', index));
+ const jacks = ['hearts', 'diamonds', 'clubs', 'spades']
+ .map((suit, index) => card(suit, 'J', index + 10));
+ nines.forEach(value => room.players[0].addCard(value));
+ jacks.forEach(value => room.players[1].addCard(value));
+ room.gameState.phase = GamePhases.PLAYING;
+ room.gameState.trumpSuit = 'spades';
+ room.gameState.trumpRank = '2';
+ room.gameState.selectedRule = TAI_CHI_FOUR_SYMBOLS_RULE;
+ engine.setFirstPlayer(room.players[0].id);
+
+ const leadResult = engine.playCards(room.players[0].id, nines.map(value => value.id));
+ assert.equal(leadResult.throwFailed, null);
+ assert.equal(room.gameState.leadingPattern.type, PatternTypes.TAI_CHI_FOUR_SYMBOLS);
+ const followResult = engine.playCards(room.players[1].id, jacks.map(value => value.id));
+ assert.equal(followResult.currentWinningPlayerId, room.players[1].id);
+ assert.equal(room.gameState.currentWinnerIndex, 1);
+});
+
+test('Bot保底出牌能识别太极四象的跨花色跟牌义务', () => {
+ const botService = new BotService();
+ const leadingCards = ['hearts', 'diamonds', 'clubs', 'spades']
+ .map((suit, index) => card(suit, '9', index));
+ const leadingPattern = detectPattern(
+ leadingCards,
+ 'spades',
+ '2',
+ TAI_CHI_FOUR_SYMBOLS_RULE
+ );
+ const taiChiQueens = ['hearts', 'diamonds', 'clubs', 'spades']
+ .map((suit, index) => card(suit, 'Q', index + 10));
+ const hand = [
+ card('hearts', '3', 30),
+ ...taiChiQueens,
+ card('clubs', 'A', 31)
+ ];
+
+ const selectedIds = botService.getFallbackAction({
+ leadingPattern,
+ trumpSuit: 'spades',
+ trumpRank: '2',
+ selectedRule: TAI_CHI_FOUR_SYMBOLS_RULE
+ }, hand);
+ const selectedIdSet = new Set(selectedIds);
+ const selectedCards = hand.filter(value => selectedIdSet.has(value.id));
+
+ assert.equal(selectedCards.length, 4);
+ assert.equal(
+ detectPattern(
+ selectedCards,
+ 'spades',
+ '2',
+ TAI_CHI_FOUR_SYMBOLS_RULE
+ ).type,
+ PatternTypes.TAI_CHI_FOUR_SYMBOLS
+ );
+});
+
+test('势如破竹仅在庄家方获胜时让原庄家连庄', () => {
+ const calculate = (selectedRule, attackerScore) => {
+ const room = createRoom();
+ const engine = new GameEngine(room, createIo());
+ room.gameState.selectedRule = selectedRule;
+ room.gameState.buryingPlayerId = room.players[0].id;
+ room.gameState.dealerPlayerIndex = 0;
+ room.gameState.attackerScore = attackerScore;
+ return { room, result: engine.calculateUpgrade() };
+ };
+
+ const dealerWin = calculate(IRRESISTIBLE_FORCE_RULE, 40);
+ assert.equal(dealerWin.result.attackerWon, false);
+ assert.equal(dealerWin.result.dealerContinues, true);
+ assert.equal(dealerWin.result.nextDealerIndex, 0);
+ assert.equal(dealerWin.room.gameState.dealerPlayerIndex, 0);
+
+ const attackerWin = calculate(IRRESISTIBLE_FORCE_RULE, 80);
+ assert.equal(attackerWin.result.attackerWon, true);
+ assert.equal(attackerWin.result.dealerContinues, false);
+ assert.equal(attackerWin.result.nextDealerIndex, 1);
+
+ const normalDealerWin = calculate(NORMAL_RULE, 40);
+ assert.equal(normalDealerWin.result.dealerContinues, false);
+ assert.equal(normalDealerWin.result.nextDealerIndex, 2);
+});
+
+test('单步调试禁止甩牌,但保留单张、对子和拖拉机', () => {
+ const throwCards = [card('hearts', '3'), card('hearts', '7')];
+ const pairCards = [card('hearts', '3', 0), card('hearts', '3', 1)];
+ const tractorCards = [
+ card('hearts', '3', 0),
+ card('hearts', '3', 1),
+ card('hearts', '4', 0),
+ card('hearts', '4', 1)
+ ];
+
+ assert.equal(validateLeadingPlay(throwCards, 'spades', '2', NORMAL_RULE).valid, true);
+ assert.deepEqual(
+ validateLeadingPlay(throwCards, 'spades', '2', SINGLE_STEP_DEBUG_RULE),
+ { valid: false, message: '单步调试规则下不能甩牌', pattern: null }
+ );
+ assert.equal(
+ validateLeadingPlay([throwCards[0]], 'spades', '2', SINGLE_STEP_DEBUG_RULE).valid,
+ true
+ );
+ assert.equal(
+ validateLeadingPlay(pairCards, 'spades', '2', SINGLE_STEP_DEBUG_RULE).pattern.type,
+ PatternTypes.PAIR
+ );
+ assert.equal(
+ validateLeadingPlay(tractorCards, 'spades', '2', SINGLE_STEP_DEBUG_RULE).pattern.type,
+ PatternTypes.TRACTOR
+ );
+
+ const room = createRoom();
+ const engine = new GameEngine(room, createIo());
+ room.gameState.phase = GamePhases.PLAYING;
+ room.gameState.selectedRule = SINGLE_STEP_DEBUG_RULE;
+ throwCards.forEach(value => room.players[0].addCard(value));
+ engine.setFirstPlayer(room.players[0].id);
+ assert.throws(
+ () => engine.playCards(room.players[0].id, throwCards.map(value => value.id)),
+ /单步调试规则下不能甩牌/
+ );
+ assert.equal(room.players[0].cards.length, 2);
+});
+
+test('改革开放由庄家队友拿起首次底牌再埋,完成后仍由原庄家首发', () => {
+ const room = createRoom();
+ const io = createIo();
+ const engine = new GameEngine(room, io);
+ const dealer = room.players[0];
+ const teammate = room.players[2];
+ const suits = ['spades', 'hearts', 'clubs', 'diamonds'];
+ const ranks = ['2', '3', '4', '5', '6', '7', '8', '9', '10', 'J', 'Q', 'K', 'A'];
+ const deckCard = index => card(
+ suits[index % suits.length],
+ ranks[Math.floor(index / suits.length) % ranks.length],
+ Math.floor(index / 52)
+ );
+ const dealerCards = Array.from({ length: 33 }, (_, index) => deckCard(index));
+ const teammateCards = Array.from({ length: 25 }, (_, index) => deckCard(index + 60));
+ dealerCards.forEach(value => dealer.addCard(value));
+ teammateCards.forEach(value => teammate.addCard(value));
+ room.gameState.phase = GamePhases.BURYING;
+ room.gameState.selectedRule = REFORM_AND_OPENING_UP_RULE;
+ room.gameState.buryingPlayerId = dealer.id;
+ room.gameState.bottomCardsCount = 8;
+
+ const firstBottom = dealerCards.slice(0, 8);
+ const firstResult = engine.buryCards(dealer.id, firstBottom.map(value => value.id));
+
+ assert.equal(firstResult.completed, false);
+ assert.equal(room.gameState.phase, GamePhases.BURYING);
+ assert.equal(room.gameState.buryingPlayerId, dealer.id);
+ assert.equal(room.gameState.secondaryBuryingPlayerId, teammate.id);
+ assert.equal(room.gameState.bottomCards.length, 0);
+ assert.equal(dealer.cards.length, 25);
+ assert.equal(teammate.cards.length, 33);
+ assert.throws(
+ () => engine.buryCards(dealer.id, dealer.cards.slice(0, 8).map(value => value.id)),
+ /你不是埋底玩家/
+ );
+
+ const publicStart = io.events.find(({ event }) => event === 'secondary_burying_started');
+ assert.equal(publicStart.payload.secondaryPlayerId, teammate.id);
+ assert.equal(Object.hasOwn(publicStart.payload, 'bottomCards'), false);
+ const privateBottom = io.events.find(({ event }) => event === 'secondary_bottom_cards_received');
+ assert.equal(privateBottom.target, teammate.socketId);
+ assert.deepEqual(
+ new Set(privateBottom.payload.bottomCards.map(value => value.id)),
+ new Set(firstBottom.map(value => value.id))
+ );
+
+ const finalBottom = [...firstBottom.slice(0, 2), ...teammateCards.slice(0, 6)];
+ const finalResult = engine.buryCards(teammate.id, finalBottom.map(value => value.id));
+
+ assert.equal(finalResult.completed, true);
+ assert.equal(finalResult.isSecondary, true);
+ assert.equal(room.gameState.phase, GamePhases.PLAYING);
+ assert.equal(room.gameState.secondaryBuryingPlayerId, null);
+ assert.equal(room.gameState.reformAndOpeningUpTeammatePlayerId, teammate.id);
+ assert.equal(room.gameState.firstPlayerId, dealer.id);
+ assert.equal(room.gameState.currentPlayerIndex, 0);
+ assert.equal(room.gameState.roundStartPlayerIndex, 0);
+ assert.equal(dealer.cards.length, 25);
+ assert.equal(teammate.cards.length, 25);
+ assert.deepEqual(
+ new Set(room.gameState.bottomCards.map(value => value.id)),
+ new Set(finalBottom.map(value => value.id))
+ );
+ assert.equal(
+ io.events.filter(({ event }) => event === 'first_player_set').at(-1).payload.playerId,
+ dealer.id
+ );
+
+ const viewBottomAs = viewer => {
+ const handlers = new Map();
+ const emitted = [];
+ const socket = {
+ id: viewer.socketId,
+ on(event, handler) {
+ handlers.set(event, handler);
+ },
+ emit(event, payload) {
+ emitted.push({ event, payload });
+ }
+ };
+ registerGameHandlers(createIo(), socket, {
+ getRoom: roomId => roomId === room.id ? room : null
+ });
+ handlers.get('view_my_bottom_cards')({ roomId: room.id });
+ return emitted;
+ };
+ const expectedBottomIds = new Set(finalBottom.map(value => value.id));
+ for (const viewer of [dealer, teammate]) {
+ const response = viewBottomAs(viewer)
+ .find(({ event }) => event === 'my_bottom_cards');
+ assert.ok(response);
+ assert.equal(response.payload.isReformAndOpeningUp, true);
+ assert.deepEqual(
+ new Set(response.payload.bottomCards.map(value => value.id)),
+ expectedBottomIds
+ );
+ }
+ assert.match(
+ viewBottomAs(room.players[1]).find(({ event }) => event === 'error').payload.message,
+ /只有本局庄家和庄家队友/
+ );
+});
+
+test('改革开放的庄家队友为Bot时会自动完成再埋底', async () => {
+ const room = createRoom();
+ const io = createIo();
+ const engine = new GameEngine(room, io);
+ const dealer = room.players[0];
+ const teammate = room.players[2];
+ teammate.isBot = true;
+ const ranks = ['2', '3', '4', '5', '6', '7', '8', '9', '10', 'J', 'Q', 'K', 'A'];
+ Array.from({ length: 33 }, (_, index) =>
+ card(['spades', 'hearts', 'clubs', 'diamonds'][index % 4], ranks[index % 13], Math.floor(index / 52))
+ ).forEach(value => dealer.addCard(value));
+ Array.from({ length: 25 }, (_, index) =>
+ card(['spades', 'hearts', 'clubs', 'diamonds'][index % 4], ranks[(index + 5) % 13], 1)
+ ).forEach(value => teammate.addCard(value));
+ room.gameState.phase = GamePhases.BURYING;
+ room.gameState.selectedRule = REFORM_AND_OPENING_UP_RULE;
+ room.gameState.buryingPlayerId = dealer.id;
+ room.gameState.bottomCardsCount = 8;
+
+ engine.buryCards(dealer.id, dealer.cards.slice(0, 8).map(value => value.id));
+ await new Promise(resolve => setTimeout(resolve, 650));
+
+ assert.equal(room.gameState.phase, GamePhases.PLAYING);
+ assert.equal(room.gameState.secondaryBuryingPlayerId, null);
+ assert.equal(room.gameState.firstPlayerId, dealer.id);
+ assert.equal(teammate.cards.length, 25);
+ assert.equal(room.gameState.bottomCards.length, 8);
+ assert.equal(
+ io.events.filter(({ event }) => event === 'cards_buried').at(-1).payload.isSecondary,
+ true
+ );
+});
+
+test('一马当先只让庄家队友领出第一轮,不改变庄家、底牌归属或后续领出者', () => {
+ const room = createRoom();
+ const io = createIo();
+ const engine = new GameEngine(room, io);
+ const dealer = room.players[0];
+ const dealerTeammate = room.players[2];
+ const buriedCards = ['2', '3', '4', '5', '6', '7', '8', '9']
+ .map((rank, index) => card('spades', rank, index));
+ const trickCards = [
+ card('hearts', '4', 20),
+ card('hearts', 'A', 20),
+ card('hearts', '2', 20),
+ card('hearts', '3', 20)
+ ];
+ const spareCards = ['6', '7', '8', '9']
+ .map((rank, index) => card('clubs', rank, 30 + index));
+
+ buriedCards.forEach(value => dealer.addCard(value));
+ room.players.forEach((player, index) => {
+ player.addCard(trickCards[index]);
+ player.addCard(spareCards[index]);
+ });
+ room.gameState.phase = GamePhases.BURYING;
+ room.gameState.selectedRule = ONE_HORSE_LEADS_RULE;
+ room.gameState.buryingPlayerId = dealer.id;
+ room.gameState.dealerPlayerIndex = 0;
+ room.gameState.bottomCardsCount = 8;
+ room.gameState.trumpSuit = 'spades';
+ room.gameState.trumpRank = '5';
+
+ engine.buryCards(dealer.id, buriedCards.map(value => value.id));
+
+ assert.equal(room.gameState.buryingPlayerId, dealer.id);
+ assert.equal(room.gameState.dealerPlayerIndex, 0);
+ assert.deepEqual(
+ room.gameState.bottomCards.map(value => value.id),
+ buriedCards.map(value => value.id)
+ );
+ assert.equal(room.gameState.firstPlayerId, dealerTeammate.id);
+ assert.equal(room.gameState.currentPlayerIndex, 2);
+ assert.equal(room.gameState.roundStartPlayerIndex, 2);
+ assert.ok(io.events.some(({ event, payload }) =>
+ event === 'first_player_set' && payload.playerId === dealerTeammate.id));
+
+ let result;
+ for (const playerIndex of [2, 3, 0, 1]) {
+ result = engine.playCards(room.players[playerIndex].id, [trickCards[playerIndex].id]);
+ }
+
+ assert.equal(result.roundWinner.playerId, room.players[1].id);
+ assert.equal(room.gameState.currentPlayerIndex, 1);
+ assert.equal(room.gameState.roundStartPlayerIndex, 1);
+ assert.equal(room.gameState.firstPlayerId, dealerTeammate.id);
+});
+
+test('冰山一角由每家暗选两张,全部确认后统一公开,打出后由原玩家补选', () => {
+ const room = createRoom();
+ const io = createIo();
+ const engine = new GameEngine(room, io);
+ room.gameState.phase = GamePhases.PLAYING;
+ room.gameState.selectedRule = getRuleById(RuleIds.ICEBERG_TIP);
+ room.players.forEach((player, playerIndex) => {
+ ['2', '3', '4', '5'].forEach((rank, cardIndex) => {
+ player.addCard(card(['spades', 'hearts', 'clubs', 'diamonds'][playerIndex], rank, cardIndex));
+ });
+ });
+
+ engine.activateRuleHandVisibility();
+
+ assert.deepEqual([...room.gameState.icebergPendingPlayerIds], room.players.map(player => player.id));
+ assert.equal(io.events.filter(({ event }) => event === 'iceberg_reveal_selection_required').length, 4);
+ assert.equal(io.events.some(({ event }) => event === 'rule_visible_hands_updated'), false);
+
+ const chosenByPlayer = new Map(room.players.map(player => [
+ player.id,
+ [player.cards[0].id, player.cards[2].id]
+ ]));
+ assert.throws(
+ () => engine.submitIcebergRevealSelection(room.players[0].id, []),
+ /必须选择2张牌明置/
+ );
+ room.players.slice(0, 3).forEach(player => {
+ engine.submitIcebergRevealSelection(player.id, chosenByPlayer.get(player.id));
+ });
+ assert.equal(io.events.some(({ event }) => event === 'rule_visible_hands_updated'), false);
+ engine.submitIcebergRevealSelection(
+ room.players[3].id,
+ chosenByPlayer.get(room.players[3].id)
+ );
+
+ assert.equal(room.gameState.icebergPendingPlayerIds.size, 0);
+ room.players.forEach(player => {
+ assert.deepEqual(
+ [...room.gameState.icebergRevealedCardIdsByPlayer.get(player.id)].sort(),
+ [...chosenByPlayer.get(player.id)].sort()
+ );
+ });
+ const initialUpdates = io.events.filter(({ event }) => event === 'rule_visible_hands_updated');
+ assert.equal(initialUpdates.length, 4);
+ initialUpdates.forEach(({ target, payload }) => {
+ assert.notEqual(target, room.id);
+ assert.equal(payload.hands.length, 4);
+ assert.ok(payload.hands.every(hand => hand.cards.length === 2));
+ });
+
+ const player = room.players[0];
+ const initiallyRevealed = [...chosenByPlayer.get(player.id)];
+ engine.setFirstPlayer(player.id);
+ engine.playCards(player.id, [initiallyRevealed[0]]);
+
+ assert.deepEqual([...room.gameState.icebergPendingPlayerIds], [player.id]);
+ assert.equal(room.gameState.icebergRevealedCardIdsByPlayer.get(player.id).size, 1);
+ assert.throws(
+ () => engine.playCards(room.players[1].id, [room.players[1].cards[0].id]),
+ /选择明牌/
+ );
+
+ const replacement = player.cards.find(value =>
+ !room.gameState.icebergRevealedCardIdsByPlayer.get(player.id).has(value.id)
+ );
+ engine.submitIcebergRevealSelection(player.id, [replacement.id]);
+ const replenished = room.gameState.icebergRevealedCardIdsByPlayer.get(player.id);
+ assert.equal(replenished.size, 2);
+ assert.equal(replenished.has(initiallyRevealed[0]), false);
+ assert.equal(replenished.has(initiallyRevealed[1]), true);
+ assert.equal(replenished.has(replacement.id), true);
+});
+
+test('冰山一角中的Bot会自动选择自己的两张明牌而不阻塞牌局', () => {
+ const room = createRoom();
+ const io = createIo();
+ const engine = new GameEngine(room, io);
+ room.gameState.phase = GamePhases.PLAYING;
+ room.gameState.selectedRule = getRuleById(RuleIds.ICEBERG_TIP);
+ room.players.forEach((player, playerIndex) => {
+ player.isBot = true;
+ ['2', '3', '4'].forEach((rank, cardIndex) => {
+ player.addCard(card(['spades', 'hearts', 'clubs', 'diamonds'][playerIndex], rank, cardIndex));
+ });
+ });
+
+ engine.activateRuleHandVisibility();
+
+ assert.equal(engine.hasPendingIcebergSelection(), false);
+ assert.equal(room.gameState.icebergPendingPlayerIds.size, 0);
+ assert.equal(io.events.some(({ event }) => event === 'iceberg_reveal_selection_required'), false);
+ room.players.forEach(player => {
+ assert.equal(room.gameState.icebergRevealedCardIdsByPlayer.get(player.id).size, 2);
+ });
+ assert.equal(io.events.filter(({ event }) => event === 'rule_visible_hands_updated').length, 4);
+});
+
+test('互通有无只向每个玩家私发其对家手牌', () => {
+ const room = createRoom();
+ const io = createIo();
+ const engine = new GameEngine(room, io);
+ room.gameState.phase = GamePhases.PLAYING;
+ room.gameState.selectedRule = getRuleById(RuleIds.MUTUAL_VISIBILITY);
+ room.players.forEach((player, index) => player.addCard(card('spades', String(index + 2), index)));
+
+ engine.activateRuleHandVisibility();
+
+ const updates = io.events.filter(({ event }) => event === 'rule_visible_hands_updated');
+ assert.equal(updates.length, 4);
+ room.players.forEach((viewer, viewerIndex) => {
+ const update = updates.find(({ target }) => target === viewer.socketId);
+ assert.ok(update);
+ assert.equal(update.payload.hands.length, 1);
+ assert.equal(update.payload.hands[0].playerId, room.players[(viewerIndex + 2) % 4].id);
+ assert.equal(update.payload.hands[0].cards.length, 1);
+ });
+ assert.equal(updates.some(({ target }) => target === room.id), false);
+});
+
+test('新增明牌规则在庄家埋底完成前都不会发送任何牌面', () => {
+ for (const ruleId of [
+ RuleIds.ICEBERG_TIP,
+ RuleIds.MUTUAL_VISIBILITY,
+ RuleIds.OPEN_AND_HONEST
+ ]) {
+ const room = createRoom();
+ const io = createIo();
+ const engine = new GameEngine(room, io);
+ const dealer = room.players[0];
+ ['2', '3', '4', '5', '6', '7', '8', '9']
+ .map(rank => card('spades', rank))
+ .forEach(value => dealer.addCard(value));
+ room.players.slice(1).forEach((player, index) => {
+ player.addCard(card('hearts', String(index + 2), index));
+ });
+ room.gameState.phase = GamePhases.BURYING;
+ room.gameState.buryingPlayerId = dealer.id;
+ room.gameState.bottomCardsCount = 8;
+ room.gameState.selectedRule = getRuleById(ruleId);
+
+ engine.handleDealerAssigned(dealer);
+ assert.equal(io.events.some(({ event }) => event === 'rule_visible_hands_updated'), false);
+
+ engine.buryCards(dealer.id, dealer.cards.map(value => value.id));
+ const updates = io.events.filter(({ event }) => event === 'rule_visible_hands_updated');
+ if (ruleId === RuleIds.OPEN_AND_HONEST) {
+ assert.equal(updates.length, 0);
+ } else {
+ assert.equal(updates.length, 4);
+ }
+ }
+});
+
+test('为人坦荡只在一轮结束且四家手牌均不多于五张时同时公开', () => {
+ const room = createRoom();
+ const io = createIo();
+ const engine = new GameEngine(room, io);
+ room.gameState.phase = GamePhases.PLAYING;
+ room.gameState.selectedRule = getRuleById(RuleIds.OPEN_AND_HONEST);
+ room.players.forEach((player, playerIndex) => {
+ ['2', '3', '4', '5', '6', '7', '8'].forEach((rank, cardIndex) => {
+ player.addCard(card('spades', rank, playerIndex * 10 + cardIndex));
+ });
+ });
+
+ engine.setFirstPlayer(room.players[0].id);
+ engine.activateRuleHandVisibility();
+ assert.equal(io.events.some(({ event }) => event === 'rule_visible_hands_updated'), false);
+ let firstRoundFinalPlay;
+ room.players.forEach(player => {
+ firstRoundFinalPlay = engine.playCards(player.id, [player.cards[0].id]);
+ });
+ assert.equal(firstRoundFinalPlay.roundUpdate.type, 'round_ended');
+ assert.deepEqual(room.players.map(player => player.cards.length), [6, 6, 6, 6]);
+ assert.equal(room.gameState.areAllHandsRevealed, false);
+ assert.equal(io.events.some(({ event }) => event === 'rule_visible_hands_updated'), false);
+
+ room.players.slice(0, 3).forEach(player => {
+ engine.playCards(player.id, [player.cards[0].id]);
+ assert.equal(room.gameState.areAllHandsRevealed, false);
+ assert.equal(io.events.some(({ event }) => event === 'rule_visible_hands_updated'), false);
+ });
+ const finalPlay = engine.playCards(room.players[3].id, [room.players[3].cards[0].id]);
+
+ assert.equal(finalPlay.roundUpdate.type, 'round_ended');
+ assert.equal(room.gameState.areAllHandsRevealed, true);
+ const updates = io.events.filter(({ event }) => event === 'rule_visible_hands_updated');
+ assert.equal(updates.length, 4);
+ updates.forEach(({ payload }) => {
+ assert.deepEqual(payload.hands.map(hand => hand.cards.length), [5, 5, 5, 5]);
+ assert.match(payload.announcement, /本轮结束/);
+ });
+});
+
+test('十面埋伏在庄家埋底后由队友暗选,首次出现前不进入公共快照', () => {
+ const room = createRoom();
+ const io = createIo();
+ const engine = new GameEngine(room, io);
+ const dealer = room.players[0];
+ const selector = room.players[2];
+ const bottomCards = ['3', '4', '6', '8', '9', 'J', 'Q', 'A']
+ .map((rank, index) => card('clubs', rank, index));
+ const firstPlayCard = card('hearts', '7', 20);
+ [...bottomCards, firstPlayCard].forEach(value => dealer.addCard(value));
+ room.players.slice(1).forEach((player, index) => {
+ player.addCard(card('diamonds', String(index + 2), 30 + index));
+ });
+ room.gameState.phase = GamePhases.BURYING;
+ room.gameState.buryingPlayerId = dealer.id;
+ room.gameState.bottomCardsCount = 8;
+ room.gameState.trumpSuit = 'spades';
+ room.gameState.trumpRank = '2';
+ room.gameState.selectedRule = getRuleById(RuleIds.TEN_SIDED_AMBUSH);
+
+ engine.buryCards(dealer.id, bottomCards.map(value => value.id));
+
+ assert.equal(room.gameState.phase, GamePhases.PLAYING);
+ assert.equal(room.gameState.tenSidedAmbushSelectorPlayerId, selector.id);
+ assert.equal(room.gameState.isTenSidedAmbushSelectionPending, true);
+ assert.equal(room.gameState.toJSON().tenSidedAmbush.rank, null);
+ assert.equal(room.gameState.toJSON().tenSidedAmbush.attackerNetCardCount, 0);
+ const selectionRequest = io.events.find(({ event }) =>
+ event === 'ten_sided_ambush_selection_required'
+ );
+ assert.equal(selectionRequest.target, selector.socketId);
+ assert.ok(selectionRequest.payload.eligibleRanks.includes('7'));
+ ['2', '5', '10', 'K'].forEach(rank => {
+ assert.equal(selectionRequest.payload.eligibleRanks.includes(rank), false);
+ });
+ assert.throws(() => engine.playCards(dealer.id, [firstPlayCard.id]), /等待.*指定/);
+ assert.throws(() => engine.selectTenSidedAmbushRank(room.players[1].id, '7'), /只有庄家队友/);
+ assert.throws(() => engine.selectTenSidedAmbushRank(selector.id, '2'), /不能选择/);
+ assert.throws(() => engine.selectTenSidedAmbushRank(selector.id, '5'), /不能选择/);
+
+ engine.selectTenSidedAmbushRank(selector.id, '7');
+ assert.equal(room.gameState.tenSidedAmbushRank, '7');
+ assert.equal(room.gameState.toJSON().tenSidedAmbush.rank, null);
+ const privateConfirmation = io.events.find(({ event }) =>
+ event === 'ten_sided_ambush_rank_selected'
+ );
+ assert.equal(privateConfirmation.target, selector.socketId);
+ assert.equal(privateConfirmation.payload.rank, '7');
+ const publicLock = io.events.find(({ event }) => event === 'ten_sided_ambush_rank_locked');
+ assert.equal(Object.hasOwn(publicLock.payload, 'rank'), false);
+
+ const result = engine.playCards(dealer.id, [firstPlayCard.id]);
+ assert.equal(result.tenSidedAmbushReveal.rank, '7');
+ assert.equal(result.tenSidedAmbushReveal.source, 'play');
+ assert.equal(room.gameState.toJSON().tenSidedAmbush.rank, '7');
+});
+
+test('十面埋伏的Bot队友自动暗选,不阻塞庄家开始出牌', () => {
+ const room = createRoom();
+ const engine = new GameEngine(room, createIo(), () => 0);
+ const dealer = room.players[0];
+ room.players[2].isBot = true;
+ room.gameState.phase = GamePhases.BURYING;
+ room.gameState.buryingPlayerId = dealer.id;
+ room.gameState.bottomCardsCount = 0;
+ room.gameState.trumpRank = '2';
+ room.gameState.selectedRule = getRuleById(RuleIds.TEN_SIDED_AMBUSH);
+
+ engine.buryCards(dealer.id, []);
+
+ assert.equal(engine.hasPendingTenSidedAmbushSelection(), false);
+ assert.equal(room.gameState.tenSidedAmbushRank, '3');
+ assert.equal(room.gameState.toJSON().tenSidedAmbush.rank, null);
+});
+
+test('十面埋伏逐墩按赢家阵营对每张伏击牌反向计5分', () => {
+ const playTrick = ({ cards, startingScore = 0 }) => {
+ const room = createRoom();
+ const engine = new GameEngine(room, createIo());
+ room.gameState.phase = GamePhases.PLAYING;
+ room.gameState.trumpSuit = 'spades';
+ room.gameState.trumpRank = '2';
+ room.gameState.selectedRule = getRuleById(RuleIds.TEN_SIDED_AMBUSH);
+ room.gameState.buryingPlayerId = room.players[0].id;
+ room.gameState.tenSidedAmbushSelectorPlayerId = room.players[2].id;
+ room.gameState.tenSidedAmbushRank = '7';
+ room.gameState.attackerScore = startingScore;
+ room.players.forEach((player, index) => {
+ player.addCard(cards[index]);
+ player.addCard(card('clubs', String(index + 3), 80 + index));
+ });
+ engine.setFirstPlayer(room.players[0].id);
+ let result;
+ room.players.forEach((player, index) => {
+ result = engine.playCards(player.id, [cards[index].id]);
+ });
+ return { room, result };
+ };
+
+ const defenderWin = playTrick({
+ cards: [
+ card('hearts', 'A', 40),
+ card('hearts', '7', 41),
+ card('hearts', '3', 42),
+ card('hearts', '10', 43)
+ ]
+ });
+ assert.equal(defenderWin.result.roundUpdate.scoreInfo.winnerIsAttacker, false);
+ assert.equal(defenderWin.result.roundUpdate.scoreInfo.ambushCardCount, 1);
+ assert.equal(defenderWin.result.roundUpdate.scoreInfo.ambushScoreDelta, 5);
+ assert.equal(defenderWin.result.roundUpdate.scoreInfo.ambushAttackerNetCardDelta, -1);
+ assert.equal(defenderWin.room.gameState.tenSidedAmbushAttackerNetCardCount, -1);
+ assert.equal(defenderWin.room.gameState.attackerScore, 5);
+
+ const attackerWin = playTrick({
+ cards: [
+ card('hearts', '3', 50),
+ card('hearts', 'A', 51),
+ card('hearts', '7', 52),
+ card('hearts', '10', 53)
+ ]
+ });
+ assert.equal(attackerWin.result.roundUpdate.scoreInfo.winnerIsAttacker, true);
+ assert.equal(attackerWin.result.roundUpdate.scoreInfo.roundPoints, 10);
+ assert.equal(attackerWin.result.roundUpdate.scoreInfo.ambushScoreDelta, -5);
+ assert.equal(attackerWin.result.roundUpdate.scoreInfo.ambushAttackerNetCardDelta, 1);
+ assert.equal(attackerWin.room.gameState.tenSidedAmbushAttackerNetCardCount, 1);
+ assert.equal(attackerWin.room.gameState.attackerScore, 5);
+});
+
+test('十面埋伏底牌按抠底倍数:闲家拿底扣分,庄家守底加分', () => {
+ const calculate = (lastRoundWinnerIndex) => {
+ const room = createRoom();
+ const engine = new GameEngine(room, createIo());
+ room.gameState.selectedRule = getRuleById(RuleIds.TEN_SIDED_AMBUSH);
+ room.gameState.buryingPlayerId = room.players[0].id;
+ room.gameState.tenSidedAmbushSelectorPlayerId = room.players[2].id;
+ room.gameState.tenSidedAmbushRank = '7';
+ room.gameState.attackerScore = 40;
+ room.gameState.bottomCards = [
+ card('clubs', '5', 60),
+ card('hearts', '7', 61),
+ card('diamonds', '7', 62)
+ ];
+ room.gameState.lastRoundWinnerIndex = lastRoundWinnerIndex;
+ room.gameState.lastRoundLeadingPattern = { type: PatternTypes.SINGLE, length: 1 };
+ return { room, result: engine.calculateBottomScore() };
+ };
+
+ const attackerBottom = calculate(1);
+ assert.equal(attackerBottom.result.bottomMultiplier, 2);
+ assert.equal(attackerBottom.result.bottomPoints, 5);
+ assert.equal(attackerBottom.result.ambushPoints, 10);
+ assert.equal(attackerBottom.result.ambushScoreDelta, -20);
+ assert.equal(attackerBottom.result.ambushAttackerNetCardDelta, 4);
+ assert.equal(attackerBottom.result.ambushAttackerNetCardCount, 4);
+ assert.equal(attackerBottom.result.bottomScoreGained, -10);
+ assert.equal(attackerBottom.result.baseScore, 40);
+ assert.equal(attackerBottom.room.gameState.attackerScore, 30);
+ assert.equal(attackerBottom.result.ambushRevealedFromBottom, true);
+
+ const dealerBottom = calculate(0);
+ assert.equal(dealerBottom.result.ambushScoreDelta, 20);
+ assert.equal(dealerBottom.result.ambushAttackerNetCardDelta, -4);
+ assert.equal(dealerBottom.result.ambushAttackerNetCardCount, -4);
+ assert.equal(dealerBottom.result.bottomScoreGained, 20);
+ assert.equal(dealerBottom.result.baseScore, 40);
+ assert.equal(dealerBottom.room.gameState.attackerScore, 60);
+ assert.equal(dealerBottom.result.ambushRevealedFromBottom, true);
+});
+
+test('三权分立由初始2、3、4号位并行暗选,重复点数首次出现时一起揭晓并叠加计分', () => {
+ const room = createRoom();
+ const io = createIo();
+ const engine = new GameEngine(room, io);
+ const trickCards = [
+ card('hearts', '3', 100),
+ card('spades', '7', 101),
+ card('hearts', '4', 102),
+ card('hearts', '6', 103)
+ ];
+
+ room.gameState.phase = GamePhases.PLAYING;
+ room.gameState.trumpSuit = 'spades';
+ room.gameState.trumpRank = '2';
+ room.gameState.selectedRule = THREE_POWERS_RULE;
+ room.gameState.buryingPlayerId = room.players[0].id;
+ room.players.forEach((player, index) => {
+ player.addCard(trickCards[index]);
+ player.addCard(card('clubs', String(index + 8), 110 + index));
+ });
+ engine.setFirstPlayer(room.players[0].id);
+ engine.activateThreePowers();
+
+ const slotsBySource = Object.fromEntries(
+ room.gameState.threePowersSlots.map(slot => [slot.sourceRank, slot])
+ );
+ assert.equal(slotsBySource['10'].selectorPlayerId, room.players[1].id);
+ assert.equal(slotsBySource['5'].selectorPlayerId, room.players[2].id);
+ assert.equal(slotsBySource.K.selectorPlayerId, room.players[3].id);
+ assert.deepEqual(room.gameState.threePowersSlots.map(slot => slot.sourceRank), ['5', '10', 'K']);
+ assert.ok(engine.getEligibleThreePowersRanks().includes('5'));
+ assert.ok(engine.getEligibleThreePowersRanks().includes('10'));
+ assert.ok(engine.getEligibleThreePowersRanks().includes('K'));
+ assert.equal(engine.getEligibleThreePowersRanks().includes('2'), false);
+ assert.ok(room.gameState.toJSON().threePowers.slots.every(slot => slot.rank === null));
+ assert.throws(
+ () => engine.playCards(room.players[0].id, [trickCards[0].id]),
+ /完成三权分立点数选择/
+ );
+ assert.throws(
+ () => engine.selectThreePowersRank(room.players[1].id, '5', '7'),
+ /不能设置这个分牌重载槽/
+ );
+ assert.throws(
+ () => engine.selectThreePowersRank(room.players[1].id, '10', '2'),
+ /不能选择级牌或王牌/
+ );
+
+ engine.selectThreePowersRank(room.players[1].id, '10', '7');
+ engine.selectThreePowersRank(room.players[2].id, '5', '7');
+ engine.selectThreePowersRank(room.players[3].id, 'K', '7');
+
+ assert.equal(engine.hasPendingThreePowersSelection(), false);
+ assert.ok(room.gameState.toJSON().threePowers.slots.every(slot => slot.rank === null));
+ assert.equal(engine.getRuleCardPoints(card('diamonds', '5', 120)), 0);
+ assert.equal(engine.getRuleCardPoints(card('diamonds', '7', 121)), 25);
+ const privateConfirmations = io.events.filter(({ event }) => event === 'three_powers_rank_selected');
+ assert.equal(privateConfirmations.length, 3);
+ privateConfirmations.forEach(({ target, payload }) => {
+ assert.equal(target, room.findPlayerById(slotsBySource[payload.sourceRank].selectorPlayerId)?.socketId);
+ assert.equal(payload.rank, '7');
+ });
+ io.events
+ .filter(({ event }) => event === 'three_powers_rank_locked')
+ .forEach(({ payload }) => assert.equal(Object.hasOwn(payload, 'rank'), false));
+
+ const leadResult = engine.playCards(room.players[0].id, [trickCards[0].id]);
+ assert.equal(leadResult.threePowersReveal, null);
+ const revealResult = engine.playCards(room.players[1].id, [trickCards[1].id]);
+ assert.deepEqual(
+ revealResult.threePowersReveal.slots.map(slot => [slot.sourceRank, slot.rank]),
+ [['5', '7'], ['10', '7'], ['K', '7']]
+ );
+ assert.ok(room.gameState.toJSON().threePowers.slots.every(slot => slot.rank === '7'));
+ engine.playCards(room.players[2].id, [trickCards[2].id]);
+ const finalResult = engine.playCards(room.players[3].id, [trickCards[3].id]);
+
+ assert.equal(finalResult.roundUpdate.scoreInfo.winnerIsAttacker, true);
+ assert.equal(finalResult.roundUpdate.scoreInfo.roundPoints, 25);
+ assert.equal(room.gameState.attackerScore, 25);
+ assert.deepEqual(room.gameState.collectedPointCards.map(value => value.id), [trickCards[1].id]);
+});
+
+test('三权分立未曾出牌的重载点数在底牌揭晓并按抠底倍数计分', () => {
+ const room = createRoom();
+ const engine = new GameEngine(room, createIo());
+ room.gameState.selectedRule = THREE_POWERS_RULE;
+ room.gameState.buryingPlayerId = room.players[0].id;
+ room.gameState.lastRoundWinnerIndex = 1;
+ room.gameState.lastRoundLeadingPattern = { type: PatternTypes.SINGLE, length: 1 };
+ room.gameState.bottomCards = [card('clubs', '7', 130), card('clubs', '5', 131)];
+ room.gameState.threePowersSlots = [
+ { sourceRank: '5', pointValue: 5, selectorPlayerId: room.players[2].id, selectedRank: '7', isRevealed: false },
+ { sourceRank: '10', pointValue: 10, selectorPlayerId: room.players[1].id, selectedRank: '7', isRevealed: false },
+ { sourceRank: 'K', pointValue: 10, selectorPlayerId: room.players[3].id, selectedRank: '7', isRevealed: false }
+ ];
+
+ const result = engine.calculateBottomScore();
+
+ assert.equal(result.bottomPoints, 25);
+ assert.equal(result.bottomMultiplier, 2);
+ assert.equal(result.bottomScoreGained, 50);
+ assert.equal(room.gameState.attackerScore, 50);
+ assert.deepEqual(
+ result.threePowersRevealFromBottom.slots.map(slot => [slot.sourceRank, slot.rank]),
+ [['5', '7'], ['10', '7'], ['K', '7']]
+ );
+ assert.ok(room.gameState.toJSON().threePowers.slots.every(slot => slot.rank === '7'));
+});
+
+test('三条摸牌后换牌规则分别指向对家、上家和下家', () => {
+ const cases = [
+ [RuleIds.KNOW_YOURSELF_AND_ENEMY, '知己知彼', 2, [2, 3, 0, 1]],
+ [RuleIds.NEWS_MINISTER_I, '新闻部长I', -1, [3, 0, 1, 2]],
+ [RuleIds.NEWS_MINISTER_II, '新闻部长II', 1, [1, 2, 3, 0]]
+ ];
+
+ for (const [ruleId, name, offset, expectedTargets] of cases) {
+ const room = createRoom();
+ const engine = new GameEngine(room, createIo());
+ room.gameState.phase = GamePhases.DRAWING;
+ room.gameState.selectedRule = getRuleById(ruleId);
+
+ assert.equal(getOpeningCardExchangeOffset(room.gameState.selectedRule), offset);
+ assert.equal(room.gameState.selectedRule.name, name);
+ engine.startOpeningCardExchange();
+
+ room.players.forEach((player, index) => {
+ assert.equal(
+ room.gameState.cardExchange.targetByPlayerId[player.id],
+ room.players[expectedTargets[index]].id
+ );
+ });
+ }
+});
+
+test('开局换牌收齐前不改手牌,收齐后同时交换且只私发实际牌面', () => {
+ const cases = [
+ [RuleIds.KNOW_YOURSELF_AND_ENEMY, 2],
+ [RuleIds.NEWS_MINISTER_I, -1],
+ [RuleIds.NEWS_MINISTER_II, 1]
+ ];
+ const suits = ['spades', 'hearts', 'clubs', 'diamonds'];
+
+ for (const [ruleId, offset] of cases) {
+ const room = createRoom();
+ const io = createIo();
+ const engine = new GameEngine(room, io);
+ let dealerCompletions = 0;
+ let completedDealerId = null;
+ engine.drawingManager = {
+ completeDealerAssignment: dealerId => {
+ dealerCompletions++;
+ completedDealerId = dealerId;
+ }
+ };
+ room.gameState.phase = GamePhases.DRAWING;
+ room.gameState.selectedRule = getRuleById(ruleId);
+ room.gameState.pendingDealerPlayerId = room.players[0].id;
+
+ room.players.forEach((player, index) => {
+ ['2', '3', '4', '5'].forEach(rank => player.addCard(card(suits[index], rank)));
+ });
+ const selectedByPlayer = room.players.map(player => player.cards.slice(0, 2));
+ const originalHands = room.players.map(player => player.cards.map(value => value.id));
+
+ engine.startOpeningCardExchange();
+ room.players.slice(0, 3).forEach((player, index) => {
+ engine.submitOpeningCardExchange(player.id, selectedByPlayer[index].map(value => value.id));
+ });
+
+ assert.deepEqual(
+ room.players.map(player => player.cards.map(value => value.id)),
+ originalHands,
+ '收齐四份选择前不应改动任何手牌'
+ );
+ assert.equal(dealerCompletions, 0);
+
+ engine.submitOpeningCardExchange(
+ room.players[3].id,
+ selectedByPlayer[3].map(value => value.id)
+ );
+
+ room.players.forEach((player, recipientIndex) => {
+ const senderIndex = (recipientIndex - offset + room.players.length) % room.players.length;
+ const expectedIncoming = selectedByPlayer[senderIndex].map(value => value.id);
+ const handIds = new Set(player.cards.map(value => value.id));
+ assert.equal(player.cards.length, 4);
+ expectedIncoming.forEach(id => assert.ok(handIds.has(id)));
+ selectedByPlayer[recipientIndex].forEach(value => assert.ok(!handIds.has(value.id)));
+ });
+ assert.equal(room.gameState.cardExchange, null);
+ assert.equal(dealerCompletions, 1);
+ assert.equal(completedDealerId, room.players[0].id);
+
+ const publicResolution = io.events.find(({ event }) => event === 'card_exchange_resolved');
+ assert.ok(publicResolution);
+ assert.equal(publicResolution.payload.transfers.length, 4);
+ assert.equal('receivedCards' in publicResolution.payload, false);
+ const privateUpdates = io.events.filter(({ event }) => event === 'card_exchange_hand_updated');
+ assert.equal(privateUpdates.length, 4);
+ privateUpdates.forEach(update => {
+ assert.notEqual(update.target, room.id);
+ assert.equal(update.payload.sentCardIds.length, 2);
+ assert.equal(update.payload.receivedCards.length, 2);
+ assert.equal(update.payload.ruleName, getRuleById(ruleId).name);
+ assert.equal(update.payload.operation, 'exchange');
+ assert.equal(update.payload.animationDuration, publicResolution.payload.animationDuration);
+ });
+ }
+});
+
+test('发牌完成后先保留反主窗口,锁定庄家后换牌,换牌完成才发底牌', () => {
+ const room = createRoom();
+ const io = createIo();
+ const engine = new GameEngine(room, io);
+ const manager = new DrawingPhaseManager(
+ room,
+ io,
+ dealer => engine.handleDealerAssigned(dealer),
+ () => engine.handleDrawingComplete(),
+ dealer => engine.handleDealerSelected(dealer)
+ );
+ engine.drawingManager = manager;
+ room.gameState.phase = GamePhases.DRAWING;
+ room.gameState.selectedRule = getRuleById(RuleIds.NEWS_MINISTER_II);
+ room.gameState.currentTrumpDeclaration = {
+ playerId: room.players[2].id,
+ playerName: room.players[2].name,
+ type: 'pair',
+ suit: 'joker'
+ };
+
+ const suits = ['spades', 'hearts', 'clubs', 'diamonds'];
+ room.players.forEach((player, index) => {
+ ['2', '3', '4', '5'].forEach(rank => player.addCard(card(suits[index], rank)));
+ });
+ room.gameState.bottomCards = ['6', '7', '8', '9', '10', 'J', 'Q', 'K']
+ .map(rank => card('spades', rank));
+ const selectedByPlayer = room.players.map(player => player.cards.slice(0, 2));
+ const dealer = room.players[2];
+
+ engine.handleDrawingComplete();
+ assert.equal(room.gameState.postDrawStage, 'trump_window');
+ assert.equal(room.gameState.isTrumpDeclarationLocked, false);
+ assert.equal(room.gameState.cardExchange, null);
+ assert.ok(manager.dealerTimer, '发牌完成后应开启亮主/反主倒计时');
+
+ manager.assignDealer();
+ assert.equal(room.gameState.pendingDealerPlayerId, dealer.id);
+ assert.equal(room.gameState.buryingPlayerId, null);
+ assert.equal(room.gameState.phase, GamePhases.DRAWING);
+ assert.equal(room.gameState.postDrawStage, 'card_exchange');
+ assert.equal(room.gameState.isTrumpDeclarationLocked, true);
+ assert.ok(room.gameState.cardExchange);
+ assert.equal(dealer.cards.length, 4, '换牌完成前庄家不能收到任何底牌');
+ assert.equal(io.events.some(({ event }) => event === 'bottom_cards_received'), false);
+ assert.equal(manager.startDealerCountdown(), false, '锁定后反主不能重启庄家倒计时');
+ assert.equal(manager.dealerTimer, null);
+
+ room.players.forEach((player, index) => {
+ engine.submitOpeningCardExchange(
+ player.id,
+ selectedByPlayer[index].map(value => value.id)
+ );
+ });
+
+ assert.equal(room.gameState.cardExchange, null);
+ assert.equal(room.gameState.pendingDealerPlayerId, null);
+ assert.equal(room.gameState.buryingPlayerId, dealer.id);
+ assert.equal(room.gameState.phase, GamePhases.BURYING);
+ assert.equal(room.gameState.postDrawStage, null);
+ assert.equal(dealer.cards.length, 12, '换牌净张数不变,完成后才加八张底牌');
+
+ const eventNames = io.events.map(({ event }) => event);
+ assert.ok(eventNames.indexOf('dealer_selected') < eventNames.indexOf('card_exchange_started'));
+ assert.ok(eventNames.indexOf('card_exchange_resolved') < eventNames.indexOf('bottom_cards_received'));
+});
+
+test('中流砥柱必须等庄家完成埋底后才开始,并按埋底后的手牌判断', () => {
+ const room = createRoom();
+ const io = createIo();
+ const engine = new GameEngine(room, io);
+ const dealer = room.players[0];
+ const cardsToBury = ['3', '4', '5', '6', '7', '8', '9', '10']
+ .map((rank, index) => card('hearts', rank, 1900 + index));
+ const keptCards = [
+ card('spades', '3', 1910),
+ card('clubs', 'J', 1911),
+ card('clubs', 'Q', 1912),
+ card('clubs', 'K', 1913),
+ card('clubs', 'A', 1914)
+ ];
+ [...cardsToBury, ...keptCards].forEach(value => dealer.addCard(value));
+ room.players.slice(1).forEach((player, playerIndex) => {
+ ['3', '4', '6', '7', '8'].forEach((rank, rankIndex) => {
+ player.addCard(card('clubs', rank, 1920 + playerIndex * 10 + rankIndex));
+ });
+ });
+
+ room.gameState.selectedRule = MAINSTAY_RULE;
+ room.gameState.trumpSuit = 'spades';
+ room.gameState.trumpRank = '2';
+ room.gameState.dealerPlayerIndex = 0;
+ room.gameState.bottomCardsCount = cardsToBury.length;
+ room.gameState.phase = GamePhases.DRAWING;
+
+ assert.equal(engine.handleDealerSelected(dealer), false);
+ assert.equal(room.gameState.mainstayCurrentAction, null);
+ assert.equal(io.events.some(({ event }) => event === 'mainstay_started'), false);
+
+ room.gameState.phase = GamePhases.BURYING;
+ room.gameState.buryingPlayerId = dealer.id;
+ const result = engine.buryCards(dealer.id, cardsToBury.map(value => value.id));
+
+ assert.equal(result.completed, true);
+ assert.deepEqual(dealer.cards.map(value => value.id), keptCards.map(value => value.id));
+ assert.equal(room.gameState.mainstayCurrentAction.actorPlayerId, dealer.id);
+ assert.equal(room.gameState.mainstayCurrentAction.trumpCount, 1);
+ assert.equal(room.gameState.currentPlayerIndex, null);
+ assert.throws(
+ () => engine.playCards(dealer.id, [keptCards[0].id]),
+ /请先完成中流砥柱/
+ );
+ const eventNames = io.events.map(({ event }) => event);
+ assert.ok(
+ eventNames.indexOf('cards_buried') < eventNames.indexOf('mainstay_started'),
+ '必须先完成并公布埋底,再询问中流砥柱'
+ );
+});
+
+test('中流砥柱按实时手牌依次判断,同队两人可以先后发动并把全部主牌交回', () => {
+ const room = createRoom();
+ const io = createIo();
+ const engine = new GameEngine(room, io);
+ let resumedTurns = 0;
+ engine.onBotTurn = () => {
+ resumedTurns++;
+ };
+ room.gameState.phase = GamePhases.PLAYING;
+ room.gameState.selectedRule = MAINSTAY_RULE;
+ room.gameState.trumpSuit = 'spades';
+ room.gameState.trumpRank = '2';
+ room.gameState.dealerPlayerIndex = 0;
+ room.gameState.firstPlayerId = room.players[0].id;
+ room.gameState.currentPlayerIndex = 0;
+ room.gameState.roundStartPlayerIndex = 0;
+ room.gameState.currentRound = 1;
+
+ const hands = [
+ [
+ card('spades', '3', 2000), card('hearts', '2', 2001),
+ card('hearts', '3', 2002), card('hearts', '4', 2003), card('hearts', '5', 2004),
+ card('hearts', '6', 2005), card('hearts', '7', 2006), card('hearts', '8', 2007)
+ ],
+ ['3', '4', '5', '6', '7', '8', '9', '10'].map((rank, index) =>
+ card('clubs', rank, 2100 + index)
+ ),
+ [
+ card('spades', '4', 2200), card('diamonds', '2', 2201),
+ card('diamonds', '3', 2202), card('diamonds', '4', 2203), card('diamonds', '5', 2204),
+ card('diamonds', '6', 2205), card('diamonds', '7', 2206), card('diamonds', '8', 2207)
+ ],
+ ['3', '4', '5', '6', '7', '8', '9', '10'].map((rank, index) =>
+ card('hearts', rank, 2300 + index)
+ )
+ ];
+ room.players.forEach((player, index) => hands[index].forEach(value => player.addCard(value)));
+
+ const firstPlayerOriginalTrumps = engine.getMainstayTrumpCards(room.players[0]);
+ const teammateOriginalTrumps = engine.getMainstayTrumpCards(room.players[2]);
+ engine.startMainstay();
+ assert.equal(room.gameState.currentPlayerIndex, null, '四家决定期间必须冻结第一轮出牌权');
+ assert.equal(room.gameState.mainstayCurrentAction.actorPlayerId, room.players[0].id);
+ assert.equal(room.gameState.mainstayCurrentAction.trumpCount, 2);
+ assert.equal('trumpCount' in room.gameState.toJSON().mainstay.currentAction, false);
+ assert.equal(
+ io.events.find(({ target, event }) => (
+ target === room.players[0].socketId && event === 'mainstay_decision_required'
+ ))?.payload?.trumpCount,
+ 2,
+ '具体主牌数只应私发给当前玩家'
+ );
+ assert.equal(
+ 'trumpCount' in io.events.find(({ target, event }) => (
+ target === room.id && event === 'mainstay_action_pending'
+ )).payload,
+ false
+ );
+
+ const firstActionId = room.gameState.mainstayCurrentAction.id;
+ engine.respondMainstay(room.players[0].id, true);
+ const firstGive = [
+ ...firstPlayerOriginalTrumps,
+ ...room.players[0].cards.filter(value => !isTrumpCard(value, 'spades', '2')).slice(0, 3)
+ ];
+ engine.submitMainstayCards(room.players[0].id, firstActionId, firstGive.map(value => value.id));
+ assert.equal(room.gameState.mainstayCurrentAction.stage, 'return');
+ assert.equal(room.gameState.mainstayCurrentAction.chooserPlayerId, room.players[2].id);
+
+ // 队友第一次只返还副牌,保留刚收到的两张主牌,供自己稍后发动时一并交回。
+ const firstReturn = room.players[2].cards
+ .filter(value => !isTrumpCard(value, 'spades', '2'))
+ .slice(0, 5);
+ engine.submitMainstayCards(room.players[2].id, firstActionId, firstReturn.map(value => value.id));
+ assert.equal(room.gameState.mainstayCurrentAction.actorPlayerId, room.players[1].id);
+ engine.respondMainstay(room.players[1].id, false);
+
+ assert.equal(room.gameState.mainstayCurrentAction.actorPlayerId, room.players[2].id);
+ assert.equal(room.gameState.mainstayCurrentAction.trumpCount, 4);
+ const teammateActionId = room.gameState.mainstayCurrentAction.id;
+ engine.respondMainstay(room.players[2].id, true);
+ const allCurrentTeammateTrumps = engine.getMainstayTrumpCards(room.players[2]);
+ assert.deepEqual(
+ new Set(allCurrentTeammateTrumps.map(value => value.id)),
+ new Set([...firstPlayerOriginalTrumps, ...teammateOriginalTrumps].map(value => value.id))
+ );
+ const teammateGive = [
+ ...allCurrentTeammateTrumps,
+ room.players[2].cards.find(value => !isTrumpCard(value, 'spades', '2'))
+ ];
+ engine.submitMainstayCards(
+ room.players[2].id,
+ teammateActionId,
+ teammateGive.map(value => value.id)
+ );
+
+ const secondReturn = room.players[0].cards
+ .filter(value => !isTrumpCard(value, 'spades', '2'))
+ .slice(0, 5);
+ engine.submitMainstayCards(room.players[0].id, teammateActionId, secondReturn.map(value => value.id));
+ const firstPlayerHandIds = new Set(room.players[0].cards.map(value => value.id));
+ [...firstPlayerOriginalTrumps, ...teammateOriginalTrumps]
+ .forEach(value => assert.ok(firstPlayerHandIds.has(value.id), '主牌应已被队友全部交回'));
+
+ assert.equal(room.gameState.mainstayCurrentAction.actorPlayerId, room.players[3].id);
+ engine.respondMainstay(room.players[3].id, false);
+ assert.equal(room.gameState.mainstayCurrentAction, null);
+ assert.equal(room.gameState.mainstayPlayerQueue.length, 0);
+ assert.equal(room.gameState.currentPlayerIndex, 0, '全部决定完成后恢复原定首发玩家');
+ assert.equal(resumedTurns, 1);
+ assert.deepEqual(
+ room.gameState.mainstayResults.filter(result => result.accepted).map(result => result.playerId),
+ [room.players[0].id, room.players[2].id]
+ );
+});
+
+test('中流砥柱首轮交牌必须包含当前全部主牌,无主局则不进入流程', () => {
+ const room = createRoom();
+ const engine = new GameEngine(room, createIo());
+ room.gameState.phase = GamePhases.PLAYING;
+ room.gameState.selectedRule = MAINSTAY_RULE;
+ room.gameState.trumpSuit = 'hearts';
+ room.gameState.trumpRank = '2';
+ room.gameState.dealerPlayerIndex = 0;
+ room.gameState.currentPlayerIndex = 0;
+ room.gameState.roundStartPlayerIndex = 0;
+ room.gameState.currentRound = 1;
+ const firstHand = [
+ card('hearts', '3', 2400), card('clubs', '2', 2401),
+ card('clubs', '3', 2402), card('clubs', '4', 2403), card('clubs', '5', 2404),
+ card('clubs', '6', 2405), card('clubs', '7', 2406)
+ ];
+ firstHand.forEach(value => room.players[0].addCard(value));
+ room.players.slice(1).forEach((player, playerOffset) => {
+ ['3', '4', '5', '6', '7'].forEach((rank, rankOffset) => {
+ player.addCard(card('spades', rank, 2500 + playerOffset * 10 + rankOffset));
+ });
+ });
+
+ engine.startMainstay();
+ const actionId = room.gameState.mainstayCurrentAction.id;
+ engine.respondMainstay(room.players[0].id, true);
+ assert.throws(
+ () => engine.submitMainstayCards(
+ room.players[0].id,
+ actionId,
+ firstHand.filter(value => value.id !== firstHand[1].id).slice(0, 5).map(value => value.id)
+ ),
+ /必须包括当前全部主牌/
+ );
+
+ const noTrumpRoom = createRoom();
+ const noTrumpIo = createIo();
+ const noTrumpEngine = new GameEngine(noTrumpRoom, noTrumpIo);
+ noTrumpRoom.gameState.phase = GamePhases.PLAYING;
+ noTrumpRoom.gameState.selectedRule = MAINSTAY_RULE;
+ noTrumpRoom.gameState.trumpSuit = 'no_trump';
+ noTrumpRoom.gameState.currentPlayerIndex = 0;
+ assert.equal(noTrumpEngine.startMainstay(), false);
+ assert.equal(noTrumpRoom.gameState.mainstayCurrentAction, null);
+ assert.ok(noTrumpIo.events.some(({ event, payload }) => (
+ event === 'mainstay_completed' && payload.reason === 'no_trump'
+ )));
+});
+
+test('中流砥柱以庄家为一号位开始,而不是固定从房间数组下标零开始', () => {
+ const room = createRoom();
+ const engine = new GameEngine(room, createIo());
+ room.gameState.phase = GamePhases.PLAYING;
+ room.gameState.selectedRule = MAINSTAY_RULE;
+ room.gameState.trumpSuit = 'hearts';
+ room.gameState.trumpRank = '2';
+ room.gameState.dealerPlayerIndex = 1;
+ room.gameState.currentPlayerIndex = 1;
+ room.gameState.roundStartPlayerIndex = 1;
+ room.gameState.currentRound = 1;
+ room.players.forEach((player, playerIndex) => {
+ ['3', '4', '5', '6', '7'].forEach((rank, rankIndex) => {
+ player.addCard(card('clubs', rank, 2600 + playerIndex * 10 + rankIndex));
+ });
+ });
+
+ engine.startMainstay();
+
+ assert.equal(room.gameState.mainstayCurrentAction.actorPlayerId, room.players[1].id);
+ assert.equal(room.gameState.mainstayCurrentAction.position, 1);
+ assert.deepEqual(
+ room.gameState.mainstayPlayerQueue,
+ [room.players[2].id, room.players[3].id, room.players[0].id]
+ );
+});
+
+test('欢乐成双在庄家锁定后让庄家与原上家交换位置', () => {
+ const room = createRoom();
+ const io = createIo();
+ const engine = new GameEngine(room, io);
+ const originalPlayers = [...room.players];
+ const dealer = originalPlayers[0];
+ const originalUpstream = originalPlayers[3];
+ room.gameState.phase = GamePhases.DRAWING;
+ room.gameState.selectedRule = HAPPY_TWINS_RULE;
+ room.gameState.pendingDealerPlayerId = dealer.id;
+ room.gameState.dealerPlayerIndex = 0;
+
+ assert.equal(engine.handleDealerSelected(dealer), false);
+
+ assert.deepEqual(
+ room.players.map(player => player.id),
+ [originalUpstream.id, originalPlayers[1].id, originalPlayers[2].id, dealer.id]
+ );
+ assert.deepEqual(room.players.map(player => player.position), [0, 1, 2, 3]);
+ assert.equal(room.gameState.dealerPlayerIndex, 3);
+ assert.equal(room.gameState.happyTwins.dealerPlayerId, dealer.id);
+ assert.equal(room.gameState.happyTwins.upstreamPlayerId, originalUpstream.id);
+ assert.equal(room.gameState.happyTwins.restored, false);
+ assert.ok(io.events.some(({ event, payload }) => (
+ event === 'happy_twins_positions_swapped'
+ && payload.dealerPlayerId === dealer.id
+ && payload.upstreamPlayerId === originalUpstream.id
+ )));
+});
+
+test('欢乐成双首局换位后按玩家身份迁移牌权,并仍由庄家本人首发', () => {
+ const room = createRoom();
+ const io = createIo();
+ const engine = new GameEngine(room, io);
+ const originalPlayers = [...room.players];
+ const dealer = originalPlayers[2];
+ const originalUpstream = originalPlayers[1];
+ const buriedCard = card('clubs', '3', 2700);
+ const openingCard = card('diamonds', '6', 2701);
+ dealer.addCard(buriedCard);
+ dealer.addCard(openingCard);
+
+ room.gameState.phase = GamePhases.DRAWING;
+ room.gameState.selectedRule = HAPPY_TWINS_RULE;
+ room.gameState.pendingDealerPlayerId = dealer.id;
+ room.gameState.dealerPlayerIndex = 2;
+ // 模拟庄家锁定前已经按座位保存的行动状态;换位后这些状态必须继续跟随原玩家。
+ room.gameState.currentPlayerIndex = 2;
+ room.gameState.roundStartPlayerIndex = 2;
+ room.gameState.currentWinnerIndex = 2;
+ room.gameState.lastRoundWinnerIndex = 2;
+ room.gameState.nextRuleChooserIndex = 2;
+
+ engine.applyHappyTwinsPositionSwap(dealer);
+
+ assert.deepEqual(
+ room.players.map(player => player.id),
+ [originalPlayers[0].id, dealer.id, originalUpstream.id, originalPlayers[3].id]
+ );
+ assert.equal(room.gameState.dealerPlayerIndex, 1);
+ assert.equal(room.gameState.currentPlayerIndex, 1);
+ assert.equal(room.gameState.roundStartPlayerIndex, 1);
+ assert.equal(room.gameState.currentWinnerIndex, 1);
+ assert.equal(room.gameState.lastRoundWinnerIndex, 1);
+ assert.equal(room.gameState.nextRuleChooserIndex, 1);
+
+ room.gameState.phase = GamePhases.BURYING;
+ room.gameState.pendingDealerPlayerId = null;
+ room.gameState.buryingPlayerId = dealer.id;
+ room.gameState.bottomCardsCount = 1;
+ const result = engine.buryCards(dealer.id, [buriedCard.id]);
+
+ assert.equal(result.firstPlayer.id, dealer.id);
+ assert.equal(room.gameState.firstPlayerId, dealer.id);
+ assert.equal(room.gameState.currentPlayerIndex, room.getPlayerIndex(dealer.id));
+ assert.equal(room.gameState.roundStartPlayerIndex, room.getPlayerIndex(dealer.id));
+ assert.notEqual(result.firstPlayer.id, originalUpstream.id);
+ assert.ok(io.events.some(({ event, payload }) => (
+ event === 'first_player_set'
+ && payload.playerId === dealer.id
+ && payload.currentPlayerIndex === room.getPlayerIndex(dealer.id)
+ )));
+});
+
+test('欢乐成双终局恢复原座次:庄家方胜由固定队友上庄,闲家胜由原上家上庄', () => {
+ const setup = attackerScore => {
+ const room = createRoom();
+ const io = createIo();
+ const engine = new GameEngine(room, io);
+ const originalPlayers = [...room.players];
+ const dealer = originalPlayers[0];
+ room.gameState.team1Level = 7;
+ room.gameState.team2Level = 10;
+ room.gameState.phase = GamePhases.DRAWING;
+ room.gameState.selectedRule = HAPPY_TWINS_RULE;
+ room.gameState.pendingDealerPlayerId = dealer.id;
+ room.gameState.dealerPlayerIndex = 0;
+ engine.applyHappyTwinsPositionSwap(dealer);
+ room.gameState.buryingPlayerId = dealer.id;
+ room.gameState.attackerScore = attackerScore;
+ room.gameState.lastRoundWinnerIndex = 0;
+ room.gameState.lastRoundLeadingPattern = { type: PatternTypes.SINGLE, length: 1 };
+ return { room, io, engine, originalPlayers, dealer };
+ };
+
+ const dealerWin = setup(40);
+ const dealerWinResult = dealerWin.engine.calculateUpgrade();
+ assert.equal(dealerWinResult.attackerWon, false);
+ assert.equal(
+ dealerWinResult.nextDealerName,
+ dealerWin.originalPlayers[2].name,
+ '换位不改变组队关系,庄家的原队友应成为下一庄'
+ );
+ assert.equal(dealerWinResult.nextDealerIndex, 2);
+ assert.equal(dealerWinResult.oldDealerLevel, 7);
+ assert.equal(dealerWinResult.nextDealerLevel, 8);
+ assert.equal(dealerWin.room.gameState.team1Level, 8);
+ assert.equal(dealerWin.room.gameState.team2Level, 10);
+ assert.deepEqual(
+ dealerWin.room.players.map(player => player.id),
+ dealerWin.originalPlayers.map(player => player.id)
+ );
+ assert.equal(dealerWin.room.gameState.happyTwins.restored, true);
+
+ const attackerWin = setup(120);
+ const originalUpstream = attackerWin.originalPlayers[3];
+ attackerWin.engine.finishGame();
+ const attackerWinResult = attackerWin.room.gameState.upgradeResult;
+ assert.equal(attackerWinResult.attackerWon, true);
+ assert.equal(
+ attackerWinResult.nextDealerName,
+ originalUpstream.name,
+ '交换后庄家的下家,也就是交换前的上家,应成为下一庄'
+ );
+ assert.equal(attackerWinResult.nextDealerIndex, 3);
+ assert.equal(attackerWinResult.oldAttackerLevel, 10);
+ assert.equal(attackerWinResult.nextDealerLevel, 11);
+ assert.equal(attackerWin.room.gameState.team1Level, 7);
+ assert.equal(attackerWin.room.gameState.team2Level, 11);
+ assert.deepEqual(
+ attackerWin.room.players.map(player => player.id),
+ attackerWin.originalPlayers.map(player => player.id)
+ );
+ assert.equal(attackerWin.room.gameState.dealerPlayerIndex, 3);
+ assert.equal(attackerWin.room.gameState.lastRoundWinnerIndex, 3);
+ assert.equal(attackerWin.room.gameState.nextRuleChooserIndex, 3);
+ assert.ok(attackerWin.io.events.some(({ event, payload }) => (
+ event === 'happy_twins_positions_restored'
+ && payload.nextDealerPlayerId === originalUpstream.id
+ && payload.nextDealerIndex === 3
+ )));
+});
+
+test('欢乐成双只换座位不换队伍:与庄家隔座的原闲家赢墩仍计入闲家分', () => {
+ const room = createRoom();
+ const io = createIo();
+ const engine = new GameEngine(room, io);
+ const originalPlayers = [...room.players];
+ const player = originalPlayers[0];
+ const dealer = originalPlayers[3];
+ const originalTeammate = originalPlayers[2];
+
+ room.gameState.phase = GamePhases.DRAWING;
+ room.gameState.selectedRule = HAPPY_TWINS_RULE;
+ room.gameState.pendingDealerPlayerId = dealer.id;
+ room.gameState.dealerPlayerIndex = 3;
+ engine.applyHappyTwinsPositionSwap(dealer);
+
+ assert.deepEqual(
+ room.players.map(currentPlayer => currentPlayer.id),
+ [player.id, originalPlayers[1].id, dealer.id, originalTeammate.id],
+ '庄家应与玩家的原队友换位并坐到玩家对面'
+ );
+ assert.deepEqual(room.gameState.toJSON().happyTwins.teamIndexByPlayerId, {
+ [originalPlayers[0].id]: 0,
+ [originalPlayers[1].id]: 1,
+ [originalPlayers[2].id]: 0,
+ [originalPlayers[3].id]: 1
+ });
+
+ const trickCards = [
+ card('hearts', 'A', 910),
+ card('hearts', '10', 911),
+ card('hearts', 'K', 912),
+ card('hearts', '5', 913)
+ ];
+ room.players.forEach((currentPlayer, index) => {
+ currentPlayer.addCard(trickCards[index]);
+ currentPlayer.addCard(card('clubs', String(index + 3), 920 + index));
+ });
+ room.gameState.phase = GamePhases.PLAYING;
+ room.gameState.trumpSuit = 'spades';
+ room.gameState.trumpRank = '2';
+ room.gameState.buryingPlayerId = dealer.id;
+ engine.setFirstPlayer(player.id);
+
+ engine.playCards(player.id, [trickCards[0].id]);
+ engine.playCards(originalPlayers[1].id, [trickCards[1].id]);
+ engine.playCards(dealer.id, [trickCards[2].id]);
+ const result = engine.playCards(originalTeammate.id, [trickCards[3].id]);
+
+ assert.equal(result.roundWinner.playerId, player.id);
+ assert.equal(result.roundUpdate.scoreInfo.winnerIsAttacker, true);
+ assert.equal(result.roundUpdate.scoreInfo.attackerRoundPointsAwarded, 25);
+ assert.equal(room.gameState.attackerScore, 25);
+ assert.deepEqual(
+ room.gameState.collectedPointCards.map(currentCard => currentCard.id),
+ [trickCards[1].id, trickCards[2].id, trickCards[3].id]
+ );
+});
+
+test('围三阙一不影响摸牌阶段亮主', () => {
+ const room = createRoom();
+ const io = createIo();
+ const handlers = new Map();
+ const socketEvents = [];
+ const player = room.players[0];
+ room.gameState.phase = GamePhases.DRAWING;
+ room.gameState.selectedRule = ENCIRCLE_THREE_MISSING_ONE_RULE;
+ room.gameState.trumpRank = '2';
+ player.cards = [card('hearts', '2', 940)];
+
+ const socket = {
+ id: player.socketId,
+ on(event, handler) {
+ handlers.set(event, handler);
+ },
+ emit(event, payload) {
+ socketEvents.push({ event, payload });
+ }
+ };
+ registerPlayerHandlers(io, socket, {
+ getRoom: roomId => roomId === room.id ? room : null
+ });
+
+ handlers.get('declare_trump')({ roomId: room.id, suit: 'hearts', count: 1 });
+
+ assert.equal(socketEvents.some(({ event }) => event === 'error'), false);
+ assert.equal(room.gameState.currentTrumpDeclaration.playerId, player.id);
+ assert.equal(room.gameState.trumpSuit, 'hearts');
+ assert.deepEqual(room.gameState.encircleThreeMissingOneSeenSuits, []);
+ assert.ok(io.events.some(({ event, payload }) => (
+ event === 'trump_declared'
+ && payload.playerId === player.id
+ && payload.suit === 'hearts'
+ )));
+});
+
+test('围三阙一按每次出牌即时记录,集齐三种普通花色后只从下一轮换主并重置记录', () => {
+ const room = createRoom();
+ const io = createIo();
+ const engine = new GameEngine(room, io);
+ const firstRoundCards = [
+ card('hearts', '3', 950),
+ card('clubs', '4', 951),
+ card('diamonds', '5', 952),
+ card('hearts', '6', 953)
+ ];
+ const reserveCards = [
+ card('spades', '7', 960),
+ card('spades', '8', 961),
+ card('spades', '9', 962),
+ card('clubs', '10', 963)
+ ];
+ room.players.forEach((player, index) => {
+ player.addCard(firstRoundCards[index]);
+ player.addCard(reserveCards[index]);
+ });
+ room.gameState.phase = GamePhases.PLAYING;
+ room.gameState.selectedRule = ENCIRCLE_THREE_MISSING_ONE_RULE;
+ room.gameState.trumpSuit = 'hearts';
+ room.gameState.trumpRank = '2';
+ room.gameState.buryingPlayerId = room.players[0].id;
+ engine.setFirstPlayer(room.players[0].id);
+
+ engine.playCards(room.players[0].id, [firstRoundCards[0].id]);
+ assert.deepEqual(room.gameState.encircleThreeMissingOneSeenSuits, ['hearts']);
+ assert.equal(room.gameState.trumpSuit, 'hearts');
+
+ engine.undoLastPlay(room.players[0].id);
+ assert.deepEqual(
+ room.gameState.encircleThreeMissingOneSeenSuits,
+ [],
+ '撤回出牌时必须同时撤回该次原子记录'
+ );
+
+ engine.playCards(room.players[0].id, [firstRoundCards[0].id]);
+ engine.playCards(room.players[1].id, [firstRoundCards[1].id]);
+ assert.deepEqual(room.gameState.encircleThreeMissingOneSeenSuits, ['hearts', 'clubs']);
+ assert.equal(room.gameState.trumpSuit, 'hearts');
+
+ engine.playCards(room.players[2].id, [firstRoundCards[2].id]);
+ assert.deepEqual(
+ room.gameState.encircleThreeMissingOneSeenSuits,
+ ['hearts', 'clubs', 'diamonds']
+ );
+ assert.equal(room.gameState.trumpSuit, 'hearts', '第三种花色出现时本轮主花色不能立刻改变');
+
+ const result = engine.playCards(room.players[3].id, [firstRoundCards[3].id]);
+ const transition = result.roundUpdate.encircleThreeMissingOneTransition;
+
+ assert.deepEqual(transition.recordedSuits, ['hearts', 'clubs', 'diamonds']);
+ assert.equal(transition.missingSuit, 'spades');
+ assert.equal(transition.previousTrumpSuit, 'hearts');
+ assert.equal(transition.nextTrumpSuit, 'spades');
+ assert.equal(transition.replaced, true);
+ assert.equal(transition.effectiveRound, 2);
+ assert.equal(room.gameState.trumpSuit, 'spades');
+ assert.deepEqual(room.gameState.encircleThreeMissingOneSeenSuits, []);
+
+ engine.recordEncircleThreeMissingOnePlay([
+ card('joker', 'big_joker', 970),
+ card('spades', '2', 971),
+ card('hearts', '7', 972)
+ ]);
+ assert.deepEqual(room.gameState.encircleThreeMissingOneSeenSuits, ['hearts']);
+ engine.recordEncircleThreeMissingOnePlay([card('clubs', '8', 973)]);
+ engine.recordEncircleThreeMissingOnePlay([card('diamonds', '9', 974)]);
+ const unchanged = engine.applyEncircleThreeMissingOneAtRoundEnd(2);
+ assert.deepEqual(unchanged.recordedSuits, ['hearts', 'clubs', 'diamonds']);
+ assert.equal(unchanged.missingSuit, 'spades');
+ assert.equal(unchanged.replaced, false, '缺少的正是当前主花色时不得换主');
+ assert.equal(room.gameState.trumpSuit, 'spades');
+ assert.deepEqual(room.gameState.encircleThreeMissingOneSeenSuits, []);
+ assert.equal(
+ io.events.filter(({ event }) => event === 'trump_updated').length,
+ 1,
+ '只有真正换主时才广播主花色更新'
+ );
+});
+
+test('围三阙一一次出牌凑齐四种花色时清空整批记录并从下一次出牌重新记录', () => {
+ const room = createRoom();
+ const engine = new GameEngine(room, createIo());
+ room.gameState.selectedRule = ENCIRCLE_THREE_MISSING_ONE_RULE;
+ room.gameState.trumpRank = '2';
+
+ const initial = engine.recordEncircleThreeMissingOnePlay([
+ card('hearts', '3', 980),
+ card('clubs', '4', 981)
+ ]);
+ assert.equal(initial.flushed, false);
+ assert.deepEqual(room.gameState.encircleThreeMissingOneSeenSuits, ['hearts', 'clubs']);
+
+ const flushed = engine.recordEncircleThreeMissingOnePlay([
+ card('diamonds', '5', 982),
+ card('spades', '6', 983),
+ card('joker', 'big_joker', 984),
+ card('hearts', '2', 985)
+ ]);
+ assert.equal(flushed.flushed, true);
+ assert.deepEqual(flushed.previousSuits, ['hearts', 'clubs']);
+ assert.deepEqual(flushed.addedSuits, ['diamonds', 'spades']);
+ assert.deepEqual(flushed.seenSuits, []);
+ assert.deepEqual(room.gameState.encircleThreeMissingOneSeenSuits, []);
+
+ const restarted = engine.recordEncircleThreeMissingOnePlay([card('clubs', '7', 986)]);
+ assert.equal(restarted.flushed, false);
+ assert.deepEqual(restarted.seenSuits, ['clubs']);
+ assert.deepEqual(room.gameState.encircleThreeMissingOneSeenSuits, ['clubs']);
+});
+
+test('三六九等把亮主与亮劣分成独立反亮链,并全桌锁定已用花色', () => {
+ const room = createRoom();
+ const io = createIo();
+ room.gameState.phase = GamePhases.DRAWING;
+ room.gameState.selectedRule = THREE_SIX_NINE_GRADES_RULE;
+ room.gameState.trumpRank = '2';
+ room.players[0].cards = [card('hearts', '2', 990), card('hearts', '2', 991)];
+ room.players[1].cards = [
+ card('hearts', '2', 992), card('hearts', '2', 993), card('clubs', '2', 994)
+ ];
+ room.players[2].cards = [card('diamonds', '2', 995), card('diamonds', '2', 996)];
+ room.players[3].cards = [card('joker', 'small_joker', 997), card('joker', 'small_joker', 998)];
+
+ const connect = player => {
+ const handlers = new Map();
+ const emitted = [];
+ registerPlayerHandlers(io, {
+ id: player.socketId,
+ on(event, handler) {
+ handlers.set(event, handler);
+ },
+ emit(event, payload) {
+ emitted.push({ event, payload });
+ }
+ }, {
+ getRoom: roomId => roomId === room.id ? room : null
+ });
+ return { handlers, emitted };
+ };
+ const sockets = room.players.map(connect);
+ const declare = (playerIndex, suit, count, declarationRole) => {
+ const socket = sockets[playerIndex];
+ const before = socket.emitted.length;
+ socket.handlers.get('declare_trump')({
+ roomId: room.id,
+ suit,
+ count,
+ declarationRole
+ });
+ return socket.emitted.slice(before);
+ };
+
+ assert.equal(declare(0, 'hearts', 1, 'trump').some(event => event.event === 'error'), false);
+ assert.equal(room.gameState.trumpSuit, 'hearts');
+ assert.equal(room.gameState.threeSixNineClaimedSuits.get('hearts').declarationRole, 'trump');
+
+ const blocked = declare(1, 'hearts', 2, 'inferior');
+ assert.match(blocked.at(-1).payload.message, /已经用于亮主或亮劣/);
+ assert.equal(room.gameState.currentInferiorDeclaration, null);
+
+ assert.equal(declare(0, 'hearts', 2, 'trump').some(event => event.event === 'error'), false);
+ assert.equal(room.gameState.currentTrumpDeclaration.count, 2, '原声明者仍可用第二张同花色级牌加固');
+
+ assert.equal(declare(1, 'clubs', 1, 'inferior').some(event => event.event === 'error'), false);
+ assert.equal(room.gameState.inferiorSuit, 'clubs');
+ assert.equal(room.gameState.currentInferiorDeclaration.playerId, room.players[1].id);
+
+ assert.equal(declare(2, 'diamonds', 2, 'inferior').some(event => event.event === 'error'), false);
+ assert.equal(room.gameState.inferiorSuit, 'diamonds');
+ assert.equal(room.gameState.currentInferiorDeclaration.playerId, room.players[2].id);
+ assert.deepEqual(
+ Object.keys(room.gameState.toJSON().threeSixNine.claimedSuits).sort(),
+ ['clubs', 'diamonds', 'hearts']
+ );
+
+ const jokerInferior = declare(3, 'joker', 2, 'inferior');
+ assert.match(jokerInferior.at(-1).payload.message, /王只能用于亮主/);
+ assert.ok(io.events.some(({ event, payload }) => (
+ event === 'trump_declared'
+ && payload.declarationRole === 'inferior'
+ && payload.suit === 'diamonds'
+ )));
+
+ const jokerTrump = declare(3, 'joker', 2, 'trump');
+ assert.equal(jokerTrump.some(event => event.event === 'error'), false);
+ assert.equal(room.gameState.trumpSuit, 'no_trump');
+ assert.equal(room.gameState.currentTrumpDeclaration.suit, 'joker');
+ assert.equal(room.gameState.currentInferiorDeclaration, null);
+ assert.equal(room.gameState.inferiorSuit, null);
+});
+
+test('三六九等无人亮主而自然无主时保留已经亮出的劣花色', () => {
+ const room = createRoom();
+ const io = createIo();
+ room.gameState.phase = GamePhases.DRAWING;
+ room.gameState.selectedRule = THREE_SIX_NINE_GRADES_RULE;
+ room.gameState.trumpRank = '2';
+ room.gameState.currentInferiorDeclaration = {
+ playerId: room.players[1].id,
+ playerName: room.players[1].name,
+ suit: 'clubs',
+ count: 1,
+ declarationType: 'single_rank',
+ strength: 1,
+ declarationRole: 'inferior',
+ cards: [card('clubs', '2', 1000)]
+ };
+ room.gameState.inferiorSuit = 'clubs';
+ room.gameState.dealerPlayerIndex = 0;
+
+ const drawingManager = new DrawingPhaseManager(room, io);
+ drawingManager.assignDealer();
+
+ assert.equal(room.gameState.currentTrumpDeclaration, null);
+ assert.equal(room.gameState.currentInferiorDeclaration?.suit, 'clubs');
+ assert.equal(room.gameState.inferiorSuit, 'clubs');
+ assert.ok(io.events.some(({ event, payload }) => (
+ event === 'three_six_nine_updated'
+ && payload.locked === true
+ && payload.inferiorSuit === 'clubs'
+ && payload.currentInferiorDeclaration?.suit === 'clubs'
+ )));
+});
+
+test('三六九等按主牌、普通副牌、劣牌分层比较,劣花色级牌低于其他副级牌', () => {
+ const trumpSuit = 'spades';
+ const trumpRank = '2';
+ const rule = { ...THREE_SIX_NINE_GRADES_RULE, inferiorSuit: 'hearts' };
+ const trumpAce = card('spades', 'A', 1010);
+ const inferiorLevel = card('hearts', '2', 1011);
+ const sideLevel = card('clubs', '2', 1012);
+ const trumpLevel = card('spades', '2', 1013);
+
+ assert.equal(getCardStrength(trumpAce, trumpSuit, trumpRank, rule), 995);
+ assert.equal(getCardStrength(inferiorLevel, trumpSuit, trumpRank, rule), 996);
+ assert.equal(getCardStrength(sideLevel, trumpSuit, trumpRank, rule), 997);
+ assert.equal(getCardStrength(trumpLevel, trumpSuit, trumpRank, rule), 998);
+
+ const inferiorAce = singlePlay(card('hearts', 'A', 1020), trumpSuit, trumpRank, rule);
+ const lowClub = singlePlay(card('clubs', '3', 1021), trumpSuit, trumpRank, rule);
+ const highClub = singlePlay(card('clubs', 'K', 1022), trumpSuit, trumpRank, rule);
+ const highDiamond = singlePlay(card('diamonds', 'A', 1023), trumpSuit, trumpRank, rule);
+
+ assert.equal(
+ compareCards(lowClub, inferiorAce, 'hearts', trumpSuit, trumpRank, rule),
+ 1,
+ '任意合法普通副花色都能毙劣花色'
+ );
+ assert.equal(
+ compareCards(inferiorAce, lowClub, 'clubs', trumpSuit, trumpRank, rule),
+ -1,
+ '劣花色不能反过来毙普通副花色'
+ );
+ assert.equal(
+ compareCards(highClub, lowClub, 'hearts', trumpSuit, trumpRank, rule),
+ 1,
+ '同一普通副花色连续毙牌时仍按点数比较'
+ );
+ assert.equal(
+ compareCards(highDiamond, lowClub, 'hearts', trumpSuit, trumpRank, rule),
+ 0,
+ '不同普通副花色彼此不可比较,保留先毙者'
+ );
+
+ const inferiorTractorCards = [
+ card('hearts', '3', 1030), card('hearts', '3', 1031),
+ card('hearts', '4', 1032), card('hearts', '4', 1033)
+ ];
+ const brokenClubRuffCards = [
+ card('clubs', '5', 1040), card('clubs', '5', 1041),
+ card('clubs', '7', 1042), card('clubs', '7', 1043)
+ ];
+ const clubTractorCards = [
+ card('clubs', '5', 1050), card('clubs', '5', 1051),
+ card('clubs', '6', 1052), card('clubs', '6', 1053)
+ ];
+ const asPlay = cards => ({
+ cards,
+ pattern: detectPattern(cards, trumpSuit, trumpRank, rule)
+ });
+ assert.equal(
+ compareCards(
+ asPlay(brokenClubRuffCards),
+ asPlay(inferiorTractorCards),
+ 'hearts',
+ trumpSuit,
+ trumpRank,
+ rule
+ ),
+ -1,
+ '普通副牌毙劣牌时也必须满足拖拉机结构'
+ );
+ assert.equal(
+ compareCards(
+ asPlay(clubTractorCards),
+ asPlay(inferiorTractorCards),
+ 'hearts',
+ trumpSuit,
+ trumpRank,
+ rule
+ ),
+ 1,
+ '结构匹配的普通副牌拖拉机可以毙劣牌拖拉机'
+ );
+});
+
+test('铁证如山只识别小王、红桃Q、黑桃J、梅花J,并按实体张数增加倍数', () => {
+ const cards = [
+ card('joker', 'small_joker', 1060),
+ card('joker', 'small_joker', 1061),
+ card('hearts', 'Q', 1062),
+ card('spades', 'J', 1063),
+ card('clubs', 'J', 1064),
+ card('clubs', 'K', 1065)
+ ];
+ assert.deepEqual(cards.map(isIronEvidenceSpecialCard), [true, true, true, true, true, false]);
+ assert.deepEqual(
+ calculateIronEvidenceRoundScoring(cards, 15, IronEvidenceModes.MULTIPLY),
+ {
+ mode: IronEvidenceModes.MULTIPLY,
+ specialCardCount: 5,
+ multiplier: 6,
+ baseRoundPoints: 15,
+ roundPoints: 90
+ }
+ );
+ assert.equal(
+ calculateIronEvidenceRoundScoring(cards, 15, IronEvidenceModes.ZERO).roundPoints,
+ 0
+ );
+ assert.deepEqual(
+ calculateIronEvidenceRoundScoring([], 15, IronEvidenceModes.ZERO),
+ {
+ mode: IronEvidenceModes.ZERO,
+ specialCardCount: 0,
+ multiplier: 1,
+ baseRoundPoints: 15,
+ roundPoints: 15
+ }
+ );
+});
+
+test('铁证如山在轮初锁定效果:本轮打完两张大王仍翻倍,之后有铁证才清零', () => {
+ const room = createRoom();
+ const io = createIo();
+ const engine = new GameEngine(room, io);
+ room.gameState.selectedRule = IRON_EVIDENCE_RULE;
+ room.gameState.phase = GamePhases.PLAYING;
+ room.gameState.trumpSuit = 'spades';
+ room.gameState.trumpRank = '2';
+ room.gameState.buryingPlayerId = room.players[0].id;
+ room.players[0].cards = [
+ card('hearts', '5', 1070), card('clubs', '3', 1071), card('diamonds', '3', 1078)
+ ];
+ room.players[1].cards = [
+ card('joker', 'small_joker', 1072), card('clubs', '4', 1073), card('diamonds', '4', 1079)
+ ];
+ room.players[2].cards = [
+ card('joker', 'big_joker', 1074), card('clubs', '5', 1075), card('diamonds', '5', 1085)
+ ];
+ room.players[3].cards = [
+ card('joker', 'big_joker', 1076), card('spades', 'J', 1077), card('diamonds', '6', 1086)
+ ];
+ engine.setFirstPlayer(room.players[0].id);
+
+ engine.playCards(room.players[0].id, [room.players[0].cards[0].id]);
+ engine.playCards(room.players[1].id, [room.players[1].cards[0].id]);
+ engine.playCards(room.players[2].id, [room.players[2].cards[0].id]);
+ const firstRound = engine.playCards(room.players[3].id, [room.players[3].cards[0].id]);
+
+ assert.equal(firstRound.roundUpdate.scoreInfo.baseRoundPoints, 5);
+ assert.equal(firstRound.roundUpdate.scoreInfo.roundPoints, 10);
+ assert.deepEqual(firstRound.roundUpdate.scoreInfo.ironEvidence, {
+ round: 1,
+ mode: IronEvidenceModes.MULTIPLY,
+ specialCardCount: 1,
+ multiplier: 2,
+ baseRoundPoints: 5,
+ roundPoints: 10,
+ winnerPlayerId: room.players[2].id,
+ winnerPlayerName: room.players[2].name,
+ winnerIsAttacker: false
+ });
+ assert.equal(room.gameState.ironEvidencePlayedBigJokerIds.size, 2);
+ assert.equal(room.gameState.ironEvidenceRoundMode, IronEvidenceModes.ZERO);
+
+ engine.playCards(room.players[2].id, [room.players[2].cards[0].id]);
+ engine.playCards(room.players[3].id, [room.players[3].cards[0].id]);
+ engine.playCards(room.players[0].id, [room.players[0].cards[0].id]);
+ const secondRound = engine.playCards(room.players[1].id, [room.players[1].cards[0].id]);
+
+ assert.equal(secondRound.roundUpdate.scoreInfo.baseRoundPoints, 5);
+ assert.equal(secondRound.roundUpdate.scoreInfo.roundPoints, 0);
+ assert.equal(secondRound.roundUpdate.scoreInfo.ironEvidence.mode, IronEvidenceModes.ZERO);
+ assert.equal(secondRound.roundUpdate.scoreInfo.ironEvidence.specialCardCount, 1);
+ assert.equal(secondRound.roundUpdate.scoreInfo.ironEvidence.multiplier, 0);
+ assert.equal(room.gameState.attackerScore, 0);
+
+ engine.playCards(room.players[3].id, [room.players[3].cards[0].id]);
+ engine.playCards(room.players[0].id, [room.players[0].cards[0].id]);
+ engine.playCards(room.players[1].id, [room.players[1].cards[0].id]);
+ const thirdRound = engine.playCards(room.players[2].id, [room.players[2].cards[0].id]);
+
+ assert.equal(thirdRound.roundUpdate.scoreInfo.baseRoundPoints, 5);
+ assert.equal(thirdRound.roundUpdate.scoreInfo.roundPoints, 5);
+ assert.equal(thirdRound.roundUpdate.scoreInfo.ironEvidence.mode, IronEvidenceModes.ZERO);
+ assert.equal(thirdRound.roundUpdate.scoreInfo.ironEvidence.specialCardCount, 0);
+ assert.equal(thirdRound.roundUpdate.scoreInfo.ironEvidence.multiplier, 1);
+ assert.equal(room.gameState.attackerScore, 5);
+});
+
+test('铁证如山撤回大王时同步撤销已打出记录', () => {
+ const room = createRoom();
+ const io = createIo();
+ const engine = new GameEngine(room, io);
+ room.gameState.selectedRule = IRON_EVIDENCE_RULE;
+ room.gameState.phase = GamePhases.PLAYING;
+ room.gameState.trumpSuit = 'spades';
+ room.gameState.trumpRank = '2';
+ room.gameState.buryingPlayerId = room.players[0].id;
+ const bigJoker = card('joker', 'big_joker', 1080);
+ room.players[0].cards = [bigJoker, card('clubs', '3', 1081)];
+ room.players[1].cards = [card('clubs', '4', 1082)];
+ room.players[2].cards = [card('clubs', '5', 1083)];
+ room.players[3].cards = [card('clubs', '6', 1084)];
+ engine.setFirstPlayer(room.players[0].id);
+
+ engine.playCards(room.players[0].id, [bigJoker.id]);
+ assert.equal(room.gameState.ironEvidencePlayedBigJokerIds.size, 1);
+ engine.undoLastPlay(room.players[0].id);
+ assert.equal(room.gameState.ironEvidencePlayedBigJokerIds.size, 0);
+});
+
+test('守株待兔允许把5、10、K设为目标,但拒绝王和当前级牌', () => {
+ const room = createRoom();
+ const io = createIo();
+ const engine = new GameEngine(room, io, () => 0);
+ room.gameState.selectedRule = WAITING_RABBIT_RULE;
+ room.gameState.phase = GamePhases.PLAYING;
+ room.gameState.trumpSuit = 'spades';
+ room.gameState.trumpRank = '2';
+ room.gameState.buryingPlayerId = room.players[0].id;
+ room.players.forEach((player, index) => {
+ player.cards = [card('clubs', String(index + 3), 1090 + index)];
+ });
+ engine.setFirstPlayer(room.players[0].id);
+ engine.activateWaitingRabbit();
+
+ const options = engine.getWaitingRabbitSelectionOptions();
+ assert.ok(options.eligibleRanks.includes('5'));
+ assert.ok(options.eligibleRanks.includes('10'));
+ assert.ok(options.eligibleRanks.includes('K'));
+ assert.ok(!options.eligibleRanks.includes('2'));
+ assert.ok(!options.eligibleRanks.includes('small_joker'));
+
+ for (const levelRank of ['5', '10', 'K']) {
+ room.gameState.trumpRank = levelRank;
+ const levelOptions = engine.getWaitingRabbitSelectionOptions();
+ assert.ok(!levelOptions.eligibleRanks.includes(levelRank));
+ assert.throws(
+ () => engine.selectWaitingRabbitTarget(room.players[0].id, 'hearts', levelRank),
+ /当前级牌/
+ );
+ }
+ room.gameState.trumpRank = '2';
+
+ engine.selectWaitingRabbitTarget(room.players[0].id, 'hearts', '5');
+ engine.selectWaitingRabbitTarget(room.players[1].id, 'diamonds', '10');
+ engine.selectWaitingRabbitTarget(room.players[2].id, 'clubs', 'K');
+ engine.selectWaitingRabbitTarget(room.players[3].id, 'spades', 'A');
+ assert.equal(engine.hasPendingWaitingRabbitSelection(), false);
+ assert.deepEqual(
+ room.gameState.waitingRabbitDeclarationsByPlayerId.get(room.players[2].id),
+ { suit: 'clubs', rank: 'K' }
+ );
+ assert.equal(
+ Object.hasOwn(room.gameState.toJSON().waitingRabbit, 'declarationsByPlayerId'),
+ false,
+ '公共快照不能泄露暗选牌面'
+ );
+ const publicRecords = room.gameState.toJSON().waitingRabbit.behaviorRecords;
+ assert.deepEqual(publicRecords.map(record => record.type), [
+ 'target_locked',
+ 'target_locked',
+ 'target_locked',
+ 'target_locked'
+ ]);
+ assert.equal(
+ publicRecords.some(record => record.suit || record.rank || record.targetCard),
+ false,
+ '暗选行为记录也不能泄露目标牌面'
+ );
+});
+
+test('守株待兔只在轮末换牌,目标先参与本轮结算且分牌实体只在首次上桌时计分', () => {
+ const room = createRoom();
+ const io = createIo();
+ const engine = new GameEngine(room, io);
+ room.gameState.selectedRule = WAITING_RABBIT_RULE;
+ room.gameState.phase = GamePhases.PLAYING;
+ room.gameState.trumpSuit = 'spades';
+ room.gameState.trumpRank = '2';
+ room.gameState.buryingPlayerId = room.players[0].id;
+
+ const targetFive = card('hearts', '5', 1100);
+ const sourceSecondCard = card('hearts', '3', 1101);
+ const discardThree = card('clubs', '3', 1102);
+ const followerAce = card('clubs', 'A', 1103);
+ const forbiddenDiscardKing = card('diamonds', 'K', 1104);
+ const followerClubFour = card('clubs', '4', 1105);
+ const followerHeartFour = card('hearts', '4', 1106);
+ const followerClubSix = card('clubs', '6', 1107);
+ const followerHeartSix = card('hearts', '6', 1108);
+
+ room.players[0].cards = [targetFive, sourceSecondCard];
+ room.players[1].cards = [discardThree, followerAce, forbiddenDiscardKing];
+ room.players[2].cards = [followerClubFour, followerHeartFour];
+ room.players[3].cards = [followerClubSix, followerHeartSix];
+ engine.setFirstPlayer(room.players[0].id);
+ room.gameState.waitingRabbitDeclarationsByPlayerId = new Map([
+ [room.players[0].id, { suit: 'diamonds', rank: 'A' }],
+ [room.players[1].id, { suit: 'hearts', rank: '5' }],
+ [room.players[2].id, { suit: 'diamonds', rank: '10' }],
+ [room.players[3].id, { suit: 'clubs', rank: 'K' }]
+ ]);
+ room.gameState.waitingRabbitPendingSelectionPlayerIds.clear();
+
+ const firstPlay = engine.playCards(room.players[0].id, [targetFive.id]);
+ assert.equal(firstPlay.waitingRabbitDecision, null);
+ assert.equal(engine.hasPendingWaitingRabbitDecision(), false);
+ assert.equal(room.players[0].cards.some(currentCard => currentCard.id === targetFive.id), false);
+ engine.playCards(room.players[1].id, [followerAce.id]);
+ engine.playCards(room.players[2].id, [followerHeartFour.id]);
+ const firstRound = engine.playCards(room.players[3].id, [followerHeartSix.id]);
+
+ assert.equal(firstRound.roundUpdate.type, 'round_ended');
+ assert.equal(firstRound.waitingRabbitDecision.chooserPlayerId, room.players[1].id);
+ assert.equal(engine.hasPendingWaitingRabbitDecision(), true);
+ assert.equal(firstRound.roundWinner.playerId, room.players[3].id);
+ assert.equal(firstRound.roundUpdate.scoreInfo.baseRoundPoints, 5);
+ assert.deepEqual(
+ firstRound.roundUpdate.scoreInfo.roundPointCards.map(currentCard => currentCard.id),
+ [targetFive.id]
+ );
+ assert.equal(room.gameState.attackerScore, 5);
+
+ assert.throws(
+ () => engine.resolveWaitingRabbitDecision(room.players[1].id, {
+ accept: true,
+ discardCardId: forbiddenDiscardKing.id
+ }),
+ /非分牌/
+ );
+ assert.equal(engine.hasPendingWaitingRabbitDecision(), true);
+
+ const { roundResult: exchanged } = engine.resolveWaitingRabbitDecision(
+ room.players[1].id,
+ { accept: true, discardCardId: discardThree.id }
+ );
+ assert.equal(exchanged.waitingRabbitResolution.accepted, true);
+ assert.deepEqual(
+ exchanged.waitingRabbitResolution.tableCards.map(currentCard => currentCard.id),
+ [discardThree.id]
+ );
+ assert.equal(room.gameState.lastRoundWinnerIndex, room.getPlayerIndex(room.players[3].id));
+ assert.equal(room.gameState.attackerScore, 5, '轮末换牌不能倒改已结算分数');
+ assert.equal(room.players[1].cards.some(currentCard => currentCard.id === targetFive.id), true);
+ assert.equal(room.players[1].cards.some(currentCard => currentCard.id === discardThree.id), false);
+ assert.equal(room.gameState.waitingRabbitUsedPlayerIds.has(room.players[1].id), true);
+ const behaviorRecords = room.gameState.toJSON().waitingRabbit.behaviorRecords;
+ assert.deepEqual(
+ behaviorRecords.map(record => record.type),
+ ['target_triggered', 'target_exchanged']
+ );
+ assert.equal(behaviorRecords[0].targetCard.id, targetFive.id);
+ assert.equal(behaviorRecords[1].discardedCard.id, discardThree.id);
+
+ engine.playCards(room.players[3].id, [followerClubSix.id]);
+ engine.playCards(room.players[0].id, [sourceSecondCard.id]);
+ engine.playCards(room.players[1].id, [targetFive.id]);
+ const secondRound = engine.playCards(room.players[2].id, [followerClubFour.id]);
+ assert.equal(secondRound.roundUpdate.scoreInfo.baseRoundPoints, 0);
+ assert.deepEqual(secondRound.roundUpdate.scoreInfo.roundPointCards, []);
+ assert.equal(room.gameState.attackerScore, 5);
+});
+
+test('换牌期间即使客户端直接发送亮主事件也会被服务端拒绝', () => {
+ const room = createRoom();
+ const io = createIo();
+ const handlers = new Map();
+ const socketEvents = [];
+ const socket = {
+ id: room.players[0].socketId,
+ on(event, handler) {
+ handlers.set(event, handler);
+ },
+ emit(event, payload) {
+ socketEvents.push({ event, payload });
+ }
+ };
+ registerPlayerHandlers(io, socket, { getRoom: roomId => roomId === room.id ? room : null });
+ room.gameState.phase = GamePhases.DRAWING;
+ room.gameState.isTrumpDeclarationLocked = true;
+ room.gameState.cardExchange = { requiredCards: 2 };
+
+ handlers.get('declare_trump')({ roomId: room.id, suit: 'diamonds', count: 1 });
+
+ assert.deepEqual(socketEvents.at(-1), {
+ event: 'error',
+ payload: { message: '亮主和反主阶段已经结束' }
+ });
+ assert.equal(room.gameState.currentTrumpDeclaration, null);
+});
+
+test('开局换牌拒绝数量错误、重复牌、非手牌和重复提交', () => {
+ const room = createRoom();
+ const engine = new GameEngine(room, createIo());
+ room.gameState.phase = GamePhases.DRAWING;
+ room.gameState.selectedRule = getRuleById(RuleIds.NEWS_MINISTER_II);
+ room.players.forEach((player, index) => {
+ player.addCard(card('spades', String(index + 2), index));
+ player.addCard(card('hearts', String(index + 2), index));
+ player.addCard(card('clubs', String(index + 2), index));
+ });
+ engine.startOpeningCardExchange();
+
+ const player = room.players[0];
+ assert.throws(() => engine.submitOpeningCardExchange(player.id, [player.cards[0].id]), /必须选择2张牌/);
+ assert.throws(
+ () => engine.submitOpeningCardExchange(player.id, [player.cards[0].id, player.cards[0].id]),
+ /不能重复选择/
+ );
+ assert.throws(
+ () => engine.submitOpeningCardExchange(player.id, [player.cards[0].id, 'not-in-hand']),
+ /不在手中/
+ );
+ engine.submitOpeningCardExchange(player.id, player.cards.slice(0, 2).map(value => value.id));
+ assert.throws(
+ () => engine.submitOpeningCardExchange(player.id, player.cards.slice(0, 2).map(value => value.id)),
+ /已经确认过换牌/
+ );
+});
+
+test('选择底牌规则后立即写入本局底牌数和闲家初始分,重置后恢复默认', () => {
+ const cases = [
+ [RuleIds.ABUNDANT_HARVEST, 12, 30],
+ [RuleIds.EXTREME_CHALLENGE, 16, 60],
+ [RuleIds.HALF_REALM, 4, -10],
+ [RuleIds.SHARED_PROSPERITY, 0, -20],
+ [RuleIds.PERFECT_STRATEGY, 8, 10]
+ ];
+
+ for (const [ruleId, bottomCardsCount, attackerStartingScore] of cases) {
+ const room = createRoom();
+ const engine = new GameEngine(room, createIo());
+ const chooser = room.players[0];
+ const rule = getRuleById(ruleId);
+ room.gameState.isWaitingForReady = true;
+ room.gameState.isRuleSelectionPending = true;
+ room.gameState.ruleChooserPlayerId = chooser.id;
+ room.gameState.ruleOptions = [rule, NORMAL_RULE];
+
+ engine.selectRule(chooser.id, rule);
+
+ assert.equal(room.gameState.bottomCardsCount, bottomCardsCount);
+ assert.equal(room.gameState.attackerScore, attackerStartingScore);
+ assert.equal(room.gameState.toJSON().bottomCardsCount, bottomCardsCount);
+
+ room.gameState.reset();
+ assert.equal(room.gameState.bottomCardsCount, 8);
+ assert.equal(room.gameState.attackerScore, 0);
+ }
+});
+
+test('迷雾重重按洗牌后牌堆顶移除八张,牌面在终局前不进入公开状态', () => {
+ const room = createRoom();
+ const io = createIo();
+ const manager = new DrawingPhaseManager(room, io);
+ const orderedDeck = DeckService.createDeck();
+ const originalShuffle = DeckService.shuffle;
+ room.gameState.selectedRule = HEAVY_FOG_RULE;
+ room.gameState.bottomCardsCount = 8;
+
+ try {
+ DeckService.shuffle = deck => [...deck];
+ manager.start();
+ manager.stop();
+ } finally {
+ DeckService.shuffle = originalShuffle;
+ }
+
+ assert.deepEqual(
+ room.gameState.mistyFogCards.map(value => value.id),
+ orderedDeck.slice(0, 8).map(value => value.id)
+ );
+ assert.deepEqual(
+ room.gameState.bottomCards.map(value => value.id),
+ orderedDeck.slice(8, 16).map(value => value.id)
+ );
+ assert.deepEqual(
+ room.gameState.deck.map(value => value.id),
+ orderedDeck.slice(16).map(value => value.id)
+ );
+ assert.equal(room.gameState.deck.length, 92);
+ assert.equal(room.gameState.deck.length % room.players.length, 0);
+ assert.equal(Object.hasOwn(room.gameState.toJSON(), 'mistyFogCards'), false);
+
+ const drawingStarted = io.events.find(({ event }) => event === 'drawing_started');
+ assert.equal(drawingStarted.payload.removedCardsCount, 8);
+ assert.equal(Object.hasOwn(drawingStarted.payload, 'mistyFogCards'), false);
+ assert.equal(Object.hasOwn(drawingStarted.payload, 'cards'), false);
+});
+
+test('迷雾牌在逐墩分与底牌分之后公开补一半分,再据最终总分计算升级', () => {
+ const room = createRoom();
+ const engine = new GameEngine(room, createIo());
+ room.gameState.selectedRule = HEAVY_FOG_RULE;
+ room.gameState.buryingPlayerId = room.players[0].id;
+ room.gameState.dealerPlayerIndex = 0;
+ room.gameState.attackerScore = 60;
+ room.gameState.bottomCards = [
+ card('diamonds', '5', 40),
+ card('clubs', '2', 40)
+ ];
+ room.gameState.mistyFogCards = [
+ card('hearts', '5', 41),
+ card('hearts', '10', 41),
+ card('hearts', 'K', 41),
+ card('hearts', '3', 41)
+ ];
+ room.gameState.lastRoundWinnerIndex = 1;
+ room.gameState.lastRoundLeadingPattern = {
+ type: PatternTypes.SINGLE,
+ length: 1
+ };
+
+ assert.equal(Object.hasOwn(room.gameState.toJSON(), 'mistyFogCards'), false);
+ engine.finishGame();
+
+ assert.equal(room.gameState.bottomScoreResult.scoreBeforeMistyFog, 70);
+ assert.equal(room.gameState.bottomScoreResult.mistyFogPoints, 25);
+ assert.equal(room.gameState.bottomScoreResult.mistyFogBonus, 12.5);
+ assert.equal(room.gameState.bottomScoreResult.totalScore, 82.5);
+ assert.equal(room.gameState.attackerScore, 82.5);
+ assert.deepEqual(
+ room.gameState.bottomScoreResult.mistyFogCards.map(value => value.id),
+ room.gameState.mistyFogCards.map(value => value.id)
+ );
+ assert.equal(room.gameState.upgradeResult.attackerWon, true);
+});
+
+test('发牌阶段按本局规则预留底牌并保证四家手牌可均分', () => {
+ for (const bottomCardsCount of [0, 4, 8, 12, 16]) {
+ const room = createRoom();
+ const manager = new DrawingPhaseManager(room, createIo());
+ room.gameState.bottomCardsCount = bottomCardsCount;
+
+ manager.start();
+ manager.stop();
+
+ assert.equal(room.gameState.bottomCards.length, bottomCardsCount);
+ assert.equal(room.gameState.deck.length, 108 - bottomCardsCount);
+ assert.equal(room.gameState.deck.length % room.players.length, 0);
+ }
+});
+
+test('昭然若揭从摸牌开始公开底牌,普通规则的公开状态仍不泄露牌面', () => {
+ const room = createRoom();
+ const io = createIo();
+ const manager = new DrawingPhaseManager(room, io);
+ const orderedDeck = DeckService.createDeck();
+ const originalShuffle = DeckService.shuffle;
+ room.gameState.selectedRule = OPENLY_REVEALED_RULE;
+
+ try {
+ DeckService.shuffle = deck => [...deck];
+ manager.start();
+ manager.stop();
+ } finally {
+ DeckService.shuffle = originalShuffle;
+ }
+
+ const expectedBottomCards = orderedDeck.slice(0, room.gameState.bottomCardsCount)
+ .map(value => value.toJSON());
+ const drawingStarted = io.events.find(({ event }) => event === 'drawing_started');
+ assert.deepEqual(drawingStarted.payload.publicBottomCards, expectedBottomCards);
+ assert.deepEqual(room.gameState.toJSON().publicBottomCards, expectedBottomCards);
+
+ room.gameState.selectedRule = NORMAL_RULE;
+ assert.equal(Object.hasOwn(room.gameState.toJSON(), 'publicBottomCards'), false);
+
+ const normalRoom = createRoom();
+ const normalIo = createIo();
+ const normalManager = new DrawingPhaseManager(normalRoom, normalIo);
+ normalRoom.gameState.selectedRule = NORMAL_RULE;
+ normalManager.start();
+ normalManager.stop();
+ const normalDrawingStarted = normalIo.events.find(({ event }) => event === 'drawing_started');
+ assert.equal(Object.hasOwn(normalDrawingStarted.payload, 'publicBottomCards'), false);
+});
+
+test('昭然若揭允许非庄家随时请求查看当前底牌', () => {
+ const room = createRoom();
+ room.gameState.selectedRule = OPENLY_REVEALED_RULE;
+ room.gameState.buryingPlayerId = room.players[0].id;
+ room.gameState.bottomCards = [card('hearts', '5', 810), card('spades', 'A', 811)];
+ const handlers = new Map();
+ const emitted = [];
+ const socket = {
+ id: room.players[1].socketId,
+ on(event, handler) {
+ handlers.set(event, handler);
+ },
+ emit(event, payload) {
+ emitted.push({ event, payload });
+ }
+ };
+
+ registerGameHandlers(createIo(), socket, { getRoom: roomId => roomId === room.id ? room : null });
+ handlers.get('view_my_bottom_cards')({ roomId: room.id });
+
+ const response = emitted.find(({ event }) => event === 'my_bottom_cards');
+ assert.equal(response.payload.isPublic, true);
+ assert.deepEqual(
+ response.payload.bottomCards,
+ room.gameState.bottomCards.map(value => value.toJSON())
+ );
+ assert.equal(emitted.some(({ event }) => event === 'error'), false);
+});
+
+test('计划经济开局只发每人20张,20张封存牌在埋底完成前不会摸取', () => {
+ const room = createRoom();
+ const io = createIo();
+ const manager = new DrawingPhaseManager(room, io);
+ const engine = new GameEngine(room, io);
+ room.gameState.selectedRule = PLANNED_ECONOMY_RULE;
+
+ manager.start();
+ manager.stop();
+
+ assert.equal(room.gameState.bottomCards.length, 8);
+ assert.equal(room.gameState.deck.length, 80);
+ assert.equal(room.gameState.plannedEconomyReserveCards.length, 20);
+ const drawingStarted = io.events.find(({ event }) => event === 'drawing_started');
+ assert.equal(drawingStarted.payload.totalCards, 80);
+ assert.equal(drawingStarted.payload.reservedCardsCount, 20);
+
+ while (room.gameState.drawingIndex < room.gameState.deck.length) {
+ manager.dealOneCard();
+ }
+ assert.deepEqual(room.players.map(player => player.cards.length), [20, 20, 20, 20]);
+ assert.equal(room.gameState.plannedEconomyReserveCards.length, 20);
+
+ room.gameState.phase = GamePhases.BURYING;
+ assert.equal(engine.drawPlannedEconomyRoundCards(0), null);
+ assert.equal(room.gameState.plannedEconomyReserveCards.length, 20);
+ assert.deepEqual(room.players.map(player => player.cards.length), [20, 20, 20, 20]);
+});
+
+test('计划经济从第一轮结束起四家各摸1张,摸完封存牌后停止', () => {
+ const room = createRoom();
+ const engine = new GameEngine(room, createIo());
+ const trickCards = [
+ card('hearts', '3', 200),
+ card('hearts', '4', 201),
+ card('hearts', '5', 202),
+ card('hearts', '6', 203)
+ ];
+ const spareCards = [
+ card('clubs', '7', 204),
+ card('clubs', '8', 205),
+ card('clubs', '9', 206),
+ card('clubs', '10', 207)
+ ];
+ const reservedCards = [
+ card('diamonds', 'J', 208),
+ card('diamonds', 'Q', 209),
+ card('diamonds', 'K', 210),
+ card('diamonds', 'A', 211)
+ ];
+ room.players.forEach((player, index) => {
+ player.addCard(trickCards[index]);
+ player.addCard(spareCards[index]);
+ });
+ room.gameState.selectedRule = PLANNED_ECONOMY_RULE;
+ room.gameState.plannedEconomyReserveCards = [...reservedCards];
+ room.gameState.phase = GamePhases.PLAYING;
+ room.gameState.trumpSuit = 'spades';
+ room.gameState.trumpRank = '2';
+ room.gameState.buryingPlayerId = room.players[0].id;
+ engine.setFirstPlayer(room.players[0].id);
+
+ engine.playCards(room.players[0].id, [trickCards[0].id]);
+ engine.playCards(room.players[1].id, [trickCards[1].id]);
+ engine.playCards(room.players[2].id, [trickCards[2].id]);
+ const result = engine.playCards(room.players[3].id, [trickCards[3].id]);
+
+ assert.equal(result.gameFinished, false);
+ assert.equal(result.plannedEconomyDraw.round, 1);
+ assert.equal(result.plannedEconomyDraw.drawCount, 4);
+ assert.equal(result.plannedEconomyDraw.remainingCards, 0);
+ assert.deepEqual(
+ result.plannedEconomyDraw.draws.map(draw => draw.playerId),
+ room.players.map(player => player.id)
+ );
+ assert.deepEqual(room.players.map(player => player.cards.length), [2, 2, 2, 2]);
+ assert.ok(room.players.every((player, index) => player.cards.some(value => value.id === reservedCards[index].id)));
+ assert.deepEqual(result.roundUpdate.plannedEconomyDraw, {
+ round: 1,
+ drawCount: 4,
+ remainingCards: 0
+ });
+ assert.deepEqual(room.gameState.toJSON().plannedEconomy, {
+ totalReservedCards: 20,
+ remainingCards: 0,
+ completedDrawRounds: 1,
+ isDrawingEnabled: true
+ });
+ assert.equal(engine.drawPlannedEconomyRoundCards(2), null);
+});
+
+test('逐张发牌进度会公开该玩家最新手牌数', () => {
+ const room = createRoom();
+ const io = createIo();
+ const manager = new DrawingPhaseManager(room, io);
+ room.gameState.deck = [card('spades', '2')];
+ room.gameState.drawingIndex = 0;
+
+ manager.dealOneCard();
+
+ const progress = io.events.find(({ event }) => event === 'deal_progress');
+ assert.ok(progress);
+ assert.equal(progress.payload.playerId, room.players[0].id);
+ assert.equal(progress.payload.cardsCount, 1);
+ assert.equal(room.players[0].cards.length, 1);
+});
+
+test('最后一家反超时出牌结果仍保留本墩最终的大牌玩家', () => {
+ const room = createRoom();
+ const engine = new GameEngine(room, createIo());
+ const trickCards = [
+ card('hearts', '2'),
+ card('hearts', '3'),
+ card('hearts', '4'),
+ card('hearts', 'A')
+ ];
+ const spareCards = [
+ card('clubs', '6'),
+ card('clubs', '7'),
+ card('clubs', '8'),
+ card('clubs', '9')
+ ];
+
+ room.players.forEach((player, index) => {
+ player.addCard(trickCards[index]);
+ player.addCard(spareCards[index]);
+ });
+ room.gameState.phase = GamePhases.PLAYING;
+ room.gameState.trumpSuit = 'spades';
+ room.gameState.trumpRank = '5';
+ room.gameState.selectedRule = NORMAL_RULE;
+ room.gameState.buryingPlayerId = room.players[0].id;
+ engine.setFirstPlayer(room.players[0].id);
+
+ let result;
+ room.players.forEach((player, index) => {
+ result = engine.playCards(player.id, [trickCards[index].id]);
+ });
+
+ assert.equal(result.roundUpdate.type, 'round_ended');
+ assert.equal(result.roundWinner.playerId, room.players[3].id);
+ assert.equal(result.currentWinningPlayerId, room.players[3].id);
+ assert.equal(room.gameState.currentWinnerIndex, null);
+});
+
+test('埋底校验使用本局规则张数而不是固定房间配置', () => {
+ const room = createRoom();
+ const engine = new GameEngine(room, createIo());
+ const dealer = room.players[0];
+ const cards = [
+ card('spades', '2'),
+ card('hearts', '3'),
+ card('clubs', '4'),
+ card('diamonds', '5')
+ ];
+ cards.forEach(value => dealer.addCard(value));
+ room.gameState.phase = GamePhases.BURYING;
+ room.gameState.buryingPlayerId = dealer.id;
+ room.gameState.bottomCardsCount = 4;
+
+ assert.throws(() => engine.buryCards(dealer.id, cards.slice(0, 3).map(value => value.id)));
+ engine.buryCards(dealer.id, cards.map(value => value.id));
+
+ assert.equal(room.gameState.bottomCards.length, 4);
+ assert.equal(room.gameState.phase, GamePhases.PLAYING);
+});
+
+test('与民同乐会为真人庄家自动跳过零张埋底并开始出牌', () => {
+ const room = createRoom();
+ const io = createIo();
+ const engine = new GameEngine(room, io);
+ const dealer = room.players[0];
+ room.gameState.phase = GamePhases.BURYING;
+ room.gameState.bottomCardsCount = 0;
+ room.gameState.buryingPlayerId = dealer.id;
+
+ engine.handleDealerAssigned(dealer);
+
+ assert.equal(room.gameState.phase, GamePhases.PLAYING);
+ assert.equal(room.gameState.currentRound, 1);
+ assert.equal(room.gameState.currentPlayerIndex, 0);
+ assert.equal(room.gameState.bottomCards.length, 0);
+ assert.ok(io.events.some(({ event, payload }) =>
+ event === 'phase_changed' && payload.message.includes('没有底牌')));
+});
+
+test('算无遗策在庄家埋底完成前不会泄露队友手牌', () => {
+ const room = createRoom();
+ const io = createIo();
+ const engine = new GameEngine(room, io);
+ const dealer = room.players[0];
+ const openHandPlayer = room.players[2];
+ const dealerCards = ['2', '3', '4', '5', '6', '7', '8', '9']
+ .map(rank => card('spades', rank));
+ dealerCards.forEach(value => dealer.addCard(value));
+ openHandPlayer.addCard(card('joker', 'big_joker'));
+ openHandPlayer.addCard(card('hearts', 'K'));
+ room.gameState.phase = GamePhases.BURYING;
+ room.gameState.buryingPlayerId = dealer.id;
+ room.gameState.bottomCardsCount = 8;
+ room.gameState.selectedRule = getRuleById(RuleIds.PERFECT_STRATEGY);
+
+ engine.handleDealerAssigned(dealer);
+
+ assert.equal(room.gameState.phase, GamePhases.BURYING);
+ assert.equal(room.gameState.openHandPlayerId, null);
+ assert.equal(room.toJSON().gameState.openHand, null);
+ assert.equal(io.events.some(({ event }) => event === 'open_hand_revealed'), false);
+
+ engine.buryCards(dealer.id, dealerCards.map(value => value.id));
+
+ assert.equal(room.gameState.phase, GamePhases.PLAYING);
+ assert.equal(room.gameState.openHandPlayerId, openHandPlayer.id);
+ assert.equal(room.gameState.openHandControllerPlayerId, dealer.id);
+ assert.equal(io.events.filter(({ event }) => event === 'open_hand_revealed').length, 1);
+});
+
+test('算无遗策公开庄家队友的完整手牌并由庄家代打', () => {
+ const room = createRoom();
+ const io = createIo();
+ const engine = new GameEngine(room, io);
+ const dealer = room.players[0];
+ const openHandPlayer = room.players[2];
+ const openCards = [card('hearts', '6'), card('clubs', '9')];
+ openCards.forEach(value => openHandPlayer.addCard(value));
+ room.gameState.selectedRule = getRuleById(RuleIds.PERFECT_STRATEGY);
+
+ engine.activatePerfectStrategy(dealer);
+
+ assert.equal(room.gameState.openHandPlayerId, openHandPlayer.id);
+ assert.equal(room.gameState.openHandControllerPlayerId, dealer.id);
+ assert.deepEqual(
+ room.toJSON().gameState.openHand.cards.map(value => value.id),
+ openCards.map(value => value.id)
+ );
+ const revealed = io.events.find(({ event }) => event === 'open_hand_revealed');
+ assert.equal(revealed.payload.playerId, openHandPlayer.id);
+ assert.equal(revealed.payload.controllerPlayerId, dealer.id);
+});
+
+test('算无遗策拒绝明手本人和闲家操作,只接受庄家按明手座位出牌及撤回', () => {
+ const room = createRoom();
+ const engine = new GameEngine(room, createIo());
+ const dealer = room.players[0];
+ const attacker = room.players[1];
+ const openHandPlayer = room.players[2];
+ const openCards = [card('hearts', '6'), card('clubs', '9')];
+ openCards.forEach(value => openHandPlayer.addCard(value));
+ room.gameState.phase = GamePhases.PLAYING;
+ room.gameState.selectedRule = getRuleById(RuleIds.PERFECT_STRATEGY);
+ engine.activatePerfectStrategy(dealer);
+ engine.setFirstPlayer(openHandPlayer.id);
+
+ assert.throws(
+ () => engine.playCards(openHandPlayer.id, [openCards[0].id]),
+ /由庄家代为操作/
+ );
+ assert.throws(
+ () => engine.playCards(attacker.id, [openCards[0].id], openHandPlayer.id),
+ /只有庄家/
+ );
+
+ const result = engine.playCards(dealer.id, [openCards[0].id], openHandPlayer.id);
+ assert.equal(result.playerId, openHandPlayer.id);
+ assert.equal(result.controllerPlayerId, dealer.id);
+ assert.equal(result.isProxy, true);
+ assert.equal(openHandPlayer.cards.length, 1);
+ assert.equal(room.gameState.currentRoundPlays[0].playerId, openHandPlayer.id);
+
+ const undoResult = engine.undoLastPlay(dealer.id);
+ assert.equal(undoResult.playerId, openHandPlayer.id);
+ assert.equal(undoResult.isProxy, true);
+ assert.equal(openHandPlayer.cards.length, 2);
+ assert.equal(room.gameState.currentRoundPlays.length, 0);
+ assert.equal(room.gameState.leadingPattern, null);
+ const replayResult = engine.playCards(dealer.id, [openCards[0].id], openHandPlayer.id);
+ assert.equal(replayResult.playerId, openHandPlayer.id);
+ assert.equal(room.gameState.currentRoundPlays.length, 1);
+ assert.throws(() => engine.undoLastPlay(openHandPlayer.id), /由庄家代为操作/);
+});
+
+test('算无遗策由庄家代甩失败时只移除服务端强制打出的实体牌', () => {
+ const room = createRoom();
+ const engine = new GameEngine(room, createIo());
+ const openHandPlayer = room.players[0];
+ const dealer = room.players[2];
+ const hands = [
+ [card('hearts', 'K', 20), card('hearts', '3', 21)],
+ [card('hearts', 'A', 22), card('hearts', 'Q', 23)],
+ [card('hearts', 'J', 24), card('hearts', '9', 25)],
+ [card('hearts', '8', 26), card('hearts', '7', 27)]
+ ];
+ room.players.forEach((player, index) => {
+ hands[index].forEach(value => player.addCard(value));
+ });
+ room.gameState.phase = GamePhases.PLAYING;
+ room.gameState.selectedRule = getRuleById(RuleIds.PERFECT_STRATEGY);
+ room.gameState.trumpSuit = 'spades';
+ room.gameState.trumpRank = '2';
+ engine.activatePerfectStrategy(dealer);
+ engine.setFirstPlayer(openHandPlayer.id);
+
+ const result = engine.playCards(
+ dealer.id,
+ hands[0].map(value => value.id),
+ openHandPlayer.id
+ );
+
+ assert.ok(result.throwFailed);
+ assert.equal(result.isProxy, true);
+ assert.equal(result.playedCards.length, 1);
+ assert.deepEqual(
+ openHandPlayer.cards.map(value => value.id),
+ room.toJSON().gameState.openHand.cards.map(value => value.id)
+ );
+ assert.equal(openHandPlayer.cards.length, 1);
+});
+
+test('李代桃僵可违背跟牌要求且永远视为小,撤回会返还唯一使用次数', () => {
+ const room = createRoom();
+ const engine = new GameEngine(room, createIo());
+ const leader = room.players[0];
+ const follower = room.players[1];
+ const leadAces = [card('hearts', 'A', 0), card('hearts', 'A', 1)];
+ const protectedKings = [card('hearts', 'K', 0), card('hearts', 'K', 1)];
+ const discardedJokers = [card('joker', 'big_joker', 0), card('joker', 'big_joker', 1)];
+
+ leadAces.forEach(value => leader.addCard(value));
+ [...protectedKings, ...discardedJokers].forEach(value => follower.addCard(value));
+ room.gameState.phase = GamePhases.PLAYING;
+ room.gameState.trumpSuit = 'spades';
+ room.gameState.trumpRank = '2';
+ room.gameState.selectedRule = SUBSTITUTE_SACRIFICE_RULE;
+ engine.setFirstPlayer(leader.id);
+
+ assert.throws(
+ () => engine.playCards(
+ leader.id,
+ leadAces.map(value => value.id),
+ null,
+ ActiveSkillIds.SUBSTITUTE_SACRIFICE
+ ),
+ /只能在跟牌时发动/
+ );
+
+ engine.playCards(leader.id, leadAces.map(value => value.id));
+ assert.throws(
+ () => engine.playCards(follower.id, discardedJokers.map(value => value.id)),
+ /必须优先出/
+ );
+
+ const result = engine.playCards(
+ follower.id,
+ discardedJokers.map(value => value.id),
+ null,
+ ActiveSkillIds.SUBSTITUTE_SACRIFICE
+ );
+ assert.equal(result.treatedAsSmall, true);
+ assert.equal(result.activeSkillActivation.name, '李代桃僵');
+ assert.equal(result.currentWinningPlayerId, leader.id);
+ assert.deepEqual(follower.cards.map(value => value.id), protectedKings.map(value => value.id));
+ assert.equal(engine.hasUsedActiveSkill(follower.id, ActiveSkillIds.SUBSTITUTE_SACRIFICE), true);
+ assert.deepEqual(
+ room.gameState.toJSON().activeSkillUsesByPlayerId[follower.id],
+ [ActiveSkillIds.SUBSTITUTE_SACRIFICE]
+ );
+
+ const undoResult = engine.undoLastPlay(follower.id);
+ assert.equal(undoResult.restoredActiveSkillId, ActiveSkillIds.SUBSTITUTE_SACRIFICE);
+ assert.equal(engine.hasUsedActiveSkill(follower.id, ActiveSkillIds.SUBSTITUTE_SACRIFICE), false);
+ assert.equal(room.gameState.currentWinnerIndex, 0);
+
+ engine.playCards(
+ follower.id,
+ discardedJokers.map(value => value.id),
+ null,
+ ActiveSkillIds.SUBSTITUTE_SACRIFICE
+ );
+ room.gameState.currentPlayerIndex = 1;
+ assert.throws(
+ () => engine.playCards(
+ follower.id,
+ protectedKings.map(value => value.id),
+ null,
+ ActiveSkillIds.SUBSTITUTE_SACRIFICE
+ ),
+ /每名玩家每局只能发动一次/
+ );
+});
+
+test('房间底牌配置固定为八张,客户端无法覆盖', () => {
+ const room = new Room('固定底牌', 'socket-host', { bottomCardsCount: 16, dealInterval: 10 });
+ assert.equal(room.config.bottomCardsCount, 8);
+ room.updateConfig({ bottomCardsCount: 4 });
+ assert.equal(room.config.bottomCardsCount, 8);
+});
+
+test('后续规则均已注册,主动技能元数据与初始分正确', () => {
+ const defaultRules = [
+ RuleIds.CONCEALED_PASSAGE,
+ RuleIds.STEALING_BEAMS,
+ RuleIds.COSMIC_SHIFT,
+ RuleIds.LAST_STAND,
+ RuleIds.LATE_MOVER_ADVANTAGE,
+ RuleIds.GO_WITH_THE_FLOW,
+ RuleIds.MINOR_DISTURBANCE,
+ RuleIds.PLANNED_ECONOMY
+ ];
+ defaultRules.forEach(ruleId => {
+ assert.deepEqual(getRuleSetup({ id: ruleId }), {
+ bottomCardsCount: 8,
+ attackerStartingScore: 0
+ });
+ });
+ assert.deepEqual(getRuleSetup({ id: RuleIds.FATAL_BEAUTY }), {
+ bottomCardsCount: 8,
+ attackerStartingScore: 20
+ });
+ assert.deepEqual(getRuleSetup({ id: RuleIds.FREQUENT_FLUCTUATION }), {
+ bottomCardsCount: 8,
+ attackerStartingScore: -10
+ });
+ assert.equal(getRuleById(RuleIds.FREQUENT_FLUCTUATION).name, '频繁波动');
+ assert.equal(getRuleById(RuleIds.MINOR_DISTURBANCE).name, '微小扰动');
+ assert.equal(getRuleById(RuleIds.CONCEALED_PASSAGE).activeSkill.effect, 'concealed_until_round_end');
+ assert.equal(getRuleById(RuleIds.STEALING_BEAMS).activeSkill.effect, 'joker_wildcards');
+ assert.deepEqual(getRuleById(RuleIds.LATE_MOVER_ADVANTAGE).activeSkill, {
+ id: ActiveSkillIds.LATE_MOVER_ADVANTAGE,
+ name: '后发制人',
+ usageLimit: 1,
+ timing: 'third_position_before_play',
+ effect: 'yield_turn_to_next_player'
+ });
+});
+
+test('暗度陈仓不能由一号位发动,跟牌暗置后只在轮末统一给出牌面', () => {
+ const room = createRoom();
+ const engine = new GameEngine(room, createIo());
+ const trickCards = [
+ card('hearts', '3'),
+ card('spades', '4'),
+ card('hearts', '5'),
+ card('hearts', '6')
+ ];
+ const spareCards = [
+ card('clubs', '7'),
+ card('clubs', '8'),
+ card('clubs', '9'),
+ card('clubs', '10')
+ ];
+ room.players.forEach((player, index) => {
+ player.addCard(trickCards[index]);
+ player.addCard(spareCards[index]);
+ });
+ room.gameState.phase = GamePhases.PLAYING;
+ room.gameState.trumpSuit = 'spades';
+ room.gameState.trumpRank = '2';
+ room.gameState.selectedRule = getRuleById(RuleIds.CONCEALED_PASSAGE);
+ room.gameState.buryingPlayerId = room.players[0].id;
+ engine.setFirstPlayer(room.players[0].id);
+
+ assert.throws(
+ () => engine.playCards(
+ room.players[0].id,
+ [trickCards[0].id],
+ null,
+ ActiveSkillIds.CONCEALED_PASSAGE
+ ),
+ /只能在跟牌时发动/
+ );
+ engine.playCards(room.players[0].id, [trickCards[0].id]);
+ const concealed = engine.playCards(
+ room.players[1].id,
+ [trickCards[1].id],
+ null,
+ ActiveSkillIds.CONCEALED_PASSAGE
+ );
+ assert.equal(concealed.concealed, true);
+ assert.equal(concealed.roundReveal, null);
+ assert.equal(concealed.currentWinningPlayerId, null);
+ assert.equal(concealed.trumpAction, null);
+ assert.equal(engine.hasUsedActiveSkill(room.players[1].id, ActiveSkillIds.CONCEALED_PASSAGE), true);
+ assert.equal(room.gameState.toJSON().currentWinnerIndex, null);
+
+ const undone = engine.undoLastPlay(room.players[1].id);
+ assert.equal(undone.concealed, true);
+ assert.deepEqual(undone.cards.map(value => value.id), [trickCards[1].id]);
+ assert.equal(engine.hasUsedActiveSkill(room.players[1].id, ActiveSkillIds.CONCEALED_PASSAGE), false);
+ engine.playCards(
+ room.players[1].id,
+ [trickCards[1].id],
+ null,
+ ActiveSkillIds.CONCEALED_PASSAGE
+ );
+
+ engine.playCards(room.players[2].id, [trickCards[2].id]);
+ const final = engine.playCards(room.players[3].id, [trickCards[3].id]);
+ assert.equal(final.roundUpdate.type, 'round_ended');
+ assert.equal(final.roundReveal.plays.length, 4);
+ assert.deepEqual(
+ final.roundReveal.plays.find(play => play.playerId === room.players[1].id).cards.map(value => value.id),
+ [trickCards[1].id]
+ );
+});
+
+test('无人生还让二至四号位自动暗置,并在四家出完后统一展示和正常结算', () => {
+ const room = createRoom();
+ const engine = new GameEngine(room, createIo());
+ const trickCards = [
+ card('hearts', '5', 2100),
+ card('hearts', '6', 2101),
+ card('hearts', '7', 2102),
+ card('hearts', 'K', 2103)
+ ];
+ const spareCards = [
+ card('clubs', '3', 2110),
+ card('clubs', '4', 2111),
+ card('clubs', '6', 2112),
+ card('clubs', '7', 2113)
+ ];
+ room.players.forEach((player, index) => {
+ player.addCard(trickCards[index]);
+ player.addCard(spareCards[index]);
+ });
+ room.gameState.phase = GamePhases.PLAYING;
+ room.gameState.trumpSuit = 'spades';
+ room.gameState.trumpRank = '2';
+ room.gameState.selectedRule = NO_ONE_SURVIVES_RULE;
+ room.gameState.buryingPlayerId = room.players[0].id;
+ room.gameState.dealerPlayerIndex = 0;
+ engine.setFirstPlayer(room.players[0].id);
+
+ const leading = engine.playCards(room.players[0].id, [trickCards[0].id]);
+ assert.equal(leading.concealed, false);
+ assert.equal(leading.currentWinningPlayerId, room.players[0].id);
+ assert.equal(leading.roundReveal, null);
+
+ const second = engine.playCards(room.players[1].id, [trickCards[1].id]);
+ assert.equal(second.concealed, true);
+ assert.equal(second.activeSkillActivation, null);
+ assert.equal(second.currentWinningPlayerId, null);
+ assert.equal(second.roundReveal, null);
+ assert.equal(room.gameState.toJSON().currentWinnerIndex, null);
+
+ const third = engine.playCards(room.players[2].id, [trickCards[2].id]);
+ assert.equal(third.concealed, true);
+ assert.equal(third.currentWinningPlayerId, null);
+
+ const final = engine.playCards(room.players[3].id, [trickCards[3].id]);
+ assert.equal(final.concealed, true);
+ assert.equal(final.roundUpdate.type, 'round_ended');
+ assert.equal(final.roundUpdate.roundWinner.playerId, room.players[3].id);
+ assert.equal(final.roundReveal.plays.length, 4);
+ assert.deepEqual(
+ final.roundReveal.plays.map(play => play.concealed),
+ [false, true, true, true]
+ );
+ assert.deepEqual(
+ final.roundReveal.plays.map(play => play.cards[0].id),
+ trickCards.map(value => value.id)
+ );
+ assert.equal(room.gameState.attackerScore, 15);
+ assert.equal(
+ engine.hasUsedActiveSkill(room.players[1].id, ActiveSkillIds.CONCEALED_PASSAGE),
+ false
+ );
+
+ const nextLeading = engine.playCards(room.players[3].id, [spareCards[3].id]);
+ const nextSecond = engine.playCards(room.players[0].id, [spareCards[0].id]);
+ assert.equal(nextLeading.concealed, false, '新一轮赢家成为一号位后应当明置');
+ assert.equal(nextSecond.concealed, true, '暗置按每轮出牌位置判断,而不是固定座位');
+});
+
+test('暗置出牌先私发本人牌面再广播牌背,其他玩家收不到真实牌面', () => {
+ const room = createRoom();
+ const io = createIo();
+ const playedCard = card('hearts', '10', 2120).toJSON();
+
+ emitCardsPlayed(io, room, {
+ playerId: room.players[1].id,
+ playerName: room.players[1].name,
+ playedCards: [playedCard],
+ concealed: true,
+ remainingCount: 12,
+ currentWinningPlayerId: null
+ });
+
+ assert.equal(io.events.length, 2);
+ assert.deepEqual(io.events[0], {
+ target: room.players[1].socketId,
+ event: 'concealed_cards_played_private',
+ payload: {
+ playerId: room.players[1].id,
+ cards: [playedCard]
+ }
+ });
+ assert.equal(io.events[1].target, room.id);
+ assert.equal(io.events[1].event, 'cards_played');
+ assert.deepEqual(io.events[1].payload.cards, []);
+ assert.deepEqual(io.events[1].payload.removedCardIds, [playedCard.id]);
+ assert.equal(io.events[1].payload.cardsCount, 1);
+ assert.equal(io.events[1].payload.concealed, true);
+});
+
+test('红颜祸水把黑桃转换为带来源标记的红桃,红桃为主时补足40分', () => {
+ const transformed = DeckService.transformSpadesToHearts([
+ card('spades', 'A', 0),
+ card('hearts', 'A', 0)
+ ]);
+ assert.equal(transformed[0].suit, 'hearts');
+ assert.equal(transformed[0].originalSuit, 'spades');
+ assert.equal(transformed[0].id, 'spades-A-0');
+ assert.equal(transformed[1].originalSuit, null);
+
+ const room = createRoom();
+ const io = createIo();
+ const engine = new GameEngine(room, io);
+ room.gameState.selectedRule = getRuleById(RuleIds.FATAL_BEAUTY);
+ room.gameState.attackerScore = getRuleSetup(room.gameState.selectedRule).attackerStartingScore;
+ room.gameState.trumpSuit = 'hearts';
+ assert.equal(engine.applyFatalBeautyTrumpBonus(), true);
+ assert.equal(engine.applyFatalBeautyTrumpBonus(), false);
+ assert.equal(room.gameState.attackerScore, 40);
+ assert.equal(io.events.filter(({ event }) => event === 'fatal_beauty_trump_bonus').length, 1);
+});
+
+test('偷梁换柱只采用玩家明确指定的牌面,可组成对子和拖拉机且王仍不计分', () => {
+ const five = card('hearts', '5');
+ const six = card('hearts', '6');
+ const smallJoker = card('joker', 'small_joker');
+ const bigJoker = card('joker', 'big_joker');
+ const rule = getRuleById(RuleIds.STEALING_BEAMS);
+
+ const pair = resolveJokerSubstitutionPlay({
+ selectedCards: [five, smallJoker],
+ handCards: [five, smallJoker],
+ substitutions: [{ cardId: smallJoker.id, suit: 'hearts', rank: '5' }],
+ trumpSuit: 'spades',
+ trumpRank: '2',
+ activeRule: rule
+ });
+ assert.equal(pair.valid, true);
+ assert.equal(pair.pattern.type, PatternTypes.PAIR);
+ assert.equal(pair.usesSkill, true);
+ assert.equal(pair.substitutions[0].rank, '5');
+ assert.equal(calculateRoundPoints(pair.effectiveCards), 5);
+
+ const tractor = resolveJokerSubstitutionPlay({
+ selectedCards: [five, six, smallJoker, bigJoker],
+ handCards: [five, six, smallJoker, bigJoker],
+ substitutions: [
+ { cardId: smallJoker.id, suit: 'hearts', rank: '5' },
+ { cardId: bigJoker.id, suit: 'hearts', rank: '6' }
+ ],
+ trumpSuit: 'spades',
+ trumpRank: '2',
+ activeRule: rule
+ });
+ assert.equal(tractor.valid, true);
+ assert.equal(tractor.pattern.type, PatternTypes.TRACTOR);
+
+ const singleJoker = resolveJokerSubstitutionPlay({
+ selectedCards: [bigJoker],
+ handCards: [bigJoker],
+ trumpSuit: 'spades',
+ trumpRank: '2',
+ activeRule: rule
+ });
+ assert.equal(singleJoker.valid, true);
+ assert.equal(singleJoker.usesSkill, false);
+ assert.equal(singleJoker.pattern.type, PatternTypes.SINGLE);
+
+ const missingChoice = resolveJokerSubstitutionPlay({
+ selectedCards: [five, smallJoker],
+ handCards: [five, smallJoker],
+ trumpSuit: 'spades',
+ trumpRank: '2',
+ activeRule: rule
+ });
+ assert.equal(missingChoice.valid, false);
+ assert.match(missingChoice.message, /明确选择/);
+
+ const spoofedChoice = resolveJokerSubstitutionPlay({
+ selectedCards: [five, smallJoker],
+ handCards: [five, smallJoker, bigJoker],
+ substitutions: [{ cardId: bigJoker.id, suit: 'hearts', rank: '5' }],
+ trumpSuit: 'spades',
+ trumpRank: '2',
+ activeRule: rule
+ });
+ assert.equal(spoofedChoice.valid, false);
+ assert.match(spoofedChoice.message, /必须包含/);
+
+ const room = createRoom();
+ const engine = new GameEngine(room, createIo());
+ room.players[0].cards = [five, smallJoker, card('clubs', '3', 0)];
+ room.players.slice(1).forEach((player, index) => {
+ player.cards = [card('hearts', String(index + 6), index), card('clubs', '4', index)];
+ });
+ room.gameState.phase = GamePhases.PLAYING;
+ room.gameState.selectedRule = rule;
+ room.gameState.trumpSuit = 'spades';
+ room.gameState.trumpRank = '2';
+ room.gameState.buryingPlayerId = room.players[0].id;
+ engine.setFirstPlayer(room.players[0].id);
+
+ const result = engine.playCards(
+ room.players[0].id,
+ [five.id, smallJoker.id],
+ null,
+ ActiveSkillIds.STEALING_BEAMS,
+ { jokerSubstitutions: [{ cardId: smallJoker.id, suit: 'hearts', rank: '5' }] }
+ );
+ assert.deepEqual(result.playedCards.map(currentCard => currentCard.rank), ['5', '5']);
+ assert.equal(result.playedCards.some(currentCard => currentCard.isJokerSubstitution), true);
+ assert.equal(calculateRoundPoints(result.playedCards), 5, '变成5的王仍不产生牌面分');
+ assert.equal(engine.hasUsedActiveSkill(room.players[0].id, ActiveSkillIds.STEALING_BEAMS), true);
+});
+
+test('斗转星移和随波逐流只在轮末且四家均达到阈值后交换整手', () => {
+ const cosmicRoom = createRoom();
+ const cosmicEngine = new GameEngine(cosmicRoom, createIo());
+ cosmicRoom.gameState.selectedRule = getRuleById(RuleIds.COSMIC_SHIFT);
+ cosmicRoom.players.forEach((player, playerIndex) => {
+ const ranks = ['3', '4', '5', '6', '7', '8', '9', '10', 'J', 'Q', 'K', 'A', '3'];
+ for (let index = 0; index < 13; index++) {
+ player.addCard(card('hearts', ranks[index], playerIndex * 100 + index));
+ }
+ });
+ cosmicRoom.gameState.phase = GamePhases.PLAYING;
+ cosmicRoom.gameState.trumpSuit = 'spades';
+ cosmicRoom.gameState.trumpRank = '2';
+ cosmicRoom.gameState.buryingPlayerId = cosmicRoom.players[0].id;
+ cosmicEngine.setFirstPlayer(cosmicRoom.players[0].id);
+
+ const firstResult = cosmicEngine.playCards(
+ cosmicRoom.players[0].id,
+ [cosmicRoom.players[0].cards[0].id]
+ );
+ assert.equal(firstResult.wholeHandExchange, null, '一号位出完后不能立刻换牌');
+ assert.equal(cosmicRoom.gameState.wholeHandExchangeTriggers.size, 0);
+ cosmicEngine.playCards(cosmicRoom.players[1].id, [cosmicRoom.players[1].cards[0].id]);
+ cosmicEngine.playCards(cosmicRoom.players[2].id, [cosmicRoom.players[2].cards[0].id]);
+ const expectedCosmicHands = cosmicRoom.players.map((player, index) =>
+ index === 3 ? player.cards.slice(1).map(value => value.id) : player.cards.map(value => value.id)
+ );
+ const finalResult = cosmicEngine.playCards(
+ cosmicRoom.players[3].id,
+ [cosmicRoom.players[3].cards[0].id]
+ );
+ const cosmicResult = finalResult.wholeHandExchange;
+ assert.equal(cosmicResult.triggerKey, 'cosmic_shift_12');
+ assert.deepEqual(cosmicRoom.players[0].cards.map(value => value.id).sort(), [...expectedCosmicHands[2]].sort());
+ assert.deepEqual(cosmicRoom.players[1].cards.map(value => value.id).sort(), [...expectedCosmicHands[3]].sort());
+
+ const flowRoom = createRoom();
+ const flowEngine = new GameEngine(flowRoom, createIo());
+ flowRoom.gameState.selectedRule = getRuleById(RuleIds.GO_WITH_THE_FLOW);
+ flowRoom.players.forEach((player, playerIndex) => {
+ for (let index = 0; index < 16; index++) {
+ player.addCard(card(['hearts', 'diamonds', 'clubs', 'spades'][playerIndex], String(3 + (index % 10)), index));
+ }
+ });
+ const flowSnapshots = flowRoom.players.map(player => player.cards.map(value => value.id));
+ const firstFlow = flowEngine.applyWholeHandExchangeAtRoundEnd();
+ assert.equal(firstFlow.triggerKey, 'go_with_the_flow_16');
+ assert.deepEqual(flowRoom.players[1].cards.map(value => value.id).sort(), [...flowSnapshots[0]].sort());
+ flowRoom.players[0].cards = flowRoom.players[0].cards.slice(0, 9);
+ assert.equal(flowEngine.applyWholeHandExchangeAtRoundEnd(), null, '不能只因一家的手牌达到阈值就交换');
+ flowRoom.players.forEach(player => {
+ player.cards = player.cards.slice(0, 9);
+ });
+ const secondFlow = flowEngine.applyWholeHandExchangeAtRoundEnd();
+ assert.equal(secondFlow.triggerKey, 'go_with_the_flow_9');
+});
+
+test('频繁波动和微小扰动只在对应轮末收齐四家选择后同时交换一张牌', () => {
+ const playExchangeRound = ({ ruleId, trickRanks }) => {
+ const room = createRoom();
+ const io = createIo();
+ const engine = new GameEngine(room, io);
+ const trickCards = trickRanks.map((rank, index) => card('hearts', rank, index));
+ const giftCards = ['9', 'J', 'Q', 'A'].map((rank, index) => card('clubs', rank, index + 10));
+ room.players.forEach((player, index) => {
+ player.addCard(trickCards[index]);
+ player.addCard(giftCards[index]);
+ });
+ room.gameState.phase = GamePhases.PLAYING;
+ room.gameState.trumpSuit = 'spades';
+ room.gameState.trumpRank = '2';
+ room.gameState.selectedRule = getRuleById(ruleId);
+ room.gameState.attackerScore = getRuleSetup(room.gameState.selectedRule).attackerStartingScore;
+ room.gameState.buryingPlayerId = room.players[0].id;
+ engine.setFirstPlayer(room.players[0].id);
+
+ const first = engine.playCards(room.players[0].id, [trickCards[0].id]);
+ assert.equal(first.roundCardExchange, null, '不能在一号位出牌后提前触发');
+ engine.playCards(room.players[1].id, [trickCards[1].id]);
+ engine.playCards(room.players[2].id, [trickCards[2].id]);
+ const final = engine.playCards(room.players[3].id, [trickCards[3].id]);
+ return { room, io, engine, final, giftCards };
+ };
+
+ const frequent = playExchangeRound({
+ ruleId: RuleIds.FREQUENT_FLUCTUATION,
+ trickRanks: ['5', '6', '7', '8']
+ });
+ assert.equal(frequent.final.roundCardExchange.stage, 'round');
+ assert.equal(frequent.final.roundCardExchange.requiredCards, 1);
+ assert.equal(frequent.room.gameState.cardExchange.triggerRound, 1);
+ assert.equal(
+ frequent.room.gameState.cardExchange.targetByPlayerId[frequent.room.players[0].id],
+ frequent.room.players[1].id
+ );
+ assert.throws(
+ () => frequent.engine.playCards(frequent.room.players[3].id, [frequent.giftCards[3].id]),
+ /先完成本轮换牌/
+ );
+ frequent.room.players.forEach((player, index) => {
+ const result = frequent.engine.submitOpeningCardExchange(player.id, [frequent.giftCards[index].id]);
+ if (index < 3) assert.equal(result.resolved, false);
+ else assert.equal(result.resolved, true);
+ });
+ assert.equal(frequent.room.gameState.cardExchange, null);
+ assert.deepEqual(
+ frequent.room.players.map(player => player.cards[0].id),
+ [frequent.giftCards[3].id, frequent.giftCards[0].id, frequent.giftCards[1].id, frequent.giftCards[2].id]
+ );
+ assert.equal(
+ frequent.io.events.filter(({ event }) => event === 'card_exchange_resolved').length,
+ 1
+ );
+
+ const minor = playExchangeRound({
+ ruleId: RuleIds.MINOR_DISTURBANCE,
+ trickRanks: ['3', '6', '7', '8']
+ });
+ assert.equal(minor.final.roundCardExchange.stage, 'round');
+ assert.equal(
+ minor.room.gameState.cardExchange.targetByPlayerId[minor.room.players[0].id],
+ minor.room.players[2].id
+ );
+ minor.room.players[0].isBot = true;
+ minor.room.players[2].isBot = true;
+ const automatic = minor.engine.submitAutomaticRoundCardExchanges();
+ assert.equal(automatic.resolved, false);
+ assert.deepEqual(
+ [...minor.room.gameState.cardExchange.submittedPlayerIds].sort(),
+ [minor.room.players[0].id, minor.room.players[2].id].sort()
+ );
+ minor.engine.submitOpeningCardExchange(minor.room.players[1].id, [minor.giftCards[1].id]);
+ minor.engine.submitOpeningCardExchange(minor.room.players[3].id, [minor.giftCards[3].id]);
+ assert.deepEqual(
+ minor.room.players.map(player => player.cards[0].id),
+ [minor.giftCards[2].id, minor.giftCards[3].id, minor.giftCards[0].id, minor.giftCards[1].id]
+ );
+
+ const noTriggerRoom = createRoom();
+ noTriggerRoom.gameState.selectedRule = getRuleById(RuleIds.FREQUENT_FLUCTUATION);
+ noTriggerRoom.players.forEach((player, index) => player.addCard(card('clubs', '3', index + 30)));
+ const noTriggerEngine = new GameEngine(noTriggerRoom, createIo());
+ assert.equal(
+ noTriggerEngine.prepareRoundCardExchangeAtRoundEnd([card('hearts', '4', 99)]),
+ null,
+ '频繁波动在无分轮不能触发'
+ );
+});
+
+test('弃掷逦迤在含分轮末同时暗弃,庄家方分牌只在终局公开并补分', () => {
+ const room = createRoom();
+ const io = createIo();
+ const engine = new GameEngine(room, io);
+ const trickCards = ['5', '6', '7', '8'].map((rank, index) => card('hearts', rank, index));
+ const discardedCards = [
+ card('clubs', '4', 10),
+ card('clubs', 'K', 11),
+ card('diamonds', '5', 12),
+ card('clubs', '3', 13)
+ ];
+ const remainingCards = ['A', 'Q', 'J', '9'].map(
+ (rank, index) => card('spades', rank, index + 20)
+ );
+ room.players.forEach((player, index) => {
+ player.addCard(trickCards[index]);
+ player.addCard(discardedCards[index]);
+ player.addCard(remainingCards[index]);
+ });
+ room.gameState.phase = GamePhases.PLAYING;
+ room.gameState.trumpSuit = 'spades';
+ room.gameState.trumpRank = '2';
+ room.gameState.selectedRule = getRuleById(RuleIds.LINGERING_DISCARD);
+ room.gameState.buryingPlayerId = room.players[0].id;
+ room.gameState.dealerPlayerIndex = 0;
+ engine.setFirstPlayer(room.players[0].id);
+
+ engine.playCards(room.players[0].id, [trickCards[0].id]);
+ engine.playCards(room.players[1].id, [trickCards[1].id]);
+ engine.playCards(room.players[2].id, [trickCards[2].id]);
+ const final = engine.playCards(room.players[3].id, [trickCards[3].id]);
+
+ assert.equal(final.roundCardExchange.operation, 'discard');
+ assert.equal(room.gameState.cardExchange.operation, 'discard');
+ assert.ok(Object.values(room.gameState.cardExchange.targetByPlayerId).every(value => value === null));
+ assert.equal(Object.hasOwn(room.gameState.toJSON(), 'lingeringDiscardedCards'), false);
+
+ room.players.slice(0, 3).forEach((player, index) => {
+ const result = engine.submitOpeningCardExchange(player.id, [discardedCards[index].id]);
+ assert.equal(result.resolved, false);
+ });
+ assert.deepEqual(room.players.map(player => player.cards.length), [2, 2, 2, 2]);
+ assert.equal(room.gameState.attackerScore, 5, '弃出的分牌不能在终局前改变分数');
+
+ const resolved = engine.submitOpeningCardExchange(
+ room.players[3].id,
+ [discardedCards[3].id]
+ );
+ assert.equal(resolved.resolved, true);
+ assert.equal(resolved.operation, 'discard');
+ assert.deepEqual(room.players.map(player => player.cards.length), [1, 1, 1, 1]);
+ assert.deepEqual(
+ room.players.map(player => player.cards[0].id),
+ remainingCards.map(value => value.id)
+ );
+ assert.equal(room.gameState.attackerScore, 5);
+ const publicResolution = io.events.find(({ event }) => event === 'card_exchange_resolved');
+ assert.equal(publicResolution.payload.operation, 'discard');
+ assert.equal(JSON.stringify(publicResolution.payload).includes(discardedCards[0].id), false);
+ assert.equal(JSON.stringify(room.toJSON()).includes(discardedCards[0].id), false);
+ const privateUpdates = io.events.filter(({ event }) => event === 'card_exchange_hand_updated');
+ assert.equal(privateUpdates.length, 4);
+ privateUpdates.forEach(({ target, payload }) => {
+ const playerIndex = room.players.findIndex(player => player.socketId === target);
+ assert.deepEqual(payload.sentCardIds, [discardedCards[playerIndex].id]);
+ assert.deepEqual(payload.receivedCards, []);
+ assert.equal(payload.ruleName, '弃掷逦迤');
+ assert.equal(payload.operation, 'discard');
+ assert.equal(payload.animationDuration, publicResolution.payload.animationDuration);
+ assert.equal(
+ discardedCards.some((value, index) =>
+ index !== playerIndex && JSON.stringify(payload).includes(value.id)
+ ),
+ false,
+ '每名玩家的私有结果也不能包含他人的弃牌'
+ );
+ });
+
+ engine.finishGame();
+ const settlement = room.gameState.bottomScoreResult;
+ assert.equal(settlement.scoreBeforeLingeringDiscard, 5);
+ assert.equal(settlement.lingeringDiscardPoints, 5);
+ assert.equal(settlement.lingeringDiscardBonus, 5);
+ assert.equal(settlement.totalScore, 10);
+ assert.deepEqual(
+ settlement.lingeringDiscardCards.map(value => value.id).sort(),
+ [discardedCards[2].id],
+ '只公开庄家方弃出的分牌,庄家本人弃出的非分牌也不展示'
+ );
+ assert.equal(
+ settlement.lingeringDiscardCards.some(value => value.id === discardedCards[1].id),
+ false,
+ '闲家弃牌不应在终局公开'
+ );
+
+ const noTriggerRoom = createRoom();
+ noTriggerRoom.gameState.selectedRule = getRuleById(RuleIds.LINGERING_DISCARD);
+ noTriggerRoom.players.forEach((player, index) => {
+ player.addCard(card('clubs', '3', index + 40));
+ });
+ const noTriggerEngine = new GameEngine(noTriggerRoom, createIo());
+ assert.equal(
+ noTriggerEngine.prepareRoundDiscardAtRoundEnd([card('hearts', '4', 99)]),
+ null,
+ '无分轮不能触发暗弃'
+ );
+});
+
+test('弃掷逦迤暗弃最后一张手牌后直接进入终局结算', () => {
+ const room = createRoom();
+ const engine = new GameEngine(room, createIo());
+ const finalCards = ['10', '3', '5', '4'].map(
+ (rank, index) => card('clubs', rank, index + 70)
+ );
+ room.players.forEach((player, index) => player.addCard(finalCards[index]));
+ room.gameState.phase = GamePhases.PLAYING;
+ room.gameState.selectedRule = getRuleById(RuleIds.LINGERING_DISCARD);
+ room.gameState.buryingPlayerId = room.players[0].id;
+ room.gameState.dealerPlayerIndex = 0;
+ room.gameState.lastRoundWinnerIndex = 0;
+ room.gameState.lastRoundLeadingPattern = { type: PatternTypes.SINGLE, length: 1 };
+
+ const pending = engine.prepareRoundDiscardAtRoundEnd([card('hearts', '5', 99)]);
+ assert.equal(pending.operation, 'discard');
+ let resolved;
+ room.players.forEach((player, index) => {
+ resolved = engine.submitOpeningCardExchange(player.id, [finalCards[index].id]);
+ });
+
+ assert.equal(resolved.gameFinished, true);
+ assert.equal(room.gameState.phase, GamePhases.REVEALING);
+ assert.equal(room.gameState.bottomScoreResult.lingeringDiscardPoints, 15);
+ assert.deepEqual(room.players.map(player => player.cards.length), [0, 0, 0, 0]);
+});
+
+test('后发制人只让三号位与四号位换序,不改变本轮牌力结算', () => {
+ const room = createRoom();
+ const engine = new GameEngine(room, createIo());
+ const trickCards = ['3', '4', '5', '6'].map((rank, index) => card('hearts', rank, index));
+ room.players.forEach((player, index) => {
+ player.addCard(trickCards[index]);
+ player.addCard(card('clubs', String(index + 7), index + 10));
+ });
+ room.gameState.phase = GamePhases.PLAYING;
+ room.gameState.trumpSuit = 'spades';
+ room.gameState.trumpRank = '2';
+ room.gameState.selectedRule = getRuleById(RuleIds.LATE_MOVER_ADVANTAGE);
+ room.gameState.buryingPlayerId = room.players[0].id;
+ engine.setFirstPlayer(room.players[0].id);
+
+ assert.throws(
+ () => engine.activateLateMoverAdvantage(room.players[0].id),
+ /只能由本轮三号位/
+ );
+ engine.playCards(room.players[0].id, [trickCards[0].id]);
+ engine.playCards(room.players[1].id, [trickCards[1].id]);
+
+ const activation = engine.activateLateMoverAdvantage(room.players[2].id);
+ assert.equal(activation.currentPlayerIndex, 3);
+ assert.equal(room.gameState.currentPlayerIndex, 3);
+ assert.equal(
+ engine.hasUsedActiveSkill(room.players[2].id, ActiveSkillIds.LATE_MOVER_ADVANTAGE),
+ true
+ );
+ assert.throws(
+ () => engine.activateLateMoverAdvantage(room.players[2].id),
+ /只能发动一次|还没有轮到你/
+ );
+
+ const fourthPlay = engine.playCards(room.players[3].id, [trickCards[3].id]);
+ assert.equal(fourthPlay.roundUpdate.type, 'turn_changed');
+ assert.equal(room.gameState.currentPlayerIndex, 2, '四号位出完后应回到原三号位');
+ const finalPlay = engine.playCards(room.players[2].id, [trickCards[2].id]);
+ assert.equal(finalPlay.roundUpdate.type, 'round_ended');
+ assert.equal(finalPlay.roundUpdate.roundWinner.playerId, room.players[3].id);
+ assert.deepEqual(
+ room.gameState.playHistory.slice(-4).map(play => play.playerId),
+ [room.players[0].id, room.players[1].id, room.players[3].id, room.players[2].id],
+ '实际行动顺序应为一、二、四、三号位'
+ );
+});
+
+test('礼崩乐坏禁止一号位主动出A,但不妨碍跟牌出A', () => {
+ const room = createRoom();
+ const engine = new GameEngine(room, createIo());
+ const leaderAce = card('hearts', 'A', 80);
+ const leaderThree = card('hearts', '3', 80);
+ const followerAce = card('hearts', 'A', 81);
+ room.players[0].addCard(leaderAce);
+ room.players[0].addCard(leaderThree);
+ room.players[1].addCard(followerAce);
+ room.players[1].addCard(card('clubs', '4', 80));
+ room.players[2].addCard(card('hearts', '5', 80));
+ room.players[2].addCard(card('clubs', '6', 80));
+ room.players[3].addCard(card('hearts', '7', 80));
+ room.players[3].addCard(card('clubs', '8', 80));
+ room.gameState.phase = GamePhases.PLAYING;
+ room.gameState.selectedRule = RITES_COLLAPSE_RULE;
+ room.gameState.trumpSuit = 'spades';
+ room.gameState.trumpRank = '2';
+ room.gameState.buryingPlayerId = room.players[0].id;
+ engine.setFirstPlayer(room.players[0].id);
+
+ assert.throws(
+ () => engine.playCards(room.players[0].id, [leaderAce.id]),
+ /不能主动打出A/
+ );
+ engine.playCards(room.players[0].id, [leaderThree.id]);
+ assert.doesNotThrow(() => engine.playCards(room.players[1].id, [followerAce.id]));
+
+ const bot = new BotService('simple');
+ assert.deepEqual(
+ bot.getFallbackAction({
+ selectedRule: RITES_COLLAPSE_RULE,
+ currentRound: 1,
+ leadingPattern: null,
+ trumpSuit: 'spades',
+ trumpRank: '2'
+ }, [leaderAce, leaderThree]),
+ [leaderThree.id],
+ 'Bot首发兜底同样必须避开A'
+ );
+
+ const onlyARoom = createRoom();
+ const onlyAEngine = new GameEngine(onlyARoom, createIo());
+ const onlyA = [card('hearts', 'A', 180), card('spades', 'A', 181)];
+ onlyA.forEach(value => onlyARoom.players[0].addCard(value));
+ onlyARoom.players.slice(1).forEach((player, index) => {
+ player.addCard(card('clubs', String(index + 3), 180 + index));
+ });
+ onlyARoom.gameState.phase = GamePhases.PLAYING;
+ onlyARoom.gameState.selectedRule = RITES_COLLAPSE_RULE;
+ onlyARoom.gameState.trumpSuit = 'spades';
+ onlyARoom.gameState.trumpRank = '2';
+ onlyARoom.gameState.buryingPlayerId = onlyARoom.players[0].id;
+ onlyAEngine.setFirstPlayer(onlyARoom.players[0].id);
+ assert.doesNotThrow(() => onlyAEngine.playCards(onlyARoom.players[0].id, [onlyA[0].id]));
+ assert.deepEqual(
+ bot.getFallbackAction({
+ selectedRule: RITES_COLLAPSE_RULE,
+ currentRound: 1,
+ leadingPattern: null,
+ trumpSuit: 'spades',
+ trumpRank: '2'
+ }, onlyA),
+ [onlyA[0].id],
+ 'Bot只剩A时也必须能够首发'
+ );
+});
+
+test('君子一言按埋底后的有效花色统计,唯一最短自动声明,并列时等待本人选择', () => {
+ const room = createRoom();
+ const io = createIo();
+ const engine = new GameEngine(room, io, () => 0);
+ const hands = [
+ [card('diamonds', '3', 190), card('clubs', '4', 191), card('spades', '5', 192)],
+ [card('clubs', '5', 193), card('diamonds', '2', 194)],
+ [card('hearts', '7', 195), card('clubs', '8', 196), card('diamonds', '2', 197)],
+ [card('hearts', '10', 198), card('diamonds', 'J', 199), card('spades', 'Q', 200)]
+ ];
+ room.players.forEach((player, index) => hands[index].forEach(value => player.addCard(value)));
+ room.gameState.phase = GamePhases.PLAYING;
+ room.gameState.selectedRule = GENTLEMAN_PROMISE_RULE;
+ room.gameState.trumpSuit = 'spades';
+ room.gameState.trumpRank = '2';
+ room.gameState.buryingPlayerId = room.players[0].id;
+ engine.setFirstPlayer(room.players[0].id);
+
+ const playerOneCounts = engine.getGentlemanPromiseSuitCounts(room.players[1]);
+ assert.equal(Object.hasOwn(playerOneCounts, 'spades'), false, '原主花色不应保留为永远为0的独立栏目');
+ assert.deepEqual(playerOneCounts, {
+ hearts: 0,
+ diamonds: 0,
+ clubs: 1,
+ trump: 1
+ }, '方片级牌只能计入主,不能再计入方片');
+
+ const activation = engine.activateGentlemanPromise();
+ assert.equal(activation.pending, true);
+ assert.deepEqual(
+ Object.fromEntries(room.gameState.gentlemanPromiseDeclarationsByPlayerId),
+ {
+ [room.players[0].id]: 'hearts',
+ [room.players[2].id]: 'diamonds',
+ [room.players[3].id]: 'clubs'
+ }
+ );
+ const requestEvent = io.events.find(({ target, event }) => (
+ target === room.players[1].socketId && event === 'gentleman_promise_selection_required'
+ ));
+ assert.deepEqual(requestEvent?.payload?.eligibleSuits, ['hearts', 'diamonds']);
+ assert.throws(
+ () => engine.playCards(room.players[0].id, [hands[0][0].id]),
+ /完成最短花色声明/
+ );
+ assert.throws(
+ () => engine.selectGentlemanPromiseSuit(room.players[1].id, 'clubs'),
+ /并列最少/
+ );
+
+ const result = engine.selectGentlemanPromiseSuit(room.players[1].id, 'diamonds');
+ assert.equal(result.pending, false);
+ assert.equal(
+ room.gameState.toJSON().gentlemanPromise.declarationsByPlayerId[room.players[1].id],
+ 'diamonds'
+ );
+ assert.doesNotThrow(() => engine.playCards(room.players[0].id, [hands[0][0].id]));
+});
+
+test('潜龙在渊排除级牌统计最多点数,唯一候选自动声明,并列时等待本人选择', () => {
+ const room = createRoom();
+ const io = createIo();
+ const engine = new GameEngine(room, io, () => 0);
+ const hands = [
+ [card('hearts', '3', 601), card('diamonds', '3', 602), card('clubs', '4', 603)],
+ [card('hearts', '5', 604), card('diamonds', '5', 605), card('clubs', '10', 606), card('spades', '10', 607)],
+ [card('hearts', '2', 608), card('diamonds', '2', 609), card('clubs', '2', 610), card('spades', '2', 611), card('hearts', 'Q', 612), card('diamonds', 'Q', 613), card('clubs', 'K', 614)],
+ [card('hearts', 'A', 615), card('diamonds', 'A', 616), card('clubs', 'K', 617)]
+ ];
+ room.players.forEach((player, index) => hands[index].forEach(value => player.addCard(value)));
+ room.gameState.phase = GamePhases.PLAYING;
+ room.gameState.selectedRule = HIDDEN_DRAGON_RULE;
+ room.gameState.trumpSuit = 'spades';
+ room.gameState.trumpRank = '2';
+ room.gameState.buryingPlayerId = room.players[0].id;
+ engine.setFirstPlayer(room.players[0].id);
+
+ assert.deepEqual(engine.getHiddenDragonRankCounts(room.players[2]), {
+ 3: 0,
+ 4: 0,
+ 5: 0,
+ 6: 0,
+ 7: 0,
+ 8: 0,
+ 9: 0,
+ 10: 0,
+ J: 0,
+ Q: 2,
+ K: 1,
+ A: 0
+ });
+
+ const activation = engine.activateHiddenDragonInAbyss();
+ assert.equal(activation.pending, true);
+ assert.deepEqual(
+ Object.fromEntries(room.gameState.hiddenDragonDeclarationsByPlayerId),
+ {
+ [room.players[0].id]: '3',
+ [room.players[2].id]: 'Q',
+ [room.players[3].id]: 'A'
+ }
+ );
+ const requestEvent = io.events.find(({ target, event }) => (
+ target === room.players[1].socketId && event === 'hidden_dragon_selection_required'
+ ));
+ assert.deepEqual(requestEvent?.payload?.eligibleRanks, ['5', '10']);
+ assert.equal(requestEvent?.payload?.maximumCount, 2);
+ assert.throws(
+ () => engine.playCards(room.players[0].id, [hands[0][2].id]),
+ /完成潜龙点数声明/
+ );
+ assert.throws(
+ () => engine.selectHiddenDragonRank(room.players[1].id, 'J'),
+ /并列最多/
+ );
+
+ const result = engine.selectHiddenDragonRank(room.players[1].id, '10');
+ assert.equal(result.pending, false);
+ assert.equal(
+ room.gameState.toJSON().hiddenDragon.declarationsByPlayerId[room.players[1].id],
+ '10'
+ );
+});
+
+test('潜龙在渊在本次出牌后首次降至12张时结算,且撤回会恢复点数历史与阵营分', () => {
+ const room = createRoom();
+ const io = createIo();
+ const engine = new GameEngine(room, io, () => 0);
+ const crossingCard = card('hearts', '4', 630);
+ const declaredCard = card('hearts', 'K', 631);
+ const fillerRanks = ['3', '4', '5', '6', '7', '8', '9', '10', 'J', 'Q', 'A'];
+ room.players[0].addCard(crossingCard);
+ room.players[0].addCard(declaredCard);
+ fillerRanks.forEach((rank, index) => {
+ room.players[0].addCard(card('diamonds', rank, 640 + index));
+ });
+ const followerCards = room.players.slice(1).map((player, index) => {
+ const value = card('clubs', String(index + 3), 660 + index);
+ player.addCard(value);
+ return value;
+ });
+ room.gameState.phase = GamePhases.PLAYING;
+ room.gameState.selectedRule = HIDDEN_DRAGON_RULE;
+ room.gameState.trumpSuit = 'spades';
+ room.gameState.trumpRank = '2';
+ room.gameState.buryingPlayerId = room.players[0].id;
+ room.gameState.hiddenDragonDeclarationsByPlayerId.set(room.players[0].id, 'K');
+ engine.setFirstPlayer(room.players[0].id);
+
+ engine.playCards(room.players[0].id, [crossingCard.id]);
+ assert.equal(room.players[0].cards.length, 12);
+ assert.equal(room.gameState.attackerScore, -10, '庄家方成功应使闲家净得分减少10');
+ assert.equal(room.gameState.hiddenDragonResults[0].success, true);
+ assert.equal(room.gameState.hiddenDragonResults[0].awardedPoints, 10);
+ assert.equal(room.gameState.hiddenDragonEvaluatedPlayerIds.has(room.players[0].id), true);
+
+ engine.undoLastPlay(room.players[0].id);
+ assert.equal(room.players[0].cards.length, 13);
+ assert.equal(room.gameState.attackerScore, 0);
+ assert.equal(room.gameState.hiddenDragonResults.length, 0);
+ assert.equal(room.gameState.hiddenDragonEvaluatedPlayerIds.has(room.players[0].id), false);
+ assert.deepEqual(
+ Array.from(room.gameState.hiddenDragonPlayedRanksByPlayerId.get(room.players[0].id)),
+ []
+ );
+
+ engine.playCards(room.players[0].id, [declaredCard.id]);
+ assert.equal(room.players[0].cards.length, 12);
+ assert.equal(room.gameState.attackerScore, 0);
+ assert.equal(room.gameState.hiddenDragonResults[0].success, false, '跨过12张门槛的本次出牌也必须计入历史');
+ assert.equal(room.gameState.hiddenDragonPlayedRanksByPlayerId.get(room.players[0].id).has('K'), true);
+ assert.equal(
+ room.gameState.toJSON().hiddenDragon.playedDeclaredRankByPlayerId[room.players[0].id],
+ true
+ );
+ room.gameState.hiddenDragonDeclarationsByPlayerId.set(room.players[1].id, 'A');
+ engine.playCards(room.players[1].id, [followerCards[0].id]);
+ assert.equal(room.gameState.hiddenDragonResults[1].success, true);
+ assert.equal(room.gameState.hiddenDragonResults[1].team, 'attacker');
+ assert.equal(room.gameState.attackerScore, 10, '闲家方成功应使闲家净得分增加10');
+ assert.deepEqual(room.gameState.toJSON().hiddenDragon.results, room.gameState.hiddenDragonResults);
+});
+
+test('行政审查以24张初始手牌开打,公开声明后只由庄家方共同满足条件再看底埋底', () => {
+ const room = createRoom();
+ const io = createIo();
+ const engine = new GameEngine(room, io, () => 0);
+ const openingCards = [
+ card('hearts', '3', 710),
+ card('hearts', '7', 711),
+ card('clubs', '7', 712),
+ card('hearts', '5', 713)
+ ];
+ room.players.forEach((player, playerIndex) => {
+ player.addCard(openingCards[playerIndex]);
+ for (let index = 0; index < 23; index += 1) {
+ player.addCard(card(
+ playerIndex === 2 ? 'clubs' : 'diamonds',
+ String(3 + (index % 7)),
+ 720 + playerIndex * 30 + index
+ ));
+ }
+ });
+ const sealedBottomCards = Array.from({ length: 12 }, (_, index) => (
+ card('spades', String(3 + (index % 7)), 850 + index)
+ ));
+ room.gameState.phase = GamePhases.DRAWING;
+ room.gameState.selectedRule = ADMINISTRATIVE_REVIEW_RULE;
+ room.gameState.bottomCardsCount = 12;
+ room.gameState.bottomCards = sealedBottomCards;
+ room.gameState.trumpSuit = 'spades';
+ room.gameState.trumpRank = '2';
+ const dealer = room.players[0];
+ const drawingManager = new DrawingPhaseManager(
+ room,
+ io,
+ assignedDealer => engine.handleDealerAssigned(assignedDealer)
+ );
+ engine.drawingManager = drawingManager;
+
+ drawingManager.completeDealerAssignment(dealer);
+ assert.deepEqual(room.players.map(player => player.cards.length), [24, 24, 24, 24]);
+ assert.equal(
+ io.events.some(({ event }) => event === 'bottom_cards_received'),
+ false,
+ '条件满足前不能把底牌牌面私发给庄家'
+ );
+ assert.equal(room.gameState.phase, GamePhases.PLAYING);
+ assert.equal(room.gameState.currentPlayerIndex, null, '两项公开声明完成前不能开始出牌');
+ assert.equal(room.gameState.administrativeReview.suitSelectorPlayerId, room.players[1].id);
+ assert.equal(room.gameState.administrativeReview.rankSelectorPlayerId, room.players[3].id);
+
+ assert.throws(
+ () => engine.selectAdministrativeReviewDeclaration(room.players[1].id, 'suit', 'spades'),
+ /当前副花色/
+ );
+ engine.selectAdministrativeReviewDeclaration(room.players[1].id, 'suit', 'hearts');
+ assert.equal(room.gameState.toJSON().administrativeReview.suit, 'hearts');
+ const finalDeclaration = engine.selectAdministrativeReviewDeclaration(
+ room.players[3].id,
+ 'rank',
+ '7'
+ );
+ assert.equal(finalDeclaration.pending, false);
+ assert.equal(room.gameState.currentPlayerIndex, 0);
+ assert.equal(room.gameState.currentRound, 1);
+ assert.deepEqual(
+ io.events.filter(({ event }) => event === 'administrative_review_declared')
+ .map(({ payload }) => [payload.type, payload.value]),
+ [['suit', 'hearts'], ['rank', '7']],
+ '两项声明都必须向全桌广播'
+ );
+
+ engine.playCards(dealer.id, [openingCards[0].id]);
+ assert.equal(room.gameState.administrativeReview.suitMatched, true);
+ assert.equal(room.gameState.administrativeReview.rankMatched, false);
+ engine.undoLastPlay(dealer.id);
+ assert.equal(room.gameState.administrativeReview.suitMatched, false, '撤回需恢复审查进度');
+ engine.playCards(dealer.id, [openingCards[0].id]);
+
+ engine.playCards(room.players[1].id, [openingCards[1].id]);
+ assert.equal(
+ room.gameState.administrativeReview.rankMatched,
+ false,
+ '闲家打出指定点数不能替庄家方满足条件'
+ );
+ engine.playCards(room.players[2].id, [openingCards[2].id]);
+ assert.equal(room.gameState.administrativeReview.rankMatched, true);
+ assert.equal(room.gameState.administrativeReview.isBottomReleased, true);
+ assert.equal(room.gameState.phase, GamePhases.BURYING);
+ assert.equal(dealer.cards.length, 35, '庄家打过一张后收到12张封存底牌');
+ const bottomEvent = io.events.find(({ target, event }) => (
+ target === dealer.socketId && event === 'bottom_cards_received'
+ ));
+ assert.deepEqual(
+ bottomEvent?.payload?.bottomCards?.map(value => value.id),
+ sealedBottomCards.map(value => value.id)
+ );
+ assert.equal(
+ io.events.some(({ target, event }) => (
+ target !== dealer.socketId && event === 'bottom_cards_received'
+ )),
+ false
+ );
+
+ const cardsToBury = dealer.cards.slice(0, 12);
+ const preservedRound = room.gameState.currentRound;
+ const preservedPlayerIndex = room.gameState.currentPlayerIndex;
+ const preservedPlayCount = room.gameState.currentRoundPlays.length;
+ engine.buryCards(dealer.id, cardsToBury.map(value => value.id));
+ assert.equal(room.gameState.phase, GamePhases.PLAYING);
+ assert.equal(room.gameState.administrativeReview.isBuried, true);
+ assert.equal(room.gameState.currentRound, preservedRound);
+ assert.equal(room.gameState.currentPlayerIndex, preservedPlayerIndex);
+ assert.equal(room.gameState.currentRoundPlays.length, preservedPlayCount);
+ assert.equal(dealer.cards.length, 23);
+ assert.deepEqual(room.gameState.bottomCards.map(value => value.id), cardsToBury.map(value => value.id));
+ assert.doesNotThrow(() => engine.playCards(room.players[3].id, [openingCards[3].id]));
+});
+
+test('政治审查逐次询问未使用技能的队友,收回不禁牌且第四手审查前不结算', () => {
+ const room = createRoom();
+ const io = createIo();
+ const engine = new GameEngine(room, io, () => 0.9);
+ const trickCards = ['3', '4', '5', '6'].map((rank, index) => (
+ card('hearts', rank, 720 + index)
+ ));
+ room.players.forEach((player, index) => player.addCard(trickCards[index]));
+ room.gameState.phase = GamePhases.PLAYING;
+ room.gameState.selectedRule = POLITICAL_REVIEW_RULE;
+ room.gameState.trumpSuit = 'spades';
+ room.gameState.trumpRank = '2';
+ room.gameState.buryingPlayerId = room.players[0].id;
+ engine.setFirstPlayer(room.players[0].id);
+
+ const firstPending = engine.playCards(room.players[0].id, [trickCards[0].id]);
+ assert.equal(firstPending.politicalReviewDeferred, true);
+ assert.equal(room.players[0].cards.length, 1, '审查决定前不得真正移除手牌');
+ assert.equal(room.gameState.currentRoundPlays.length, 0);
+ assert.equal(room.gameState.currentPlayerIndex, 0);
+ assert.equal(room.gameState.politicalReviewPending.reviewerPlayerId, room.players[2].id);
+
+ const firstDecision = engine.respondPoliticalReview(room.players[2].id, false);
+ assert.equal(firstDecision.returned, false);
+ assert.equal(engine.hasUsedActiveSkill(room.players[2].id, RuleIds.POLITICAL_REVIEW), false);
+ const firstCommitted = engine.playCards(
+ room.players[0].id,
+ [trickCards[0].id],
+ null,
+ null,
+ { politicalReviewApprovalId: firstDecision.id }
+ );
+ assert.equal(firstCommitted.politicalReviewDeferred, undefined);
+ assert.equal(room.players[0].cards.length, 0);
+ assert.equal(room.gameState.currentPlayerIndex, 1);
+
+ const secondPending = engine.playCards(room.players[1].id, [trickCards[1].id]);
+ assert.equal(secondPending.politicalReviewPending.reviewerPlayerId, room.players[3].id);
+ const secondDecision = engine.respondPoliticalReview(room.players[3].id, true);
+ assert.equal(secondDecision.returned, true);
+ assert.equal(room.players[1].cards.length, 1, '选择收回后牌仍须留在原出牌者手中');
+ assert.equal(room.gameState.currentPlayerIndex, 1);
+ assert.equal(engine.hasUsedActiveSkill(room.players[3].id, RuleIds.POLITICAL_REVIEW), true);
+ const repeatedSecond = engine.playCards(room.players[1].id, [trickCards[1].id]);
+ assert.equal(repeatedSecond.politicalReviewDeferred, undefined, '完全相同的牌必须允许立即重出');
+ assert.equal(room.gameState.bushGateRestriction, null, '政治审查不得生成任何禁出牌记录');
+
+ const thirdPending = engine.playCards(room.players[2].id, [trickCards[2].id]);
+ assert.equal(thirdPending.politicalReviewPending.reviewerPlayerId, room.players[0].id);
+ engine.respondPoliticalReview(room.players[0].id, true);
+ assert.equal(room.players[2].cards.length, 1);
+ engine.playCards(room.players[2].id, [trickCards[2].id]);
+
+ const fourthPending = engine.playCards(room.players[3].id, [trickCards[3].id]);
+ assert.equal(fourthPending.politicalReviewDeferred, true);
+ assert.equal(room.gameState.currentRound, 1, '第四手审查完成前不能提前推进轮次');
+ assert.equal(room.gameState.currentRoundPlays.length, 3, '第四手尚未真正进入牌桌');
+ assert.equal(room.gameState.phase, GamePhases.PLAYING);
+ engine.respondPoliticalReview(room.players[1].id, true);
+ assert.equal(room.players[3].cards.length, 1);
+ const finalResult = engine.playCards(room.players[3].id, [trickCards[3].id]);
+ assert.equal(finalResult.gameFinished, true);
+ assert.deepEqual(
+ room.gameState.toJSON().politicalReview.usedPlayerIds.sort(),
+ [room.players[0].id, room.players[1].id, room.players[3].id].sort()
+ );
+});
+
+test('政治审查放行Bot时固定提交原候选牌,不受Bot再次计算动作影响', () => {
+ const room = createRoom();
+ const engine = new GameEngine(room, createIo(), () => 0.9);
+ const approvedCard = card('hearts', '3', 730);
+ const recalculatedCard = card('hearts', '7', 731);
+ room.players[0].isBot = true;
+ room.players[0].addCard(approvedCard);
+ room.players[0].addCard(recalculatedCard);
+ room.players.slice(1).forEach((player, index) => (
+ player.addCard(card('hearts', `${index + 4}`, 732 + index))
+ ));
+ room.gameState.phase = GamePhases.PLAYING;
+ room.gameState.selectedRule = POLITICAL_REVIEW_RULE;
+ room.gameState.trumpSuit = 'spades';
+ room.gameState.trumpRank = '2';
+ room.gameState.buryingPlayerId = room.players[0].id;
+ engine.setFirstPlayer(room.players[0].id);
+
+ engine.playCards(room.players[0].id, [approvedCard.id]);
+ engine.respondPoliticalReview(room.players[2].id, false);
+ const result = engine.playCards(room.players[0].id, [recalculatedCard.id]);
+ assert.equal(result.playedCards[0].id, approvedCard.id);
+ assert.deepEqual(room.players[0].cards.map(value => value.id), [recalculatedCard.id]);
+});
+
+test('焦点人物按队内一致表决反复轮换候选,逐墩只算焦点双倍而底牌正常结算', () => {
+ const room = createRoom();
+ const io = createIo();
+ const engine = new GameEngine(room, io, sequenceRandom([0.1, 0.1]));
+ const trickCards = [
+ card('hearts', '5', 310),
+ card('hearts', 'A', 311),
+ card('hearts', '10', 312),
+ card('hearts', 'K', 313)
+ ];
+ room.players.forEach((player, index) => player.addCard(trickCards[index]));
+ room.gameState.phase = GamePhases.PLAYING;
+ room.gameState.selectedRule = FOCUS_FIGURE_RULE;
+ room.gameState.trumpSuit = 'spades';
+ room.gameState.trumpRank = '2';
+ room.gameState.buryingPlayerId = room.players[0].id;
+ room.gameState.bottomCards = [card('clubs', '5', 314)];
+ engine.setFirstPlayer(room.players[0].id);
+
+ engine.activateFocusFigure();
+ assert.equal(engine.hasPendingFocusFigureVote(), true);
+ assert.equal(room.gameState.toJSON().focusFigure.isVotingPending, true);
+ assert.equal(room.gameState.toJSON().focusFigure.teams, undefined, '终局前不能公开候选或焦点');
+ assert.deepEqual(
+ room.gameState.toJSON().focusFigure.capturedPointsByPlayerId,
+ Object.fromEntries(room.players.map(player => [player.id, 0])),
+ '终局前应公开每名玩家打出的分牌被闲家收走的牌面分'
+ );
+ assert.equal(room.gameState.toJSON().attackerScore, null, '终局前公共快照不能泄露实际得分');
+
+ const teamOneRequests = io.events.filter(({ event, payload }) => (
+ event === 'focus_figure_vote_required' && payload.team === 1
+ ));
+ assert.deepEqual(
+ new Set(teamOneRequests.map(({ target }) => target)),
+ new Set([room.players[0].socketId, room.players[2].socketId])
+ );
+ assert.ok(teamOneRequests.every(({ payload }) => payload.nomineePlayerId === room.players[0].id));
+ assert.equal(
+ io.events.some(({ target, event, payload }) => (
+ event === 'focus_figure_vote_required' &&
+ payload.team === 1 &&
+ [room.players[1].socketId, room.players[3].socketId].includes(target)
+ )),
+ false,
+ '另一队不能收到本队候选'
+ );
+ assert.throws(
+ () => engine.playCards(room.players[0].id, [trickCards[0].id]),
+ /完成焦点人物表决/
+ );
+
+ engine.submitFocusFigureVote(room.players[0].id, false, { team: 1, attempt: 1 });
+ const switched = engine.submitFocusFigureVote(room.players[2].id, true, { team: 1, attempt: 1 });
+ assert.equal(switched.nomineeChanged, true);
+ assert.equal(room.gameState.focusFigureTeams[0].nomineePlayerId, room.players[2].id);
+ assert.equal(room.gameState.focusFigureTeams[0].attempt, 2);
+ assert.throws(
+ () => engine.submitFocusFigureVote(room.players[0].id, true, { team: 1, attempt: 1 }),
+ /候选已经变更/
+ );
+
+ engine.submitFocusFigureVote(room.players[1].id, true, { team: 2, attempt: 1 });
+ engine.submitFocusFigureVote(room.players[3].id, true, { team: 2, attempt: 1 });
+ engine.submitFocusFigureVote(room.players[0].id, true, { team: 1, attempt: 2 });
+ const completed = engine.submitFocusFigureVote(room.players[2].id, true, { team: 1, attempt: 2 });
+ assert.equal(completed.pending, false);
+ assert.equal(room.gameState.focusFigureTeams[0].finalPlayerId, room.players[2].id);
+ assert.equal(room.gameState.focusFigureTeams[1].finalPlayerId, room.players[1].id);
+ assert.equal(room.gameState.toJSON().focusFigure.teams, undefined);
+
+ engine.playCards(room.players[0].id, [trickCards[0].id]);
+ engine.playCards(room.players[1].id, [trickCards[1].id]);
+ engine.playCards(room.players[2].id, [trickCards[2].id]);
+ const result = engine.playCards(room.players[3].id, [trickCards[3].id]);
+
+ assert.equal(result.roundUpdate.scoreInfo.focusFigureScoringPending, true);
+ assert.equal(result.roundUpdate.scoreInfo.attackerScore, null);
+ assert.deepEqual(
+ room.gameState.collectedPointCards.map(value => value.id),
+ [trickCards[0].id, trickCards[2].id, trickCards[3].id]
+ );
+ assert.equal(room.gameState.bottomScoreResult.focusFigure.focusTrickScore, 20);
+ assert.equal(
+ room.gameState.bottomScoreResult.focusFigure.normalBottomScore,
+ 10,
+ '底牌5分应不看庄家是否焦点,仍按单张抠底2倍正常结算'
+ );
+ assert.equal(room.gameState.bottomScoreResult.totalScore, 30);
+ assert.equal(room.gameState.attackerScore, 30);
+ const playerBreakdown = Object.fromEntries(
+ room.gameState.bottomScoreResult.focusFigure.players.map(player => [player.playerId, player])
+ );
+ assert.deepEqual(
+ [playerBreakdown[room.players[2].id].capturedPoints, playerBreakdown[room.players[2].id].countedPoints],
+ [10, 20]
+ );
+ assert.deepEqual(
+ [playerBreakdown[room.players[3].id].capturedPoints, playerBreakdown[room.players[3].id].countedPoints],
+ [10, 0]
+ );
+ assert.equal(room.gameState.toJSON().focusFigure.isRevealed, true);
+ assert.equal(room.gameState.toJSON().focusFigure.teams.length, 2);
+ assert.equal(room.gameState.toJSON().attackerScore, 30);
+});
+
+test('再衰三竭从连续第三次最大开始递增罚分,换人接牌的当轮计为新连续第1轮', () => {
+ const room = createRoom();
+ const engine = new GameEngine(room, createIo());
+ const ranksByPlayer = ['A', 'K', 'Q', 'J'];
+ const cardsByPlayer = room.players.map((player, playerIndex) => (
+ Array.from({ length: 4 }, (_, roundIndex) => (
+ card('hearts', ranksByPlayer[playerIndex], 270 + playerIndex * 10 + roundIndex)
+ ))
+ ));
+ room.players.forEach((player, index) => {
+ cardsByPlayer[index].forEach(value => player.addCard(value));
+ });
+ room.gameState.phase = GamePhases.PLAYING;
+ room.gameState.selectedRule = REPEATED_EXHAUSTION_RULE;
+ room.gameState.trumpSuit = 'spades';
+ room.gameState.trumpRank = '2';
+ room.gameState.buryingPlayerId = room.players[0].id;
+ engine.setFirstPlayer(room.players[0].id);
+
+ let finalResult = null;
+ for (let roundIndex = 0; roundIndex < 3; roundIndex++) {
+ room.players.forEach((player, playerIndex) => {
+ finalResult = engine.playCards(player.id, [cardsByPlayer[playerIndex][roundIndex].id]);
+ });
+ assert.equal(room.gameState.attackerScore, roundIndex === 2 ? 5 : 0);
+ }
+ assert.deepEqual(finalResult.roundUpdate.scoreInfo.repeatedExhaustion, {
+ playerId: room.players[0].id,
+ playerName: room.players[0].name,
+ streak: 3,
+ penalty: 5,
+ scoreDelta: 5,
+ winnerIsAttacker: false
+ });
+
+ const firstTakeover = engine.applyRepeatedExhaustionAtRoundEnd(1);
+ const secondAttackerWin = engine.applyRepeatedExhaustionAtRoundEnd(1);
+ const thirdAttackerWin = engine.applyRepeatedExhaustionAtRoundEnd(1);
+ assert.equal(firstTakeover.streak, 1, '接到牌权的当轮就是新赢家连续记录的第1轮');
+ assert.equal(secondAttackerWin.streak, 2);
+ assert.deepEqual(
+ { streak: thirdAttackerWin.streak, penalty: thirdAttackerWin.penalty, scoreDelta: thirdAttackerWin.scoreDelta },
+ { streak: 3, penalty: 5, scoreDelta: -5 }
+ );
+ assert.equal(room.gameState.attackerScore, 0);
+});
+
+test('冷却时间与时间冷却记录上轮出牌,但遇到基本跟牌义务时解禁首花色', () => {
+ const cases = [
+ {
+ rule: COOLDOWN_TIME_RULE,
+ type: 'rank',
+ playerOneFirst: card('diamonds', '7', 200),
+ secondLead: card('clubs', '9', 201),
+ restricted: card('clubs', '7', 202),
+ alternative: card('spades', '8', 203),
+ expectedValue: '7'
+ },
+ {
+ rule: TIME_COOLING_RULE,
+ type: 'suit',
+ playerOneFirst: card('diamonds', '4', 210),
+ secondLead: card('diamonds', '9', 211),
+ restricted: card('diamonds', 'K', 212),
+ alternative: card('clubs', '8', 213),
+ expectedValue: 'diamonds'
+ }
+ ];
+
+ for (const testCase of cases) {
+ const room = createRoom();
+ const engine = new GameEngine(room, createIo());
+ const firstRoundCards = [
+ card('hearts', 'A', 220),
+ testCase.playerOneFirst,
+ card('clubs', '5', 221),
+ card('spades', '6', 222)
+ ];
+ const remainingByPlayer = [
+ [testCase.secondLead, card('clubs', 'Q', 223)],
+ [testCase.restricted, testCase.alternative],
+ [card('diamonds', '8', 224), card('clubs', '8', 225)],
+ [card('diamonds', '9', 226), card('clubs', '9', 227)]
+ ];
+ room.players.forEach((player, index) => {
+ player.addCard(firstRoundCards[index]);
+ remainingByPlayer[index].forEach(value => player.addCard(value));
+ });
+ room.gameState.phase = GamePhases.PLAYING;
+ room.gameState.selectedRule = testCase.rule;
+ room.gameState.trumpSuit = 'no_trump';
+ room.gameState.trumpRank = '2';
+ room.gameState.buryingPlayerId = room.players[0].id;
+ engine.setFirstPlayer(room.players[0].id);
+
+ room.players.forEach((player, index) => {
+ engine.playCards(player.id, [firstRoundCards[index].id]);
+ });
+
+ assert.equal(room.gameState.currentRound, 2);
+ assert.equal(room.gameState.currentPlayerIndex, 0);
+ assert.equal(room.gameState.cardCooldownType, testCase.type);
+ assert.deepEqual(
+ room.gameState.cardCooldownValuesByPlayerId.get(room.players[1].id),
+ [testCase.expectedValue]
+ );
+ assert.deepEqual(
+ room.gameState.toJSON().cardCooldown.valuesByPlayerId[room.players[1].id],
+ [testCase.expectedValue]
+ );
+
+ engine.playCards(room.players[0].id, [testCase.secondLead.id]);
+ assert.throws(
+ () => engine.playCards(room.players[1].id, [testCase.alternative.id]),
+ /必须|花色|跟牌/
+ );
+ assert.doesNotThrow(
+ () => engine.playCards(room.players[1].id, [testCase.restricted.id]),
+ '冷却不能凌驾于基本跟牌花色义务之上'
+ );
+ }
+});
+
+test('冷却规则在手牌全部受限时解除,Bot也只从未冷却牌中选择', () => {
+ const room = createRoom();
+ const engine = new GameEngine(room, createIo());
+ const twoSevens = [card('hearts', '7', 230), card('clubs', '7', 231)];
+ twoSevens.forEach(value => room.players[0].addCard(value));
+ room.players.slice(1).forEach((player, index) => {
+ player.addCard(card('clubs', String(index + 3), 232 + index));
+ });
+ room.gameState.phase = GamePhases.PLAYING;
+ room.gameState.selectedRule = COOLDOWN_TIME_RULE;
+ room.gameState.trumpSuit = 'spades';
+ room.gameState.trumpRank = '2';
+ room.gameState.buryingPlayerId = room.players[0].id;
+ room.gameState.cardCooldownType = 'rank';
+ room.gameState.cardCooldownValuesByPlayerId.set(room.players[0].id, ['7']);
+ engine.setFirstPlayer(room.players[0].id);
+ assert.doesNotThrow(() => engine.playCards(room.players[0].id, [twoSevens[0].id]));
+
+ const bot = new BotService('simple');
+ const cooled = card('diamonds', '7', 240);
+ const available = card('diamonds', '8', 241);
+ const botState = {
+ selectedRule: COOLDOWN_TIME_RULE,
+ currentRound: 2,
+ leadingPattern: null,
+ trumpSuit: 'spades',
+ trumpRank: '2',
+ cardCooldownValuesByPlayerId: new Map([[room.players[1].id, ['7']]])
+ };
+ assert.deepEqual(
+ bot.getFallbackAction(botState, [cooled, available], room.players[1].id),
+ [available.id]
+ );
+});
+
+test('时间冷却把主花色、各花色级牌和王统一视为主花色', () => {
+ const room = createRoom();
+ const engine = new GameEngine(room, createIo());
+ const playedLevelCard = card('diamonds', '2', 250);
+ room.gameState.selectedRule = TIME_COOLING_RULE;
+ room.gameState.trumpSuit = 'spades';
+ room.gameState.trumpRank = '2';
+ room.gameState.currentRoundPlays = [{
+ playerId: room.players[0].id,
+ cards: [playedLevelCard]
+ }];
+
+ engine.updateCardCooldownRestrictionsForNextRound();
+ assert.deepEqual(
+ room.gameState.cardCooldownValuesByPlayerId.get(room.players[0].id),
+ ['trump'],
+ '非主花色的级牌也应记录为主花色'
+ );
+
+ const hand = [
+ card('clubs', '2', 251),
+ card('spades', 'K', 252),
+ card('joker', 'small_joker', 253),
+ card('hearts', '7', 254)
+ ];
+ assert.deepEqual(
+ getCardCooldownDisabledCards({
+ gameState: room.gameState,
+ playerId: room.players[0].id,
+ playerCards: hand
+ }).map(value => value.id),
+ hand.slice(0, 3).map(value => value.id),
+ '任意一张主牌触发的冷却都应禁用全部主牌'
+ );
+});
+
+test('举贤任能只由一号位发动,并把原一号位移到本轮最后', () => {
+ const room = createRoom();
+ const engine = new GameEngine(room, createIo());
+ const trickCards = ['3', '4', '5', '6'].map(
+ (rank, index) => card('hearts', rank, index + 90)
+ );
+ room.players.forEach((player, index) => {
+ player.addCard(trickCards[index]);
+ player.addCard(card('clubs', String(index + 7), index + 90));
+ });
+ room.gameState.phase = GamePhases.PLAYING;
+ room.gameState.selectedRule = RECOMMEND_TALENT_RULE;
+ room.gameState.trumpSuit = 'spades';
+ room.gameState.trumpRank = '2';
+ room.gameState.buryingPlayerId = room.players[0].id;
+ engine.setFirstPlayer(room.players[0].id);
+
+ const activation = engine.activateRecommendTalent(room.players[0].id);
+ assert.equal(activation.currentPlayerIndex, 1);
+ assert.equal(
+ engine.hasUsedActiveSkill(room.players[0].id, ActiveSkillIds.RECOMMEND_TALENT),
+ true
+ );
+ assert.throws(
+ () => engine.activateRecommendTalent(room.players[1].id),
+ /一号位/
+ );
+
+ engine.playCards(room.players[1].id, [trickCards[1].id]);
+ engine.playCards(room.players[2].id, [trickCards[2].id]);
+ engine.playCards(room.players[3].id, [trickCards[3].id]);
+ const finalPlay = engine.playCards(room.players[0].id, [trickCards[0].id]);
+ assert.equal(finalPlay.roundUpdate.type, 'round_ended');
+ assert.deepEqual(
+ room.gameState.playHistory.slice(-4).map(play => play.playerId),
+ [room.players[1].id, room.players[2].id, room.players[3].id, room.players[0].id]
+ );
+});
+
+test('意外保险让闲家赢高分轮最多计30分,庄家方赢高分轮则补超额给闲家', () => {
+ const playFortyPointTrick = ({ attackerWins }) => {
+ const room = createRoom();
+ const engine = new GameEngine(room, createIo());
+ const trickCards = attackerWins
+ ? [
+ card('hearts', 'K', 100),
+ card('spades', 'K', 100),
+ card('clubs', 'K', 100),
+ card('diamonds', 'K', 100)
+ ]
+ : [
+ card('hearts', 'K', 110),
+ card('diamonds', 'K', 110),
+ card('clubs', 'K', 110),
+ card('spades', 'K', 110)
+ ];
+ room.players.forEach((player, index) => {
+ player.addCard(trickCards[index]);
+ player.addCard(card('clubs', String(index + 3), index + 120));
+ });
+ room.gameState.phase = GamePhases.PLAYING;
+ room.gameState.selectedRule = ACCIDENT_INSURANCE_RULE;
+ room.gameState.trumpSuit = attackerWins ? 'spades' : 'no_trump';
+ room.gameState.trumpRank = '2';
+ room.gameState.buryingPlayerId = room.players[0].id;
+ engine.setFirstPlayer(room.players[0].id);
+
+ let result;
+ room.players.forEach((player, index) => {
+ result = engine.playCards(player.id, [trickCards[index].id]);
+ });
+ return { room, scoreInfo: result.roundUpdate.scoreInfo };
+ };
+
+ const attackerResult = playFortyPointTrick({ attackerWins: true });
+ assert.equal(attackerResult.scoreInfo.winnerIsAttacker, true);
+ assert.equal(attackerResult.scoreInfo.attackerRoundPointsAwarded, 30);
+ assert.equal(attackerResult.scoreInfo.accidentInsuranceWithheld, 10);
+ assert.equal(attackerResult.room.gameState.attackerScore, 30);
+
+ const dealerResult = playFortyPointTrick({ attackerWins: false });
+ assert.equal(dealerResult.scoreInfo.winnerIsAttacker, false);
+ assert.equal(dealerResult.scoreInfo.attackerRoundPointsAwarded, 10);
+ assert.equal(dealerResult.scoreInfo.accidentInsuranceBonus, 10);
+ assert.equal(dealerResult.room.gameState.attackerScore, 10);
+});
+
+test('绝处逢生可暂拒后再次询问,确认后整手都按主牌处理', () => {
+ const room = createRoom();
+ const io = createIo();
+ const engine = new GameEngine(room, io);
+ const player = room.players[0];
+ const opponentHeart = card('hearts', 'Q', 99);
+ ['3', '4', '6', '7', '8'].forEach((rank, index) => player.addCard(card('hearts', rank, index)));
+ room.players[1].addCard(opponentHeart);
+ room.gameState.selectedRule = getRuleById(RuleIds.LAST_STAND);
+ room.gameState.trumpSuit = 'spades';
+ room.gameState.trumpRank = '2';
+
+ assert.ok(engine.requestLastStandIfEligible(player));
+ assert.equal(engine.hasPendingLastStandDecision(), true);
+ engine.respondLastStand(player.id, false);
+ assert.equal(room.gameState.lastStandActivatedPlayerIds.has(player.id), false);
+ assert.ok(engine.requestLastStandIfEligible(player));
+ engine.respondLastStand(player.id, true);
+ assert.equal(room.gameState.lastStandActivatedPlayerIds.has(player.id), true);
+ assert.ok(player.cards.every(value => value.isLastStandTrump));
+ assert.ok(player.cards.every(value => getCardStrength(value, 'spades', '2', room.gameState.selectedRule) > 900));
+ assert.equal(opponentHeart.isLastStandTrump, false);
+ assert.equal(isTrumpCard(opponentHeart, 'spades', '2'), false, '对手同花色牌仍然是副牌');
+ assert.ok(getCardStrength(opponentHeart, 'spades', '2', room.gameState.selectedRule) < 900);
+});
+
+test('绝处逢生无人亮主时随机定主、拒绝一对王,并在埋底手牌变化后检查发动条件', () => {
+ const randomRoom = createRoom();
+ const randomIo = createIo();
+ randomRoom.gameState.selectedRule = getRuleById(RuleIds.LAST_STAND);
+ randomRoom.gameState.phase = GamePhases.DRAWING;
+ const manager = new DrawingPhaseManager(
+ randomRoom,
+ randomIo,
+ null,
+ null,
+ null,
+ () => 0.8
+ );
+ manager.assignDealer();
+ assert.equal(randomRoom.gameState.trumpSuit, 'spades');
+ assert.ok(randomIo.events.some(({ event, payload }) =>
+ event === 'trump_updated' && payload.systemSelected === true
+ ));
+
+ const declarationRoom = createRoom();
+ const declarationIo = createIo();
+ const handlers = new Map();
+ const socketEvents = [];
+ const socket = {
+ id: declarationRoom.players[0].socketId,
+ on(event, handler) {
+ handlers.set(event, handler);
+ },
+ emit(event, payload) {
+ socketEvents.push({ event, payload });
+ }
+ };
+ declarationRoom.gameState.phase = GamePhases.DRAWING;
+ declarationRoom.gameState.selectedRule = getRuleById(RuleIds.LAST_STAND);
+ declarationRoom.players[0].addCard(card('joker', 'big_joker', 0));
+ declarationRoom.players[0].addCard(card('joker', 'big_joker', 1));
+ registerPlayerHandlers(declarationIo, socket, {
+ getRoom: roomId => roomId === declarationRoom.id ? declarationRoom : null
+ });
+ handlers.get('declare_trump')({
+ roomId: declarationRoom.id,
+ suit: 'joker',
+ count: 2
+ });
+ assert.equal(socketEvents.at(-1).payload.message, '绝处逢生不能用一对王反主');
+
+ const buryRoom = createRoom();
+ const buryIo = createIo();
+ const buryEngine = new GameEngine(buryRoom, buryIo);
+ const dealer = buryRoom.players[0];
+ const survivorCards = ['3', '4', '6', '7', '8']
+ .map((rank, index) => card('hearts', rank, index));
+ const buriedCards = ['3', '4', '5', '6', '7', '8', '9', '10']
+ .map((rank, index) => card('spades', rank, index + 20));
+ [...survivorCards, ...buriedCards].forEach(value => dealer.addCard(value));
+ buryRoom.gameState.phase = GamePhases.BURYING;
+ buryRoom.gameState.selectedRule = getRuleById(RuleIds.LAST_STAND);
+ buryRoom.gameState.trumpSuit = 'spades';
+ buryRoom.gameState.trumpRank = '2';
+ buryRoom.gameState.buryingPlayerId = dealer.id;
+ buryEngine.buryCards(dealer.id, buriedCards.map(value => value.id));
+ assert.equal(buryRoom.gameState.lastStandPendingPlayerIds.has(dealer.id), true);
+ assert.ok(buryIo.events.some(({ target, event }) =>
+ target === dealer.socketId && event === 'last_stand_decision_required'
+ ));
+});
+
+test('时间倒流注册为每人一次、可在轮中预备的主动技能', () => {
+ const rule = getRuleById(RuleIds.TIME_REVERSAL);
+ assert.equal(rule.name, '时间倒流');
+ assert.deepEqual(rule.activeSkill, {
+ id: ActiveSkillIds.TIME_REVERSAL,
+ name: '时间倒流',
+ usageLimit: 1,
+ timing: 'anytime_during_round',
+ effect: 'rewind_completed_round'
+ });
+});
+
+test('时间倒流允许多人预备,首个确认发动者完整恢复手牌、得分与牌权', () => {
+ const room = createRoom();
+ const io = createIo();
+ const engine = new GameEngine(room, io);
+ room.gameState.phase = GamePhases.PLAYING;
+ room.gameState.selectedRule = getRuleById(RuleIds.TIME_REVERSAL);
+ room.gameState.trumpSuit = 'spades';
+ room.gameState.trumpRank = '2';
+ room.gameState.buryingPlayerId = room.players[0].id;
+
+ const trickCards = [
+ card('hearts', 'K'),
+ card('hearts', 'A'),
+ card('hearts', '5'),
+ card('hearts', '10')
+ ];
+ room.players.forEach((player, index) => {
+ player.addCard(trickCards[index]);
+ player.addCard(card('clubs', String(index + 3)));
+ });
+ engine.setFirstPlayer(room.players[0].id);
+
+ const reservation = engine.activateTimeReversal(room.players[2].id);
+ assert.equal(reservation.round, 1);
+ const secondReservation = engine.activateTimeReversal(room.players[3].id);
+ assert.equal(secondReservation.round, 1);
+ const thirdReservation = engine.activateTimeReversal(room.players[1].id);
+ assert.equal(thirdReservation.round, 1);
+
+ engine.playCards(room.players[0].id, [trickCards[0].id]);
+ engine.playCards(room.players[1].id, [trickCards[1].id]);
+ engine.playCards(room.players[2].id, [trickCards[2].id]);
+ const finalPlay = engine.playCards(room.players[3].id, [trickCards[3].id]);
+
+ assert.equal(finalPlay.timeReversalPending, true);
+ assert.equal(room.gameState.currentRound, 2);
+ assert.equal(room.gameState.attackerScore, 25);
+ assert.throws(
+ () => engine.playCards(room.players[1].id, [room.players[1].cards[0].id]),
+ /等待时间倒流决定/
+ );
+
+ engine.promptTimeReversalDecision();
+ assert.deepEqual(
+ io.events
+ .filter(({ event }) => event === 'time_reversal_decision_required')
+ .map(({ target }) => target)
+ .sort(),
+ [room.players[1].socketId, room.players[2].socketId, room.players[3].socketId].sort()
+ );
+ const declined = engine.respondTimeReversal(room.players[3].id, false);
+ assert.equal(declined.resolved, false);
+ assert.equal(
+ engine.hasUsedActiveSkill(room.players[3].id, ActiveSkillIds.TIME_REVERSAL),
+ false
+ );
+ const result = engine.respondTimeReversal(room.players[2].id, true);
+ assert.equal(result.accepted, true);
+ assert.equal(room.gameState.currentRound, 1);
+ assert.equal(room.gameState.currentPlayerIndex, 0);
+ assert.equal(room.gameState.roundStartPlayerIndex, 0);
+ assert.equal(room.gameState.currentRoundPlays.length, 0);
+ assert.equal(room.gameState.playHistory.length, 0);
+ assert.equal(room.gameState.attackerScore, 0);
+ assert.equal(room.gameState.collectedPointCards.length, 0);
+ assert.deepEqual(room.players.map(player => player.cards.length), [2, 2, 2, 2]);
+ assert.equal(
+ engine.hasUsedActiveSkill(room.players[2].id, ActiveSkillIds.TIME_REVERSAL),
+ true
+ );
+ assert.throws(
+ () => engine.respondTimeReversal(room.players[1].id, true),
+ /当前没有等待你的时间倒流决定/
+ );
+ assert.equal(
+ engine.hasUsedActiveSkill(room.players[1].id, ActiveSkillIds.TIME_REVERSAL),
+ false
+ );
+ assert.throws(
+ () => engine.activateTimeReversal(room.players[3].id),
+ /本轮已经发动过/
+ );
+});
+
+test('第四家出牌后的两秒仍可为刚结束的一轮预备时间倒流', () => {
+ const room = createRoom();
+ const engine = new GameEngine(room, createIo());
+ room.gameState.phase = GamePhases.PLAYING;
+ room.gameState.selectedRule = getRuleById(RuleIds.TIME_REVERSAL);
+ room.gameState.trumpSuit = 'spades';
+ room.gameState.trumpRank = '2';
+ room.gameState.buryingPlayerId = room.players[0].id;
+ const trickCards = ['7', '8', '9', '10'].map((rank, index) => card('hearts', rank, index));
+ room.players.forEach((player, index) => {
+ player.addCard(trickCards[index]);
+ player.addCard(card('clubs', String(index + 3), index + 10));
+ });
+ engine.setFirstPlayer(room.players[0].id);
+
+ room.players.forEach((player, index) => {
+ engine.playCards(player.id, [trickCards[index].id]);
+ });
+ assert.equal(room.gameState.currentRound, 2);
+ assert.equal(room.gameState.timeReversalDecisionState, 'holding');
+ assert.equal(room.gameState.timeReversalWindowRound, 1);
+ const pendingLeader = room.players[room.gameState.currentPlayerIndex];
+ const pendingLeaderCardId = pendingLeader.cards[0].id;
+ assert.throws(
+ () => engine.playCards(pendingLeader.id, [pendingLeaderCardId]),
+ /本轮正在等待时间倒流决定/
+ );
+ assert.equal(pendingLeader.cards.some(cardData => cardData.id === pendingLeaderCardId), true);
+
+ const lateReservation = engine.activateTimeReversal(room.players[1].id);
+ assert.equal(lateReservation.round, 1, '轮末窗口预备的目标必须是刚结束的第一轮');
+ engine.promptTimeReversalDecision();
+ const result = engine.respondTimeReversal(room.players[1].id, true);
+ assert.equal(result.round, 1);
+ assert.equal(room.gameState.currentRound, 1);
+ assert.equal(room.gameState.currentPlayerIndex, 0);
+ assert.deepEqual(room.players.map(player => player.cards.length), [2, 2, 2, 2]);
+});
+
+test('时间倒流选择保留结果不消耗技能,末轮也要等决定后才结算游戏', () => {
+ const room = createRoom();
+ const engine = new GameEngine(room, createIo());
+ room.gameState.phase = GamePhases.PLAYING;
+ room.gameState.selectedRule = getRuleById(RuleIds.TIME_REVERSAL);
+ room.gameState.trumpSuit = 'spades';
+ room.gameState.trumpRank = '2';
+ room.gameState.buryingPlayerId = room.players[0].id;
+ const trickCards = [
+ card('hearts', '3'),
+ card('hearts', '4'),
+ card('hearts', '5'),
+ card('hearts', '6')
+ ];
+ room.players.forEach((player, index) => player.addCard(trickCards[index]));
+ engine.setFirstPlayer(room.players[0].id);
+ engine.activateTimeReversal(room.players[0].id);
+
+ engine.playCards(room.players[0].id, [trickCards[0].id]);
+ engine.playCards(room.players[1].id, [trickCards[1].id]);
+ engine.playCards(room.players[2].id, [trickCards[2].id]);
+ const finalPlay = engine.playCards(room.players[3].id, [trickCards[3].id]);
+ assert.equal(finalPlay.gameFinished, false);
+ assert.equal(finalPlay.timeReversalPending, true);
+ assert.equal(room.gameState.phase, GamePhases.PLAYING);
+
+ engine.promptTimeReversalDecision();
+ const result = engine.respondTimeReversal(room.players[0].id, false);
+ assert.equal(result.accepted, false);
+ assert.equal(result.gameFinished, true);
+ assert.equal(room.gameState.phase, GamePhases.REVEALING);
+ assert.equal(
+ engine.hasUsedActiveSkill(room.players[0].id, ActiveSkillIds.TIME_REVERSAL),
+ false
+ );
+});
+
+test('等价互惠注册为一号位每人一次的主动拼点技能', () => {
+ assert.equal(EQUIVALENT_RECIPROCITY_RULE.name, '等价互惠');
+ assert.deepEqual(EQUIVALENT_RECIPROCITY_RULE.activeSkill, {
+ id: ActiveSkillIds.EQUIVALENT_RECIPROCITY,
+ name: '等价互惠',
+ usageLimit: 1,
+ timing: 'first_position_before_play',
+ effect: 'compare_and_exchange'
+ });
+});
+
+test('等价互惠由一号位选人并秘密拼点,主牌胜副牌后交换且输家一方失去5分', () => {
+ const room = createRoom();
+ const engine = new GameEngine(room, createIo());
+ const initiator = room.players[0];
+ const target = room.players[1];
+ const sideAce = card('hearts', 'A');
+ const trumpThree = card('spades', '3');
+ initiator.addCard(sideAce);
+ initiator.addCard(card('clubs', '4'));
+ target.addCard(trumpThree);
+ target.addCard(card('diamonds', '5'));
+ room.players[2].addCard(card('clubs', '6'));
+ room.players[3].addCard(card('clubs', '7'));
+ room.gameState.phase = GamePhases.PLAYING;
+ room.gameState.selectedRule = EQUIVALENT_RECIPROCITY_RULE;
+ room.gameState.trumpSuit = 'spades';
+ room.gameState.trumpRank = '2';
+ room.gameState.dealerPlayerIndex = 0;
+ room.gameState.attackerScore = 20;
+ engine.setFirstPlayer(initiator.id);
+
+ const challenge = engine.startEquivalentReciprocity(initiator.id, target.id);
+ assert.equal(room.gameState.toJSON().equivalentReciprocity.initiatorPlayerId, initiator.id);
+ assert.throws(
+ () => engine.playCards(initiator.id, [sideAce.id]),
+ /先完成等价互惠拼点/
+ );
+ const firstSelection = engine.submitEquivalentReciprocityCard(
+ initiator.id,
+ challenge.challengeId,
+ sideAce.id
+ );
+ assert.equal(firstSelection.resolved, false);
+ assert.deepEqual(room.gameState.toJSON().equivalentReciprocity.selectedPlayerIds, [initiator.id]);
+
+ const result = engine.submitEquivalentReciprocityCard(
+ target.id,
+ challenge.challengeId,
+ trumpThree.id
+ );
+ assert.equal(result.resolved, true);
+ assert.equal(result.winnerPlayerId, target.id, '任意主牌必须大于任意副牌');
+ assert.equal(result.loserPlayerId, initiator.id);
+ assert.equal(result.attackerScoreDelta, 5, '庄家方输掉拼点时闲家应增加5分');
+ assert.equal(room.gameState.attackerScore, 25);
+ assert.ok(initiator.cards.some(value => value.id === trumpThree.id));
+ assert.ok(target.cards.some(value => value.id === sideAce.id));
+ assert.equal(room.gameState.equivalentReciprocityChallenge, null);
+ assert.equal(
+ engine.hasUsedActiveSkill(initiator.id, ActiveSkillIds.EQUIVALENT_RECIPROCITY),
+ true
+ );
+ assert.throws(
+ () => engine.startEquivalentReciprocity(initiator.id, room.players[2].id),
+ /只能发动一次/
+ );
+});
+
+test('等价互惠的副牌只比较点数,同点平局不改分但仍交换', () => {
+ const room = createRoom();
+ const engine = new GameEngine(room, createIo());
+ const initiator = room.players[0];
+ const target = room.players[2];
+ const heartKing = card('hearts', 'K');
+ const diamondKing = card('diamonds', 'K');
+ initiator.addCard(heartKing);
+ target.addCard(diamondKing);
+ room.players[1].addCard(card('clubs', '3'));
+ room.players[3].addCard(card('clubs', '4'));
+ room.gameState.phase = GamePhases.PLAYING;
+ room.gameState.selectedRule = EQUIVALENT_RECIPROCITY_RULE;
+ room.gameState.trumpSuit = 'spades';
+ room.gameState.trumpRank = '2';
+ room.gameState.dealerPlayerIndex = 0;
+ room.gameState.attackerScore = 35;
+ engine.setFirstPlayer(initiator.id);
+
+ const challenge = engine.startEquivalentReciprocity(initiator.id, target.id);
+ engine.submitEquivalentReciprocityCard(initiator.id, challenge.challengeId, heartKing.id);
+ const result = engine.submitEquivalentReciprocityCard(
+ target.id,
+ challenge.challengeId,
+ diamondKing.id
+ );
+
+ assert.equal(result.isTie, true);
+ assert.equal(result.winnerPlayerId, null);
+ assert.equal(result.loserPlayerId, null);
+ assert.equal(result.attackerScoreDelta, 0);
+ assert.equal(room.gameState.attackerScore, 35);
+ assert.ok(initiator.cards.some(value => value.id === diamondKing.id));
+ assert.ok(target.cards.some(value => value.id === heartKing.id));
+});
+
+test('经久不衰已注册并使用默认牌局配置', () => {
+ assert.equal(ENDURING_RULE.name, '经久不衰');
+ assert.deepEqual(getRuleSetup(ENDURING_RULE), {
+ bottomCardsCount: 8,
+ attackerStartingScore: 0
+ });
+});
+
+test('经久不衰只在有效花色、牌型和长度一致时继承较高牌力', () => {
+ const trumpSuit = 'spades';
+ const trumpRank = '2';
+ const bigJoker = card('joker', 'big_joker');
+ const previousSingle = {
+ cards: [bigJoker],
+ pattern: detectPattern([bigJoker], trumpSuit, trumpRank, ENDURING_RULE)
+ };
+ const lowTrump = card('spades', '3');
+ const matchingSingle = {
+ cards: [lowTrump],
+ pattern: detectPattern([lowTrump], trumpSuit, trumpRank, ENDURING_RULE)
+ };
+
+ const inherited = resolveEnduringComparison(
+ matchingSingle,
+ previousSingle,
+ trumpSuit,
+ trumpRank,
+ ENDURING_RULE
+ );
+ assert.equal(inherited.inherited, true);
+ assert.equal(inherited.comparisonPattern.suit, 'trump');
+ assert.equal(
+ inherited.comparisonPattern.strength,
+ previousSingle.pattern.strength,
+ '任意主牌可继承上轮大王的牌力'
+ );
+
+ const sideAce = card('hearts', 'A');
+ const wrongSuit = resolveEnduringComparison(
+ {
+ cards: [sideAce],
+ pattern: detectPattern([sideAce], trumpSuit, trumpRank, ENDURING_RULE)
+ },
+ previousSingle,
+ trumpSuit,
+ trumpRank,
+ ENDURING_RULE
+ );
+ assert.equal(wrongSuit.inherited, false, '副牌不能继承上轮主牌牌力');
+
+ const lowTrumpPairCards = [card('spades', '3', 0), card('spades', '3', 1)];
+ const wrongPattern = resolveEnduringComparison(
+ {
+ cards: lowTrumpPairCards,
+ pattern: detectPattern(lowTrumpPairCards, trumpSuit, trumpRank, ENDURING_RULE)
+ },
+ previousSingle,
+ trumpSuit,
+ trumpRank,
+ ENDURING_RULE
+ );
+ assert.equal(wrongPattern.inherited, false, '对子不能继承上轮单牌牌力');
+});
+
+test('经久不衰的甩牌必须同花色且逐项匹配相同组件结构', () => {
+ const trumpSuit = 'spades';
+ const trumpRank = '2';
+ const createThrowPlay = cards => {
+ const parsed = parseThrowCombination(cards, trumpSuit, trumpRank, ENDURING_RULE);
+ return {
+ cards,
+ pattern: {
+ type: PatternTypes.THROW,
+ suit: parsed.suit,
+ components: parsed.components,
+ length: cards.length,
+ strength: Math.max(...parsed.components.map(component => component.strength))
+ }
+ };
+ };
+ const previous = createThrowPlay([
+ card('hearts', 'A', 0),
+ card('hearts', 'A', 1),
+ card('hearts', 'K')
+ ]);
+ const matching = createThrowPlay([
+ card('hearts', '4', 0),
+ card('hearts', '4', 1),
+ card('hearts', '3')
+ ]);
+ const matchingResolution = resolveEnduringComparison(
+ matching,
+ previous,
+ trumpSuit,
+ trumpRank,
+ ENDURING_RULE
+ );
+ assert.equal(matchingResolution.inherited, true);
+ assert.equal(matchingResolution.inheritedComponentCount, 2);
+ assert.deepEqual(
+ matchingResolution.comparisonPattern.components
+ .map(component => `${component.type}:${component.length}`)
+ .sort(),
+ previous.pattern.components
+ .map(component => `${component.type}:${component.length}`)
+ .sort()
+ );
+
+ const differentStructure = createThrowPlay([
+ card('hearts', 'Q'),
+ card('hearts', 'J'),
+ card('hearts', '10')
+ ]);
+ assert.equal(
+ resolveEnduringComparison(
+ differentStructure,
+ previous,
+ trumpSuit,
+ trumpRank,
+ ENDURING_RULE
+ ).inherited,
+ false
+ );
+
+ const differentSuit = createThrowPlay([
+ card('diamonds', '4', 0),
+ card('diamonds', '4', 1),
+ card('diamonds', '3')
+ ]);
+ assert.equal(
+ resolveEnduringComparison(
+ differentSuit,
+ previous,
+ trumpSuit,
+ trumpRank,
+ ENDURING_RULE
+ ).inherited,
+ false
+ );
+});
+
+test('经久不衰在整轮结束后固化上轮,并在下轮实际参与胜负比较', () => {
+ const room = createRoom();
+ const engine = new GameEngine(room, createIo());
+ const firstRoundCards = [
+ card('joker', 'big_joker'),
+ card('spades', '4'),
+ card('spades', '5'),
+ card('spades', '6')
+ ];
+ const secondRoundCards = [
+ card('spades', '3'),
+ card('joker', 'small_joker'),
+ card('spades', '7'),
+ card('spades', '8')
+ ];
+ room.players.forEach((player, index) => {
+ player.addCard(firstRoundCards[index]);
+ player.addCard(secondRoundCards[index]);
+ });
+ room.gameState.phase = GamePhases.PLAYING;
+ room.gameState.selectedRule = ENDURING_RULE;
+ room.gameState.trumpSuit = 'spades';
+ room.gameState.trumpRank = '2';
+ room.gameState.buryingPlayerId = room.players[0].id;
+ engine.setFirstPlayer(room.players[0].id);
+
+ room.players.forEach((player, index) => {
+ engine.playCards(player.id, [firstRoundCards[index].id]);
+ });
+ assert.equal(room.gameState.currentRound, 2);
+ assert.equal(
+ room.gameState.enduringLastPlaysByPlayerId.get(room.players[0].id).pattern.strength,
+ 1000
+ );
+
+ const inheritedLead = engine.playCards(room.players[0].id, [secondRoundCards[0].id]);
+ assert.equal(inheritedLead.enduringInheritance?.sourceCards[0].rank, 'big_joker');
+ assert.equal(room.gameState.currentRoundPlays[0].comparisonPattern.strength, 1000);
+ engine.playCards(room.players[1].id, [secondRoundCards[1].id]);
+ assert.equal(
+ room.gameState.currentWinnerIndex,
+ 0,
+ '继承大王牌力的低主牌应压住小王'
+ );
+ engine.playCards(room.players[2].id, [secondRoundCards[2].id]);
+ const finalResult = engine.playCards(room.players[3].id, [secondRoundCards[3].id]);
+ assert.equal(finalResult.roundWinner.playerId, room.players[0].id);
+});
+
+test('平均池化只在整轮结束后以队友有效单牌的平均牌力重算赢家', () => {
+ const room = createRoom();
+ const engine = new GameEngine(room, createIo());
+ const trickCards = [
+ card('hearts', 'A'),
+ card('hearts', 'Q'),
+ card('hearts', '3'),
+ card('hearts', 'K')
+ ];
+ room.players.forEach((player, index) => {
+ player.addCard(trickCards[index]);
+ player.addCard(card('clubs', String(index + 4)));
+ });
+ room.gameState.phase = GamePhases.PLAYING;
+ room.gameState.selectedRule = AVERAGE_POOLING_RULE;
+ room.gameState.trumpSuit = 'spades';
+ room.gameState.trumpRank = '2';
+ room.gameState.buryingPlayerId = room.players[0].id;
+ engine.setFirstPlayer(room.players[0].id);
+
+ engine.playCards(room.players[0].id, [trickCards[0].id]);
+ engine.playCards(room.players[1].id, [trickCards[1].id]);
+ engine.playCards(room.players[2].id, [trickCards[2].id]);
+ const result = engine.playCards(room.players[3].id, [trickCards[3].id]);
+
+ assert.equal(result.roundWinner.playerId, room.players[1].id);
+ assert.equal(result.roundUpdate.averagePooling.triggered, true);
+ assert.deepEqual(
+ result.roundUpdate.averagePooling.teams.map(team => team.averageStrength),
+ [8.5, 12.5]
+ );
+});
+
+test('average pooling: one pooled team beats a pair plus loose same-suit cards', () => {
+ const room = createRoom();
+ const engine = new GameEngine(room, createIo());
+ const trickCards = [
+ [card('clubs', '9', 0), card('clubs', '9', 1)],
+ [card('clubs', 'Q', 0), card('clubs', 'Q', 1)],
+ [card('clubs', '5', 0), card('clubs', '5', 1)],
+ [card('clubs', '3', 0), card('clubs', '4', 0)]
+ ];
+ room.players.forEach((player, index) => {
+ trickCards[index].forEach(currentCard => player.addCard(currentCard));
+ player.addCard(card('diamonds', String(index + 6)));
+ });
+ room.gameState.phase = GamePhases.PLAYING;
+ room.gameState.selectedRule = AVERAGE_POOLING_RULE;
+ room.gameState.trumpSuit = 'spades';
+ room.gameState.trumpRank = '2';
+ room.gameState.buryingPlayerId = room.players[0].id;
+ engine.setFirstPlayer(room.players[0].id);
+
+ engine.playCards(room.players[0].id, trickCards[0].map(currentCard => currentCard.id));
+ engine.playCards(room.players[1].id, trickCards[1].map(currentCard => currentCard.id));
+ engine.playCards(room.players[2].id, trickCards[2].map(currentCard => currentCard.id));
+ const result = engine.playCards(
+ room.players[3].id,
+ trickCards[3].map(currentCard => currentCard.id)
+ );
+
+ assert.equal(result.roundUpdate.averagePooling.exclusiveTeam, 1);
+ assert.equal(result.roundWinner.playerId, room.players[0].id);
+ assert.equal(result.roundUpdate.nextRoundLeader.playerId, room.players[0].id);
+ assert.equal(result.roundUpdate.averagePooling.teams[0].averageStrength, 7);
+});
+
+test('average pooling: pairs split into single channels against an AK throw', () => {
+ const room = createRoom();
+ const engine = new GameEngine(room, createIo());
+ const trickCards = [
+ [card('clubs', 'A', 0), card('clubs', 'K', 0)],
+ [card('clubs', 'Q', 0), card('clubs', 'Q', 1)],
+ [card('hearts', '8', 0), card('hearts', '7', 0)],
+ [card('clubs', '10', 0), card('clubs', '10', 1)]
+ ];
+ room.players.forEach((player, index) => {
+ trickCards[index].forEach(currentCard => player.addCard(currentCard));
+ player.addCard(card('diamonds', String(index + 3)));
+ });
+ room.gameState.phase = GamePhases.PLAYING;
+ room.gameState.selectedRule = AVERAGE_POOLING_RULE;
+ room.gameState.trumpSuit = 'spades';
+ room.gameState.trumpRank = '2';
+ room.gameState.buryingPlayerId = room.players[0].id;
+ engine.setFirstPlayer(room.players[0].id);
+
+ engine.playCards(room.players[0].id, trickCards[0].map(currentCard => currentCard.id));
+ engine.playCards(room.players[1].id, trickCards[1].map(currentCard => currentCard.id));
+ engine.playCards(room.players[2].id, trickCards[2].map(currentCard => currentCard.id));
+ const result = engine.playCards(
+ room.players[3].id,
+ trickCards[3].map(currentCard => currentCard.id)
+ );
+
+ assert.equal(result.roundUpdate.averagePooling.exclusiveTeam, 2);
+ assert.equal(result.roundWinner.playerId, room.players[1].id);
+ assert.equal(result.roundUpdate.nextRoundLeader.playerId, room.players[1].id);
+ assert.deepEqual(result.roundUpdate.averagePooling.teams[0].averageStrengths, [11, 11]);
+});
+
+test('average pooling: a complete ruff outranks the other team pooling successfully', () => {
+ const room = createRoom();
+ const engine = new GameEngine(room, createIo());
+ const trickCards = [
+ [card('clubs', '9', 0), card('clubs', '9', 1)],
+ [card('spades', '3', 0), card('spades', '3', 1)],
+ [card('clubs', '5', 0), card('clubs', '5', 1)],
+ [card('hearts', '8', 0), card('hearts', '7', 0)]
+ ];
+ room.players.forEach((player, index) => {
+ trickCards[index].forEach(currentCard => player.addCard(currentCard));
+ player.addCard(card('diamonds', String(index + 3)));
+ });
+ room.gameState.phase = GamePhases.PLAYING;
+ room.gameState.selectedRule = AVERAGE_POOLING_RULE;
+ room.gameState.trumpSuit = 'spades';
+ room.gameState.trumpRank = '2';
+ room.gameState.buryingPlayerId = room.players[0].id;
+ engine.setFirstPlayer(room.players[0].id);
+
+ engine.playCards(room.players[0].id, trickCards[0].map(currentCard => currentCard.id));
+ engine.playCards(room.players[1].id, trickCards[1].map(currentCard => currentCard.id));
+ engine.playCards(room.players[2].id, trickCards[2].map(currentCard => currentCard.id));
+ const result = engine.playCards(
+ room.players[3].id,
+ trickCards[3].map(currentCard => currentCard.id)
+ );
+
+ assert.equal(result.roundUpdate.averagePooling.triggered, false);
+ assert.equal(result.roundUpdate.averagePooling.exclusiveTeam, null);
+ assert.deepEqual(result.roundUpdate.averagePooling.teams, []);
+ assert.equal(result.roundUpdate.averagePooling.ruffPriority, true);
+ assert.deepEqual(result.roundUpdate.averagePooling.ruffPlayerIds, [room.players[1].id]);
+ assert.equal(result.roundWinner.playerId, room.players[1].id);
+ assert.equal(result.roundUpdate.nextRoundLeader.playerId, room.players[1].id);
+});
+
+test('梦中杀人允许同点数或同有效花色醒来,多人成功时先出者保持最大', () => {
+ const room = createRoom();
+ const engine = new GameEngine(room, createIo());
+ const trickCards = [
+ card('hearts', 'A'),
+ card('diamonds', 'A'),
+ card('hearts', '3'),
+ card('hearts', 'K')
+ ];
+ room.players.forEach((player, index) => {
+ player.addCard(trickCards[index]);
+ player.addCard(card('clubs', String(index + 4)));
+ });
+ room.gameState.phase = GamePhases.PLAYING;
+ room.gameState.selectedRule = DREAM_KILLING_RULE;
+ room.gameState.trumpSuit = 'spades';
+ room.gameState.trumpRank = '2';
+ room.gameState.buryingPlayerId = room.players[0].id;
+ engine.setFirstPlayer(room.players[0].id);
+
+ assert.equal(engine.activateDreamKilling(room.players[1].id).sleeping, true);
+ assert.equal(engine.activateDreamKilling(room.players[2].id).sleeping, true);
+ assert.deepEqual(
+ new Set(room.gameState.toJSON().dreamKilling.sleepingPlayerIds),
+ new Set([room.players[1].id, room.players[2].id])
+ );
+
+ engine.playCards(room.players[0].id, [trickCards[0].id]);
+ const rankMatch = engine.playCards(
+ room.players[1].id,
+ [trickCards[1].id],
+ null,
+ null,
+ { dreamKillingRandom: true }
+ );
+ assert.equal(rankMatch.dreamKilling.success, true);
+ assert.equal(rankMatch.dreamKilling.matchedRank, true);
+ const suitMatch = engine.playCards(
+ room.players[2].id,
+ [trickCards[2].id],
+ null,
+ null,
+ { dreamKillingRandom: true }
+ );
+ assert.equal(suitMatch.dreamKilling.success, true);
+ assert.equal(suitMatch.dreamKilling.matchedSuit, true);
+ assert.equal(engine.isDreamKillingSleeping(room.players[1].id), false);
+ assert.equal(engine.isDreamKillingSleeping(room.players[2].id), false);
+
+ const result = engine.playCards(room.players[3].id, [trickCards[3].id]);
+ assert.equal(result.roundWinner.playerId, room.players[1].id);
+ assert.equal(result.roundUpdate.nextRoundLeader.playerId, room.players[1].id);
+});
+
+test('梦中杀人只允许没有主牌的玩家发动,梦中不能手动指定出牌', () => {
+ const room = createRoom();
+ const engine = new GameEngine(room, createIo());
+ room.gameState.phase = GamePhases.PLAYING;
+ room.gameState.selectedRule = DREAM_KILLING_RULE;
+ room.gameState.trumpSuit = 'spades';
+ room.gameState.trumpRank = '2';
+ room.players[0].addCard(card('spades', '4'));
+ room.players[1].addCard(card('hearts', '4'));
+ engine.setFirstPlayer(room.players[1].id);
+
+ assert.throws(() => engine.activateDreamKilling(room.players[0].id), /仍有主牌/);
+ engine.activateDreamKilling(room.players[1].id);
+ assert.throws(
+ () => engine.playCards(room.players[1].id, [room.players[1].cards[0].id]),
+ /仍在梦中/
+ );
+});
+
+test('dream killing random play ignores suit and pattern-following legality while asleep', () => {
+ const room = createRoom();
+ const engine = new GameEngine(room, createIo());
+ const leadingPair = [card('hearts', '9', 0), card('hearts', '9', 1)];
+ const requiredPair = [card('hearts', '3', 0), card('hearts', '3', 1)];
+ const illegalRandomCards = [card('clubs', '4', 0), card('diamonds', '5', 0)];
+ leadingPair.forEach(currentCard => room.players[0].addCard(currentCard));
+ room.players[0].addCard(card('clubs', '6'));
+ [...requiredPair, ...illegalRandomCards].forEach(currentCard =>
+ room.players[1].addCard(currentCard)
+ );
+ room.gameState.phase = GamePhases.PLAYING;
+ room.gameState.selectedRule = DREAM_KILLING_RULE;
+ room.gameState.trumpSuit = 'spades';
+ room.gameState.trumpRank = '2';
+ room.gameState.buryingPlayerId = room.players[0].id;
+ engine.setFirstPlayer(room.players[0].id);
+ engine.activateDreamKilling(room.players[1].id);
+
+ engine.playCards(room.players[0].id, leadingPair.map(currentCard => currentCard.id));
+ const botService = new BotService(room.config.botType);
+ const randomIds = botService.getRandomAction(
+ room.gameState,
+ room.players[1].cards,
+ sequenceRandom([0.6, 0.9])
+ );
+ assert.deepEqual(randomIds, illegalRandomCards.map(currentCard => currentCard.id));
+
+ const result = engine.playCards(
+ room.players[1].id,
+ randomIds,
+ null,
+ null,
+ { dreamKillingRandom: true }
+ );
+ assert.equal(result.dreamKilling.success, false);
+ assert.equal(engine.isDreamKillingSleeping(room.players[1].id), true);
+ assert.equal(room.gameState.currentRoundPlays[1].pattern.type, PatternTypes.INVALID);
+ assert.equal(room.gameState.currentWinnerIndex, 0);
+});
+
+test('珠联璧合仅一队达成时由该队后出者获胜并取得下轮牌权', () => {
+ const room = createRoom();
+ const engine = new GameEngine(room, createIo());
+ const trickCards = [
+ card('hearts', '3', 0),
+ card('hearts', 'A', 0),
+ card('hearts', '3', 1),
+ card('hearts', 'K', 0)
+ ];
+ room.players.forEach((player, index) => {
+ player.addCard(trickCards[index]);
+ player.addCard(card('clubs', String(index + 4)));
+ });
+ room.gameState.phase = GamePhases.PLAYING;
+ room.gameState.selectedRule = JOINT_HARMONY_RULE;
+ room.gameState.trumpSuit = 'spades';
+ room.gameState.trumpRank = '2';
+ room.gameState.buryingPlayerId = room.players[0].id;
+ engine.setFirstPlayer(room.players[0].id);
+
+ trickCards.slice(0, 3).forEach((value, index) => {
+ engine.playCards(room.players[index].id, [value.id]);
+ });
+ const result = engine.playCards(room.players[3].id, [trickCards[3].id]);
+ assert.equal(result.roundUpdate.jointHarmony.triggered, true);
+ assert.equal(result.roundUpdate.jointHarmony.bothTeams, false);
+ assert.equal(result.roundWinner.playerId, room.players[2].id);
+ assert.equal(result.roundUpdate.nextRoundLeader.playerId, room.players[2].id);
+});
+
+test('两队同时珠联璧合时不覆盖牌面正常结算结果', () => {
+ const room = createRoom();
+ const engine = new GameEngine(room, createIo());
+ const trickCards = [
+ card('hearts', '3', 0),
+ card('hearts', 'A', 0),
+ card('hearts', '3', 1),
+ card('hearts', 'A', 1)
+ ];
+ room.players.forEach((player, index) => {
+ player.addCard(trickCards[index]);
+ player.addCard(card('clubs', String(index + 4)));
+ });
+ room.gameState.phase = GamePhases.PLAYING;
+ room.gameState.selectedRule = JOINT_HARMONY_RULE;
+ room.gameState.trumpSuit = 'spades';
+ room.gameState.trumpRank = '2';
+ room.gameState.buryingPlayerId = room.players[0].id;
+ engine.setFirstPlayer(room.players[0].id);
+
+ trickCards.slice(0, 3).forEach((value, index) => {
+ engine.playCards(room.players[index].id, [value.id]);
+ });
+ const result = engine.playCards(room.players[3].id, [trickCards[3].id]);
+ assert.equal(result.roundUpdate.jointHarmony.bothTeams, true);
+ assert.equal(result.roundWinner.playerId, room.players[1].id);
+});
+
+test('神兵天降使用独立无王牌堆,发动后本轮互斥且轮末两张一起替换', () => {
+ const room = createRoom();
+ const engine = new GameEngine(room, createIo(), sequenceRandom([0.17, 0.71, 0.33, 0.89]));
+ const trickCards = [
+ card('hearts', '3', 0),
+ card('hearts', '4', 0),
+ card('hearts', '5', 0),
+ card('hearts', '6', 0)
+ ];
+ room.players.forEach((player, index) => {
+ player.addCard(trickCards[index]);
+ player.addCard(card('clubs', String(index + 7), 0));
+ });
+ room.gameState.phase = GamePhases.PLAYING;
+ room.gameState.selectedRule = DIVINE_WEAPON_RULE;
+ room.gameState.trumpSuit = 'spades';
+ room.gameState.trumpRank = '2';
+ room.gameState.buryingPlayerId = room.players[0].id;
+ engine.setFirstPlayer(room.players[0].id);
+
+ assert.equal(room.gameState.divineWeaponCards.length, 2);
+ assert.equal(room.gameState.divineWeaponReserveCards.length, 50);
+ assert.equal(
+ [...room.gameState.divineWeaponCards, ...room.gameState.divineWeaponReserveCards]
+ .some(currentCard => currentCard.suit === 'joker'),
+ false
+ );
+
+ const divineAce = card('hearts', 'A', 0);
+ divineAce.id = 'divine-hearts-A';
+ const divineTwo = card('diamonds', '2', 0);
+ divineTwo.id = 'divine-diamonds-2';
+ room.gameState.divineWeaponCards = [divineAce, divineTwo];
+ room.gameState.divineWeaponReserveCards = room.gameState.divineWeaponReserveCards.filter(
+ currentCard => currentCard.id !== divineAce.id && currentCard.id !== divineTwo.id
+ );
+
+ const firstResult = engine.playCards(
+ room.players[0].id,
+ [trickCards[0].id],
+ null,
+ ActiveSkillIds.DIVINE_WEAPON,
+ {
+ divineWeaponCardId: divineAce.id,
+ divineWeaponSourceCardId: trickCards[0].id
+ }
+ );
+ assert.equal(firstResult.playedCards[0].rank, 'A');
+ assert.equal(firstResult.playedCards[0].suit, 'hearts');
+ assert.equal(firstResult.playedCards[0].originalRank, '3');
+ assert.equal(firstResult.playedCards[0].isDivineWeaponTransformed, true);
+ assert.equal(room.gameState.divineWeaponUsedThisRound, true);
+ assert.equal(room.gameState.divineWeaponUsedCardId, divineAce.id);
+ assert.throws(() => engine.playCards(
+ room.players[1].id,
+ [trickCards[1].id],
+ null,
+ ActiveSkillIds.DIVINE_WEAPON,
+ {
+ divineWeaponCardId: divineAce.id,
+ divineWeaponSourceCardId: trickCards[1].id
+ }
+ ), /本轮已有玩家发动/);
+
+ engine.playCards(room.players[1].id, [trickCards[1].id]);
+ engine.playCards(room.players[2].id, [trickCards[2].id]);
+ const roundResult = engine.playCards(room.players[3].id, [trickCards[3].id]);
+ assert.equal(roundResult.roundWinner.playerId, room.players[0].id);
+ assert.equal(roundResult.roundUpdate.divineWeaponRefresh.generation, 2);
+ assert.deepEqual(
+ roundResult.roundUpdate.divineWeaponRefresh.previousCards.map(currentCard => currentCard.id),
+ [divineAce.id, divineTwo.id]
+ );
+ assert.equal(room.gameState.divineWeaponUsedThisRound, false);
+ assert.equal(room.gameState.divineWeaponUsedCardId, null);
+ assert.equal(room.gameState.divineWeaponCards.length, 2);
+ assert.equal(room.gameState.divineWeaponCards.some(card => card.id === divineTwo.id), false);
+ assert.equal(room.gameState.divineWeaponCards.some(card => card.id === divineAce.id), false);
+ assert.equal(
+ room.gameState.activeSkillUsesByPlayerId.get(room.players[0].id)
+ .has(ActiveSkillIds.DIVINE_WEAPON),
+ true
+ );
+});
+
+test('神兵天降出牌撤回后恢复原牌并返还技能次数与本轮名额', () => {
+ const room = createRoom();
+ const engine = new GameEngine(room, createIo(), () => 0.5);
+ const sourceCard = card('clubs', '7', 0);
+ room.players[0].addCard(sourceCard);
+ room.players[0].addCard(card('diamonds', '3', 0));
+ room.players.slice(1).forEach((player, index) => {
+ player.addCard(card('clubs', String(index + 8), 0));
+ player.addCard(card('diamonds', String(index + 4), 0));
+ });
+ room.gameState.phase = GamePhases.PLAYING;
+ room.gameState.selectedRule = DIVINE_WEAPON_RULE;
+ room.gameState.trumpSuit = 'spades';
+ room.gameState.trumpRank = '2';
+ room.gameState.buryingPlayerId = room.players[0].id;
+ engine.setFirstPlayer(room.players[0].id);
+
+ const targetCard = card('clubs', 'K', 0);
+ targetCard.id = 'divine-clubs-K';
+ room.gameState.divineWeaponCards[0] = targetCard;
+ engine.playCards(
+ room.players[0].id,
+ [sourceCard.id],
+ null,
+ ActiveSkillIds.DIVINE_WEAPON,
+ {
+ divineWeaponCardId: targetCard.id,
+ divineWeaponSourceCardId: sourceCard.id
+ }
+ );
+
+ const undoResult = engine.undoLastPlay(room.players[0].id);
+ const restoredCard = room.players[0].cards.find(currentCard => currentCard.id === sourceCard.id);
+ assert.equal(undoResult.restoredActiveSkillId, ActiveSkillIds.DIVINE_WEAPON);
+ assert.equal(restoredCard.suit, 'clubs');
+ assert.equal(restoredCard.rank, '7');
+ assert.equal(room.gameState.divineWeaponUsedThisRound, false);
+ assert.equal(room.gameState.divineWeaponUsedByPlayerId, null);
+ assert.equal(room.gameState.divineWeaponUsedCardId, null);
+ assert.equal(engine.hasUsedActiveSkill(room.players[0].id, ActiveSkillIds.DIVINE_WEAPON), false);
+});
+
+test('神兵天降本轮无人发动时保留原有两张神兵牌', () => {
+ const room = createRoom();
+ const engine = new GameEngine(room, createIo(), () => 0.5);
+ const firstRoundCards = ['3', '4', '5', '6'].map(rank => card('hearts', rank, 0));
+ room.players.forEach((player, index) => {
+ player.addCard(firstRoundCards[index]);
+ player.addCard(card('clubs', String(index + 7), 0));
+ });
+ room.gameState.phase = GamePhases.PLAYING;
+ room.gameState.selectedRule = DIVINE_WEAPON_RULE;
+ room.gameState.trumpSuit = 'spades';
+ room.gameState.trumpRank = '2';
+ room.gameState.buryingPlayerId = room.players[0].id;
+ engine.setFirstPlayer(room.players[0].id);
+
+ const originalIds = room.gameState.divineWeaponCards.map(currentCard => currentCard.id);
+ const originalGeneration = room.gameState.divineWeaponGeneration;
+ for (let index = 0; index < room.players.length; index += 1) {
+ const result = engine.playCards(room.players[index].id, [firstRoundCards[index].id]);
+ if (index === room.players.length - 1) {
+ assert.equal(result.roundUpdate.divineWeaponRefresh, null);
+ }
+ }
+
+ assert.deepEqual(room.gameState.divineWeaponCards.map(currentCard => currentCard.id), originalIds);
+ assert.equal(room.gameState.divineWeaponGeneration, originalGeneration);
+});
+
+test('神兵天降只改变牌面牌力,仍按转化前的实体牌计分', () => {
+ const room = createRoom();
+ const engine = new GameEngine(room, createIo(), () => 0.5);
+ const physicalFive = card('hearts', '5', 0);
+ const trickCards = [physicalFive, card('hearts', '3', 0), card('hearts', '4', 0), card('hearts', '6', 0)];
+ room.players.forEach((player, index) => player.addCard(trickCards[index]));
+ room.gameState.phase = GamePhases.PLAYING;
+ room.gameState.selectedRule = DIVINE_WEAPON_RULE;
+ room.gameState.trumpSuit = 'spades';
+ room.gameState.trumpRank = '2';
+ room.gameState.buryingPlayerId = room.players[0].id;
+ engine.setFirstPlayer(room.players[0].id);
+
+ const divineTen = card('hearts', '10', 0);
+ divineTen.id = 'divine-hearts-10';
+ room.gameState.divineWeaponCards[0] = divineTen;
+ const transformedResult = engine.playCards(
+ room.players[0].id,
+ [physicalFive.id],
+ null,
+ ActiveSkillIds.DIVINE_WEAPON,
+ {
+ divineWeaponCardId: divineTen.id,
+ divineWeaponSourceCardId: physicalFive.id
+ }
+ );
+ assert.equal(transformedResult.playedCards[0].rank, '10');
+ assert.equal(transformedResult.playedCards[0].originalRank, '5');
+ assert.equal(getCardPoints(transformedResult.playedCards[0]), 5);
+
+ engine.playCards(room.players[1].id, [trickCards[1].id]);
+ engine.playCards(room.players[2].id, [trickCards[2].id]);
+ const roundResult = engine.playCards(room.players[3].id, [trickCards[3].id]);
+ assert.equal(roundResult.roundUpdate.scoreInfo.roundPoints, 5);
+ assert.equal(roundResult.roundUpdate.scoreInfo.roundPointCards.length, 1);
+ assert.equal(roundResult.roundUpdate.scoreInfo.roundPointCards[0].rank, '10');
+ assert.equal(roundResult.roundUpdate.scoreInfo.roundPointCards[0].originalRank, '5');
+});
+
+test('神兵天降不能把最后一张首花色副牌变成级牌来伪造缺门毙牌', () => {
+ const room = createRoom();
+ const engine = new GameEngine(room, createIo(), () => 0.5);
+ const leadCard = card('clubs', '9', 0);
+ const lastLeadSuitCard = card('clubs', '7', 0);
+ room.players[0].addCard(leadCard);
+ room.players[1].addCard(lastLeadSuitCard);
+ room.players[2].addCard(card('clubs', '6', 0));
+ room.players[3].addCard(card('clubs', '5', 0));
+ room.gameState.phase = GamePhases.PLAYING;
+ room.gameState.selectedRule = DIVINE_WEAPON_RULE;
+ room.gameState.trumpSuit = 'spades';
+ room.gameState.trumpRank = '2';
+ room.gameState.buryingPlayerId = room.players[0].id;
+ engine.setFirstPlayer(room.players[0].id);
+
+ const offSuitLevelCard = card('clubs', '2', 0);
+ offSuitLevelCard.id = 'divine-clubs-2';
+ room.gameState.divineWeaponCards[0] = offSuitLevelCard;
+ engine.playCards(room.players[0].id, [leadCard.id]);
+
+ assert.throws(() => engine.playCards(
+ room.players[1].id,
+ [lastLeadSuitCard.id],
+ null,
+ ActiveSkillIds.DIVINE_WEAPON,
+ {
+ divineWeaponCardId: offSuitLevelCard.id,
+ divineWeaponSourceCardId: lastLeadSuitCard.id
+ }
+ ), /同花色.*必须优先出/);
+ assert.equal(room.players[1].cards.some(currentCard => currentCard.id === lastLeadSuitCard.id), true);
+ assert.equal(room.gameState.divineWeaponUsedThisRound, false);
+
+ room.players[1].removeCards([lastLeadSuitCard.id]);
+ const trueVoidSourceCard = card('diamonds', '7', 0);
+ room.players[1].addCard(trueVoidSourceCard);
+ const allowedLevelCard = card('diamonds', '2', 0);
+ allowedLevelCard.id = 'divine-diamonds-2';
+ room.gameState.divineWeaponCards[0] = allowedLevelCard;
+
+ const legalRuffResult = engine.playCards(
+ room.players[1].id,
+ [trueVoidSourceCard.id],
+ null,
+ ActiveSkillIds.DIVINE_WEAPON,
+ {
+ divineWeaponCardId: allowedLevelCard.id,
+ divineWeaponSourceCardId: trueVoidSourceCard.id
+ }
+ );
+ assert.equal(legalRuffResult.playedCards[0].suit, 'diamonds');
+ assert.equal(legalRuffResult.playedCards[0].rank, '2');
+ assert.equal(room.gameState.currentRoundPlays.at(-1).pattern.suit, 'trump');
+ assert.equal(room.gameState.divineWeaponUsedThisRound, true);
+});
+
+test('魔术戏法只在整轮结算时交换两个座位的出牌结果', () => {
+ const room = createRoom();
+ const engine = new GameEngine(room, createIo());
+ const trickCards = [
+ card('hearts', '9', 0),
+ card('hearts', 'Q', 0),
+ card('hearts', '5', 0),
+ card('hearts', '6', 0)
+ ];
+ room.players.forEach((player, index) => {
+ player.addCard(trickCards[index]);
+ player.addCard(card('clubs', String(index + 3), 0));
+ });
+ room.gameState.phase = GamePhases.PLAYING;
+ room.gameState.selectedRule = MAGIC_TRICK_RULE;
+ room.gameState.trumpSuit = 'spades';
+ room.gameState.trumpRank = '2';
+ room.gameState.buryingPlayerId = room.players[0].id;
+ engine.setFirstPlayer(room.players[0].id);
+
+ assert.throws(() => engine.prepareMagicTrick(
+ room.players[0].id,
+ [room.players[0].id, room.players[1].id]
+ ), /不能选择自己/);
+
+ const prepared = engine.prepareMagicTrick(
+ room.players[0].id,
+ [room.players[1].id, room.players[2].id]
+ );
+ assert.deepEqual(prepared.targetPlayerIds, [room.players[1].id, room.players[2].id]);
+ assert.equal(engine.hasUsedActiveSkill(room.players[0].id, ActiveSkillIds.MAGIC_TRICK), false);
+ assert.equal(room.toJSON().gameState.magicTrickSelection, undefined);
+
+ engine.playCards(room.players[0].id, [trickCards[0].id]);
+ engine.playCards(room.players[1].id, [trickCards[1].id]);
+ assert.equal(room.gameState.currentWinnerIndex, 1);
+ engine.playCards(room.players[2].id, [trickCards[2].id]);
+ const result = engine.playCards(room.players[3].id, [trickCards[3].id]);
+
+ assert.equal(result.roundUpdate.magicTrick.triggered, true);
+ assert.equal(result.roundWinner.playerId, room.players[2].id);
+ assert.equal(result.roundUpdate.nextRoundLeader.playerId, room.players[2].id);
+ assert.equal(
+ result.roundUpdate.magicTrick.plays.find(play => play.playerId === room.players[2].id).cards[0].rank,
+ 'Q'
+ );
+ assert.equal(engine.hasUsedActiveSkill(room.players[0].id, ActiveSkillIds.MAGIC_TRICK), true);
+ assert.equal(room.gameState.magicTrickSelection, null);
+});
+
+test('戛然而止只在完整轮末触发,底牌照常归属并补庄家本人余牌半分', () => {
+ const room = createRoom();
+ const engine = new GameEngine(room, createIo());
+ const trickCards = [
+ card('hearts', '3', 0),
+ card('hearts', 'A', 0),
+ card('hearts', '4', 0),
+ card('hearts', '6', 0)
+ ];
+ room.players[0].cards = [
+ trickCards[0],
+ card('clubs', 'K', 0),
+ card('clubs', '5', 0),
+ card('clubs', '7', 0),
+ card('clubs', '8', 0)
+ ];
+ room.players.slice(1).forEach((player, offset) => {
+ player.cards = [
+ trickCards[offset + 1],
+ card('diamonds', '3', offset),
+ card('diamonds', '4', offset),
+ card('diamonds', '6', offset),
+ card('diamonds', '7', offset)
+ ];
+ });
+ room.gameState.phase = GamePhases.PLAYING;
+ room.gameState.selectedRule = ABRUPT_STOP_RULE;
+ room.gameState.trumpSuit = 'spades';
+ room.gameState.trumpRank = '2';
+ room.gameState.buryingPlayerId = room.players[0].id;
+ room.gameState.bottomCards = [card('diamonds', 'K', 1)];
+ engine.setFirstPlayer(room.players[0].id);
+
+ for (let index = 0; index < 3; index += 1) {
+ const partial = engine.playCards(room.players[index].id, [trickCards[index].id]);
+ assert.equal(partial.gameFinished, false);
+ assert.equal(room.gameState.phase, GamePhases.PLAYING);
+ }
+ const result = engine.playCards(room.players[3].id, [trickCards[3].id]);
+
+ assert.equal(result.roundUpdate.abruptStop.triggered, true);
+ assert.equal(result.roundWinner.playerId, room.players[1].id);
+ assert.equal(result.gameFinished, true);
+ assert.equal(room.gameState.bottomScoreResult.attackerWonBottom, true);
+ assert.equal(room.gameState.bottomScoreResult.bottomScoreGained, 20);
+ assert.equal(room.gameState.bottomScoreResult.abruptStop.dealerPlayerId, room.players[0].id);
+ assert.equal(room.gameState.bottomScoreResult.abruptStop.dealerRemainingPoints, 15);
+ assert.deepEqual(
+ room.gameState.bottomScoreResult.abruptStop.dealerRemainingCards
+ .map(currentCard => currentCard.rank)
+ .sort(),
+ ['5', '7', '8', 'K']
+ );
+ assert.equal(room.gameState.bottomScoreResult.abruptStop.attackerBonus, 7.5);
+ assert.equal(room.gameState.bottomScoreResult.totalScore, 27.5);
+});
+
+test('聚类分析可把一张数字牌临时转成相邻点数并组成对子', () => {
+ const room = createRoom();
+ const engine = new GameEngine(room, createIo());
+ const seven = card('hearts', '7', 0);
+ const eight = card('hearts', '8', 0);
+ room.players[0].cards = [seven, eight, card('clubs', '3', 0)];
+ room.players.slice(1).forEach((player, index) => {
+ player.cards = [card('hearts', String(index + 3), 0), card('clubs', String(index + 4), 0)];
+ });
+ room.gameState.phase = GamePhases.PLAYING;
+ room.gameState.selectedRule = CLUSTER_ANALYSIS_RULE;
+ room.gameState.trumpSuit = 'spades';
+ room.gameState.trumpRank = '2';
+ room.gameState.buryingPlayerId = room.players[0].id;
+ engine.setFirstPlayer(room.players[0].id);
+
+ const preview = resolveClusterAnalysisPlay({
+ selectedCards: [seven, eight],
+ handCards: room.players[0].cards,
+ substitutions: [{
+ cardId: seven.id,
+ suit: 'hearts',
+ fromRank: '7',
+ toRank: '8'
+ }],
+ trumpSuit: 'spades',
+ trumpRank: '2',
+ activeRule: CLUSTER_ANALYSIS_RULE
+ });
+ assert.equal(preview.valid, true);
+ assert.equal(preview.pattern.type, PatternTypes.PAIR);
+
+ const invalidJump = resolveClusterAnalysisPlay({
+ selectedCards: [seven, eight],
+ handCards: room.players[0].cards,
+ substitutions: [{ cardId: seven.id, fromRank: '7', toRank: '9' }],
+ trumpSuit: 'spades',
+ trumpRank: '2',
+ activeRule: CLUSTER_ANALYSIS_RULE
+ });
+ assert.equal(invalidJump.valid, false);
+ assert.match(invalidJump.message, /不能转换/);
+
+ const result = engine.playCards(
+ room.players[0].id,
+ [seven.id, eight.id],
+ null,
+ ActiveSkillIds.CLUSTER_ANALYSIS,
+ {
+ clusterAnalysisSubstitutions: [{
+ cardId: seven.id,
+ suit: 'hearts',
+ fromRank: '7',
+ toRank: '8'
+ }]
+ }
+ );
+ assert.deepEqual(result.playedCards.map(currentCard => currentCard.rank), ['8', '8']);
+ assert.equal(result.playedCards.some(currentCard => currentCard.isClusterAnalysisTransformed), true);
+ assert.equal(room.gameState.leadingPattern.type, PatternTypes.PAIR);
+ assert.equal(engine.hasUsedActiveSkill(room.players[0].id, ActiveSkillIds.CLUSTER_ANALYSIS), false);
+});
+
+test('聚类分析支持把多张 J 分别转成 Q,且不受已出牌面限制', () => {
+ const firstJack = card('spades', 'J', 0);
+ const secondJack = card('spades', 'J', 1);
+ const result = resolveClusterAnalysisPlay({
+ selectedCards: [firstJack, secondJack],
+ handCards: [firstJack, secondJack],
+ substitutions: [
+ { cardId: firstJack.id, suit: 'spades', fromRank: 'J', toRank: 'Q' },
+ { cardId: secondJack.id, suit: 'spades', fromRank: 'J', toRank: 'Q' }
+ ],
+ trumpSuit: 'hearts',
+ trumpRank: '2',
+ activeRule: CLUSTER_ANALYSIS_RULE
+ });
+
+ assert.equal(result.valid, true);
+ assert.equal(result.pattern.type, PatternTypes.PAIR);
+ assert.deepEqual(result.effectiveCards.map(currentCard => currentCard.rank), ['Q', 'Q']);
+ assert.equal(result.substitutions.length, 2);
+});
+
+test('聚类分析能以多张转化组成更大组件时会令对手甩牌失败', () => {
+ const room = createRoom();
+ const engine = new GameEngine(room, createIo());
+ const leadingThrow = [
+ card('hearts', '9', 0),
+ card('hearts', '9', 1),
+ card('hearts', 'A', 0)
+ ];
+ room.players[0].cards = [...leadingThrow, card('clubs', '3', 0)];
+ room.players[1].cards = [card('hearts', 'J', 0), card('hearts', 'J', 1), card('clubs', '4', 0)];
+ room.players[2].cards = [card('hearts', '3', 0), card('hearts', '4', 0), card('clubs', '5', 0)];
+ room.players[3].cards = [card('hearts', '5', 0), card('hearts', '6', 0), card('clubs', '6', 0)];
+ room.gameState.phase = GamePhases.PLAYING;
+ room.gameState.selectedRule = CLUSTER_ANALYSIS_RULE;
+ room.gameState.trumpSuit = 'spades';
+ room.gameState.trumpRank = '2';
+ room.gameState.buryingPlayerId = room.players[0].id;
+ engine.setFirstPlayer(room.players[0].id);
+
+ const result = engine.playCards(room.players[0].id, leadingThrow.map(currentCard => currentCard.id));
+ assert.ok(result.throwFailed);
+ assert.equal(result.playedCards.length, 2);
+ assert.deepEqual(result.playedCards.map(currentCard => currentCard.rank), ['9', '9']);
+});
+
+test('禁术秘法要求每张原主牌显式转为副花色,非王保持原点数', () => {
+ assert.equal(FORBIDDEN_MAGIC_RULE.name, '禁术秘法');
+ assert.equal(FORBIDDEN_MAGIC_RULE.activeSkill.timing, 'anytime_prepare_round_start_confirm');
+ assert.equal(FORBIDDEN_MAGIC_RULE.activeSkill.usageLimit, 1);
+
+ const mainAce = card('hearts', 'A', 300);
+ const offSuitLevel = card('clubs', '6', 301);
+ const bigJoker = card('joker', 'big_joker', 302);
+ const hand = [mainAce, offSuitLevel, bigJoker];
+
+ const demotedAce = resolveForbiddenMagicPlay({
+ selectedCards: [mainAce],
+ handCards: hand,
+ trumpSuit: 'hearts',
+ trumpRank: '6',
+ activeRule: FORBIDDEN_MAGIC_RULE
+ });
+ assert.equal(demotedAce.valid, false);
+ assert.match(demotedAce.message, /主牌不能直接打出/);
+
+ const transformedAce = resolveForbiddenMagicPlay({
+ selectedCards: [mainAce],
+ handCards: hand,
+ substitutions: [{ cardId: mainAce.id, suit: 'clubs', rank: 'A' }],
+ trumpSuit: 'hearts',
+ trumpRank: '6',
+ activeRule: FORBIDDEN_MAGIC_RULE
+ });
+ assert.equal(transformedAce.valid, true);
+ assert.equal(transformedAce.pattern.suit, 'clubs');
+ assert.equal(isTrumpCard(transformedAce.effectiveCards[0], 'hearts', '6'), false);
+ assert.equal(getCardStrength(transformedAce.effectiveCards[0], 'hearts', '6'), 14);
+
+ const demotedLevel = resolveForbiddenMagicPlay({
+ selectedCards: [offSuitLevel],
+ handCards: hand,
+ trumpSuit: 'hearts',
+ trumpRank: '6',
+ activeRule: FORBIDDEN_MAGIC_RULE
+ });
+ assert.equal(demotedLevel.valid, false);
+ assert.match(demotedLevel.message, /主牌不能直接打出/);
+
+ const transformedLevel = resolveForbiddenMagicPlay({
+ selectedCards: [offSuitLevel],
+ handCards: hand,
+ substitutions: [{ cardId: offSuitLevel.id, suit: 'clubs', rank: '6' }],
+ trumpSuit: 'hearts',
+ trumpRank: '6',
+ activeRule: FORBIDDEN_MAGIC_RULE
+ });
+ assert.equal(transformedLevel.valid, true);
+ assert.equal(transformedLevel.pattern.suit, 'clubs');
+ assert.equal(getCardStrength(transformedLevel.effectiveCards[0], 'hearts', '6'), 6);
+
+ const forbiddenMainSuit = resolveForbiddenMagicPlay({
+ selectedCards: [mainAce],
+ handCards: hand,
+ substitutions: [{ cardId: mainAce.id, suit: 'hearts', rank: 'A' }],
+ trumpSuit: 'hearts',
+ trumpRank: '6',
+ activeRule: FORBIDDEN_MAGIC_RULE
+ });
+ assert.equal(forbiddenMainSuit.valid, false);
+ assert.match(forbiddenMainSuit.message, /不能选择当前主花色/);
+
+ const forbiddenJokerSuit = resolveForbiddenMagicPlay({
+ selectedCards: [bigJoker],
+ handCards: hand,
+ substitutions: [{ cardId: bigJoker.id, suit: 'hearts', rank: 'Q' }],
+ trumpSuit: 'hearts',
+ trumpRank: '6',
+ activeRule: FORBIDDEN_MAGIC_RULE
+ });
+ assert.equal(forbiddenJokerSuit.valid, false);
+ assert.match(forbiddenJokerSuit.message, /不能选择当前主花色/);
+
+ const legalJoker = resolveForbiddenMagicPlay({
+ selectedCards: [bigJoker],
+ handCards: hand,
+ substitutions: [{ cardId: bigJoker.id, suit: 'spades', rank: 'Q' }],
+ trumpSuit: 'hearts',
+ trumpRank: '6',
+ activeRule: FORBIDDEN_MAGIC_RULE
+ });
+ assert.equal(legalJoker.valid, true);
+ assert.equal(legalJoker.effectiveCards[0].rank, 'Q');
+ assert.equal(legalJoker.effectiveCards[0].suit, 'spades');
+ assert.equal(getCardPoints(legalJoker.effectiveCards[0]), 0, '王变成分牌点数也仍按原牌0分');
+});
+
+test('禁术秘法允许多人随时预备、轮首逐个确认,确认后永久生效且拒绝不消耗', () => {
+ const room = createRoom();
+ const engine = new GameEngine(room, createIo());
+ const firstCards = [
+ card('clubs', '3', 310),
+ card('hearts', '5', 311),
+ card('clubs', '4', 312),
+ card('clubs', '6', 313)
+ ];
+ room.players.forEach((player, index) => {
+ player.addCard(firstCards[index]);
+ player.addCard(card('diamonds', String(index + 7), 320 + index));
+ });
+ room.gameState.phase = GamePhases.PLAYING;
+ room.gameState.selectedRule = FORBIDDEN_MAGIC_RULE;
+ room.gameState.trumpSuit = 'hearts';
+ room.gameState.trumpRank = '2';
+ room.gameState.buryingPlayerId = room.players[0].id;
+ engine.setFirstPlayer(room.players[0].id);
+
+ const secondSeat = engine.activateForbiddenMagic(room.players[1].id);
+ const thirdSeat = engine.activateForbiddenMagic(room.players[2].id);
+ assert.equal(secondSeat.targetRound, 1);
+ assert.equal(thirdSeat.targetRound, 1);
+ assert.equal(room.gameState.forbiddenMagicCurrentDecisionPlayerId, room.players[1].id);
+ assert.deepEqual(room.gameState.forbiddenMagicDecisionQueue, [room.players[2].id]);
+ assert.equal(room.gameState.forbiddenMagicActivePlayerIds.size, 0);
+ assert.deepEqual(
+ new Set(room.gameState.toJSON().forbiddenMagic.reservations.map(item => item.playerId)),
+ new Set([room.players[1].id, room.players[2].id])
+ );
+ assert.throws(
+ () => engine.playCards(room.players[0].id, [firstCards[0].id]),
+ /确认是否发动禁术秘法/
+ );
+
+ const secondAccepted = engine.respondForbiddenMagic(room.players[1].id, true);
+ assert.equal(secondAccepted.resolved, false);
+ assert.equal(room.gameState.forbiddenMagicCurrentDecisionPlayerId, room.players[2].id);
+ const thirdAccepted = engine.respondForbiddenMagic(room.players[2].id, true);
+ assert.equal(thirdAccepted.resolved, true);
+ assert.deepEqual(
+ new Set(room.gameState.forbiddenMagicActivePlayerIds),
+ new Set([room.players[1].id, room.players[2].id])
+ );
+
+ engine.playCards(room.players[0].id, [firstCards[0].id]);
+ const fourthSeat = engine.activateForbiddenMagic(room.players[3].id);
+ assert.equal(fourthSeat.targetRound, 2, '轮中预备应留到下一轮确认');
+ const transformed = engine.playCards(
+ room.players[1].id,
+ [firstCards[1].id],
+ null,
+ null,
+ {
+ forbiddenMagicSubstitutions: [{
+ cardId: firstCards[1].id,
+ suit: 'clubs',
+ rank: '5'
+ }]
+ }
+ );
+ assert.equal(transformed.playedCards[0].suit, 'clubs');
+ assert.equal(transformed.playedCards[0].isForbiddenMagicDemoted, true);
+ assert.equal(getCardPoints(transformed.playedCards[0]), 5);
+ engine.playCards(room.players[2].id, [firstCards[2].id]);
+ engine.playCards(room.players[3].id, [firstCards[3].id]);
+
+ assert.equal(room.gameState.currentRound, 2);
+ assert.deepEqual(
+ new Set(room.gameState.forbiddenMagicActivePlayerIds),
+ new Set([room.players[1].id, room.players[2].id]),
+ '已发动者在轮末后仍应永久生效'
+ );
+ assert.equal(room.gameState.forbiddenMagicCurrentDecisionPlayerId, room.players[3].id);
+ assert.throws(
+ () => engine.activateForbiddenMagic(room.players[1].id),
+ /只能发动一次/
+ );
+ const declined = engine.respondForbiddenMagic(room.players[3].id, false);
+ assert.equal(declined.resolved, true);
+ assert.equal(room.gameState.activeSkillUsesByPlayerId.has(room.players[3].id), false);
+ assert.doesNotThrow(() => engine.activateForbiddenMagic(room.players[3].id));
+});
+
+test('禁术秘法发动者的潜在转化牌也能阻止其他玩家甩牌', () => {
+ const room = createRoom();
+ const engine = new GameEngine(room, createIo());
+ const leadingThrow = [
+ card('clubs', '9', 340),
+ card('clubs', 'K', 341)
+ ];
+ const mainSuitAce = card('hearts', 'A', 342);
+ room.players[0].cards = [...leadingThrow, card('diamonds', '3', 343)];
+ room.players[1].cards = [mainSuitAce, card('diamonds', '4', 344)];
+ room.players[2].cards = [card('clubs', '3', 345), card('diamonds', '5', 346)];
+ room.players[3].cards = [card('clubs', '4', 347), card('diamonds', '6', 348)];
+ room.gameState.phase = GamePhases.PLAYING;
+ room.gameState.selectedRule = FORBIDDEN_MAGIC_RULE;
+ room.gameState.trumpSuit = 'hearts';
+ room.gameState.trumpRank = '2';
+ room.gameState.buryingPlayerId = room.players[0].id;
+ engine.setFirstPlayer(room.players[0].id);
+
+ engine.activateForbiddenMagic(room.players[1].id);
+ engine.respondForbiddenMagic(room.players[1].id, true);
+ const result = engine.playCards(
+ room.players[0].id,
+ leadingThrow.map(currentCard => currentCard.id)
+ );
+
+ assert.ok(result.throwFailed);
+ assert.equal(result.playedCards.length, 1);
+ assert.equal(result.playedCards[0].rank, '9');
+});
+
+test('取长补短注册特殊牌面,并为未来叠加变动保留完整边界', () => {
+ assert.equal(STRENGTH_COMPENSATION_RULE.name, '取长补短');
+ const shift = (suit, rank, trumpSuit, trumpRank, delta) => (
+ shiftStrengthCompensationCardFace({ suit, rank }, trumpSuit, trumpRank, delta)
+ );
+ assert.deepEqual(shift('hearts', 'A', 'spades', '2', 1), { suit: 'hearts', rank: 'B' });
+ assert.deepEqual(shift('hearts', 'A', 'spades', '2', 2), { suit: 'hearts', rank: 'C' });
+ assert.deepEqual(shift('hearts', 'A', 'spades', '2', 3), { suit: 'hearts', rank: 'D' });
+ assert.deepEqual(shift('hearts', '3', 'spades', '2', -1), { suit: 'hearts', rank: '1' });
+ assert.deepEqual(shift('hearts', '1', 'spades', '2', -1), { suit: 'hearts', rank: '0' });
+ assert.deepEqual(shift('hearts', '0', 'spades', '2', -1), { suit: 'hearts', rank: '-1' });
+ assert.deepEqual(shift('hearts', '-1', 'spades', '2', -1), { suit: 'hearts', rank: '-2' });
+ assert.deepEqual(shift('joker', 'big_joker', 'spades', '2', 1), {
+ suit: 'joker', rank: 'county_prince_joker'
+ });
+ assert.deepEqual(shift('joker', 'big_joker', 'spades', '2', 2), {
+ suit: 'joker', rank: 'prince_joker'
+ });
+ assert.deepEqual(shift('joker', 'big_joker', 'spades', '2', 3), {
+ suit: 'joker', rank: 'white_joker'
+ });
+ assert.deepEqual(
+ ['A', 'B', 'C', 'D'].map(rank => (
+ getCardStrength(card('hearts', rank), 'spades', '2', STRENGTH_COMPENSATION_RULE)
+ )),
+ [14, 15, 16, 17]
+ );
+ assert.deepEqual(
+ ['big_joker', 'county_prince_joker', 'prince_joker', 'white_joker'].map(rank => (
+ getCardStrength(card('joker', rank), 'spades', '2', STRENGTH_COMPENSATION_RULE)
+ )),
+ [1000, 1001, 1002, 1003]
+ );
+});
+
+test('取长补短沿主牌完整序列平移,不把主级牌按普通点数改写', () => {
+ const plusCases = [
+ [card('spades', 'A'), { suit: 'hearts', rank: '2' }],
+ [card('hearts', '2'), { suit: 'spades', rank: '2' }],
+ [card('spades', '2'), { suit: 'joker', rank: 'small_joker' }],
+ [card('joker', 'small_joker'), { suit: 'joker', rank: 'big_joker' }],
+ [card('joker', 'big_joker'), { suit: 'joker', rank: 'county_prince_joker' }]
+ ];
+ const minusCases = [
+ [card('joker', 'big_joker'), { suit: 'joker', rank: 'small_joker' }],
+ [card('joker', 'small_joker'), { suit: 'spades', rank: '2' }],
+ [card('spades', '2'), { suit: 'hearts', rank: '2' }],
+ [card('hearts', '2'), { suit: 'spades', rank: 'A' }],
+ [card('spades', 'A'), { suit: 'spades', rank: 'K' }]
+ ];
+
+ plusCases.forEach(([source, expected]) => {
+ assert.deepEqual(
+ shiftStrengthCompensationCardFace(source, 'spades', '2', 1),
+ expected
+ );
+ });
+ minusCases.forEach(([source, expected]) => {
+ assert.deepEqual(
+ shiftStrengthCompensationCardFace(source, 'spades', '2', -1),
+ expected
+ );
+ });
+
+ const originalMainChain = [
+ card('spades', 'A'),
+ card('hearts', '2'),
+ card('spades', '2'),
+ card('joker', 'small_joker'),
+ card('joker', 'big_joker')
+ ];
+ const shiftedMainChain = originalMainChain.map(source => ({
+ ...shiftStrengthCompensationCardFace(source, 'spades', '2', 1),
+ isStrengthCompensated: true
+ }));
+ const shiftedStrengths = shiftedMainChain.map(source => (
+ getCardStrength(source, 'spades', '2', STRENGTH_COMPENSATION_RULE)
+ ));
+ assert.deepEqual(shiftedStrengths, [997, 998, 999, 1000, 1001]);
+});
+
+test('取长补短的无主M牌仍是主牌,副牌再大也不能跨入主牌链', () => {
+ const underLevel = shiftStrengthCompensationCardFace(
+ card('hearts', '2'),
+ 'no_trump',
+ '2',
+ -1
+ );
+ assert.deepEqual(underLevel, { suit: 'hearts', rank: Ranks.NO_TRUMP_MINUS });
+ assert.deepEqual(
+ shiftStrengthCompensationCardFace(underLevel, 'no_trump', '2', 1),
+ { suit: 'hearts', rank: '2' }
+ );
+ assert.deepEqual(
+ shiftStrengthCompensationCardFace(card('hearts', 'A'), 'no_trump', '2', 10000),
+ { suit: 'hearts', rank: 'D' }
+ );
+
+ const compensatedUnderLevel = { ...underLevel, isStrengthCompensated: true };
+ const noTrumpLevel = card('spades', '2');
+ const maximumSideCard = card('clubs', 'D');
+ assert.equal(isTrumpCard(compensatedUnderLevel, 'no_trump', '2'), true);
+ assert.ok(
+ getCardStrength(compensatedUnderLevel, 'no_trump', '2', STRENGTH_COMPENSATION_RULE)
+ > getCardStrength(maximumSideCard, 'no_trump', '2', STRENGTH_COMPENSATION_RULE)
+ );
+ assert.ok(
+ getCardStrength(noTrumpLevel, 'no_trump', '2', STRENGTH_COMPENSATION_RULE)
+ > getCardStrength(compensatedUnderLevel, 'no_trump', '2', STRENGTH_COMPENSATION_RULE)
+ );
+});
+
+test('取长补短开局实际把主A、副级、主级和王按整条主牌链改面', () => {
+ const room = createRoom();
+ const engine = new GameEngine(room, createIo());
+ room.gameState.phase = GamePhases.PLAYING;
+ room.gameState.selectedRule = STRENGTH_COMPENSATION_RULE;
+ room.gameState.trumpSuit = 'spades';
+ room.gameState.trumpRank = '2';
+ room.gameState.buryingPlayerId = room.players[0].id;
+ room.players[0].cards = [card('clubs', '5', 790)];
+ room.players[1].cards = [
+ card('spades', 'A', 791),
+ card('hearts', '2', 792),
+ card('spades', '2', 793),
+ card('joker', 'small_joker', 794),
+ card('joker', 'big_joker', 795)
+ ];
+ room.players[2].cards = [card('diamonds', '6', 796)];
+ room.players[3].cards = [card('clubs', '7', 797)];
+
+ engine.setFirstPlayer(room.players[0].id);
+
+ assert.deepEqual(
+ room.players[1].cards.map(value => [value.suit, value.rank]),
+ [
+ ['hearts', '2'],
+ ['spades', '2'],
+ ['joker', 'small_joker'],
+ ['joker', 'big_joker'],
+ ['joker', 'county_prince_joker']
+ ]
+ );
+});
+
+test('取长补短以庄家为0逆时针编号,B和郡王参与真实牌力但沿用实体分值', () => {
+ const room = createRoom();
+ const io = createIo();
+ const engine = new GameEngine(room, io);
+ room.gameState.phase = GamePhases.PLAYING;
+ room.gameState.selectedRule = STRENGTH_COMPENSATION_RULE;
+ room.gameState.trumpSuit = 'spades';
+ room.gameState.trumpRank = '9';
+ room.gameState.buryingPlayerId = room.players[2].id;
+ room.players[0].cards = [card('diamonds', '7', 800)];
+ room.players[1].cards = [card('clubs', '2', 801), card('joker', 'big_joker', 802)];
+ room.players[2].cards = [card('spades', '8', 803)];
+ room.players[3].cards = [
+ card('hearts', 'A', 804),
+ card('joker', 'big_joker', 805),
+ card('hearts', '5', 806)
+ ];
+
+ engine.setFirstPlayer(room.players[2].id);
+
+ assert.deepEqual(
+ room.players[3].cards.map(value => value.rank),
+ ['B', 'county_prince_joker', '6']
+ );
+ assert.deepEqual(room.players[1].cards.map(value => value.rank), ['1', 'small_joker']);
+ assert.ok(room.players[3].cards.every(value => value.isStrengthCompensated));
+ assert.equal(room.players[3].cards[0].originalRank, 'A');
+ assert.equal(room.players[1].cards[0].originalRank, '2');
+ assert.ok(
+ getCardStrength(room.players[3].cards[0], 'spades', '9', STRENGTH_COMPENSATION_RULE) >
+ getCardStrength(card('hearts', 'A', 807), 'spades', '9', STRENGTH_COMPENSATION_RULE)
+ );
+ assert.ok(
+ getCardStrength(room.players[3].cards[1], 'spades', '9', STRENGTH_COMPENSATION_RULE) >
+ getCardStrength(card('joker', 'big_joker', 808), 'spades', '9', STRENGTH_COMPENSATION_RULE)
+ );
+ assert.equal(getCardPoints(room.players[3].cards[2]), 5);
+
+ const status = room.gameState.toJSON().strengthCompensation;
+ assert.equal(status.round, 1);
+ assert.equal(status.dealerPlayerId, room.players[2].id);
+ assert.equal(status.plusSeatNumber, 1);
+ assert.equal(status.plusPlayerId, room.players[3].id);
+ assert.equal(status.minusSeatNumber, 3);
+ assert.equal(status.minusPlayerId, room.players[1].id);
+ assert.equal(
+ io.events.filter(event => event.event === 'strength_compensation_hand_updated').length,
+ 4
+ );
+});
+
+test('取长补短在完整一轮结算后恢复旧牌面并轮换到下一组座位', () => {
+ const room = createRoom();
+ const io = createIo();
+ const engine = new GameEngine(room, io);
+ room.gameState.phase = GamePhases.PLAYING;
+ room.gameState.selectedRule = STRENGTH_COMPENSATION_RULE;
+ room.gameState.trumpSuit = 'spades';
+ room.gameState.trumpRank = '9';
+ room.gameState.buryingPlayerId = room.players[0].id;
+ room.players[0].cards = [card('hearts', '5', 810), card('clubs', '5', 811)];
+ room.players[1].cards = [card('hearts', 'A', 812), card('clubs', 'A', 813)];
+ room.players[2].cards = [card('hearts', '4', 814), card('clubs', '4', 815)];
+ room.players[3].cards = [card('hearts', '2', 816), card('clubs', '2', 817)];
+ engine.setFirstPlayer(room.players[0].id);
+
+ engine.playCards(room.players[0].id, ['hearts-5-810']);
+ engine.playCards(room.players[1].id, ['hearts-A-812']);
+ engine.playCards(room.players[2].id, ['hearts-4-814']);
+ const result = engine.playCards(room.players[3].id, ['hearts-2-816']);
+
+ assert.equal(result.roundUpdate.type, 'round_ended');
+ assert.equal(result.roundWinner.playerId, room.players[1].id);
+ assert.equal(room.gameState.currentRound, 2);
+ assert.equal(room.players[0].cards[0].rank, '4');
+ assert.equal(room.players[0].cards[0].originalRank, '5');
+ assert.equal(room.players[1].cards[0].rank, 'A');
+ assert.equal(room.players[1].cards[0].isStrengthCompensated, false);
+ assert.equal(room.players[2].cards[0].rank, '5');
+ assert.equal(room.players[2].cards[0].originalRank, '4');
+ assert.equal(room.players[3].cards[0].rank, '2');
+ assert.equal(room.players[3].cards[0].isStrengthCompensated, false);
+ assert.equal(result.roundUpdate.strengthCompensation.plusSeatNumber, 2);
+ assert.equal(result.roundUpdate.strengthCompensation.minusSeatNumber, 0);
+});
+
+test('以守为攻按力争上游全序给上一轮首家临时加牌面,并区分A与大王以上三级', () => {
+ const room = createRoom();
+ const io = createIo();
+ const engine = new GameEngine(room, io);
+ room.gameState.phase = GamePhases.PLAYING;
+ room.gameState.selectedRule = DEFENSE_AS_OFFENSE_RULE;
+ room.gameState.trumpSuit = 'spades';
+ room.gameState.trumpRank = '2';
+ room.gameState.buryingPlayerId = room.players[0].id;
+ room.gameState.dealerPlayerIndex = 0;
+
+ const firstRoundCards = [
+ card('hearts', '3', 1810),
+ card('hearts', '4', 1811),
+ card('hearts', '5', 1812),
+ card('hearts', '6', 1813)
+ ];
+ const secondRoundCards = [
+ card('hearts', '5', 1820),
+ card('hearts', '6', 1821),
+ card('hearts', '5', 1822),
+ card('hearts', '7', 1823)
+ ];
+ const cappedBigJoker = card('joker', 'big_joker', 1830);
+ const promotedAce = card('hearts', 'A', 1831);
+ const cappedWhiteJoker = card('joker', 'white_joker', 1832);
+ const nextTargetBigJoker = card('joker', 'big_joker', 1833);
+ const cappedD = card('hearts', 'D', 1834);
+
+ room.players[0].cards = [
+ firstRoundCards[0],
+ secondRoundCards[0],
+ cappedBigJoker,
+ promotedAce,
+ cappedWhiteJoker,
+ cappedD
+ ];
+ room.players[1].cards = [
+ firstRoundCards[1],
+ secondRoundCards[1],
+ card('clubs', '3', 1841),
+ card('diamonds', '4', 1842),
+ card('spades', '5', 1843)
+ ];
+ room.players[2].cards = [
+ firstRoundCards[2],
+ secondRoundCards[2],
+ card('clubs', '4', 1851),
+ card('diamonds', '5', 1852),
+ card('spades', '6', 1853)
+ ];
+ room.players[3].cards = [
+ firstRoundCards[3],
+ secondRoundCards[3],
+ nextTargetBigJoker,
+ card('clubs', '5', 1861),
+ card('diamonds', '6', 1862)
+ ];
+ engine.setFirstPlayer(room.players[0].id);
+
+ engine.playCards(room.players[0].id, [firstRoundCards[0].id]);
+ engine.playCards(room.players[1].id, [firstRoundCards[1].id]);
+ engine.playCards(room.players[2].id, [firstRoundCards[2].id]);
+ const firstRoundResult = engine.playCards(room.players[3].id, [firstRoundCards[3].id]);
+
+ assert.deepEqual(firstRoundResult.roundUpdate.defenseAsOffense, {
+ triggerRound: 1,
+ round: 2,
+ playerIndex: 0,
+ playerId: room.players[0].id,
+ playerName: room.players[0].name,
+ delta: 3,
+ active: true
+ });
+ assert.equal(room.gameState.toJSON().defenseAsOffense.delta, 3);
+ assert.equal(secondRoundCards[0].rank, '8');
+ assert.equal(secondRoundCards[0].originalRank, '5');
+ assert.equal(secondRoundCards[0].isDefenseAsOffenseBoosted, true);
+ assert.equal(getCardPoints(secondRoundCards[0]), 5);
+ assert.equal(cappedBigJoker.rank, 'white_joker');
+ assert.equal(promotedAce.rank, 'D');
+ assert.equal(cappedD.rank, 'D');
+ assert.equal(cappedWhiteJoker.rank, 'white_joker');
+
+ engine.emitDefenseAsOffenseHands(firstRoundResult.roundUpdate.defenseAsOffense);
+ const privateUpdates = io.events.filter(
+ event => event.event === 'defense_as_offense_hand_updated'
+ );
+ assert.equal(privateUpdates.length, 4);
+ assert.equal(privateUpdates[0].payload.delta, 3);
+
+ engine.playCards(room.players[3].id, [secondRoundCards[3].id]);
+ const boostedPlay = engine.playCards(room.players[0].id, [secondRoundCards[0].id]);
+ assert.equal(boostedPlay.currentWinningPlayerId, room.players[0].id);
+ assert.equal(boostedPlay.playedCards[0].rank, '8');
+ engine.undoLastPlay(room.players[0].id);
+ const restoredBoostedCard = room.players[0].cards.find(
+ value => value.id === secondRoundCards[0].id
+ );
+ assert.equal(restoredBoostedCard.rank, '8');
+ assert.equal(restoredBoostedCard.originalRank, '5');
+ assert.equal(restoredBoostedCard.isDefenseAsOffenseBoosted, true);
+ engine.playCards(room.players[0].id, [secondRoundCards[0].id]);
+ engine.playCards(room.players[1].id, [secondRoundCards[1].id]);
+ const secondRoundResult = engine.playCards(room.players[2].id, [secondRoundCards[2].id]);
+
+ assert.equal(secondRoundResult.roundWinner.playerId, room.players[0].id);
+ assert.equal(cappedBigJoker.rank, 'big_joker');
+ assert.equal(cappedBigJoker.isDefenseAsOffenseBoosted, false);
+ assert.equal(promotedAce.rank, 'A');
+ assert.equal(cappedD.rank, 'D');
+ assert.equal(cappedWhiteJoker.rank, 'white_joker');
+ assert.equal(room.gameState.defenseAsOffense.playerId, room.players[3].id);
+ assert.equal(room.gameState.defenseAsOffense.delta, 1);
+ assert.equal(room.gameState.defenseAsOffenseLastRound.playerId, room.players[0].id);
+ assert.equal(room.gameState.defenseAsOffenseLastRound.round, 2);
+ assert.equal(room.gameState.defenseAsOffenseLastRound.delta, 3);
+ assert.equal(nextTargetBigJoker.rank, 'county_prince_joker');
+});
+
+test('以守为攻只统计严格大于一号位的玩家,同牌力不会增加X', () => {
+ const room = createRoom();
+ const engine = new GameEngine(room, createIo());
+ room.gameState.selectedRule = DEFENSE_AS_OFFENSE_RULE;
+ room.gameState.trumpSuit = 'spades';
+ room.gameState.trumpRank = '2';
+ const playedCards = [
+ card('hearts', '5', 1870),
+ card('hearts', '5', 1871),
+ card('hearts', '6', 1872),
+ card('hearts', '4', 1873)
+ ];
+ room.gameState.currentRoundPlays = playedCards.map((playedCard, playerIndex) => ({
+ playerIndex,
+ playerId: room.players[playerIndex].id,
+ cards: [playedCard],
+ pattern: detectPattern(
+ [playedCard],
+ room.gameState.trumpSuit,
+ room.gameState.trumpRank,
+ room.gameState.selectedRule
+ )
+ }));
+
+ const status = engine.getDefenseAsOffenseStatusForNextRound(1);
+ assert.equal(status.playerId, room.players[0].id);
+ assert.equal(status.delta, 1);
+});
+
+test('以守为攻在无主局把级牌提升两级为大王', () => {
+ const room = createRoom();
+ const engine = new GameEngine(room, createIo());
+ room.gameState.selectedRule = DEFENSE_AS_OFFENSE_RULE;
+ room.gameState.trumpSuit = 'no_trump';
+ room.gameState.trumpRank = '2';
+ room.gameState.currentRound = 2;
+ const levelCard = card('hearts', '2', 1880);
+ room.players[1].cards = [levelCard];
+
+ const transition = engine.applyDefenseAsOffenseForRound({
+ triggerRound: 1,
+ round: 2,
+ playerIndex: 1,
+ playerId: room.players[1].id,
+ playerName: room.players[1].name,
+ delta: 2
+ }, { emit: false });
+
+ assert.equal(transition.delta, 2);
+ assert.equal(levelCard.originalRank, '2');
+ assert.equal(levelCard.rank, 'big_joker');
+});
+
+test('手无寸铁移除四张王,并让四家各持24张牌', () => {
+ assert.equal(UNARMED_RULE.name, '手无寸铁');
+ const preparedDeck = DeckService.prepareUnarmedDeck(DeckService.createDeck());
+ assert.equal(preparedDeck.length, 104);
+ assert.ok(preparedDeck.every(value => value.suit !== 'joker'));
+ assert.ok(preparedDeck.every(value => value.isUnarmed));
+ assert.ok(preparedDeck.every(value => value.toJSON().isUnarmed));
+
+ const room = createRoom();
+ const io = createIo();
+ const manager = new DrawingPhaseManager(room, io);
+ const originalShuffle = DeckService.shuffle;
+ room.gameState.selectedRule = UNARMED_RULE;
+ room.gameState.bottomCardsCount = 8;
+ try {
+ DeckService.shuffle = deck => [...deck];
+ manager.start();
+ manager.stop();
+ } finally {
+ DeckService.shuffle = originalShuffle;
+ }
+
+ assert.equal(room.gameState.bottomCards.length, 8);
+ assert.equal(room.gameState.deck.length, 96);
+ while (room.gameState.drawingIndex < room.gameState.deck.length) {
+ manager.dealOneCard();
+ }
+ assert.deepEqual(room.players.map(player => player.cards.length), [24, 24, 24, 24]);
+ const drawingStarted = io.events.find(({ event }) => event === 'drawing_started');
+ assert.equal(drawingStarted.payload.removedCardsCount, 4);
+});
+
+test('手无寸铁的级牌只负责亮主,出牌时回到原花色和点数', () => {
+ const makeUnarmed = (suit, rank, copyIndex = 0) => {
+ const value = card(suit, rank, copyIndex);
+ value.isUnarmed = true;
+ return value;
+ };
+ const heartLevel = makeUnarmed('hearts', '2', 900);
+ const clubLevel = makeUnarmed('clubs', '2', 901);
+ const clubThree = makeUnarmed('clubs', '3', 902);
+ const sideAce = makeUnarmed('spades', 'A', 903);
+
+ assert.equal(validateDeclaration([heartLevel], 'hearts', 1, '2').valid, true);
+ assert.equal(isTrumpCard(heartLevel, 'hearts', '2'), true);
+ assert.equal(isTrumpCard(clubLevel, 'hearts', '2'), false);
+ assert.ok(
+ getCardStrength(clubThree, 'hearts', '2', UNARMED_RULE)
+ > getCardStrength(clubLevel, 'hearts', '2', UNARMED_RULE)
+ );
+ assert.ok(
+ getCardStrength(heartLevel, 'hearts', '2', UNARMED_RULE)
+ > getCardStrength(sideAce, 'hearts', '2', UNARMED_RULE)
+ );
+
+ const tractor = detectPattern([
+ makeUnarmed('clubs', '2', 0),
+ makeUnarmed('clubs', '2', 1),
+ makeUnarmed('clubs', '3', 0),
+ makeUnarmed('clubs', '3', 1)
+ ], 'hearts', '2', UNARMED_RULE);
+ assert.equal(tractor.type, PatternTypes.TRACTOR);
+ assert.equal(tractor.suit, 'clubs');
+
+ const cannotSkipLevelRank = detectPattern([
+ makeUnarmed('clubs', 'Q', 0),
+ makeUnarmed('clubs', 'Q', 1),
+ makeUnarmed('clubs', 'A', 0),
+ makeUnarmed('clubs', 'A', 1)
+ ], 'hearts', 'K', UNARMED_RULE);
+ assert.equal(cannotSkipLevelRank.type, PatternTypes.INVALID);
+});
+
+test('木牛流马只在轮首等待持有者,放入新牌后必须立即交给队友', () => {
+ const room = createRoom();
+ const io = createIo();
+ const engine = new GameEngine(room, io);
+ room.gameState.phase = GamePhases.PLAYING;
+ room.gameState.selectedRule = WOODEN_OX_FLOWING_HORSE_RULE;
+ room.gameState.trumpSuit = 'spades';
+ room.gameState.trumpRank = '2';
+ room.players[0].cards = [card('hearts', '9', 900), card('clubs', '4', 901)];
+ room.players[1].cards = [card('diamonds', '5', 902), card('clubs', '6', 903)];
+ room.players[2].cards = [card('clubs', '7', 904), card('spades', '7', 905)];
+ room.players[3].cards = [card('spades', '8', 906), card('diamonds', '3', 907)];
+
+ engine.setFirstPlayer(room.players[0].id);
+ const teamOneMule = room.gameState.woodenOxMulesByTeam.get(1);
+ const teamZeroMule = room.gameState.woodenOxMulesByTeam.get(0);
+ assert.equal(teamOneMule.holderPlayerId, room.players[1].id);
+ assert.equal(teamZeroMule.holderPlayerId, room.players[2].id);
+ assert.deepEqual(
+ [...room.gameState.woodenOxRoundWindow.pendingPlayerIds].sort(),
+ [room.players[1].id, room.players[2].id].sort()
+ );
+ assert.throws(
+ () => engine.playCards(room.players[0].id, [room.players[0].cards[0].id]),
+ /木牛流马/
+ );
+
+ const loadedCard = room.players[1].cards[0];
+ engine.manageWoodenOx(room.players[1].id, 'load_and_pass', loadedCard.id);
+ assert.equal(teamOneMule.holderPlayerId, room.players[3].id);
+ assert.equal(teamOneMule.storedCard.id, loadedCard.id);
+ assert.equal(teamOneMule.transfersUsed, 1);
+ assert.equal(room.players[1].cards.some(item => item.id === loadedCard.id), false);
+ engine.manageWoodenOx(room.players[2].id, 'skip');
+ assert.equal(engine.hasPendingWoodenOxDecision(), false);
+
+ engine.playCards(room.players[0].id, [room.players[0].cards[0].id]);
+ assert.throws(
+ () => engine.manageWoodenOx(room.players[3].id, 'skip'),
+ /首张牌打出前/
+ );
+ engine.playCards(room.players[1].id, [room.players[1].cards[0].id]);
+ engine.playCards(room.players[2].id, [room.players[2].cards[0].id]);
+ engine.playCards(room.players[3].id, [room.players[3].cards[0].id]);
+
+ assert.equal(room.gameState.currentRound, 2);
+ assert.equal(teamOneMule.storedCard.id, loadedCard.id);
+ assert.equal(room.gameState.woodenOxRoundWindow.pendingPlayerIds.has(room.players[3].id), true);
+ const publicMule = room.gameState.toJSON().woodenOx.mules.find(mule => mule.teamIndex === 1);
+ assert.equal(publicMule.hasStoredCard, true);
+ assert.equal(Object.hasOwn(publicMule, 'storedCard'), false);
+});
+
+test('木牛流马以庄家为1号位并逆时针发给2、3号位', () => {
+ const room = createRoom();
+ const io = createIo();
+ const engine = new GameEngine(room, io);
+ room.gameState.phase = GamePhases.PLAYING;
+ room.gameState.selectedRule = WOODEN_OX_FLOWING_HORSE_RULE;
+ room.gameState.buryingPlayerId = room.players[2].id;
+ room.players.slice(1).forEach(player => {
+ player.isBot = true;
+ });
+
+ engine.setFirstPlayer(room.players[2].id);
+
+ const teamOneMule = room.gameState.woodenOxMulesByTeam.get(1);
+ const teamZeroMule = room.gameState.woodenOxMulesByTeam.get(0);
+ assert.equal(teamOneMule.initialHolderPlayerId, room.players[3].id);
+ assert.equal(teamZeroMule.initialHolderPlayerId, room.players[0].id);
+ assert.deepEqual(
+ [...room.gameState.woodenOxRoundWindow.pendingPlayerIds].sort(),
+ [room.players[0].id]
+ );
+ assert.deepEqual(
+ io.events
+ .filter(({ event }) => event === 'wooden_ox_decision_required')
+ .map(({ target }) => target)
+ .sort(),
+ [room.players[0].socketId]
+ );
+});
+
+test('木牛流马中的牌可延后打出但不产生跟牌义务,撤回时仍回到盒中', () => {
+ const room = createRoom();
+ const engine = new GameEngine(room, createIo());
+ room.gameState.phase = GamePhases.PLAYING;
+ room.gameState.selectedRule = WOODEN_OX_FLOWING_HORSE_RULE;
+ room.gameState.trumpSuit = 'spades';
+ room.gameState.trumpRank = '2';
+ room.players.forEach((player, index) => {
+ player.cards = [card('clubs', `${index + 4}`, 920 + index)];
+ });
+ room.players[0].cards = [card('hearts', '9', 930), card('clubs', '4', 931)];
+ room.players[1].cards = [card('spades', 'A', 932), card('clubs', '5', 933)];
+
+ engine.setFirstPlayer(room.players[0].id);
+ const teamOneMule = room.gameState.woodenOxMulesByTeam.get(1);
+ teamOneMule.storedCard = card('hearts', '2', 934);
+ engine.manageWoodenOx(room.players[1].id, 'skip');
+ engine.manageWoodenOx(room.players[2].id, 'skip');
+ engine.playCards(room.players[0].id, [room.players[0].cards[0].id]);
+
+ // 盒中有首花色牌,但实体手牌缺门,因此仍可打黑桃。
+ const offSuitPhysicalCard = room.players[1].cards[0];
+ assert.doesNotThrow(() => engine.playCards(room.players[1].id, [offSuitPhysicalCard.id]));
+ assert.equal(teamOneMule.storedCard.id, 'hearts-2-934');
+
+ const undoRoom = createRoom();
+ const undoEngine = new GameEngine(undoRoom, createIo());
+ undoRoom.gameState.phase = GamePhases.PLAYING;
+ undoRoom.gameState.selectedRule = WOODEN_OX_FLOWING_HORSE_RULE;
+ undoRoom.gameState.trumpSuit = 'spades';
+ undoRoom.gameState.trumpRank = '2';
+ undoRoom.players.forEach((player, index) => {
+ player.cards = [card('clubs', `${index + 4}`, 940 + index)];
+ });
+ undoRoom.gameState.buryingPlayerId = undoRoom.players[2].id;
+ undoEngine.setFirstPlayer(undoRoom.players[2].id);
+ const teamZeroMule = undoRoom.gameState.woodenOxMulesByTeam.get(0);
+ const storedCard = card('clubs', 'A', 950);
+ teamZeroMule.storedCard = storedCard;
+ undoEngine.manageWoodenOx(undoRoom.players[3].id, 'skip');
+ undoEngine.manageWoodenOx(undoRoom.players[0].id, 'skip');
+ undoEngine.playCards(undoRoom.players[2].id, [undoRoom.players[2].cards[0].id]);
+ undoEngine.playCards(undoRoom.players[3].id, [undoRoom.players[3].cards[0].id]);
+ const physicalCountBefore = undoRoom.players[0].cards.length;
+ undoEngine.playCards(undoRoom.players[0].id, [storedCard.id]);
+ assert.equal(teamZeroMule.storedCard, null);
+ assert.equal(undoRoom.players[0].cards.length, physicalCountBefore);
+
+ const undoResult = undoEngine.undoLastPlay(undoRoom.players[0].id);
+ assert.equal(teamZeroMule.storedCard.id, storedCard.id);
+ assert.equal(undoRoom.players[0].cards.length, physicalCountBefore);
+ assert.deepEqual(undoResult.cards, []);
+ assert.deepEqual(undoResult.woodenOxCards.map(item => item.id), [storedCard.id]);
+});
+
+test('每队木牛流马最多四次单程传递,即两次完整往返', () => {
+ const room = createRoom();
+ const engine = new GameEngine(room, createIo());
+ room.gameState.phase = GamePhases.PLAYING;
+ room.gameState.selectedRule = WOODEN_OX_FLOWING_HORSE_RULE;
+ room.players.forEach((player, index) => {
+ player.cards = [
+ card('clubs', '4', 960 + index * 2),
+ card('diamonds', '6', 961 + index * 2)
+ ];
+ });
+ engine.setFirstPlayer(room.players[0].id);
+
+ const mule = room.gameState.woodenOxMulesByTeam.get(1);
+ for (let transfer = 0; transfer < 4; transfer += 1) {
+ const holder = room.findPlayerById(mule.holderPlayerId);
+ const otherPendingPlayerId = [...room.gameState.woodenOxRoundWindow.pendingPlayerIds]
+ .find(playerId => playerId !== holder.id);
+ engine.manageWoodenOx(holder.id, 'load_and_pass', holder.cards[0].id);
+ if (otherPendingPlayerId) engine.manageWoodenOx(otherPendingPlayerId, 'skip');
+ if (transfer < 3) {
+ room.gameState.currentRound += 1;
+ engine.openWoodenOxRoundWindow();
+ }
+ }
+
+ assert.equal(mule.transfersUsed, 4);
+ assert.equal(mule.holderPlayerId, room.players[1].id);
+ room.gameState.currentRound += 1;
+ engine.openWoodenOxRoundWindow();
+ assert.equal(room.gameState.woodenOxRoundWindow.pendingPlayerIds.has(mule.holderPlayerId), false);
+ const publicMule = room.gameState.toJSON().woodenOx.mules.find(item => item.teamIndex === 1);
+ assert.equal(publicMule.completedRoundTrips, 2);
+ assert.equal(publicMule.maxRoundTrips, 2);
+});
+
+test('队友已无牌可出时,轮首不能继续扣留木牛流马', () => {
+ const room = createRoom();
+ const engine = new GameEngine(room, createIo());
+ room.gameState.phase = GamePhases.PLAYING;
+ room.gameState.selectedRule = WOODEN_OX_FLOWING_HORSE_RULE;
+ room.players[0].cards = [card('clubs', '4', 980)];
+ room.players[1].cards = [card('diamonds', '6', 981), card('clubs', '7', 982)];
+ room.players[2].cards = [card('hearts', '8', 983)];
+ room.players[3].cards = [];
+ engine.setFirstPlayer(room.players[0].id);
+
+ assert.equal(
+ room.gameState.woodenOxRoundWindow.requiredTransferPlayerIds.has(room.players[1].id),
+ true
+ );
+ assert.throws(
+ () => engine.manageWoodenOx(room.players[1].id, 'skip'),
+ /必须把木牛流马交给队友/
+ );
+ const selected = room.players[1].cards[0];
+ assert.doesNotThrow(() => (
+ engine.manageWoodenOx(room.players[1].id, 'load_and_pass', selected.id)
+ ));
+ assert.equal(engine.getPlayableCardCount(room.players[3]), 1);
+});
+
+test('木牛流马持有者为Bot时会自动跳过,必须平衡牌数时则自动交给队友', () => {
+ const room = createRoom();
+ const engine = new GameEngine(room, createIo());
+ room.gameState.phase = GamePhases.PLAYING;
+ room.gameState.selectedRule = WOODEN_OX_FLOWING_HORSE_RULE;
+ room.players.forEach((player, index) => {
+ player.cards = [card('clubs', `${index + 4}`, 990 + index)];
+ });
+ room.players[1].isBot = true;
+ room.players[2].isBot = true;
+ engine.setFirstPlayer(room.players[0].id);
+ assert.equal(engine.hasPendingWoodenOxDecision(), false);
+
+ room.players[3].cards = [];
+ room.gameState.currentRound += 1;
+ engine.openWoodenOxRoundWindow();
+ const teamOneMule = room.gameState.woodenOxMulesByTeam.get(1);
+ assert.equal(teamOneMule.holderPlayerId, room.players[3].id);
+ assert.equal(teamOneMule.transfersUsed, 1);
+ assert.equal(engine.getPlayableCardCount(room.players[3]), 1);
+ assert.equal(engine.hasPendingWoodenOxDecision(), false);
+});
+
+test('同舟共济在自己的出牌回合即时给队友牌,并在轮末由收牌者等量返还', () => {
+ const room = createRoom();
+ const engine = new GameEngine(room, createIo());
+ const hands = [
+ [card('hearts', '3'), card('hearts', '4'), card('hearts', '5')],
+ [card('hearts', '6'), card('hearts', '7'), card('hearts', '8')],
+ [card('hearts', '9'), card('hearts', '10'), card('hearts', 'J')],
+ [card('hearts', 'Q'), card('hearts', 'K'), card('hearts', 'A')]
+ ];
+ hands.forEach((cards, index) => cards.forEach(value => room.players[index].addCard(value)));
+ room.gameState.phase = GamePhases.PLAYING;
+ room.gameState.selectedRule = MUTUAL_SUPPORT_RULE;
+ room.gameState.trumpSuit = 'spades';
+ room.gameState.trumpRank = '2';
+ room.gameState.buryingPlayerId = room.players[0].id;
+ room.gameState.dealerPlayerIndex = 0;
+ engine.setFirstPlayer(room.players[0].id);
+
+ const activation = engine.activateMutualSupport(
+ room.players[0].id,
+ 'give',
+ [hands[0][0].id]
+ );
+ assert.equal(activation.resolved, true);
+ assert.equal(activation.transfer.cardsCount, 1);
+ assert.equal(room.players[0].cards.length, 2);
+ assert.equal(room.players[2].cards.length, 4);
+ assert.equal(
+ room.gameState.activeSkillUsesByPlayerId.get(room.players[0].id).has(ActiveSkillIds.MUTUAL_SUPPORT),
+ true
+ );
+ assert.throws(
+ () => engine.activateMutualSupport(room.players[0].id, 'request'),
+ /只能发动一次/
+ );
+
+ engine.playCards(room.players[0].id, [hands[0][1].id]);
+ engine.playCards(room.players[1].id, [hands[1][0].id]);
+ engine.playCards(room.players[2].id, [hands[2][0].id]);
+ const roundResult = engine.playCards(room.players[3].id, [hands[3][0].id]);
+
+ assert.equal(roundResult.roundUpdate.type, 'round_ended');
+ assert.equal(roundResult.roundUpdate.mutualSupportReturnPending, true);
+ assert.equal(roundResult.mutualSupportReturn.stage, 'return');
+ assert.equal(roundResult.mutualSupportReturn.chooserPlayerId, room.players[2].id);
+ assert.equal(roundResult.mutualSupportReturn.requiredCards, 1);
+ assert.equal(room.gameState.toJSON().mutualSupport.pendingAction.requiredCards, 1);
+ assert.throws(
+ () => engine.playCards(room.players[3].id, [hands[3][1].id]),
+ /同舟共济交牌/
+ );
+
+ const returnedCardId = hands[2][1].id;
+ const returnResult = engine.submitMutualSupportCards(
+ room.players[2].id,
+ roundResult.mutualSupportReturn.id,
+ [returnedCardId]
+ );
+ assert.equal(returnResult.stage, 'return');
+ assert.equal(returnResult.allReturnsCompleted, true);
+ assert.equal(room.gameState.mutualSupportPendingAction, null);
+ assert.equal(room.players[0].cards.some(value => value.id === returnedCardId), true);
+ assert.equal(room.players[0].cards.length, 2);
+ assert.equal(room.players[2].cards.length, 2);
+});
+
+test('同舟共济向队友要牌允许明确给0张,Bot也会在可给范围内自动选择', () => {
+ const room = createRoom();
+ const engine = new GameEngine(room, createIo());
+ for (let playerIndex = 0; playerIndex < 4; playerIndex += 1) {
+ [3, 4, 5].forEach((rank, copyIndex) => {
+ room.players[playerIndex].addCard(card('clubs', String(rank), playerIndex * 3 + copyIndex));
+ });
+ }
+ room.gameState.phase = GamePhases.PLAYING;
+ room.gameState.selectedRule = MUTUAL_SUPPORT_RULE;
+ room.gameState.trumpSuit = 'spades';
+ room.gameState.trumpRank = '2';
+ room.gameState.buryingPlayerId = room.players[0].id;
+ room.gameState.dealerPlayerIndex = 0;
+ engine.setFirstPlayer(room.players[0].id);
+
+ const request = engine.activateMutualSupport(room.players[0].id, 'request');
+ assert.equal(request.pending, true);
+ assert.equal(request.chooserPlayerId, room.players[2].id);
+ assert.equal(request.minCards, 0);
+ assert.equal(request.maxCards, 2);
+ assert.throws(
+ () => engine.playCards(room.players[0].id, [room.players[0].cards[0].id]),
+ /同舟共济交牌/
+ );
+
+ const declined = engine.submitMutualSupportCards(room.players[2].id, request.actionId, []);
+ assert.equal(declined.transfer.cardsCount, 0);
+ assert.equal(room.gameState.mutualSupportPendingAction, null);
+ assert.equal(room.gameState.mutualSupportRoundTransfers.length, 0);
+
+ const secondRoom = createRoom();
+ const secondEngine = new GameEngine(secondRoom, createIo());
+ secondRoom.players.forEach((player, playerIndex) => {
+ [6, 7, 8].forEach((rank, copyIndex) => {
+ player.addCard(card('diamonds', String(rank), playerIndex * 3 + copyIndex));
+ });
+ });
+ secondRoom.players[2].isBot = true;
+ secondRoom.gameState.phase = GamePhases.PLAYING;
+ secondRoom.gameState.selectedRule = MUTUAL_SUPPORT_RULE;
+ secondRoom.gameState.trumpSuit = 'spades';
+ secondRoom.gameState.trumpRank = '2';
+ secondRoom.gameState.buryingPlayerId = secondRoom.players[0].id;
+ secondRoom.gameState.dealerPlayerIndex = 0;
+ secondEngine.setFirstPlayer(secondRoom.players[0].id);
+
+ const botRequest = secondEngine.activateMutualSupport(secondRoom.players[0].id, 'request');
+ const botCardIds = secondEngine.selectMutualSupportCardsForBot(secondRoom.players[2].id);
+ assert.equal(botCardIds.length, 2);
+ const botResponse = secondEngine.submitMutualSupportCards(
+ secondRoom.players[2].id,
+ botRequest.actionId,
+ botCardIds
+ );
+ assert.equal(botResponse.transfer.cardsCount, 2);
+ assert.equal(secondRoom.gameState.mutualSupportRoundTransfers[0].count, 2);
+});
+
+test('烛尽天明由庄家队友选择初始烛态,选择前不能出牌', () => {
+ const room = createRoom();
+ const io = createIo();
+ const engine = new GameEngine(room, io);
+ room.gameState.phase = GamePhases.PLAYING;
+ room.gameState.selectedRule = CANDLE_TO_DAWN_RULE;
+ room.gameState.buryingPlayerId = room.players[0].id;
+ room.gameState.dealerPlayerIndex = 0;
+ room.players.forEach((player, index) => player.addCard(card('clubs', String(index + 3), index)));
+ engine.setFirstPlayer(room.players[0].id);
+
+ const initialized = engine.initializeCandleToDawn(room.players[0]);
+ assert.equal(initialized.selectorPlayerId, room.players[2].id);
+ assert.equal(room.gameState.candleSelectionPending, true);
+ assert.equal(room.gameState.candleLit, null);
+ assert.equal(
+ io.events.some(entry => (
+ entry.target === room.players[2].socketId
+ && entry.event === 'candle_initial_choice_required'
+ )),
+ true
+ );
+ assert.throws(
+ () => engine.playCards(room.players[0].id, [room.players[0].cards[0].id]),
+ /初始状态/
+ );
+ assert.throws(
+ () => engine.selectInitialCandleState(room.players[1].id, true),
+ /庄家队友/
+ );
+
+ const selected = engine.selectInitialCandleState(room.players[2].id, true);
+ assert.equal(selected.isLit, true);
+ assert.equal(room.gameState.candleSelectionPending, false);
+ assert.equal(room.gameState.toJSON().candleToDawn.isLit, true);
+});
+
+test('烛尽天明先按本轮烛态计分,结算后才由第四手切换下轮烛态', () => {
+ const room = createRoom();
+ const engine = new GameEngine(room, createIo());
+ const roundCards = [
+ card('hearts', '5'),
+ card('clubs', '10'),
+ card('hearts', 'K'),
+ card('spades', '3')
+ ];
+ room.players.forEach((player, index) => {
+ player.addCard(roundCards[index]);
+ player.addCard(card(index === 2 ? 'hearts' : 'clubs', String(index + 6), 20 + index));
+ });
+ room.gameState.phase = GamePhases.PLAYING;
+ room.gameState.selectedRule = CANDLE_TO_DAWN_RULE;
+ room.gameState.trumpSuit = 'spades';
+ room.gameState.trumpRank = '2';
+ room.gameState.buryingPlayerId = room.players[0].id;
+ room.gameState.dealerPlayerIndex = 0;
+ room.gameState.candleSelectorPlayerId = room.players[2].id;
+ room.gameState.candleSelectionPending = false;
+ room.gameState.candleLit = true;
+ engine.setFirstPlayer(room.players[0].id);
+
+ engine.playCards(room.players[0].id, [roundCards[0].id]);
+ engine.playCards(room.players[1].id, [roundCards[1].id]);
+ engine.playCards(room.players[2].id, [roundCards[2].id]);
+ const result = engine.playCards(room.players[3].id, [roundCards[3].id]);
+
+ assert.equal(result.roundUpdate.scoreInfo.originalRoundPoints, 25);
+ assert.equal(result.roundUpdate.scoreInfo.roundPoints, 30);
+ assert.equal(result.roundUpdate.scoreInfo.candleLit, true);
+ assert.equal(result.roundUpdate.scoreInfo.attackerScore, 30);
+ assert.deepEqual(result.roundUpdate.candleTransition, {
+ round: 1,
+ previousLit: true,
+ nextLit: false,
+ changed: true,
+ triggerColor: 'black',
+ fourthPlayerId: room.players[3].id,
+ fourthPlayerName: room.players[3].name
+ });
+ assert.equal(room.gameState.candleLit, false);
+ assert.equal(room.gameState.candleLastTransition.previousLit, true);
+ assert.equal(room.gameState.currentRound, 2);
+ assert.equal(result.roundUpdate.scoreInfo.roundPointCards.length, 3);
+
+ assert.equal(engine.getCandleRoundCardPoints(card('hearts', '5'), true), 10);
+ assert.equal(engine.getCandleRoundCardPoints(card('clubs', '5'), true), 0);
+ assert.equal(engine.getCandleRoundCardPoints(card('hearts', '5'), false), 0);
+ assert.equal(engine.getCandleRoundCardPoints(card('clubs', '5'), false), 10);
+ assert.equal(engine.getCandleCardColor(card('joker', Ranks.SMALL_JOKER)), 'black');
+ assert.equal(engine.getCandleCardColor(card('joker', Ranks.BIG_JOKER)), 'red');
+ // 烛只改每轮分数;底牌仍走通用原分解析。
+ assert.equal(engine.getRuleCardPoints(card('hearts', '5')), 5);
+});
+
+test('文化革命先二选一再替换原主,花色声明会让原主花色回到副牌', () => {
+ const room = createRoom();
+ const engine = new GameEngine(room, createIo());
+ room.gameState.phase = GamePhases.PLAYING;
+ room.gameState.selectedRule = CULTURAL_REVOLUTION_RULE;
+ room.gameState.trumpSuit = 'hearts';
+ room.gameState.trumpRank = '2';
+ engine.setFirstPlayer(room.players[0].id);
+
+ const activation = engine.activateCulturalRevolution(
+ room.players[0].id,
+ 'suit',
+ 'spades'
+ );
+
+ assert.equal(activation.declarationType, 'suit');
+ assert.equal(activation.expiresAfterRound, 2);
+ assert.equal(room.gameState.trumpSuit, 'spades');
+ assert.equal(room.gameState.trumpRank, '2');
+ assert.equal(isTrumpCard(card('spades', '3'), room.gameState.trumpSuit, room.gameState.trumpRank), true);
+ assert.equal(isTrumpCard(card('hearts', '3'), room.gameState.trumpSuit, room.gameState.trumpRank), false);
+ assert.equal(room.gameState.toJSON().culturalRevolution.declaration.value, 'spades');
+ assert.throws(
+ () => engine.activateCulturalRevolution(room.players[0].id, 'rank', 'K'),
+ /每名玩家每局只能发动一次/
+ );
+});
+
+test('文化革命允许10和K成为级牌,覆盖旧声明时不叠加并重新持续两轮', () => {
+ for (const replacementRank of ['10', 'K']) {
+ const room = createRoom();
+ const engine = new GameEngine(room, createIo());
+ room.gameState.phase = GamePhases.PLAYING;
+ room.gameState.selectedRule = CULTURAL_REVOLUTION_RULE;
+ room.gameState.trumpSuit = 'hearts';
+ room.gameState.trumpRank = '2';
+ engine.setFirstPlayer(room.players[0].id);
+
+ engine.activateCulturalRevolution(room.players[0].id, 'suit', 'spades');
+ assert.equal(engine.expireCulturalRevolutionAtRoundEnd(1), null);
+
+ room.gameState.currentRound = 2;
+ room.gameState.currentPlayerIndex = 1;
+ room.gameState.roundStartPlayerIndex = 1;
+ room.gameState.currentRoundPlays = [];
+ room.gameState.playersPlayedThisRound.clear();
+ const overwritten = engine.activateCulturalRevolution(
+ room.players[1].id,
+ 'rank',
+ replacementRank
+ );
+
+ assert.equal(overwritten.expiresAfterRound, 3);
+ assert.equal(room.gameState.trumpSuit, 'hearts');
+ assert.equal(room.gameState.trumpRank, replacementRank);
+ assert.equal(isTrumpCard(card('spades', '3'), room.gameState.trumpSuit, room.gameState.trumpRank), false);
+ assert.equal(isTrumpCard(card('clubs', replacementRank), room.gameState.trumpSuit, room.gameState.trumpRank), true);
+ assert.equal(isTrumpCard(card('clubs', '2'), room.gameState.trumpSuit, room.gameState.trumpRank), false);
+ assert.equal(engine.expireCulturalRevolutionAtRoundEnd(2), null);
+
+ const expired = engine.expireCulturalRevolutionAtRoundEnd(3);
+ assert.equal(expired.restoredTrumpSuit, 'hearts');
+ assert.equal(expired.restoredTrumpRank, '2');
+ assert.equal(room.gameState.trumpSuit, 'hearts');
+ assert.equal(room.gameState.trumpRank, '2');
+ assert.equal(room.gameState.culturalRevolution, null);
+ }
+});
+
+test('三人成虎按扩展牌面降低四级,并保持实体牌原分值', () => {
+ assert.deepEqual(
+ ['2', '3', '4', '5', 'A'].map(rank => shiftThreeTigersRank(rank)),
+ ['-2', '-1', '0', '1', '10']
+ );
+ assert.equal(shiftThreeTigersRank('4', '4'), '-1');
+ assert.equal(shiftThreeTigersRank('5', '4'), '0');
+ assert.equal(shiftThreeTigersRank('8', '4'), '3');
+ assert.equal(shiftThreeTigersRank('9', '4'), '5');
+
+ const transformedTwo = transformThreeTigersCard(
+ card('hearts', '2'),
+ 'hearts',
+ '9',
+ 'spades'
+ );
+ const transformedThree = transformThreeTigersCard(
+ card('hearts', '3'),
+ 'hearts',
+ '9',
+ 'spades'
+ );
+ assert.equal(transformedTwo.rank, '-2');
+ assert.equal(transformedTwo.originalRank, '2');
+ assert.equal(transformedTwo.isThreeTigersTrump, true);
+ assert.equal(isTrumpCard(transformedTwo, 'spades', '9'), true);
+ assert.ok(
+ getCardStrength(transformedThree, 'spades', '9', THREE_TIGERS_RULE)
+ > getCardStrength(transformedTwo, 'spades', '9', THREE_TIGERS_RULE)
+ );
+
+ const transformedFive = transformThreeTigersCard(
+ card('hearts', '5'),
+ 'hearts',
+ '9',
+ 'spades'
+ );
+ assert.equal(transformedFive.rank, '1');
+ assert.equal(getCardPoints(transformedFive), 5);
+
+ const levelCard = transformThreeTigersCard(
+ card('hearts', '4'),
+ 'hearts',
+ '4',
+ 'spades'
+ );
+ const trumpSuitCard = transformThreeTigersCard(
+ card('spades', '8'),
+ 'spades',
+ '4',
+ 'spades'
+ );
+ const joker = transformThreeTigersCard(
+ card('joker', 'small_joker'),
+ 'joker',
+ '4',
+ 'spades'
+ );
+ assert.equal(levelCard.rank, '4');
+ assert.equal(levelCard.isThreeTigersTransformed, undefined);
+ assert.equal(trumpSuitCard.rank, '8');
+ assert.equal(trumpSuitCard.isThreeTigersTransformed, undefined);
+ assert.equal(joker.rank, 'small_joker');
+ assert.equal(joker.isThreeTigersTransformed, undefined);
+});
+
+test('三人成虎在第三名同花色玩家落牌后立刻转换整桌、重算赢家且按原牌计分', () => {
+ const room = createRoom();
+ const engine = new GameEngine(room, createIo());
+ const roundCards = [
+ card('hearts', '5', 900),
+ card('hearts', '6', 901),
+ card('hearts', '10', 902),
+ card('hearts', 'K', 903)
+ ];
+ room.players.forEach((player, index) => {
+ player.addCard(roundCards[index]);
+ player.addCard(card('clubs', ['2', '3', '6', '7'][index], 910 + index));
+ });
+ room.gameState.phase = GamePhases.PLAYING;
+ room.gameState.selectedRule = THREE_TIGERS_RULE;
+ room.gameState.trumpSuit = 'spades';
+ room.gameState.trumpRank = '4';
+ room.gameState.buryingPlayerId = room.players[0].id;
+ room.gameState.dealerPlayerIndex = 0;
+ engine.setFirstPlayer(room.players[0].id);
+
+ const first = engine.playCards(room.players[0].id, [roundCards[0].id]);
+ const second = engine.playCards(room.players[1].id, [roundCards[1].id]);
+ assert.equal(first.threeTigersTransformation.active, false);
+ assert.equal(second.threeTigersTransformation.active, false);
+ assert.equal(second.threeTigersTransformation.suitCounts.hearts, 2);
+
+ const third = engine.playCards(room.players[2].id, [roundCards[2].id]);
+ assert.equal(third.threeTigersTransformation.active, true);
+ assert.equal(third.threeTigersTransformation.triggeredNow, true);
+ assert.equal(third.threeTigersTransformation.triggeredSuit, 'hearts');
+ assert.deepEqual(
+ third.threeTigersTransformation.plays.map(play => play.cards[0].rank),
+ ['0', '1', '6']
+ );
+ assert.ok(
+ third.threeTigersTransformation.plays
+ .every(play => play.cards[0].isThreeTigersTrump === true)
+ );
+ assert.equal(third.currentWinningPlayerId, room.players[2].id);
+
+ const fourth = engine.playCards(room.players[3].id, [roundCards[3].id]);
+ assert.equal(fourth.threeTigersTransformation.triggeredNow, false);
+ assert.equal(fourth.playedCards[0].rank, '9');
+ assert.equal(fourth.playedCards[0].isThreeTigersTransformed, true);
+ assert.equal(fourth.threeTigersTransformation.plays[3].cards[0].rank, '9');
+ assert.equal(fourth.roundUpdate.threeTigers.plays[3].cards[0].rank, '9');
+ assert.equal(fourth.roundUpdate.roundWinner.playerId, room.players[3].id);
+ assert.equal(fourth.roundUpdate.scoreInfo.baseRoundPoints, 25);
+ assert.equal(fourth.roundUpdate.scoreInfo.roundPoints, 25);
+ assert.equal(fourth.roundUpdate.threeTigers.triggeredSuit, 'hearts');
+ assert.equal(room.gameState.threeTigersRoundState, null);
+ assert.equal(room.gameState.threeTigersLastRoundState.triggeredSuit, 'hearts');
+});
+
+test('三人成虎不统计或转换主花色牌,四家连续出主牌也不会触发', () => {
+ const room = createRoom();
+ const engine = new GameEngine(room, createIo());
+ const roundCards = ['5', '10', 'K', 'A'].map((rank, index) => (
+ card('hearts', rank, 920 + index)
+ ));
+ room.players.forEach((player, index) => {
+ player.addCard(roundCards[index]);
+ player.addCard(card('clubs', ['2', '3', '6', '7'][index], 930 + index));
+ });
+ room.gameState.phase = GamePhases.PLAYING;
+ room.gameState.selectedRule = THREE_TIGERS_RULE;
+ room.gameState.trumpSuit = 'hearts';
+ room.gameState.trumpRank = '4';
+ room.gameState.buryingPlayerId = room.players[0].id;
+ room.gameState.dealerPlayerIndex = 0;
+ engine.setFirstPlayer(room.players[0].id);
+
+ assert.equal(engine.getThreeTigersPlaySuit([card('clubs', '8')]), 'clubs');
+ assert.equal(engine.getThreeTigersPlaySuit([card('hearts', '8')]), null);
+ assert.equal(engine.getThreeTigersPlaySuit([card('clubs', '4')]), null);
+ assert.equal(engine.getThreeTigersPlaySuit([card('joker', 'big_joker')]), null);
+
+ const results = roundCards.map((playedCard, index) => (
+ engine.playCards(room.players[index].id, [playedCard.id])
+ ));
+
+ for (const result of results) {
+ assert.equal(result.threeTigersTransformation.active, false);
+ assert.equal(result.threeTigersTransformation.suitCounts.hearts, 0);
+ assert.equal(result.playedCards[0].isThreeTigersTransformed, undefined);
+ }
+ const last = results[3];
+ assert.equal(last.roundUpdate.threeTigers.triggeredSuit, null);
+ assert.equal(last.roundUpdate.threeTigers.suitCounts.hearts, 0);
+ assert.equal(room.gameState.threeTigersLastRoundState.triggeredSuit, null);
+});
+
+test('撤回第三人的出牌会撤销三人成虎并恢复桌面原牌', () => {
+ const room = createRoom();
+ const engine = new GameEngine(room, createIo());
+ const roundCards = ['5', '10', 'K', 'A'].map((rank, index) => (
+ card('diamonds', rank, 930 + index)
+ ));
+ room.players.forEach((player, index) => {
+ player.addCard(roundCards[index]);
+ player.addCard(card('clubs', String(index + 3), 940 + index));
+ });
+ room.gameState.phase = GamePhases.PLAYING;
+ room.gameState.selectedRule = THREE_TIGERS_RULE;
+ room.gameState.trumpSuit = 'spades';
+ room.gameState.trumpRank = '2';
+ room.gameState.buryingPlayerId = room.players[0].id;
+ room.gameState.dealerPlayerIndex = 0;
+ engine.setFirstPlayer(room.players[0].id);
+
+ engine.playCards(room.players[0].id, [roundCards[0].id]);
+ engine.playCards(room.players[1].id, [roundCards[1].id]);
+ engine.playCards(room.players[2].id, [roundCards[2].id]);
+ assert.equal(room.gameState.threeTigersRoundState.triggeredSuit, 'diamonds');
+
+ const undone = engine.undoLastPlay(room.players[2].id);
+ assert.equal(undone.threeTigersTransformation.active, false);
+ assert.equal(undone.threeTigersTransformation.reverted, true);
+ assert.equal(undone.threeTigersTransformation.suitCounts.diamonds, 2);
+ assert.deepEqual(
+ undone.threeTigersTransformation.plays.map(play => play.cards[0].rank),
+ ['5', '10']
+ );
+ assert.equal(room.gameState.threeTigersRoundState.triggeredSuit, null);
+ assert.equal(room.gameState.currentWinnerIndex, 1);
+});
+
+test('请君入瓮按本轮实体牌面固定扣5分,多张命中也不重复扣分', () => {
+ const selfTargetRoom = createRoom();
+ const selfTargetEngine = new GameEngine(selfTargetRoom, createIo());
+ selfTargetRoom.gameState.phase = GamePhases.PLAYING;
+ selfTargetRoom.gameState.selectedRule = INVITE_INTO_URN_RULE;
+ selfTargetEngine.setFirstPlayer(selfTargetRoom.players[0].id);
+ assert.throws(
+ () => selfTargetEngine.activateInviteIntoUrn(
+ selfTargetRoom.players[0].id,
+ selfTargetRoom.players[0].id,
+ 'hearts',
+ '8'
+ ),
+ /不能指定自己/
+ );
+
+ const whiteTargetRoom = createRoom();
+ const whiteTargetEngine = new GameEngine(whiteTargetRoom, createIo());
+ whiteTargetRoom.gameState.phase = GamePhases.PLAYING;
+ whiteTargetRoom.gameState.selectedRule = INVITE_INTO_URN_RULE;
+ whiteTargetEngine.setFirstPlayer(whiteTargetRoom.players[0].id);
+ const whiteDeclaration = whiteTargetEngine.activateInviteIntoUrn(
+ whiteTargetRoom.players[0].id,
+ whiteTargetRoom.players[1].id,
+ 'joker',
+ Ranks.WHITE_JOKER
+ );
+ assert.equal(whiteDeclaration.rank, Ranks.WHITE_JOKER);
+
+ const runScenario = ({ leaderIndex, targetIndex, expectedScoreDelta }) => {
+ const room = createRoom();
+ const engine = new GameEngine(room, createIo());
+ const ranks = ['3', '4', '6', '7'];
+ ranks[targetIndex] = '8';
+ const pairs = room.players.map((player, playerIndex) => {
+ const pair = [0, 1].map(copyOffset => (
+ card('hearts', ranks[playerIndex], 1000 + playerIndex * 2 + copyOffset)
+ ));
+ pair.forEach(value => player.addCard(value));
+ player.addCard(card('clubs', String(playerIndex + 2), 1020 + playerIndex));
+ return pair;
+ });
+ room.gameState.phase = GamePhases.PLAYING;
+ room.gameState.selectedRule = INVITE_INTO_URN_RULE;
+ room.gameState.trumpSuit = 'spades';
+ room.gameState.trumpRank = '2';
+ room.gameState.buryingPlayerId = room.players[0].id;
+ room.gameState.dealerPlayerIndex = 0;
+ engine.setFirstPlayer(room.players[leaderIndex].id);
+
+ const activation = engine.activateInviteIntoUrn(
+ room.players[leaderIndex].id,
+ room.players[targetIndex].id,
+ 'hearts',
+ '8'
+ );
+ assert.equal(activation.targetPlayerId, room.players[targetIndex].id);
+ assert.equal(
+ engine.hasUsedActiveSkill(room.players[leaderIndex].id, ActiveSkillIds.INVITE_INTO_URN),
+ true
+ );
+
+ let finalResult = null;
+ for (let offset = 0; offset < room.players.length; offset += 1) {
+ const playerIndex = (leaderIndex + offset) % room.players.length;
+ finalResult = engine.playCards(
+ room.players[playerIndex].id,
+ pairs[playerIndex].map(value => value.id)
+ );
+ }
+
+ const urnResult = finalResult.roundUpdate.scoreInfo.inviteIntoUrn;
+ const targetResult = urnResult.declarations[0];
+ assert.equal(targetResult.triggered, true);
+ assert.equal(targetResult.matchingCardCount, 2);
+ assert.equal(targetResult.penalty, 5);
+ assert.equal(targetResult.scoreDelta, expectedScoreDelta);
+ assert.equal(urnResult.scoreDelta, expectedScoreDelta);
+ assert.equal(room.gameState.attackerScore, expectedScoreDelta);
+ assert.equal(room.gameState.inviteIntoUrnDeclarations.length, 0);
+ assert.equal(room.gameState.inviteIntoUrnLastResult.round, 1);
+ };
+
+ runScenario({ leaderIndex: 0, targetIndex: 1, expectedScoreDelta: -5 });
+ runScenario({ leaderIndex: 2, targetIndex: 0, expectedScoreDelta: 5 });
+});
+
+test('老骥伏枥只在四人都首次获得过牌权后生效,非法首发不消耗绝大', () => {
+ const room = createRoom();
+ const engine = new GameEngine(room, createIo());
+ const leadCard = card('hearts', '3', 1100);
+ const invalidExtra = card('clubs', '4', 1101);
+ room.players[3].addCard(leadCard);
+ room.players[3].addCard(invalidExtra);
+ const followCards = [
+ card('hearts', 'A', 1110),
+ card('hearts', 'K', 1111),
+ card('hearts', 'Q', 1112)
+ ];
+ [0, 1, 2].forEach(playerIndex => {
+ room.players[playerIndex].addCard(followCards[playerIndex]);
+ room.players[playerIndex].addCard(card('clubs', String(playerIndex + 6), 1120 + playerIndex));
+ });
+ room.gameState.phase = GamePhases.PLAYING;
+ room.gameState.selectedRule = OLD_HORSE_RULE;
+ room.gameState.trumpSuit = 'spades';
+ room.gameState.trumpRank = '2';
+ room.gameState.buryingPlayerId = room.players[0].id;
+ room.gameState.dealerPlayerIndex = 0;
+ engine.setFirstPlayer(room.players[0].id);
+
+ assert.deepEqual(
+ Array.from(room.gameState.oldHorseRightHolderPlayerIds),
+ [room.players[0].id]
+ );
+ engine.updateOldHorseAtRoundEnd(1);
+ engine.updateOldHorseAtRoundEnd(2);
+ const armed = engine.updateOldHorseAtRoundEnd(3);
+ assert.equal(armed.armedNow, true);
+ assert.equal(room.gameState.oldHorseProtectedPlayerId, room.players[3].id);
+
+ room.gameState.currentRound = 4;
+ room.gameState.currentPlayerIndex = 3;
+ room.gameState.roundStartPlayerIndex = 3;
+ room.gameState.playersPlayedThisRound.clear();
+ room.gameState.currentRoundPlays = [];
+ room.gameState.leadingPattern = null;
+ room.gameState.currentWinnerIndex = null;
+
+ assert.throws(
+ () => engine.playCards(room.players[3].id, [leadCard.id, invalidExtra.id]),
+ /无效|花色|牌型/
+ );
+ assert.equal(room.gameState.oldHorseProtectedPlayerId, room.players[3].id);
+
+ const firstLead = engine.playCards(room.players[3].id, [leadCard.id]);
+ assert.equal(firstLead.oldHorseAbsolute, true);
+ assert.equal(firstLead.currentWinningPlayerId, room.players[3].id);
+ assert.equal(room.gameState.oldHorseProtectedPlayerId, null);
+
+ const undone = engine.undoLastPlay(room.players[3].id);
+ assert.equal(undone.restoredOldHorseAbsolute, true);
+ assert.equal(room.gameState.oldHorseProtectedPlayerId, room.players[3].id);
+
+ engine.playCards(room.players[3].id, [leadCard.id]);
+ const aceFollow = engine.playCards(room.players[0].id, [followCards[0].id]);
+ assert.equal(aceFollow.currentWinningPlayerId, room.players[3].id);
+ engine.playCards(room.players[1].id, [followCards[1].id]);
+ const finalResult = engine.playCards(room.players[2].id, [followCards[2].id]);
+ assert.equal(finalResult.roundUpdate.roundWinner.playerId, room.players[3].id);
+ assert.equal(finalResult.roundUpdate.oldHorse.protectedPlayerId, null);
+});
+
+test('Trump wins按各家实体出牌分值决定下轮牌权,并列时先出者优先', () => {
+ const room = createRoom();
+ const engine = new GameEngine(room, createIo());
+ const roundCards = [
+ card('hearts', '5', 1200),
+ card('hearts', 'K', 1201),
+ card('hearts', '10', 1202),
+ card('hearts', 'A', 1203)
+ ];
+ room.players.forEach((player, index) => {
+ player.addCard(roundCards[index]);
+ player.addCard(card('clubs', String(index + 3), 1210 + index));
+ });
+ room.gameState.phase = GamePhases.PLAYING;
+ room.gameState.selectedRule = TRUMP_WINS_RULE;
+ room.gameState.trumpSuit = 'spades';
+ room.gameState.trumpRank = '2';
+ room.gameState.buryingPlayerId = room.players[0].id;
+ room.gameState.dealerPlayerIndex = 0;
+ engine.setFirstPlayer(room.players[0].id);
+
+ let finalResult = null;
+ room.players.forEach((player, index) => {
+ finalResult = engine.playCards(player.id, [roundCards[index].id]);
+ });
+
+ assert.equal(finalResult.roundUpdate.roundWinner.playerId, room.players[3].id);
+ assert.deepEqual(
+ finalResult.roundUpdate.trumpWins.players.map(player => player.points),
+ [5, 10, 10, 0]
+ );
+ assert.equal(finalResult.roundUpdate.trumpWins.highestPoints, 10);
+ assert.equal(finalResult.roundUpdate.trumpWins.leaderPlayerId, room.players[1].id);
+ assert.equal(finalResult.roundUpdate.nextRoundLeader.playerId, room.players[1].id);
+ assert.equal(room.gameState.currentPlayerIndex, 1);
+ assert.equal(room.gameState.attackerScore, 25);
+});
+
+test('草船借箭在首置至少10分失守后冻结下一轮,并公开弃牌换取最大非分数牌', () => {
+ const room = createRoom();
+ const engine = new GameEngine(room, createIo());
+ const roundCards = [
+ card('hearts', '10', 1300),
+ card('hearts', 'A', 1301),
+ card('hearts', '3', 1302),
+ card('hearts', '4', 1303)
+ ];
+ const reserveCards = [
+ card('clubs', '3', 1310),
+ card('clubs', '4', 1311),
+ card('clubs', '6', 1312),
+ card('clubs', '7', 1313)
+ ];
+ room.players.forEach((player, index) => {
+ player.addCard(roundCards[index]);
+ player.addCard(reserveCards[index]);
+ });
+ room.gameState.phase = GamePhases.PLAYING;
+ room.gameState.selectedRule = STRAW_BOAT_BORROWING_ARROWS_RULE;
+ room.gameState.trumpSuit = 'spades';
+ room.gameState.trumpRank = '2';
+ room.gameState.buryingPlayerId = room.players[0].id;
+ room.gameState.dealerPlayerIndex = 0;
+ engine.setFirstPlayer(room.players[0].id);
+
+ let finalResult = null;
+ room.players.forEach((player, index) => {
+ finalResult = engine.playCards(player.id, [roundCards[index].id]);
+ });
+
+ assert.equal(finalResult.roundUpdate.roundWinner.playerId, room.players[1].id);
+ assert.equal(finalResult.strawBoatDecision.playerId, room.players[0].id);
+ assert.equal(finalResult.strawBoatDecision.leadingPoints, 10);
+ assert.equal(finalResult.strawBoatDecision.borrowedCard.id, roundCards[1].id);
+ assert.equal(room.gameState.currentRound, 2);
+ assert.throws(
+ () => engine.playCards(room.players[1].id, [reserveCards[1].id]),
+ /草船借箭/
+ );
+
+ const botChoice = engine.selectStrawBoatBorrowingArrowsForBot(room.players[0].id);
+ assert.deepEqual(botChoice, { accept: true, cardId: reserveCards[0].id });
+
+ const resolved = engine.resolveStrawBoatBorrowingArrows(room.players[0].id, botChoice);
+ assert.equal(resolved.accepted, true);
+ assert.equal(resolved.discardedCard.id, reserveCards[0].id);
+ assert.equal(resolved.borrowedCard.id, roundCards[1].id);
+ assert.deepEqual(room.players[0].cards.map(value => value.id), [roundCards[1].id]);
+ assert.equal(room.gameState.strawBoatBorrowingArrowsDecision, null);
+ assert.equal(
+ room.gameState.toJSON().strawBoatBorrowingArrows.lastResult.discardedCard.id,
+ reserveCards[0].id
+ );
+});
+
+test('草船借箭在首置分牌由本方赢得时不触发', () => {
+ const room = createRoom();
+ const engine = new GameEngine(room, createIo());
+ const roundCards = [
+ card('hearts', '10', 1320),
+ card('hearts', '3', 1321),
+ card('hearts', 'A', 1322),
+ card('hearts', '4', 1323)
+ ];
+ room.players.forEach((player, index) => {
+ player.addCard(roundCards[index]);
+ player.addCard(card('clubs', String(index + 3), 1330 + index));
+ });
+ room.gameState.phase = GamePhases.PLAYING;
+ room.gameState.selectedRule = STRAW_BOAT_BORROWING_ARROWS_RULE;
+ room.gameState.trumpSuit = 'spades';
+ room.gameState.trumpRank = '2';
+ room.gameState.buryingPlayerId = room.players[0].id;
+ room.gameState.dealerPlayerIndex = 0;
+ engine.setFirstPlayer(room.players[0].id);
+
+ let finalResult = null;
+ room.players.forEach((player, index) => {
+ finalResult = engine.playCards(player.id, [roundCards[index].id]);
+ });
+
+ assert.equal(finalResult.roundUpdate.roundWinner.playerId, room.players[2].id);
+ assert.equal(finalResult.strawBoatDecision, null);
+ assert.equal(room.gameState.strawBoatBorrowingArrowsDecision, null);
+});
+
+test('布什戈门由二号位迫使一号位收回首发,退回牌只禁用于紧接着的重新首发', () => {
+ const room = createRoom();
+ const engine = new GameEngine(room, createIo());
+ const returnedAceA = card('hearts', 'A', 1400);
+ const returnedAceB = card('hearts', 'A', 1401);
+ const alternativeLead = card('hearts', '3', 1402);
+ const playerCards = [
+ [returnedAceA, returnedAceB, alternativeLead],
+ [card('hearts', '4', 1410), card('hearts', '8', 1411)],
+ [card('hearts', '5', 1420), card('hearts', '9', 1421)],
+ [card('hearts', '6', 1430), card('hearts', '7', 1431)]
+ ];
+ room.players.forEach((player, index) => {
+ playerCards[index].forEach(value => player.addCard(value));
+ });
+ room.gameState.phase = GamePhases.PLAYING;
+ room.gameState.selectedRule = BUSH_GATE_RULE;
+ room.gameState.trumpSuit = 'spades';
+ room.gameState.trumpRank = '2';
+ room.gameState.buryingPlayerId = room.players[0].id;
+ room.gameState.dealerPlayerIndex = 0;
+ engine.setFirstPlayer(room.players[0].id);
+
+ engine.playCards(room.players[0].id, [returnedAceA.id, returnedAceB.id]);
+ assert.throws(
+ () => engine.activateBushGate(room.players[2].id),
+ /二号位/
+ );
+
+ const activation = engine.activateBushGate(room.players[1].id);
+ assert.equal(activation.leaderPlayerId, room.players[0].id);
+ assert.deepEqual(activation.forbiddenCardIds, [returnedAceA.id, returnedAceB.id]);
+ assert.equal(room.gameState.currentRoundPlays.length, 0);
+ assert.equal(room.gameState.currentPlayerIndex, 0);
+ assert.deepEqual(
+ new Set(room.players[0].cards.map(value => value.id)),
+ new Set([returnedAceA.id, returnedAceB.id, alternativeLead.id])
+ );
+ assert.equal(
+ engine.hasUsedActiveSkill(room.players[1].id, ActiveSkillIds.BUSH_GATE),
+ true
+ );
+ assert.throws(
+ () => engine.playCards(room.players[0].id, [returnedAceA.id]),
+ /布什戈门/
+ );
+ assert.throws(
+ () => engine.playCards(room.players[0].id, [alternativeLead.id, returnedAceB.id]),
+ /布什戈门/
+ );
+
+ engine.playCards(room.players[0].id, [alternativeLead.id]);
+ assert.equal(room.gameState.bushGateRestriction, null);
+ assert.equal(room.gameState.bushGateLastResult.replayCompleted, true);
+ assert.throws(
+ () => engine.activateBushGate(room.players[1].id),
+ /只能发动一次/
+ );
+
+ const undone = engine.undoLastPlay(room.players[0].id);
+ assert.equal(undone.restoredBushGateRestriction, true);
+ assert.equal(room.gameState.bushGateRestriction.leaderPlayerId, room.players[0].id);
+ assert.throws(
+ () => engine.playCards(room.players[0].id, [returnedAceA.id]),
+ /布什戈门/
+ );
+
+ engine.playCards(room.players[0].id, [alternativeLead.id]);
+ engine.playCards(room.players[1].id, [playerCards[1][0].id]);
+ engine.playCards(room.players[2].id, [playerCards[2][0].id]);
+ const firstRound = engine.playCards(room.players[3].id, [playerCards[3][0].id]);
+ assert.equal(firstRound.roundUpdate.roundWinner.playerId, room.players[3].id);
+
+ engine.playCards(room.players[3].id, [playerCards[3][1].id]);
+ const laterUse = engine.playCards(room.players[0].id, [returnedAceA.id]);
+ assert.equal(laterUse.playedCards[0].id, returnedAceA.id);
+ assert.equal(laterUse.currentWinningPlayerId, room.players[0].id);
+});
+
+test('布什戈门在一号位没有其他牌可改出时不能发动且不消耗次数', () => {
+ const room = createRoom();
+ const engine = new GameEngine(room, createIo());
+ const onlyCard = card('hearts', 'A', 1440);
+ room.players[0].addCard(onlyCard);
+ room.players[1].addCard(card('hearts', '4', 1441));
+ room.players[2].addCard(card('hearts', '5', 1442));
+ room.players[3].addCard(card('hearts', '6', 1443));
+ room.gameState.phase = GamePhases.PLAYING;
+ room.gameState.selectedRule = BUSH_GATE_RULE;
+ room.gameState.trumpSuit = 'spades';
+ room.gameState.trumpRank = '2';
+ room.gameState.buryingPlayerId = room.players[0].id;
+ engine.setFirstPlayer(room.players[0].id);
+
+ engine.playCards(room.players[0].id, [onlyCard.id]);
+ assert.throws(
+ () => engine.activateBushGate(room.players[1].id),
+ /没有其他手牌/
+ );
+ assert.equal(
+ engine.hasUsedActiveSkill(room.players[1].id, ActiveSkillIds.BUSH_GATE),
+ false
+ );
+ assert.equal(room.gameState.currentRoundPlays.length, 1);
+});
+
+test('队友加油在出牌后无主时询问,并沿完整牌力链给对家永久提升一级', () => {
+ const room = createRoom();
+ const engine = new GameEngine(room, createIo());
+ const openingCard = card('clubs', '3', 1450);
+ const remainingSideCard = card('clubs', '4', 1451);
+ const targetCards = [
+ card('clubs', 'A', 1460),
+ card('hearts', 'A', 1461),
+ card('clubs', '2', 1462),
+ card('hearts', '2', 1463),
+ card('joker', 'small_joker', 1464),
+ card('joker', 'big_joker', 1465),
+ card('hearts', '5', 1466)
+ ];
+ room.players[0].addCard(openingCard);
+ room.players[0].addCard(remainingSideCard);
+ room.players[1].addCard(card('clubs', '6', 1470));
+ targetCards.forEach(value => room.players[2].addCard(value));
+ room.players[3].addCard(card('clubs', '7', 1480));
+ room.gameState.phase = GamePhases.PLAYING;
+ room.gameState.selectedRule = TEAMMATE_CHEER_RULE;
+ room.gameState.trumpSuit = 'hearts';
+ room.gameState.trumpRank = '2';
+ room.gameState.buryingPlayerId = room.players[0].id;
+ engine.setFirstPlayer(room.players[0].id);
+
+ const playResult = engine.playCards(room.players[0].id, [openingCard.id]);
+ assert.equal(playResult.teammateCheerRequest.playerId, room.players[0].id);
+ assert.equal(playResult.teammateCheerRequest.teammatePlayerId, room.players[2].id);
+ assert.equal(engine.hasPendingTeammateCheerDecision(), true);
+ assert.throws(
+ () => engine.playCards(room.players[1].id, [room.players[1].cards[0].id]),
+ /队友加油/
+ );
+
+ const declined = engine.respondTeammateCheer(room.players[0].id, false);
+ assert.equal(declined.accepted, false);
+ assert.equal(room.gameState.teammateCheerUsedPlayerIds.has(room.players[0].id), false);
+ assert.ok(engine.requestTeammateCheerIfEligible(room.players[0]));
+
+ const accepted = engine.respondTeammateCheer(room.players[0].id, true);
+ assert.equal(accepted.accepted, true);
+ assert.equal(accepted.buffedPlayerId, room.players[2].id);
+ assert.equal(room.gameState.teammateCheerUsedPlayerIds.has(room.players[0].id), true);
+ assert.equal(room.gameState.teammateCheerBuffedPlayerIds.has(room.players[2].id), true);
+
+ const transformedById = new Map(room.players[2].cards.map(value => [value.id, value]));
+ assert.deepEqual(
+ [transformedById.get(targetCards[0].id).suit, transformedById.get(targetCards[0].id).rank],
+ ['clubs', 'B']
+ );
+ assert.deepEqual(
+ [transformedById.get(targetCards[1].id).suit, transformedById.get(targetCards[1].id).rank],
+ ['diamonds', '2']
+ );
+ assert.deepEqual(
+ [transformedById.get(targetCards[2].id).suit, transformedById.get(targetCards[2].id).rank],
+ ['hearts', '2']
+ );
+ assert.equal(transformedById.get(targetCards[3].id).rank, 'small_joker');
+ assert.equal(transformedById.get(targetCards[4].id).rank, 'big_joker');
+ assert.equal(transformedById.get(targetCards[5].id).rank, 'county_prince_joker');
+ assert.equal(transformedById.get(targetCards[6].id).rank, '6');
+ assert.ok(room.players[2].cards.every(value => value.isTeammateCheered));
+ assert.equal(getCardPoints(transformedById.get(targetCards[6].id)), 5);
+ assert.ok(
+ getCardStrength(
+ transformedById.get(targetCards[0].id),
+ 'hearts',
+ '2',
+ TEAMMATE_CHEER_RULE
+ ) > getCardStrength(card('clubs', 'A', 1490), 'hearts', '2', TEAMMATE_CHEER_RULE)
+ );
+
+ const publicState = room.gameState.toJSON().teammateCheer;
+ assert.deepEqual(publicState.buffedPlayerIds, [room.players[2].id]);
+ assert.equal(publicState.lastResult.playerId, room.players[0].id);
+ assert.equal(publicState.lastResult.transformedCards, undefined);
+ assert.equal(engine.requestTeammateCheerIfEligible(room.players[0]), null);
+
+ const undoResult = engine.undoLastPlay(room.players[0].id);
+ assert.equal(undoResult.teammateCheerReverted.buffedPlayerId, room.players[2].id);
+ assert.equal(room.gameState.teammateCheerUsedPlayerIds.has(room.players[0].id), false);
+ assert.equal(room.gameState.teammateCheerBuffedPlayerIds.has(room.players[2].id), false);
+ const restoredById = new Map(room.players[2].cards.map(value => [value.id, value]));
+ assert.deepEqual(
+ targetCards.map(value => {
+ const restored = restoredById.get(value.id);
+ return [restored.suit, restored.rank];
+ }),
+ [
+ ['clubs', 'A'],
+ ['hearts', 'A'],
+ ['clubs', '2'],
+ ['hearts', '2'],
+ ['joker', 'small_joker'],
+ ['joker', 'big_joker'],
+ ['hearts', '5']
+ ]
+ );
+ assert.ok(room.players[2].cards.every(value => !value.isTeammateCheered));
+ assert.equal(room.gameState.teammateCheerLastResult, null);
+});
+
+test('队友加油不会在出牌者仍持有主牌或已经出完牌时询问', () => {
+ const room = createRoom();
+ const engine = new GameEngine(room, createIo());
+ const sideCard = card('clubs', '3', 1500);
+ const remainingTrump = card('hearts', '4', 1501);
+ room.players[0].addCard(sideCard);
+ room.players[0].addCard(remainingTrump);
+ room.players[1].addCard(card('clubs', '5', 1502));
+ room.players[2].addCard(card('clubs', '6', 1503));
+ room.players[3].addCard(card('clubs', '7', 1504));
+ room.gameState.phase = GamePhases.PLAYING;
+ room.gameState.selectedRule = TEAMMATE_CHEER_RULE;
+ room.gameState.trumpSuit = 'hearts';
+ room.gameState.trumpRank = '2';
+ room.gameState.buryingPlayerId = room.players[0].id;
+ engine.setFirstPlayer(room.players[0].id);
+
+ const result = engine.playCards(room.players[0].id, [sideCard.id]);
+ assert.equal(result.teammateCheerRequest, null);
+ assert.equal(engine.hasPendingTeammateCheerDecision(), false);
+
+ const emptyRoom = createRoom();
+ const emptyEngine = new GameEngine(emptyRoom, createIo());
+ const lastCard = card('clubs', '8', 1510);
+ emptyRoom.players[0].addCard(lastCard);
+ emptyRoom.players[1].addCard(card('clubs', '9', 1511));
+ emptyRoom.players[2].addCard(card('clubs', '10', 1512));
+ emptyRoom.players[3].addCard(card('clubs', 'J', 1513));
+ emptyRoom.gameState.phase = GamePhases.PLAYING;
+ emptyRoom.gameState.selectedRule = TEAMMATE_CHEER_RULE;
+ emptyRoom.gameState.trumpSuit = 'hearts';
+ emptyRoom.gameState.trumpRank = '2';
+ emptyRoom.gameState.buryingPlayerId = emptyRoom.players[0].id;
+ emptyEngine.setFirstPlayer(emptyRoom.players[0].id);
+
+ const finalHandResult = emptyEngine.playCards(emptyRoom.players[0].id, [lastCard.id]);
+ assert.equal(emptyRoom.players[0].cards.length, 0);
+ assert.equal(finalHandResult.teammateCheerRequest, null);
+ assert.equal(emptyEngine.hasPendingTeammateCheerDecision(), false);
+});
+
+test('回光返照允许持有1至3张主牌的开局一号位在首次出牌前直接发动', () => {
+ const room = createRoom();
+ const io = createIo();
+ const engine = new GameEngine(room, io);
+ const openingTrumps = [
+ card('spades', 'K', 1512),
+ card('joker', 'small_joker', 1513)
+ ];
+ [...openingTrumps, card('clubs', '4', 1514)].forEach(value => (
+ room.players[0].addCard(value)
+ ));
+ room.players[1].addCard(card('spades', 'Q', 1515));
+ room.players[2].addCard(card('spades', 'J', 1516));
+ room.players[3].addCard(card('spades', '10', 1517));
+ room.gameState.phase = GamePhases.PLAYING;
+ room.gameState.selectedRule = AFTERGLOW_RULE;
+ room.gameState.trumpSuit = 'spades';
+ room.gameState.trumpRank = '2';
+ room.gameState.buryingPlayerId = room.players[0].id;
+
+ engine.setFirstPlayer(room.players[0].id);
+
+ const pending = room.gameState.afterglowPending;
+ assert.equal(pending.playerId, room.players[0].id);
+ assert.equal(pending.trumpCount, 2);
+ assert.equal(pending.triggerTiming, 'before_first_play');
+ assert.ok(io.events.some(event => (
+ event.target === room.players[0].socketId
+ && event.event === 'afterglow_decision_required'
+ && event.payload.triggerTiming === 'before_first_play'
+ )));
+ assert.throws(
+ () => engine.playCards(room.players[0].id, [openingTrumps[0].id]),
+ /回光返照/
+ );
+
+ const accepted = engine.respondAfterglow(room.players[0].id, true);
+ assert.equal(accepted.accepted, true);
+ assert.equal(room.gameState.afterglowActivePlayerIds.has(room.players[0].id), true);
+ const boostedById = new Map(room.players[0].cards.map(value => [value.id, value]));
+ assert.equal(boostedById.get(openingTrumps[0].id).rank, 'A');
+ assert.equal(boostedById.get(openingTrumps[1].id).rank, 'big_joker');
+
+ const firstPlay = engine.playCards(room.players[0].id, [openingTrumps[0].id]);
+ assert.equal(room.gameState.playHistory.at(-1).afterglowActive, true);
+ assert.equal(firstPlay.playedCards[0].rank, 'A');
+});
+
+test('回光返照在无主局不询问且不能强制发动', () => {
+ const room = createRoom();
+ const io = createIo();
+ const engine = new GameEngine(room, io);
+ const player = room.players[0];
+ [
+ card('spades', '2', 1518),
+ card('joker', 'small_joker', 1519),
+ card('clubs', '4', 1520)
+ ].forEach(value => player.addCard(value));
+ room.players[1].addCard(card('clubs', '5', 1521));
+ room.players[2].addCard(card('clubs', '6', 1522));
+ room.players[3].addCard(card('clubs', '7', 1523));
+ room.gameState.phase = GamePhases.PLAYING;
+ room.gameState.selectedRule = AFTERGLOW_RULE;
+ room.gameState.trumpSuit = 'no_trump';
+ room.gameState.trumpRank = '2';
+ room.gameState.buryingPlayerId = player.id;
+
+ engine.setFirstPlayer(player.id);
+
+ assert.equal(engine.isEligibleForAfterglow(player), false);
+ assert.equal(engine.requestAfterglowIfEligible(player), null);
+ assert.equal(room.gameState.afterglowPending, null);
+ assert.equal(
+ io.events.some(event => event.event === 'afterglow_decision_required'),
+ false
+ );
+
+ room.gameState.afterglowPending = {
+ playerId: player.id,
+ playerName: player.name,
+ trumpCount: 2,
+ remainingCount: player.cards.length,
+ triggerTiming: 'before_first_play',
+ triggerRound: 1
+ };
+ assert.throws(
+ () => engine.respondAfterglow(player.id, true),
+ /无主局不能发动回光返照/
+ );
+ assert.equal(room.gameState.afterglowPending, null);
+ assert.equal(room.gameState.afterglowUsedPlayerIds.has(player.id), false);
+});
+
+test('回光返照只在出牌后仍有手牌且剩余1至3张主牌时询问,暂拒不消耗机会', () => {
+ const room = createRoom();
+ const engine = new GameEngine(room, createIo());
+ const openingCard = card('clubs', '4', 1520);
+ const remainingTrumps = [
+ card('clubs', '2', 1522),
+ card('joker', 'small_joker', 1523),
+ card('joker', 'big_joker', 1524)
+ ];
+ [openingCard, ...remainingTrumps].forEach(value => (
+ room.players[0].addCard(value)
+ ));
+ room.players[1].addCard(card('clubs', '5', 1525));
+ room.players[2].addCard(card('clubs', '6', 1526));
+ room.players[3].addCard(card('clubs', '7', 1527));
+ room.gameState.phase = GamePhases.PLAYING;
+ room.gameState.selectedRule = AFTERGLOW_RULE;
+ room.gameState.trumpSuit = 'clubs';
+ room.gameState.trumpRank = '2';
+ room.gameState.buryingPlayerId = room.players[0].id;
+ engine.setFirstPlayer(room.players[0].id);
+
+ const playResult = engine.playCards(room.players[0].id, [openingCard.id]);
+ assert.equal(playResult.afterglowRequest.playerId, room.players[0].id);
+ assert.equal(playResult.afterglowRequest.trumpCount, 3);
+ assert.equal(engine.hasPendingAfterglowDecision(), true);
+ assert.throws(
+ () => engine.playCards(room.players[1].id, [room.players[1].cards[0].id]),
+ /回光返照/
+ );
+
+ const declined = engine.respondAfterglow(room.players[0].id, false);
+ assert.equal(declined.accepted, false);
+ assert.equal(room.gameState.afterglowUsedPlayerIds.has(room.players[0].id), false);
+ assert.ok(engine.requestAfterglowIfEligible(room.players[0]));
+
+ const accepted = engine.respondAfterglow(room.players[0].id, true);
+ assert.equal(accepted.accepted, true);
+ assert.equal(room.gameState.afterglowUsedPlayerIds.has(room.players[0].id), true);
+ assert.equal(room.gameState.afterglowActivePlayerIds.has(room.players[0].id), true);
+ assert.deepEqual(room.gameState.toJSON().afterglow.activePlayerIds, [room.players[0].id]);
+ const boostedById = new Map(room.players[0].cards.map(value => [value.id, value]));
+ assert.deepEqual(
+ remainingTrumps.map(value => {
+ const boosted = boostedById.get(value.id);
+ return [boosted.suit, boosted.rank, boosted.originalSuit, boosted.originalRank];
+ }),
+ [
+ ['joker', 'small_joker', 'clubs', '2'],
+ ['joker', 'big_joker', 'joker', 'small_joker'],
+ ['joker', 'county_prince_joker', 'joker', 'big_joker']
+ ]
+ );
+ assert.ok(room.players[0].cards.every(value => value.isAfterglowBoosted));
+ assert.deepEqual(
+ accepted.transformedCards.map(value => value.id).sort(),
+ room.players[0].cards.map(value => value.id).sort()
+ );
+
+ const undoResult = engine.undoLastPlay(room.players[0].id);
+ assert.equal(undoResult.afterglowReverted.activationReverted, true);
+ assert.equal(room.gameState.afterglowUsedPlayerIds.has(room.players[0].id), false);
+ assert.equal(room.gameState.afterglowActivePlayerIds.has(room.players[0].id), false);
+ assert.equal(room.gameState.afterglowLastResult, null);
+ const restoredById = new Map(room.players[0].cards.map(value => [value.id, value]));
+ assert.deepEqual(
+ remainingTrumps.map(value => {
+ const restored = restoredById.get(value.id);
+ return [restored.suit, restored.rank, restored.isAfterglowBoosted];
+ }),
+ [
+ ['clubs', '2', false],
+ ['joker', 'small_joker', false],
+ ['joker', 'big_joker', false]
+ ]
+ );
+ assert.equal(undoResult.afterglowRestoredCards.length, 4);
+});
+
+test('回光返照不会在零主牌、超过三张主牌或最后一手之后误触发', () => {
+ const room = createRoom();
+ const engine = new GameEngine(room, createIo());
+ room.gameState.phase = GamePhases.PLAYING;
+ room.gameState.selectedRule = AFTERGLOW_RULE;
+ room.gameState.trumpSuit = 'spades';
+ room.gameState.trumpRank = '2';
+
+ room.players[0].cards = [card('clubs', '3', 1530), card('diamonds', '4', 1531)];
+ assert.equal(engine.isEligibleForAfterglow(room.players[0]), false);
+
+ room.players[0].cards = [
+ card('spades', '3', 1532),
+ card('spades', '4', 1533),
+ card('spades', '5', 1534),
+ card('spades', '6', 1535)
+ ];
+ assert.equal(engine.isEligibleForAfterglow(room.players[0]), false);
+
+ const finalRoom = createRoom();
+ const finalEngine = new GameEngine(finalRoom, createIo());
+ const lastCard = card('clubs', '8', 1540);
+ finalRoom.players[0].addCard(lastCard);
+ finalRoom.players[1].addCard(card('clubs', '9', 1541));
+ finalRoom.players[2].addCard(card('clubs', '10', 1542));
+ finalRoom.players[3].addCard(card('clubs', 'J', 1543));
+ finalRoom.gameState.phase = GamePhases.PLAYING;
+ finalRoom.gameState.selectedRule = AFTERGLOW_RULE;
+ finalRoom.gameState.trumpSuit = 'spades';
+ finalRoom.gameState.trumpRank = '2';
+ finalRoom.gameState.buryingPlayerId = finalRoom.players[0].id;
+ finalEngine.setFirstPlayer(finalRoom.players[0].id);
+
+ const finalResult = finalEngine.playCards(finalRoom.players[0].id, [lastCard.id]);
+ assert.equal(finalResult.afterglowRequest, null);
+ assert.equal(finalEngine.hasPendingAfterglowDecision(), false);
+});
+
+test('回光返照发动后整次出牌只能由主牌组成,主牌+1且出尽后效果结束', () => {
+ const room = createRoom();
+ const engine = new GameEngine(room, createIo());
+ const leadPair = [card('clubs', '6', 1550), card('clubs', '6', 1551)];
+ const heldLedSuit = card('clubs', '7', 1552);
+ const trumpKing = card('spades', 'K', 1553);
+ const offSuitFive = card('diamonds', '5', 1554);
+ const trumpQueen = card('spades', 'Q', 1558);
+ leadPair.forEach(value => room.players[0].addCard(value));
+ room.players[0].addCard(card('diamonds', '8', 1555));
+ [heldLedSuit, trumpKing, offSuitFive, trumpQueen].forEach(value => room.players[1].addCard(value));
+ room.players[2].addCard(card('clubs', '8', 1556));
+ room.players[3].addCard(card('clubs', '9', 1557));
+ room.gameState.phase = GamePhases.PLAYING;
+ room.gameState.selectedRule = AFTERGLOW_RULE;
+ room.gameState.trumpSuit = 'spades';
+ room.gameState.trumpRank = '2';
+ room.gameState.buryingPlayerId = room.players[0].id;
+ room.gameState.afterglowUsedPlayerIds.add(room.players[1].id);
+ room.gameState.afterglowActivePlayerIds.add(room.players[1].id);
+ engine.setFirstPlayer(room.players[0].id);
+
+ engine.playCards(room.players[0].id, leadPair.map(value => value.id));
+ assert.throws(
+ () => engine.playCards(room.players[1].id, [trumpKing.id, offSuitFive.id]),
+ /只能由主牌组成/
+ );
+
+ const result = engine.playCards(
+ room.players[1].id,
+ [trumpKing.id, trumpQueen.id]
+ );
+ const boostedTrump = result.playedCards.find(value => value.id === trumpKing.id);
+ assert.equal(boostedTrump.rank, 'A');
+ assert.equal(boostedTrump.isAfterglowBoosted, true);
+ assert.equal(getCardPoints(boostedTrump), 10);
+ assert.equal(result.currentWinningPlayerId, room.players[1].id);
+ assert.equal(result.afterglowExpired.playerId, room.players[1].id);
+ assert.equal(room.gameState.afterglowActivePlayerIds.has(room.players[1].id), false);
+
+ const undoResult = engine.undoLastPlay(room.players[1].id);
+ assert.equal(undoResult.afterglowReverted.effectRestored, true);
+ assert.equal(room.gameState.afterglowActivePlayerIds.has(room.players[1].id), true);
+ assert.ok(room.players[1].cards.some(value => value.id === trumpKing.id));
+});
+
+test('虚虚实实只虚置奇数张首家副花色,并禁止把虚置牌打出', () => {
+ const room = createRoom();
+ const engine = new GameEngine(room, createIo());
+ const leadPair = [card('clubs', '6', 1600), card('clubs', '6', 1601)];
+ const oddLedSuitCards = [
+ card('clubs', '7', 1610),
+ card('clubs', '8', 1611),
+ card('clubs', '8', 1612)
+ ];
+ const trumpPair = [card('spades', '3', 1620), card('spades', '3', 1621)];
+ leadPair.forEach(value => room.players[0].addCard(value));
+ room.players[0].addCard(card('diamonds', '4', 1602));
+ [...oddLedSuitCards, ...trumpPair].forEach(value => room.players[1].addCard(value));
+ room.players[2].addCard(card('clubs', '9', 1630));
+ room.players[3].addCard(card('clubs', '10', 1640));
+ room.gameState.phase = GamePhases.PLAYING;
+ room.gameState.selectedRule = ILLUSION_AND_REALITY_RULE;
+ room.gameState.trumpSuit = 'spades';
+ room.gameState.trumpRank = '2';
+ room.gameState.buryingPlayerId = room.players[0].id;
+ engine.setFirstPlayer(room.players[0].id);
+
+ engine.playCards(room.players[0].id, leadPair.map(value => value.id));
+ assert.throws(
+ () => engine.playCards(room.players[1].id, trumpPair.map(value => value.id)),
+ /必须优先出|跟出|副牌/
+ );
+ assert.throws(
+ () => engine.playCards(
+ room.players[1].id,
+ oddLedSuitCards.slice(0, 2).map(value => value.id),
+ null,
+ ActiveSkillIds.ILLUSION_AND_REALITY
+ ),
+ /不能打出被虚置/
+ );
+
+ const result = engine.playCards(
+ room.players[1].id,
+ trumpPair.map(value => value.id),
+ null,
+ ActiveSkillIds.ILLUSION_AND_REALITY
+ );
+ assert.equal(result.activeSkillActivation.id, ActiveSkillIds.ILLUSION_AND_REALITY);
+ assert.equal(result.activeSkillActivation.ignoredSuit, 'clubs');
+ assert.equal(engine.hasUsedActiveSkill(
+ room.players[1].id,
+ ActiveSkillIds.ILLUSION_AND_REALITY
+ ), true);
+ assert.equal(room.gameState.currentRoundPlays[1].pattern.suit, 'trump');
+
+ engine.undoLastPlay(room.players[1].id);
+ assert.equal(engine.hasUsedActiveSkill(
+ room.players[1].id,
+ ActiveSkillIds.ILLUSION_AND_REALITY
+ ), false);
+ room.players[1].removeCards([oddLedSuitCards[0].id]);
+ assert.throws(
+ () => engine.playCards(
+ room.players[1].id,
+ trumpPair.map(value => value.id),
+ null,
+ ActiveSkillIds.ILLUSION_AND_REALITY
+ ),
+ /奇数张/
+ );
+});
+
+test('貌合神离忽略花色比较牌型,但区分两个对子、一对两单与四张单牌', () => {
+ const trumpSuit = 'spades';
+ const trumpRank = '2';
+ const heartPair = [card('hearts', '6', 1700), card('hearts', '6', 1701)];
+ const clubPair = [card('clubs', '9', 1702), card('clubs', '9', 1703)];
+ const twoPairs = [
+ card('hearts', '6', 1704),
+ card('hearts', '6', 1705),
+ card('hearts', '9', 1706),
+ card('hearts', '9', 1707)
+ ];
+ const pairAndSingles = [
+ card('hearts', '6', 1708),
+ card('hearts', '6', 1709),
+ card('hearts', '9', 1710),
+ card('hearts', 'J', 1711)
+ ];
+ const singles = ['4', '6', '9', 'J'].map((rank, index) => (
+ card('hearts', rank, 1712 + index)
+ ));
+ const profile = cards => getSuitlessPatternProfile(
+ {
+ cards,
+ pattern: detectPattern(
+ cards,
+ trumpSuit,
+ trumpRank,
+ OUTWARD_HARMONY_INNER_DIVISION_RULE
+ )
+ },
+ trumpSuit,
+ trumpRank,
+ OUTWARD_HARMONY_INNER_DIVISION_RULE
+ );
+
+ assert.equal(profile(heartPair).key, profile(clubPair).key);
+ assert.equal(profile(heartPair).label, '对子');
+ assert.equal(profile(twoPairs).label, '对子×2');
+ assert.equal(profile(pairAndSingles).label, '对子+单牌×2');
+ assert.equal(profile(singles).label, '单牌×4');
+ assert.notEqual(profile(twoPairs).key, profile(pairAndSingles).key);
+ assert.notEqual(profile(pairAndSingles).key, profile(singles).key);
+});
+
+test('貌合神离在轮末分别判定两队,单方失和给对方5分、双方失和相互抵消', () => {
+ const runRound = ({ dealerPartnerPair, lastAttackerPair }) => {
+ const room = createRoom();
+ const engine = new GameEngine(room, createIo());
+ const pair = (rank, seed) => [
+ card('hearts', rank, seed),
+ card('hearts', rank, seed + 1)
+ ];
+ const singles = (ranks, seed) => ranks.map((rank, index) => (
+ card('hearts', rank, seed + index)
+ ));
+ const hands = [
+ pair('3', 1720),
+ pair('6', 1730),
+ dealerPartnerPair ? pair('8', 1740) : singles(['7', '8'], 1740),
+ lastAttackerPair ? pair('9', 1750) : singles(['9', 'J'], 1750)
+ ];
+ hands.forEach((hand, playerIndex) => {
+ hand.forEach(value => room.players[playerIndex].addCard(value));
+ });
+ room.gameState.phase = GamePhases.PLAYING;
+ room.gameState.selectedRule = OUTWARD_HARMONY_INNER_DIVISION_RULE;
+ room.gameState.trumpSuit = 'spades';
+ room.gameState.trumpRank = '2';
+ room.gameState.buryingPlayerId = room.players[0].id;
+ engine.setFirstPlayer(room.players[0].id);
+
+ engine.playCards(room.players[0].id, hands[0].map(value => value.id));
+ engine.playCards(room.players[1].id, hands[1].map(value => value.id));
+ engine.playCards(room.players[2].id, hands[2].map(value => value.id));
+ return engine.playCards(room.players[3].id, hands[3].map(value => value.id));
+ };
+
+ const dealerMismatch = runRound({
+ dealerPartnerPair: false,
+ lastAttackerPair: true
+ }).roundUpdate.scoreInfo.outwardHarmonyInnerDivision;
+ assert.equal(dealerMismatch.dealerTeam.mismatched, true);
+ assert.equal(dealerMismatch.attackerTeam.mismatched, false);
+ assert.equal(dealerMismatch.attackerAward, 5);
+ assert.equal(dealerMismatch.dealerAward, 0);
+ assert.equal(dealerMismatch.attackerScoreDelta, 5);
+ assert.equal(dealerMismatch.attackerScore, 5);
+
+ const attackerMismatch = runRound({
+ dealerPartnerPair: true,
+ lastAttackerPair: false
+ }).roundUpdate.scoreInfo.outwardHarmonyInnerDivision;
+ assert.equal(attackerMismatch.dealerTeam.mismatched, false);
+ assert.equal(attackerMismatch.attackerTeam.mismatched, true);
+ assert.equal(attackerMismatch.attackerAward, 0);
+ assert.equal(attackerMismatch.dealerAward, 5);
+ assert.equal(attackerMismatch.attackerScoreDelta, -5);
+ assert.equal(attackerMismatch.attackerScore, -5);
+
+ const bothMismatch = runRound({
+ dealerPartnerPair: false,
+ lastAttackerPair: false
+ }).roundUpdate.scoreInfo.outwardHarmonyInnerDivision;
+ assert.equal(bothMismatch.dealerTeam.mismatched, true);
+ assert.equal(bothMismatch.attackerTeam.mismatched, true);
+ assert.equal(bothMismatch.attackerAward, 5);
+ assert.equal(bothMismatch.dealerAward, 5);
+ assert.equal(bothMismatch.attackerScoreDelta, 0);
+ assert.equal(bothMismatch.attackerScore, 0);
+});
+
+test('模棱两可只允许二三号位发动,且两套方案必须不同并各自合法', () => {
+ const room = createRoom();
+ const engine = new GameEngine(room, createIo());
+ const hands = [
+ [card('hearts', '4', 1800), card('clubs', '3', 1801)],
+ [card('hearts', '5', 1810), card('hearts', '10', 1811), card('clubs', '4', 1812)],
+ [card('hearts', '6', 1820), card('hearts', '7', 1821), card('clubs', '5', 1822)],
+ [card('hearts', '8', 1830), card('hearts', '9', 1831), card('clubs', '6', 1832)]
+ ];
+ hands.forEach((hand, playerIndex) => {
+ hand.forEach(value => room.players[playerIndex].addCard(value));
+ });
+ room.gameState.phase = GamePhases.PLAYING;
+ room.gameState.selectedRule = AMBIGUOUS_RULE;
+ room.gameState.trumpSuit = 'spades';
+ room.gameState.trumpRank = '2';
+ room.gameState.buryingPlayerId = room.players[0].id;
+ engine.setFirstPlayer(room.players[0].id);
+
+ assert.throws(
+ () => engine.playCards(
+ room.players[0].id,
+ [hands[0][0].id],
+ null,
+ ActiveSkillIds.AMBIGUOUS,
+ { ambiguousAlternativeCardIds: [hands[0][1].id] }
+ ),
+ /二号位或三号位/
+ );
+
+ engine.playCards(room.players[0].id, [hands[0][0].id]);
+ assert.throws(
+ () => engine.playCards(
+ room.players[1].id,
+ [hands[1][0].id],
+ null,
+ ActiveSkillIds.AMBIGUOUS,
+ { ambiguousAlternativeCardIds: [hands[1][0].id] }
+ ),
+ /两种不同/
+ );
+ engine.playCards(room.players[1].id, [hands[1][0].id]);
+ engine.playCards(room.players[2].id, [hands[2][0].id]);
+ assert.throws(
+ () => engine.playCards(
+ room.players[3].id,
+ [hands[3][0].id],
+ null,
+ ActiveSkillIds.AMBIGUOUS,
+ { ambiguousAlternativeCardIds: [hands[3][1].id] }
+ ),
+ /二号位或三号位/
+ );
+});
+
+test('模棱两可公开两套牌,轮末按三号位到二号位选择后才结算', () => {
+ const room = createRoom();
+ const engine = new GameEngine(room, createIo());
+ const hands = [
+ [card('hearts', '4', 1840), card('clubs', '3', 1841)],
+ [card('hearts', '5', 1850), card('hearts', '10', 1851), card('clubs', '4', 1852)],
+ [card('hearts', '6', 1860), card('hearts', '7', 1861), card('clubs', '5', 1862)],
+ [card('hearts', '8', 1870), card('clubs', '6', 1871)]
+ ];
+ hands.forEach((hand, playerIndex) => {
+ hand.forEach(value => room.players[playerIndex].addCard(value));
+ });
+ room.gameState.phase = GamePhases.PLAYING;
+ room.gameState.selectedRule = AMBIGUOUS_RULE;
+ room.gameState.trumpSuit = 'spades';
+ room.gameState.trumpRank = '2';
+ room.gameState.buryingPlayerId = room.players[0].id;
+ engine.setFirstPlayer(room.players[0].id);
+
+ engine.playCards(room.players[0].id, [hands[0][0].id]);
+ const secondPlay = engine.playCards(
+ room.players[1].id,
+ [hands[1][0].id],
+ null,
+ ActiveSkillIds.AMBIGUOUS,
+ { ambiguousAlternativeCardIds: [hands[1][1].id] }
+ );
+ assert.deepEqual(
+ secondPlay.ambiguousOptions.map(option => option.cards.map(value => value.id)),
+ [[hands[1][0].id], [hands[1][1].id]]
+ );
+ assert.equal(engine.hasUsedActiveSkill(room.players[1].id, ActiveSkillIds.AMBIGUOUS), true);
+
+ const thirdPlay = engine.playCards(
+ room.players[2].id,
+ [hands[2][0].id],
+ null,
+ ActiveSkillIds.AMBIGUOUS,
+ { ambiguousAlternativeCardIds: [hands[2][1].id] }
+ );
+ assert.equal(thirdPlay.activeSkillActivation.usageConsumed, false);
+ assert.equal(engine.hasUsedActiveSkill(room.players[2].id, ActiveSkillIds.AMBIGUOUS), false);
+
+ const held = engine.playCards(room.players[3].id, [hands[3][0].id]);
+ assert.equal(held.ambiguousDecisionPending, true);
+ assert.equal(held.roundUpdate.type, 'ambiguous_choice_pending');
+ assert.equal(room.gameState.currentPlayerIndex, null);
+ assert.equal(room.gameState.currentRound, 1);
+ assert.equal(room.gameState.currentRoundPlays.length, 3);
+ assert.deepEqual(
+ held.ambiguousDecision.queuePlayerIds,
+ [room.players[2].id, room.players[1].id]
+ );
+
+ const thirdChoice = engine.resolveAmbiguousChoice(room.players[2].id, 1);
+ assert.equal(thirdChoice.completed, false);
+ assert.equal(thirdChoice.nextDecision.playerId, room.players[1].id);
+ assert.equal(room.players[2].cards.some(value => value.id === hands[2][0].id), true);
+ assert.equal(room.players[2].cards.some(value => value.id === hands[2][1].id), false);
+
+ const secondChoice = engine.resolveAmbiguousChoice(room.players[1].id, 1);
+ assert.equal(secondChoice.completed, true);
+ assert.equal(secondChoice.roundResult.roundUpdate.type, 'round_ended');
+ assert.equal(secondChoice.roundResult.roundUpdate.roundWinner.playerId, room.players[1].id);
+ assert.equal(room.players[1].cards.some(value => value.id === hands[1][0].id), true);
+ assert.equal(room.players[1].cards.some(value => value.id === hands[1][1].id), false);
+ assert.equal(room.gameState.ambiguousRoundDecision, null);
+});
+
+test('调虎离山在轮首按座次询问,并由先选定目标者抢占本方次数', () => {
+ const room = createRoom();
+ const engine = new GameEngine(room, createIo());
+ const hands = [
+ [card('hearts', 'A', 2100), card('clubs', '5', 2101), card('diamonds', '6', 2102)],
+ [card('hearts', 'K', 2110), card('clubs', 'K', 2111), card('diamonds', '7', 2112)],
+ [card('hearts', 'Q', 2120), card('clubs', '4', 2121), card('diamonds', '8', 2122)],
+ [card('hearts', 'J', 2130), card('clubs', '3', 2131), card('diamonds', '9', 2132)]
+ ];
+ hands.forEach((hand, playerIndex) => {
+ hand.forEach(value => room.players[playerIndex].addCard(value));
+ });
+ room.gameState.phase = GamePhases.PLAYING;
+ room.gameState.selectedRule = LURE_TIGER_RULE;
+ room.gameState.trumpSuit = 'spades';
+ room.gameState.trumpRank = '2';
+ room.gameState.buryingPlayerId = room.players[1].id;
+ engine.setFirstPlayer(room.players[0].id);
+
+ engine.playCards(room.players[0].id, [hands[0][0].id]);
+ const firstReservation = engine.activateLureTiger(room.players[0].id);
+ const teammateReservation = engine.activateLureTiger(room.players[2].id);
+ const opposingReservation = engine.activateLureTiger(room.players[1].id);
+ assert.equal(firstReservation.targetRound, 2);
+ assert.equal(teammateReservation.targetRound, 2);
+ assert.equal(opposingReservation.targetRound, 2);
+
+ engine.playCards(room.players[1].id, [hands[1][0].id]);
+ engine.playCards(room.players[2].id, [hands[2][0].id]);
+ engine.playCards(room.players[3].id, [hands[3][0].id]);
+
+ assert.equal(room.gameState.currentRound, 2);
+ assert.equal(room.gameState.lureTigerCurrentDecision.playerId, room.players[0].id);
+ assert.deepEqual(
+ room.gameState.lureTigerDecisionQueue,
+ [room.players[1].id, room.players[2].id]
+ );
+
+ const confirmation = engine.respondLureTiger(room.players[0].id, true);
+ assert.equal(confirmation.needsTarget, true);
+ assert.equal(room.gameState.lureTigerCurrentDecision.stage, 'target');
+ const activation = engine.selectLureTigerTarget(
+ room.players[0].id,
+ room.players[1].id
+ );
+ assert.equal(activation.targetPlayerId, room.players[1].id);
+ assert.deepEqual(Array.from(room.gameState.lureTigerUsedTeamIndexes), [0]);
+ assert.equal(room.gameState.lureTigerReservations.has(room.players[2].id), false);
+ assert.equal(room.gameState.lureTigerCurrentDecision.playerId, room.players[1].id);
+ assert.throws(
+ () => engine.activateLureTiger(room.players[2].id),
+ /阵营本局已经发动过/
+ );
+ engine.respondLureTiger(room.players[1].id, true);
+ const opposingActivation = engine.selectLureTigerTarget(
+ room.players[1].id,
+ room.players[2].id
+ );
+ assert.equal(opposingActivation.targetPlayerId, room.players[2].id);
+ assert.deepEqual(
+ Array.from(room.gameState.lureTigerUsedTeamIndexes).sort(),
+ [0, 1]
+ );
+ assert.equal(engine.hasPendingLureTigerDecision(), false);
+ const attackerScoreBeforeSilencedRound = room.gameState.attackerScore;
+
+ engine.playCards(room.players[0].id, [hands[0][1].id]);
+ const silencedPlay = engine.playCards(room.players[1].id, [hands[1][1].id]);
+ assert.equal(silencedPlay.lureTigerSilenced, true);
+ assert.equal(room.gameState.currentWinnerIndex, 0, '沉默目标的K不能压过首发5');
+ engine.playCards(room.players[2].id, [hands[2][1].id]);
+ const roundResult = engine.playCards(room.players[3].id, [hands[3][1].id]);
+ assert.equal(roundResult.roundUpdate.roundWinner.playerId, room.players[0].id);
+ assert.equal(roundResult.roundUpdate.scoreInfo.baseRoundPoints, 5);
+ assert.equal(roundResult.roundUpdate.scoreInfo.roundPoints, 5);
+ assert.equal(
+ roundResult.roundUpdate.scoreInfo.attackerScore,
+ attackerScoreBeforeSilencedRound + 5
+ );
+ assert.equal(room.gameState.currentRound, 3);
+ assert.equal(room.gameState.lureTigerSilencedPlayerIds.size, 0);
+});
+
+test('调虎离山的沉默目标不会令一号位甩牌失败', () => {
+ const createThrowRoom = selectedRule => {
+ const room = createRoom();
+ const leaderCards = [card('hearts', 'A', 2200), card('hearts', 'K', 2201)];
+ const hands = [
+ leaderCards,
+ [card('hearts', 'A', 2210), card('hearts', '4', 2211)],
+ [card('hearts', '9', 2220), card('hearts', '8', 2221)],
+ [card('hearts', '7', 2230), card('hearts', '6', 2231)]
+ ];
+ hands.forEach((hand, playerIndex) => {
+ hand.forEach(value => room.players[playerIndex].addCard(value));
+ });
+ room.gameState.phase = GamePhases.PLAYING;
+ room.gameState.selectedRule = selectedRule;
+ room.gameState.trumpSuit = 'spades';
+ room.gameState.trumpRank = '2';
+ const engine = new GameEngine(room, createIo());
+ engine.setFirstPlayer(room.players[0].id);
+ return { room, engine, leaderCards };
+ };
+
+ const normal = createThrowRoom(NORMAL_RULE);
+ const failed = normal.engine.playCards(
+ normal.room.players[0].id,
+ normal.leaderCards.map(value => value.id)
+ );
+ assert.ok(failed.throwFailed);
+
+ const silenced = createThrowRoom(LURE_TIGER_RULE);
+ silenced.engine.activateLureTiger(silenced.room.players[2].id);
+ silenced.engine.respondLureTiger(silenced.room.players[2].id, true);
+ silenced.engine.selectLureTigerTarget(
+ silenced.room.players[2].id,
+ silenced.room.players[1].id
+ );
+ const successful = silenced.engine.playCards(
+ silenced.room.players[0].id,
+ silenced.leaderCards.map(value => value.id)
+ );
+ assert.equal(successful.throwFailed, null);
+ assert.equal(silenced.room.gameState.leadingPattern.type, PatternTypes.THROW);
+});
+
+test('二律背反只在庄家埋完底后选择,收齐前不泄露牌面并同时亮出', () => {
+ const room = createRoom();
+ const io = createIo();
+ const engine = new GameEngine(room, io, () => 0);
+ const buriedCards = Array.from({ length: 8 }, (_, index) => (
+ card('diamonds', String(3 + index), 2300 + index)
+ ));
+ const openingCards = [
+ card('hearts', '3', 2310),
+ card('hearts', '4', 2311),
+ card('hearts', '6', 2312),
+ card('hearts', '8', 2313)
+ ];
+ buriedCards.forEach(value => room.players[0].addCard(value));
+ openingCards.forEach((value, index) => room.players[index].addCard(value));
+ room.gameState.phase = GamePhases.BURYING;
+ room.gameState.selectedRule = ANTINOMY_RULE;
+ room.gameState.bottomCardsCount = 8;
+ room.gameState.buryingPlayerId = room.players[0].id;
+ room.gameState.trumpSuit = 'spades';
+ room.gameState.trumpRank = '2';
+
+ assert.equal(io.events.some(({ event }) => event.startsWith('antinomy_')), false);
+ engine.buryCards(room.players[0].id, buriedCards.map(value => value.id));
+ assert.equal(room.gameState.phase, GamePhases.PLAYING);
+ assert.equal(room.gameState.currentRound, 1);
+ assert.equal(room.gameState.antinomyPendingPlayerIds.size, 4);
+ assert.deepEqual(room.gameState.toJSON().antinomy.declarationsByPlayerId, {});
+ assert.throws(
+ () => engine.playCards(room.players[0].id, [openingCards[0].id]),
+ /二律背反.*完成选择/
+ );
+
+ const choices = [
+ ['hearts', '5'],
+ ['hearts', '5'],
+ ['clubs', '7'],
+ ['spades', '9']
+ ];
+ choices.slice(0, 3).forEach(([suit, rank], index) => {
+ engine.selectAntinomyCard(room.players[index].id, suit, rank);
+ assert.deepEqual(
+ room.gameState.toJSON().antinomy.declarationsByPlayerId,
+ {},
+ '最后一人提交前不能公开任何人的选择'
+ );
+ });
+ const revealed = engine.selectAntinomyCard(
+ room.players[3].id,
+ choices[3][0],
+ choices[3][1]
+ );
+ assert.equal(revealed.pending, false);
+ assert.equal(
+ room.gameState.antinomyDeclarationsByPlayerId.get(room.players[0].id).effective,
+ false
+ );
+ assert.equal(
+ room.gameState.antinomyDeclarationsByPlayerId.get(room.players[2].id).effective,
+ true
+ );
+ assert.equal(
+ io.events.filter(({ event }) => event === 'antinomy_declarations_revealed').length,
+ 1
+ );
+});
+
+test('二律背反的唯一声明拆散实体对子和拖拉机,重复声明则取消效果', () => {
+ const room = createRoom();
+ const engine = new GameEngine(room, createIo());
+ room.gameState.selectedRule = ANTINOMY_RULE;
+ const declarations = [
+ ['hearts', '5'],
+ ['hearts', '5'],
+ ['clubs', '7'],
+ ['spades', '9']
+ ];
+ declarations.forEach(([suit, rank], index) => {
+ room.gameState.antinomyDeclarationsByPlayerId.set(room.players[index].id, {
+ suit,
+ rank,
+ faceKey: `${suit}:${rank}`
+ });
+ });
+ engine.recomputeAntinomyDeclarations();
+ const runtimeRule = engine.getRuleRuntimeContext();
+ const duplicatedPair = [card('hearts', '5', 0), card('hearts', '5', 1)];
+ const splitPair = [card('clubs', '7', 0), card('clubs', '7', 1)];
+ const wouldBeTractor = [
+ ...splitPair,
+ card('clubs', '8', 0),
+ card('clubs', '8', 1)
+ ];
+
+ assert.equal(
+ detectPattern(duplicatedPair, 'spades', '2', runtimeRule).type,
+ PatternTypes.PAIR,
+ '多人重复声明的牌面仍可成对'
+ );
+ assert.equal(
+ detectPattern(splitPair, 'spades', '2', runtimeRule).type,
+ PatternTypes.INVALID,
+ '唯一声明的两张实体牌必须视为不同牌'
+ );
+ assert.equal(
+ detectPattern(wouldBeTractor, 'spades', '2', runtimeRule).type,
+ PatternTypes.INVALID
+ );
+ assert.deepEqual(
+ parseThrowCombination(splitPair, 'spades', '2', runtimeRule).components
+ .map(component => component.type),
+ [PatternTypes.SINGLE, PatternTypes.SINGLE]
+ );
+});
+
+test('二律背反下未被拆散的对A正常压过同花对8', () => {
+ const room = createRoom();
+ const engine = new GameEngine(room, createIo());
+ const hands = [
+ [card('spades', '8', 0), card('spades', '8', 1), card('clubs', '3', 0)],
+ [card('spades', 'J', 0), card('spades', '3', 0), card('clubs', '4', 0)],
+ [card('spades', 'A', 0), card('spades', 'A', 1), card('clubs', '5', 0)],
+ [card('spades', '7', 0), card('spades', '7', 1), card('clubs', '6', 0)]
+ ];
+ hands.forEach((hand, playerIndex) => {
+ hand.forEach(value => room.players[playerIndex].addCard(value));
+ });
+ room.gameState.phase = GamePhases.PLAYING;
+ room.gameState.selectedRule = ANTINOMY_RULE;
+ room.gameState.trumpSuit = 'diamonds';
+ room.gameState.trumpRank = '2';
+ room.gameState.buryingPlayerId = room.players[0].id;
+ engine.setFirstPlayer(room.players[0].id);
+ [
+ ['clubs', 'K'],
+ ['diamonds', '2'],
+ ['clubs', 'K'],
+ ['clubs', '9']
+ ].forEach(([suit, rank], playerIndex) => {
+ engine.selectAntinomyCard(room.players[playerIndex].id, suit, rank);
+ });
+
+ engine.playCards(room.players[0].id, hands[0].slice(0, 2).map(value => value.id));
+ engine.playCards(room.players[1].id, hands[1].slice(0, 2).map(value => value.id));
+ const acePlay = engine.playCards(
+ room.players[2].id,
+ hands[2].slice(0, 2).map(value => value.id)
+ );
+
+ assert.equal(room.gameState.currentWinnerIndex, 2);
+ assert.equal(acePlay.currentWinningPlayerId, room.players[2].id);
+
+ const roundEnd = engine.playCards(
+ room.players[3].id,
+ hands[3].slice(0, 2).map(value => value.id)
+ );
+ assert.equal(roundEnd.roundWinner.playerId, room.players[2].id);
+});
+
+test('二律背反在轮末命中声明后才要求对应声明者重选,并冻结下一轮', () => {
+ const room = createRoom();
+ const io = createIo();
+ const engine = new GameEngine(room, io, () => 0);
+ const hands = [
+ [card('hearts', '3', 2330), card('spades', '3', 2331)],
+ [card('hearts', '5', 2332), card('spades', '4', 2333)],
+ [card('hearts', '7', 2334), card('spades', '5', 2335)],
+ [card('hearts', '9', 2336), card('spades', '6', 2337)]
+ ];
+ hands.forEach((hand, playerIndex) => {
+ hand.forEach(value => room.players[playerIndex].addCard(value));
+ });
+ room.gameState.phase = GamePhases.PLAYING;
+ room.gameState.selectedRule = ANTINOMY_RULE;
+ room.gameState.trumpSuit = 'clubs';
+ room.gameState.trumpRank = '2';
+ room.gameState.buryingPlayerId = room.players[0].id;
+ engine.setFirstPlayer(room.players[0].id);
+ const choices = [
+ ['hearts', '5'],
+ ['diamonds', '6'],
+ ['clubs', '7'],
+ ['spades', '8']
+ ];
+ choices.forEach(([suit, rank], index) => {
+ engine.selectAntinomyCard(room.players[index].id, suit, rank);
+ });
+
+ hands.forEach((hand, index) => engine.playCards(room.players[index].id, [hand[0].id]));
+ assert.equal(room.gameState.currentRound, 2);
+ assert.deepEqual(
+ Array.from(room.gameState.antinomyPendingPlayerIds),
+ [room.players[0].id],
+ '红桃5由别人打出,也应让声明红桃5的玩家重选'
+ );
+ assert.equal(
+ room.gameState.antinomyDeclarationsByPlayerId.get(room.players[0].id).faceKey,
+ 'hearts:5',
+ '重选收齐前继续公开旧声明,不泄露新选择'
+ );
+ assert.throws(
+ () => engine.playCards(room.players[3].id, [hands[3][1].id]),
+ /二律背反.*完成选择/
+ );
+
+ engine.selectAntinomyCard(room.players[0].id, 'diamonds', 'A');
+ assert.equal(
+ room.gameState.antinomyDeclarationsByPlayerId.get(room.players[0].id).faceKey,
+ 'diamonds:A'
+ );
+ assert.doesNotThrow(() => engine.playCards(room.players[3].id, [hands[3][1].id]));
+});
+
+test('改稻为桑由两名闲家各选向下取整的一半分牌,完成前冻结出牌', () => {
+ const room = createRoom();
+ const io = createIo();
+ const engine = new GameEngine(room, io);
+ const dealerLead = card('clubs', '3', 2400);
+ const trumpFive = card('hearts', '5', 2401);
+ const sideKing = card('clubs', 'K', 2402);
+ const sideTen = card('diamonds', '10', 2403);
+ const sideFive = card('spades', '5', 2404);
+ const nonPoint = card('clubs', '4', 2405);
+ const otherTrumpTen = card('hearts', '10', 2406);
+ const otherSideKing = card('diamonds', 'K', 2407);
+
+ room.players[0].addCard(dealerLead);
+ [trumpFive, sideKing, sideTen, sideFive, nonPoint]
+ .forEach(value => room.players[1].addCard(value));
+ room.players[2].addCard(card('clubs', '6', 2408));
+ [otherTrumpTen, otherSideKing, card('spades', '4', 2409)]
+ .forEach(value => room.players[3].addCard(value));
+ room.gameState.phase = GamePhases.PLAYING;
+ room.gameState.selectedRule = CHANGE_RICE_TO_MULBERRY_RULE;
+ room.gameState.trumpSuit = 'hearts';
+ room.gameState.trumpRank = '2';
+ room.gameState.buryingPlayerId = room.players[0].id;
+
+ engine.setFirstPlayer(room.players[0].id);
+
+ assert.deepEqual(
+ Array.from(room.gameState.riceToMulberryPendingPlayerIds),
+ [room.players[1].id, room.players[3].id]
+ );
+ assert.deepEqual(room.gameState.toJSON().riceToMulberry.completedPlayerIds, []);
+ assert.throws(
+ () => engine.playCards(room.players[0].id, [dealerLead.id]),
+ /改稻为桑/
+ );
+ assert.throws(
+ () => engine.selectRiceToMulberryCards(room.players[1].id, [trumpFive.id]),
+ /必须选择 2 张/
+ );
+ assert.throws(
+ () => engine.selectRiceToMulberryCards(
+ room.players[1].id,
+ [trumpFive.id, nonPoint.id]
+ ),
+ /只能选择.*分牌/
+ );
+
+ engine.selectRiceToMulberryCards(
+ room.players[1].id,
+ [trumpFive.id, sideKing.id]
+ );
+ assert.equal(trumpFive.suit, 'joker');
+ assert.equal(trumpFive.rank, 'big_joker');
+ assert.equal(trumpFive.originalSuit, 'hearts');
+ assert.equal(trumpFive.originalRank, '5');
+ assert.equal(sideKing.suit, 'clubs');
+ assert.equal(sideKing.rank, 'A');
+ assert.equal(sideKing.originalRank, 'K');
+ assert.equal(getCardPoints(trumpFive), 0);
+ assert.equal(getCardPoints(sideKing), 0);
+ assert.equal(getCardPoints(sideTen), 10, '未被选择的分牌仍保留分值');
+ assert.equal(room.gameState.riceToMulberryPendingPlayerIds.size, 1);
+
+ engine.selectRiceToMulberryCards(room.players[3].id, [otherSideKing.id]);
+ assert.equal(otherSideKing.suit, 'diamonds');
+ assert.equal(otherSideKing.rank, 'A');
+ assert.equal(getCardPoints(otherSideKing), 0);
+ assert.equal(room.gameState.riceToMulberryPendingPlayerIds.size, 0);
+ assert.deepEqual(
+ new Set(room.gameState.toJSON().riceToMulberry.completedPlayerIds),
+ new Set([room.players[1].id, room.players[3].id])
+ );
+ assert.equal(
+ io.events.filter(({ event }) => event === 'rice_to_mulberry_completed').length,
+ 1
+ );
+
+ engine.playCards(room.players[0].id, [dealerLead.id]);
+ engine.playCards(room.players[1].id, [sideKing.id]);
+ engine.undoLastPlay(room.players[1].id);
+ const restored = room.players[1].cards.find(value => value.id === sideKing.id);
+ assert.equal(restored.rank, 'A');
+ assert.equal(restored.isRiceToMulberryTransformed, true);
+ assert.equal(getCardPoints(restored), 0, '撤回后仍应永久为0分');
+});
+
+test('改稻为桑的闲家Bot自动完成各自的改造份额', () => {
+ const room = createRoom();
+ const engine = new GameEngine(room, createIo());
+ room.players[1].isBot = true;
+ room.players[3].isBot = true;
+ room.players[0].addCard(card('clubs', '3', 2420));
+ [
+ card('hearts', '5', 2421),
+ card('clubs', '10', 2422),
+ card('spades', 'K', 2423)
+ ].forEach(value => room.players[1].addCard(value));
+ room.players[2].addCard(card('clubs', '4', 2424));
+ [
+ card('hearts', '10', 2425),
+ card('diamonds', '5', 2426),
+ card('clubs', 'K', 2427),
+ card('spades', '10', 2428)
+ ].forEach(value => room.players[3].addCard(value));
+ room.gameState.phase = GamePhases.PLAYING;
+ room.gameState.selectedRule = CHANGE_RICE_TO_MULBERRY_RULE;
+ room.gameState.trumpSuit = 'hearts';
+ room.gameState.trumpRank = '2';
+ room.gameState.buryingPlayerId = room.players[0].id;
+
+ engine.setFirstPlayer(room.players[0].id);
+
+ assert.equal(room.gameState.riceToMulberryPendingPlayerIds.size, 0);
+ assert.equal(
+ room.players[1].cards.filter(value => value.isRiceToMulberryTransformed).length,
+ 1
+ );
+ assert.equal(
+ room.players[3].cards.filter(value => value.isRiceToMulberryTransformed).length,
+ 2
+ );
+ assert.ok(
+ [...room.players[1].cards, ...room.players[3].cards]
+ .filter(value => value.isRiceToMulberryTransformed)
+ .every(value => getCardPoints(value) === 0)
+ );
+});
+
+test('毁堤淹田在闲家赢分后先冻结计分,发动后只统计接下来的三轮', () => {
+ const room = createRoom();
+ const io = createIo();
+ const engine = new GameEngine(room, io);
+ const roundsByPlayer = [
+ [card('clubs', '3', 2500), card('diamonds', '4', 2501), card('clubs', 'K', 2502), card('hearts', '4', 2503), card('spades', '3', 2516)],
+ [card('clubs', 'A', 2504), card('diamonds', 'A', 2505), card('clubs', '3', 2506), card('hearts', '5', 2507), card('spades', '4', 2517)],
+ [card('clubs', '5', 2508), card('diamonds', '3', 2509), card('clubs', 'A', 2510), card('hearts', '3', 2511), card('spades', '5', 2518)],
+ [card('clubs', '10', 2512), card('diamonds', '10', 2513), card('clubs', '10', 2514), card('hearts', 'A', 2515), card('spades', '6', 2519)]
+ ];
+ roundsByPlayer.forEach((cards, playerIndex) => {
+ cards.forEach(value => room.players[playerIndex].addCard(value));
+ });
+ room.gameState.phase = GamePhases.PLAYING;
+ room.gameState.selectedRule = DESTROY_DYKE_RULE;
+ room.gameState.trumpSuit = 'spades';
+ room.gameState.trumpRank = '2';
+ room.gameState.buryingPlayerId = room.players[0].id;
+ engine.setFirstPlayer(room.players[0].id);
+
+ const playRound = roundIndex => {
+ let result = null;
+ for (let playIndex = 0; playIndex < 4; playIndex += 1) {
+ const playerIndex = room.gameState.currentPlayerIndex;
+ result = engine.playCards(
+ room.players[playerIndex].id,
+ [roundsByPlayer[playerIndex][roundIndex].id]
+ );
+ }
+ return result;
+ };
+
+ const pendingRound = playRound(0);
+ assert.equal(pendingRound.destroyDykeDecisionPending, true);
+ assert.equal(pendingRound.destroyDykeDecision.roundPoints, 15);
+ assert.equal(room.gameState.attackerScore, 0, '庄家决定前不能先把本轮分数加给闲家');
+ assert.equal(room.gameState.currentRound, 1);
+ assert.equal(room.gameState.currentPlayerIndex, null);
+ assert.equal(room.gameState.toJSON().destroyDyke.pending.deferredFinalPlay, undefined);
+ assert.throws(
+ () => engine.respondDestroyDyke(room.players[2].id, true),
+ /只有庄家/
+ );
+
+ const activation = engine.respondDestroyDyke(room.players[0].id, true);
+ assert.equal(activation.accepted, true);
+ assert.equal(activation.roundResult.roundUpdate.type, 'round_ended');
+ assert.equal(room.gameState.attackerScore, 0);
+ assert.deepEqual(room.gameState.destroyDykeDisaster, {
+ triggerRound: 1,
+ voidedPoints: 15,
+ roundsElapsed: 0,
+ disasterAttackerPoints: 0
+ });
+ assert.equal(room.gameState.currentRound, 2);
+
+ playRound(1);
+ assert.equal(room.gameState.attackerScore, 10);
+ assert.equal(room.gameState.destroyDykeDisaster.roundsElapsed, 1);
+ assert.equal(room.gameState.destroyDykeDisaster.disasterAttackerPoints, 10);
+
+ playRound(2);
+ assert.equal(room.gameState.attackerScore, 10, '庄家方赢得的分牌不计入灾期闲家累计');
+ assert.equal(room.gameState.destroyDykeDisaster.roundsElapsed, 2);
+ assert.equal(room.gameState.destroyDykeDisaster.disasterAttackerPoints, 10);
+
+ playRound(3);
+ assert.equal(room.gameState.attackerScore, 15);
+ assert.equal(room.gameState.destroyDykeDisaster, null);
+ assert.equal(room.gameState.destroyDykeLastResult.status, 'expired');
+ assert.equal(room.gameState.destroyDykeLastResult.voidedPoints, 15);
+ assert.equal(room.gameState.destroyDykeLastResult.disasterAttackerPoints, 15);
+ assert.ok(
+ room.gameState.collectedPointCards.every(value => ![
+ roundsByPlayer[2][0].id,
+ roundsByPlayer[3][0].id
+ ].includes(value.id)),
+ '发动轮作废的分牌不应进入闲家收分区'
+ );
+ assert.equal(
+ io.events.filter(({ event }) => event === 'destroy_dyke_disaster_resolved').at(-1).payload.status,
+ 'expired'
+ );
+});
+
+test('毁堤淹田灾期累计达到20分时立即事发并补回作废分与20分', () => {
+ const room = createRoom();
+ const engine = new GameEngine(room, createIo());
+ room.gameState.selectedRule = DESTROY_DYKE_RULE;
+ room.gameState.destroyDykeUsed = true;
+ room.gameState.destroyDykeDisaster = {
+ triggerRound: 1,
+ voidedPoints: 15,
+ roundsElapsed: 0,
+ disasterAttackerPoints: 0
+ };
+
+ room.gameState.attackerScore += 10;
+ const first = engine.advanceDestroyDykeDisasterAtRoundEnd({
+ round: 2,
+ winnerIsAttacker: true,
+ roundPoints: 10
+ });
+ assert.equal(first.status, 'active');
+ room.gameState.attackerScore += 10;
+ const incident = engine.advanceDestroyDykeDisasterAtRoundEnd({
+ round: 3,
+ winnerIsAttacker: true,
+ roundPoints: 10
+ });
+
+ assert.equal(incident.status, 'incident');
+ assert.equal(incident.reason, 'attacker_reached_20');
+ assert.equal(incident.returnedPoints, 15);
+ assert.equal(incident.incidentBonus, 20);
+ assert.equal(room.gameState.attackerScore, 55);
+ assert.equal(room.gameState.destroyDykeDisaster, null);
+});
+
+test('毁堤淹田发动轮即结束牌局时按提前结束事发', () => {
+ const room = createRoom();
+ const engine = new GameEngine(room, createIo());
+ const hands = [
+ card('clubs', '3', 2520),
+ card('clubs', 'A', 2521),
+ card('clubs', '5', 2522),
+ card('clubs', '10', 2523)
+ ];
+ hands.forEach((value, playerIndex) => room.players[playerIndex].addCard(value));
+ room.gameState.phase = GamePhases.PLAYING;
+ room.gameState.selectedRule = DESTROY_DYKE_RULE;
+ room.gameState.trumpSuit = 'spades';
+ room.gameState.trumpRank = '2';
+ room.gameState.buryingPlayerId = room.players[0].id;
+ engine.setFirstPlayer(room.players[0].id);
+ hands.forEach((value, playerIndex) => {
+ engine.playCards(room.players[playerIndex].id, [value.id]);
+ });
+
+ const response = engine.respondDestroyDyke(room.players[0].id, true);
+ assert.equal(response.roundResult.gameFinished, true);
+ assert.equal(room.gameState.destroyDykeLastResult.status, 'incident');
+ assert.equal(room.gameState.destroyDykeLastResult.reason, 'game_ended_early');
+ assert.equal(room.gameState.destroyDykeLastResult.returnedPoints, 15);
+ assert.equal(room.gameState.attackerScore, 35);
+ assert.equal(room.gameState.bottomScoreResult.destroyDyke.scoreDelta, 35);
+});
+
+test('毁堤淹田第三个灾期轮恰好结束牌局时闲家拿底仍然事发', () => {
+ const room = createRoom();
+ const engine = new GameEngine(room, createIo());
+ const hands = [
+ card('clubs', '3', 2524),
+ card('clubs', 'A', 2525),
+ card('clubs', '5', 2526),
+ card('clubs', '10', 2527)
+ ];
+ hands.forEach((value, playerIndex) => room.players[playerIndex].addCard(value));
+ room.gameState.phase = GamePhases.PLAYING;
+ room.gameState.selectedRule = DESTROY_DYKE_RULE;
+ room.gameState.trumpSuit = 'spades';
+ room.gameState.trumpRank = '2';
+ room.gameState.buryingPlayerId = room.players[0].id;
+ room.gameState.bottomCards = [card('diamonds', 'K', 2528)];
+ room.gameState.destroyDykeUsed = true;
+ room.gameState.destroyDykeDisaster = {
+ triggerRound: 1,
+ voidedPoints: 15,
+ roundsElapsed: 2,
+ disasterAttackerPoints: 0
+ };
+ engine.setFirstPlayer(room.players[0].id);
+ room.gameState.currentRound = 4;
+
+ let result = null;
+ hands.forEach((value, playerIndex) => {
+ result = engine.playCards(room.players[playerIndex].id, [value.id]);
+ });
+
+ assert.equal(result.gameFinished, true);
+ assert.equal(room.gameState.destroyDykeLastResult.status, 'incident');
+ assert.equal(room.gameState.destroyDykeLastResult.reason, 'attacker_won_bottom');
+ assert.equal(room.gameState.destroyDykeLastResult.returnedPoints, 15);
+ assert.equal(room.gameState.destroyDykeLastResult.incidentBonus, 20);
+ assert.equal(room.gameState.bottomScoreResult.attackerWonBottom, true);
+ assert.equal(room.gameState.bottomScoreResult.bottomScoreGained, 20);
+ assert.equal(room.gameState.bottomScoreResult.destroyDyke.scoreDelta, 35);
+ assert.equal(room.gameState.attackerScore, 70);
+});
+
+test('毁堤淹田本轮不发动时正常计分且保留后续机会', () => {
+ const room = createRoom();
+ const engine = new GameEngine(room, createIo());
+ const hands = [
+ [card('clubs', '3', 2530), card('diamonds', '3', 2531)],
+ [card('clubs', 'A', 2532), card('diamonds', 'A', 2533)],
+ [card('clubs', '5', 2534), card('diamonds', '5', 2535)],
+ [card('clubs', '10', 2536), card('diamonds', '10', 2537)]
+ ];
+ hands.forEach((cards, playerIndex) => cards.forEach(value => room.players[playerIndex].addCard(value)));
+ room.gameState.phase = GamePhases.PLAYING;
+ room.gameState.selectedRule = DESTROY_DYKE_RULE;
+ room.gameState.trumpSuit = 'spades';
+ room.gameState.trumpRank = '2';
+ room.gameState.buryingPlayerId = room.players[0].id;
+ engine.setFirstPlayer(room.players[0].id);
+ hands.forEach((cards, playerIndex) => engine.playCards(room.players[playerIndex].id, [cards[0].id]));
+
+ const declined = engine.respondDestroyDyke(room.players[0].id, false);
+ assert.equal(declined.accepted, false);
+ assert.equal(room.gameState.attackerScore, 15);
+ assert.equal(room.gameState.destroyDykeUsed, false);
+ assert.equal(room.gameState.destroyDykeDisaster, null);
+ assert.equal(room.gameState.destroyDykeLastResult.status, 'declined');
+});
+
+test('毁堤淹田在闲家赢得零分轮后也保留规则写明的发动窗口', () => {
+ const room = createRoom();
+ const engine = new GameEngine(room, createIo());
+ const hands = [
+ [card('clubs', '3', 2540), card('diamonds', '3', 2541)],
+ [card('clubs', 'A', 2542), card('diamonds', 'A', 2543)],
+ [card('clubs', '4', 2544), card('diamonds', '4', 2545)],
+ [card('clubs', '6', 2546), card('diamonds', '6', 2547)]
+ ];
+ hands.forEach((cards, playerIndex) => cards.forEach(value => room.players[playerIndex].addCard(value)));
+ room.gameState.phase = GamePhases.PLAYING;
+ room.gameState.selectedRule = DESTROY_DYKE_RULE;
+ room.gameState.trumpSuit = 'spades';
+ room.gameState.trumpRank = '2';
+ room.gameState.buryingPlayerId = room.players[0].id;
+ engine.setFirstPlayer(room.players[0].id);
+ let result = null;
+ hands.forEach((cards, playerIndex) => {
+ result = engine.playCards(room.players[playerIndex].id, [cards[0].id]);
+ });
+
+ assert.equal(result.destroyDykeDecisionPending, true);
+ assert.equal(result.destroyDykeDecision.roundPoints, 0);
+ engine.respondDestroyDyke(room.players[0].id, false);
+ assert.equal(room.gameState.destroyDykeUsed, false);
+});
+
+test('记录在案由每轮分牌独立触发下一轮,并在连续有分时逐轮续期', () => {
+ const room = createRoom();
+ const engine = new GameEngine(room, createIo());
+ const roundsByPlayer = [
+ [
+ card('hearts', '5', 0),
+ card('clubs', '3', 2600),
+ card('diamonds', '10', 2601),
+ card('spades', 'K', 2602),
+ card('clubs', '8', 2603)
+ ],
+ [
+ card('hearts', '5', 1),
+ card('clubs', '4', 2610),
+ card('diamonds', '3', 2611),
+ card('spades', '3', 2612),
+ card('clubs', '9', 2613)
+ ],
+ [
+ card('hearts', '6', 2620),
+ card('clubs', '6', 2621),
+ card('diamonds', '4', 2622),
+ card('spades', '4', 2623),
+ card('clubs', 'J', 2624)
+ ],
+ [
+ card('hearts', 'A', 2630),
+ card('clubs', '7', 2631),
+ card('diamonds', 'A', 2632),
+ card('spades', 'A', 2633),
+ card('clubs', 'Q', 2634)
+ ]
+ ];
+ roundsByPlayer.forEach((cards, playerIndex) => {
+ cards.forEach(value => room.players[playerIndex].addCard(value));
+ });
+ room.gameState.phase = GamePhases.PLAYING;
+ room.gameState.selectedRule = RECORD_ON_FILE_RULE;
+ room.gameState.trumpSuit = 'spades';
+ room.gameState.trumpRank = '2';
+ room.gameState.buryingPlayerId = room.players[0].id;
+ engine.setFirstPlayer(room.players[0].id);
+
+ const playRound = roundIndex => {
+ let result = null;
+ for (let playIndex = 0; playIndex < room.players.length; playIndex++) {
+ const playerIndex = room.gameState.currentPlayerIndex;
+ const player = room.players[playerIndex];
+ result = engine.playCards(player.id, [roundsByPlayer[playerIndex][roundIndex].id]);
+ }
+ return result;
+ };
+
+ const first = playRound(0);
+ assert.deepEqual(first.roundUpdate.recordOnFile, {
+ completedRound: 1,
+ wasActive: false,
+ hadPointCards: true,
+ hadLevelOrJoker: false,
+ nextActiveRound: 2
+ });
+ assert.equal(room.gameState.recordOnFileActiveRound, 2);
+ assert.equal(room.gameState.toJSON().recordOnFile.counts.hearts['5'], 2);
+
+ const second = playRound(1);
+ assert.equal(second.roundUpdate.recordOnFile.hadPointCards, false);
+ assert.equal(room.gameState.recordOnFileActiveRound, null);
+ assert.equal(room.gameState.recordOnFileLastActiveRound, 2);
+
+ const third = playRound(2);
+ assert.equal(third.roundUpdate.recordOnFile.nextActiveRound, 4);
+ assert.equal(room.gameState.recordOnFileActiveRound, 4);
+ assert.equal(room.gameState.recordOnFileLastActiveRound, null);
+
+ const fourth = playRound(3);
+ assert.equal(fourth.roundUpdate.recordOnFile.wasActive, true);
+ assert.equal(fourth.roundUpdate.recordOnFile.nextActiveRound, 5);
+ assert.equal(room.gameState.recordOnFileActiveRound, 5);
+ assert.equal(room.gameState.recordOnFileLastActiveRound, 4);
+});
+
+test('记录在案不会通过记牌器提前泄露本轮尚未揭晓的暗置牌', () => {
+ const room = createRoom();
+ const publicCard = card('hearts', '5', 2640);
+ const concealedCard = card('spades', 'A', 2641);
+ room.gameState.selectedRule = {
+ id: 'double_happiness',
+ rules: [RECORD_ON_FILE_RULE, NO_ONE_SURVIVES_RULE]
+ };
+ room.gameState.currentRound = 2;
+ room.gameState.recordOnFileActiveRound = 2;
+ room.gameState.playHistory = [
+ { round: 2, concealed: false, cards: [publicCard.toJSON()] },
+ { round: 2, concealed: true, cards: [concealedCard.toJSON()] }
+ ];
+ room.gameState.currentRoundPlays = [
+ { concealed: false, cards: [publicCard] },
+ { concealed: true, cards: [concealedCard] }
+ ];
+
+ const hiddenState = room.gameState.toJSON().recordOnFile;
+ assert.equal(hiddenState.playedCardCount, 1);
+ assert.equal(hiddenState.counts.hearts['5'], 1);
+ assert.equal(hiddenState.counts.spades.A, undefined);
+
+ room.gameState.currentRoundPlays = [];
+ const revealedState = room.gameState.toJSON().recordOnFile;
+ assert.equal(revealedState.playedCardCount, 2);
+ assert.equal(revealedState.counts.spades.A, 1);
+});
+
+test('记录在案始终按九子夺嫡晋升前的开局实体牌面统计', () => {
+ const room = createRoom();
+ const promotedJoker = card('joker', Ranks.WHITE_JOKER, 2645);
+ promotedJoker.isNinePrincesPromoted = true;
+ promotedJoker.ninePrincesPromotionCount = 3;
+ promotedJoker.ninePrincesPermanentSuit = 'joker';
+ promotedJoker.ninePrincesPermanentRank = Ranks.WHITE_JOKER;
+ promotedJoker.ninePrincesScoringSuit = 'joker';
+ promotedJoker.ninePrincesScoringRank = Ranks.BIG_JOKER;
+ room.gameState.selectedRule = {
+ id: 'double_happiness',
+ rules: [RECORD_ON_FILE_RULE, NINE_PRINCES_SUCCESSION_RULE]
+ };
+ room.gameState.currentRound = 4;
+ room.gameState.recordOnFileActiveRound = 4;
+ room.gameState.playHistory = [{
+ round: 3,
+ cards: [promotedJoker.toJSON()]
+ }];
+
+ const tracker = room.gameState.toJSON().recordOnFile;
+ assert.equal(tracker.counts.joker.big_joker, 1);
+ assert.equal(tracker.counts.joker.white_joker, undefined);
+});
+
+test('记录在案在零分轮出现当前级牌或王牌时也会触发下一轮', () => {
+ const room = createRoom();
+ const engine = new GameEngine(room, createIo());
+ const roundsByPlayer = [
+ [
+ card('clubs', '2', 2650),
+ card('joker', 'small_joker', 0),
+ card('diamonds', '3', 2651)
+ ],
+ [
+ card('hearts', '2', 2660),
+ card('joker', 'small_joker', 1),
+ card('diamonds', '4', 2661)
+ ],
+ [
+ card('diamonds', '2', 2670),
+ card('joker', 'big_joker', 0),
+ card('diamonds', '6', 2671)
+ ],
+ [
+ card('spades', '2', 2680),
+ card('joker', 'big_joker', 1),
+ card('diamonds', '7', 2681)
+ ]
+ ];
+ roundsByPlayer.forEach((cards, playerIndex) => {
+ cards.forEach(value => room.players[playerIndex].addCard(value));
+ });
+ room.gameState.phase = GamePhases.PLAYING;
+ room.gameState.selectedRule = RECORD_ON_FILE_RULE;
+ room.gameState.trumpSuit = 'spades';
+ room.gameState.trumpRank = '2';
+ room.gameState.buryingPlayerId = room.players[0].id;
+ engine.setFirstPlayer(room.players[0].id);
+
+ const playRound = roundIndex => {
+ let result = null;
+ for (let playIndex = 0; playIndex < room.players.length; playIndex++) {
+ const playerIndex = room.gameState.currentPlayerIndex;
+ const player = room.players[playerIndex];
+ result = engine.playCards(player.id, [roundsByPlayer[playerIndex][roundIndex].id]);
+ }
+ return result;
+ };
+
+ const levelRound = playRound(0);
+ assert.equal(levelRound.roundUpdate.scoreInfo.roundPoints, 0);
+ assert.equal(levelRound.roundUpdate.recordOnFile.hadPointCards, false);
+ assert.equal(levelRound.roundUpdate.recordOnFile.hadLevelOrJoker, true);
+ assert.equal(levelRound.roundUpdate.recordOnFile.nextActiveRound, 2);
+
+ const jokerRound = playRound(1);
+ assert.equal(jokerRound.roundUpdate.scoreInfo.roundPoints, 0);
+ assert.equal(jokerRound.roundUpdate.recordOnFile.hadPointCards, false);
+ assert.equal(jokerRound.roundUpdate.recordOnFile.hadLevelOrJoker, true);
+ assert.equal(jokerRound.roundUpdate.recordOnFile.nextActiveRound, 3);
+});
+
+function playWeighingThousandJinRound(plays) {
+ const room = createRoom();
+ const engine = new GameEngine(room, createIo());
+ plays.forEach((cards, playerIndex) => {
+ cards.forEach(value => room.players[playerIndex].addCard(value));
+ room.players[playerIndex].addCard(
+ card('clubs', String(playerIndex + 3), 2700 + playerIndex)
+ );
+ });
+ room.gameState.phase = GamePhases.PLAYING;
+ room.gameState.selectedRule = WEIGHING_THOUSAND_JIN_RULE;
+ room.gameState.trumpSuit = 'spades';
+ room.gameState.trumpRank = '2';
+ room.gameState.buryingPlayerId = room.players[0].id;
+ room.gameState.dealerPlayerIndex = 0;
+ engine.setFirstPlayer(room.players[0].id);
+
+ let result = null;
+ plays.forEach((cards, playerIndex) => {
+ result = engine.playCards(
+ room.players[playerIndex].id,
+ cards.map(value => value.id)
+ );
+ });
+ return { room, result };
+}
+
+test('上称千斤在庄家压过至少一名闲家时逐张减5分', () => {
+ const { room, result } = playWeighingThousandJinRound([
+ [card('hearts', 'Q', 0), card('hearts', 'Q', 1)],
+ [card('hearts', 'J', 0), card('hearts', 'J', 1)],
+ [card('hearts', '10', 0), card('hearts', '10', 1)],
+ [card('hearts', 'A', 0), card('hearts', 'A', 1)]
+ ]);
+ const scoring = result.roundUpdate.scoreInfo.weighingThousandJin;
+
+ assert.equal(result.roundUpdate.scoreInfo.winnerIsAttacker, true);
+ assert.equal(scoring.mode, 'subtract_five');
+ assert.equal(scoring.dealerRank, 2);
+ assert.equal(scoring.outrankedAttackerCount, 1);
+ assert.equal(scoring.originalRoundPoints, 20);
+ assert.equal(scoring.adjustedRoundPoints, 10);
+ assert.equal(result.roundUpdate.scoreInfo.baseRoundPoints, 10);
+ assert.equal(result.roundUpdate.scoreInfo.roundPoints, 10);
+ assert.equal(result.roundUpdate.scoreInfo.roundPointCards.length, 2);
+ assert.equal(room.gameState.attackerScore, 10);
+});
+
+test('上称千斤在庄家未压过任一闲家时逐张翻倍', () => {
+ const { room, result } = playWeighingThousandJinRound([
+ [card('hearts', 'J', 10)],
+ [card('hearts', 'Q', 10)],
+ [card('hearts', 'K', 10)],
+ [card('hearts', 'A', 10)]
+ ]);
+ const scoring = result.roundUpdate.scoreInfo.weighingThousandJin;
+
+ assert.equal(result.roundUpdate.scoreInfo.winnerIsAttacker, true);
+ assert.equal(scoring.mode, 'double');
+ assert.equal(scoring.dealerRank, 4);
+ assert.equal(scoring.outrankedAttackerCount, 0);
+ assert.equal(scoring.originalRoundPoints, 10);
+ assert.equal(scoring.adjustedRoundPoints, 20);
+ assert.equal(result.roundUpdate.scoreInfo.roundPoints, 20);
+ assert.equal(room.gameState.attackerScore, 20);
+});
+
+test('上称千斤沿用力争上游完全相同时后出者更小的顺序', () => {
+ const { result } = playWeighingThousandJinRound([
+ [card('hearts', 'J', 20)],
+ [card('hearts', 'J', 21)],
+ [card('hearts', 'K', 20)],
+ [card('hearts', 'A', 20)]
+ ]);
+ const scoring = result.roundUpdate.scoreInfo.weighingThousandJin;
+ const tiedAttacker = scoring.attackerComparisons.find(
+ comparison => comparison.playerIndex === 1
+ );
+
+ assert.equal(scoring.mode, 'subtract_five');
+ assert.equal(scoring.outrankedAttackerCount, 1);
+ assert.equal(tiedAttacker.dealerOutranks, true);
+ assert.equal(scoring.originalRoundPoints, 10);
+ assert.equal(scoring.adjustedRoundPoints, 5);
+});
+
+function playFearOfBreakingVaseRound(plays, {
+ dealerIndex = 0,
+ startingScore = 0
+} = {}) {
+ const room = createRoom();
+ const engine = new GameEngine(room, createIo());
+ plays.forEach((cards, playerIndex) => {
+ cards.forEach(value => room.players[playerIndex].addCard(value));
+ room.players[playerIndex].addCard(
+ card('clubs', String(playerIndex + 3), 2800 + playerIndex)
+ );
+ });
+ room.gameState.phase = GamePhases.PLAYING;
+ room.gameState.selectedRule = FEAR_OF_BREAKING_VASE_RULE;
+ room.gameState.trumpSuit = 'spades';
+ room.gameState.trumpRank = '2';
+ room.gameState.buryingPlayerId = room.players[dealerIndex].id;
+ room.gameState.dealerPlayerIndex = dealerIndex;
+ room.gameState.attackerScore = startingScore;
+ engine.setFirstPlayer(room.players[0].id);
+
+ let result = null;
+ plays.forEach((cards, playerIndex) => {
+ result = engine.playCards(
+ room.players[playerIndex].id,
+ cards.map(value => value.id)
+ );
+ });
+ return { room, engine, result };
+}
+
+test('投鼠忌器:首家最大且队友打出至少两对时,赢家庄家方失去10分', () => {
+ const { room, result } = playFearOfBreakingVaseRound([
+ [
+ card('hearts', 'A', 0), card('hearts', 'A', 1),
+ card('hearts', 'K', 0), card('hearts', 'K', 1)
+ ],
+ [
+ card('hearts', '10', 0), card('hearts', '10', 1),
+ card('hearts', '9', 0), card('hearts', '9', 1)
+ ],
+ [
+ card('hearts', 'Q', 0), card('hearts', 'Q', 1),
+ card('hearts', 'J', 0), card('hearts', 'J', 1)
+ ],
+ [
+ card('hearts', '8', 0), card('hearts', '8', 1),
+ card('hearts', '7', 0), card('hearts', '7', 1)
+ ]
+ ]);
+ const penalty = result.roundUpdate.scoreInfo.fearOfBreakingVase;
+
+ assert.equal(penalty.winnerPlayerIndex, 0);
+ assert.equal(penalty.triggerType, 'leader_over_protected_teammate');
+ assert.equal(penalty.vesselPlayerId, room.players[2].id);
+ assert.equal(penalty.pairCount, 2);
+ assert.equal(penalty.penalizedSide, 'dealer');
+ assert.equal(penalty.scoreDelta, 10);
+ assert.equal(room.gameState.attackerScore, 10);
+});
+
+test('投鼠忌器:第二家唯一毙牌且队友本为其余三家最大时,赢家闲家方失去10分', () => {
+ const { room, result } = playFearOfBreakingVaseRound([
+ [card('hearts', 'Q', 10)],
+ [card('spades', '3', 10)],
+ [card('hearts', 'J', 10)],
+ [card('hearts', 'A', 10)]
+ ]);
+ const penalty = result.roundUpdate.scoreInfo.fearOfBreakingVase;
+
+ assert.equal(penalty.winnerPlayerIndex, 1);
+ assert.equal(penalty.triggerType, 'sole_second_ruff_over_teammate');
+ assert.equal(penalty.vesselPlayerId, room.players[3].id);
+ assert.equal(penalty.penalizedSide, 'attacker');
+ assert.equal(penalty.scoreDelta, -10);
+ assert.equal(room.gameState.attackerScore, -10);
+});
+
+test('投鼠忌器:第二家唯一毙牌但队友并非其余三家最大时不扣分', () => {
+ const { room, result } = playFearOfBreakingVaseRound([
+ [card('hearts', 'Q', 20)],
+ [card('spades', '3', 20)],
+ [card('hearts', 'A', 20)],
+ [card('hearts', 'J', 20)]
+ ]);
+
+ assert.equal(room.gameState.lastRoundWinnerIndex, 1);
+ assert.equal(result.roundUpdate.scoreInfo.fearOfBreakingVase, null);
+ assert.equal(room.gameState.attackerScore, 0);
+});
+
+test('投鼠忌器按实体牌面统计两对,并将任意两张王视为“器”', () => {
+ const room = createRoom();
+ const engine = new GameEngine(room, createIo());
+ const twoPairs = engine.getFearOfBreakingVaseProtectedCards({
+ cards: [
+ card('hearts', '9', 30), card('hearts', '9', 31),
+ card('clubs', 'A', 30), card('clubs', 'A', 31)
+ ]
+ });
+ const twoJokers = engine.getFearOfBreakingVaseProtectedCards({
+ cards: [
+ card('joker', Ranks.SMALL_JOKER, 30),
+ card('joker', Ranks.BIG_JOKER, 30)
+ ]
+ });
+ const onePair = engine.getFearOfBreakingVaseProtectedCards({
+ cards: [
+ card('diamonds', '7', 30),
+ card('diamonds', '7', 31),
+ card('spades', 'K', 30)
+ ]
+ });
+
+ assert.deepEqual(twoPairs, { pairCount: 2, jokerCount: 0, qualifies: true });
+ assert.deepEqual(twoJokers, { pairCount: 0, jokerCount: 2, qualifies: true });
+ assert.deepEqual(onePair, { pairCount: 1, jokerCount: 0, qualifies: false });
+});
diff --git a/tractor-game-simulator/server/test/surrender.test.mjs b/tractor-game-simulator/server/test/surrender.test.mjs
new file mode 100644
index 0000000..00318f2
--- /dev/null
+++ b/tractor-game-simulator/server/test/surrender.test.mjs
@@ -0,0 +1,246 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+
+import { Room } from '../src/models/Room.js';
+import { Player } from '../src/models/Player.js';
+import { Card } from '../src/models/Card.js';
+import { GameEngine } from '../src/services/GameEngine.js';
+import { GamePhases } from '../src/utils/constants.js';
+import { getRuleById, RuleIds } from '../src/rules/ruleRegistry.js';
+
+const NORMAL_RULE = getRuleById(RuleIds.NORMAL_GAME);
+const BURN_THE_BOATS_RULE = getRuleById(RuleIds.BURN_THE_BOATS);
+const NO_ONE_SURVIVES_RULE = getRuleById(RuleIds.NO_ONE_SURVIVES);
+
+function createIo() {
+ const events = [];
+ return {
+ events,
+ to(target) {
+ return {
+ emit(event, payload) {
+ events.push({ target, event, payload });
+ }
+ };
+ }
+ };
+}
+
+function createSurrenderGame({
+ dealerIndex = 0,
+ currentRound = 2,
+ attackerScore = 0,
+ selectedRule = NORMAL_RULE
+} = {}) {
+ const room = new Room('投降测试房', 'socket-0', { dealInterval: 10 });
+ for (let index = 0; index < 4; index += 1) {
+ room.addPlayer(new Player(`socket-${index}`, `玩家${index}`, index));
+ }
+ room.gameState.phase = GamePhases.PLAYING;
+ room.gameState.selectedRule = selectedRule;
+ room.gameState.buryingPlayerId = room.players[dealerIndex].id;
+ room.gameState.dealerPlayerIndex = dealerIndex;
+ room.gameState.currentRound = currentRound;
+ room.gameState.lastRoundWinnerIndex = (dealerIndex + 1) % 4;
+ room.gameState.attackerScore = attackerScore;
+ return {
+ room,
+ engine: new GameEngine(room, createIo())
+ };
+}
+
+test('投降申请等待完整墩结束,并从庄家座位开始排序', () => {
+ const { room, engine } = createSurrenderGame({ dealerIndex: 2 });
+ room.gameState.currentRoundPlays.push({ playerId: room.players[2].id, cards: [] });
+ room.gameState.playersPlayedThisRound.add(2);
+
+ engine.requestSurrender(room.players[1].id);
+ engine.requestSurrender(room.players[0].id);
+ engine.requestSurrender(room.players[2].id);
+ assert.equal(engine.prepareSurrenderReview({ completedRound: 1 }), null);
+
+ room.gameState.currentRoundPlays = [];
+ room.gameState.playersPlayedThisRound.clear();
+ const first = engine.prepareSurrenderReview({ completedRound: 1 });
+ assert.equal(first.initiatorPlayerId, room.players[2].id);
+ assert.deepEqual(
+ room.gameState.surrenderDecisionQueue.map(decision => decision.initiatorPlayerId),
+ [room.players[0].id, room.players[1].id]
+ );
+});
+
+test('摸牌或埋底时预先申请,也必须等本局第一墩完整结束后才询问', () => {
+ const { room, engine } = createSurrenderGame({ currentRound: 1 });
+ room.gameState.lastRoundWinnerIndex = 3;
+ engine.requestSurrender(room.players[0].id);
+
+ assert.equal(engine.prepareSurrenderReview({ completedRound: 0 }), null);
+ assert.equal(room.gameState.surrenderRequests.has(room.players[0].id), true);
+});
+
+test('实际出牌流程在第四家完成本墩后才返回投降询问', () => {
+ const { room, engine } = createSurrenderGame({ currentRound: 0 });
+ const hands = [
+ [new Card('hearts', '3', 0), new Card('clubs', '3', 0)],
+ [new Card('hearts', '4', 0), new Card('clubs', '4', 0)],
+ [new Card('hearts', '5', 0), new Card('clubs', '5', 0)],
+ [new Card('hearts', '6', 0), new Card('clubs', '6', 0)]
+ ];
+ hands.forEach((hand, playerIndex) => {
+ hand.forEach(card => room.players[playerIndex].addCard(card));
+ });
+ room.gameState.trumpSuit = 'spades';
+ room.gameState.trumpRank = '2';
+ room.gameState.lastRoundWinnerIndex = null;
+ engine.setFirstPlayer(room.players[0].id);
+ engine.requestSurrender(room.players[1].id);
+
+ const first = engine.playCards(room.players[0].id, [hands[0][0].id]);
+ assert.equal(first.surrenderDecision, null);
+ engine.playCards(room.players[1].id, [hands[1][0].id]);
+ engine.playCards(room.players[2].id, [hands[2][0].id]);
+ const completed = engine.playCards(room.players[3].id, [hands[3][0].id]);
+
+ assert.equal(completed.roundUpdate.round, 1);
+ assert.equal(completed.surrenderDecision.initiatorPlayerId, room.players[1].id);
+ assert.equal(room.gameState.currentRound, 2);
+ assert.throws(
+ () => engine.playCards(room.players[3].id, [hands[3][1].id]),
+ /投降表决/
+ );
+
+ engine.respondSurrender(room.players[3].id, false);
+ assert.equal(engine.hasPendingSurrenderDecision(), false);
+ assert.doesNotThrow(
+ () => engine.playCards(room.players[3].id, [hands[3][1].id])
+ );
+});
+
+test('拒绝投降后继续询问下一位申请者,断线同步只恢复给当前队友', () => {
+ const { room, engine } = createSurrenderGame();
+ engine.requestSurrender(room.players[0].id);
+ engine.requestSurrender(room.players[1].id);
+ const first = engine.prepareSurrenderReview({ completedRound: 1 });
+ assert.equal(first.teammatePlayerId, room.players[2].id);
+
+ const privateEvents = engine.getPrivateGameStateSyncEvents(room.players[2].id);
+ assert.ok(privateEvents.some(event => event.event === 'surrender_decision_required'));
+ assert.equal(
+ engine.getPrivateGameStateSyncEvents(room.players[3].id)
+ .some(event => event.event === 'surrender_decision_required'),
+ false
+ );
+
+ const rejected = engine.respondSurrender(room.players[2].id, false);
+ assert.equal(rejected.gameFinished, false);
+ assert.equal(rejected.nextDecision.initiatorPlayerId, room.players[1].id);
+ assert.equal(rejected.nextDecision.teammatePlayerId, room.players[3].id);
+});
+
+test('庄家方投降按闲家当前实得分加80并让闲家方获胜', () => {
+ const { room, engine } = createSurrenderGame({ attackerScore: 50 });
+ room.players.forEach((player, playerIndex) => {
+ player.addCard(new Card(playerIndex % 2 === 0 ? 'hearts' : 'clubs', `${playerIndex + 3}`, 0));
+ player.addCard(new Card(playerIndex % 2 === 0 ? 'diamonds' : 'spades', 'K', 1));
+ });
+ engine.requestSurrender(room.players[0].id);
+ const decision = engine.prepareSurrenderReview({ completedRound: 3 });
+ const result = engine.respondSurrender(room.players[2].id, true);
+
+ assert.equal(result.gameFinished, true);
+ assert.equal(room.gameState.phase, GamePhases.REVEALING);
+ assert.equal(room.gameState.attackerScore, 130);
+ assert.equal(room.gameState.bottomScoreResult.bottomScoreGained, 0);
+ assert.equal(room.gameState.bottomScoreResult.surrender.scoreAdjustment, 80);
+ assert.equal(room.gameState.upgradeResult.attackerWon, true);
+ assert.equal(room.gameState.upgradeResult.attackerLevelUp, 1);
+ assert.equal(decision.surrenderingSide, 'dealer');
+ assert.equal(room.gameState.bottomScoreResult.surrender.revealedHands.length, 4);
+ assert.deepEqual(
+ room.gameState.bottomScoreResult.surrender.revealedHands.map(hand => ({
+ playerId: hand.playerId,
+ side: hand.side,
+ isDealer: hand.isDealer,
+ cardCount: hand.cards.length
+ })),
+ room.players.map((player, playerIndex) => ({
+ playerId: player.id,
+ side: playerIndex % 2 === 0 ? 'dealer' : 'attacker',
+ isDealer: playerIndex === 0,
+ cardCount: 2
+ }))
+ );
+});
+
+test('闲家方在前两墩投降固定让庄家方升1级,不伪装成75分', () => {
+ const { room, engine } = createSurrenderGame({ attackerScore: 0 });
+ engine.requestSurrender(room.players[1].id);
+ const decision = engine.prepareSurrenderReview({ completedRound: 2 });
+ engine.respondSurrender(room.players[3].id, true);
+
+ assert.equal(decision.surrenderingSide, 'attacker');
+ assert.equal(room.gameState.attackerScore, 0);
+ assert.equal(room.gameState.upgradeResult.attackerWon, false);
+ assert.equal(room.gameState.upgradeResult.dealerLevelUp, 1);
+ assert.equal(room.gameState.bottomScoreResult.surrender.earlyAttackerSurrender, true);
+});
+
+test('闲家方从第三墩起按当前实得分决定庄家升级且仍保证庄家方获胜', () => {
+ const { room, engine } = createSurrenderGame({ attackerScore: 25 });
+ engine.requestSurrender(room.players[1].id);
+ engine.prepareSurrenderReview({ completedRound: 3 });
+ engine.respondSurrender(room.players[3].id, true);
+
+ assert.equal(room.gameState.attackerScore, 25);
+ assert.equal(room.gameState.upgradeResult.attackerWon, false);
+ assert.equal(room.gameState.upgradeResult.dealerLevelUp, 2);
+});
+
+test('破釜沉舟规则禁止投降和重开', () => {
+ const { room, engine } = createSurrenderGame({
+ selectedRule: BURN_THE_BOATS_RULE
+ });
+ assert.throws(
+ () => engine.requestSurrender(room.players[0].id),
+ /禁止投降/
+ );
+ assert.throws(() => engine.restartGame(), /禁止重开/);
+});
+
+test('无人生还只暗置牌面,不会误禁投降', () => {
+ const { room, engine } = createSurrenderGame({
+ selectedRule: NO_ONE_SURVIVES_RULE
+ });
+ assert.doesNotThrow(() => engine.requestSurrender(room.players[0].id));
+});
+
+test('surrender settlement and next-game readiness survive room snapshots', () => {
+ const { room, engine } = createSurrenderGame({ attackerScore: 20 });
+ room.players.forEach((player, playerIndex) => {
+ player.addCard(new Card(playerIndex % 2 === 0 ? 'hearts' : 'clubs', 'K', 0));
+ });
+ engine.requestSurrender(room.players[0].id);
+ engine.prepareSurrenderReview({ completedRound: 3 });
+ engine.respondSurrender(room.players[2].id, true);
+
+ const settlementSnapshot = room.toJSON();
+ assert.equal(settlementSnapshot.gameState.phase, GamePhases.REVEALING);
+ assert.equal(settlementSnapshot.gameState.bottomScoreResult.surrender.revealedHands.length, 4);
+ assert.equal(settlementSnapshot.gameState.upgradeResult.attackerWon, true);
+ assert.deepEqual(
+ settlementSnapshot.gameState.revealedBottomCards,
+ room.gameState.bottomCards.map(card => card.toJSON())
+ );
+ assert.deepEqual(
+ settlementSnapshot.players.map(player => player.isReadyForNext),
+ [false, false, false, false]
+ );
+
+ for (let playerIndex = 0; playerIndex < 3; playerIndex += 1) {
+ assert.equal(engine.readyForNextGame(room.players[playerIndex].id), false);
+ assert.equal(room.toJSON().players[playerIndex].isReadyForNext, true);
+ }
+ assert.equal(engine.readyForNextGame(room.players[3].id), true);
+ assert.notEqual(room.gameState.phase, GamePhases.REVEALING);
+ assert.ok(room.players.every(player => player.isReadyForNext === false));
+});
diff --git a/tractor-game-simulator/server/test/whoDesignedStrategy.test.mjs b/tractor-game-simulator/server/test/whoDesignedStrategy.test.mjs
new file mode 100644
index 0000000..8232ad3
--- /dev/null
+++ b/tractor-game-simulator/server/test/whoDesignedStrategy.test.mjs
@@ -0,0 +1,147 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+
+import { Card } from '../src/models/Card.js';
+import { Player } from '../src/models/Player.js';
+import { Room } from '../src/models/Room.js';
+import { BotService } from '../src/services/BotService.js';
+import { GameEngine } from '../src/services/GameEngine.js';
+import {
+ chooseWhoDesignedCardsToBury,
+ chooseWhoDesignedTrumpDeclaration
+} from '../src/services/whoDesignedStrategy.js';
+import { BotTypes, GamePhases } from '../src/utils/constants.js';
+
+function card(suit, rank, copyIndex = 0) {
+ return new Card(suit, rank, copyIndex);
+}
+
+function createBotRoom() {
+ const room = new Room('WhoDesigned 测试房', 'bot-0', {
+ botType: BotTypes.WHO_DESIGNED,
+ dealInterval: 10
+ });
+ for (let index = 0; index < 4; index++) {
+ room.addPlayer(new Player(`bot-${index}`, `Bot ${index}`, index, true));
+ }
+ return room;
+}
+
+test('WhoDesigned 摸到有强度的级牌花色后会主动亮主', () => {
+ const cards = [
+ card('spades', '2'),
+ card('spades', 'A'),
+ card('spades', 'K'),
+ card('spades', 'Q'),
+ card('spades', '10')
+ ];
+
+ assert.deepEqual(
+ chooseWhoDesignedTrumpDeclaration({ cards, trumpRank: '2' }),
+ { suit: 'spades', count: 1 }
+ );
+});
+
+test('WhoDesigned 可用一对级牌反掉较弱的单张亮主', () => {
+ const cards = [
+ card('spades', '2', 0),
+ card('spades', '2', 1),
+ card('spades', 'A'),
+ card('spades', 'K'),
+ card('spades', 'Q')
+ ];
+
+ assert.deepEqual(
+ chooseWhoDesignedTrumpDeclaration({
+ cards,
+ trumpRank: '2',
+ currentTrumpDeclaration: {
+ playerId: 'other',
+ suit: 'hearts',
+ strength: 1
+ },
+ playerId: 'bot'
+ }),
+ { suit: 'spades', count: 2 }
+ );
+});
+
+test('GameEngine 在 WhoDesigned Bot 摸牌时写入并广播亮主', () => {
+ const room = createBotRoom();
+ const bot = room.players[0];
+ bot.cards = [
+ card('clubs', '2'),
+ card('clubs', 'A'),
+ card('clubs', 'K'),
+ card('clubs', 'Q'),
+ card('clubs', 'J')
+ ];
+ room.gameState.phase = GamePhases.DRAWING;
+ room.gameState.trumpRank = '2';
+
+ const events = [];
+ const io = {
+ to: target => ({
+ emit: (event, payload) => events.push({ target, event, payload })
+ })
+ };
+ const engine = new GameEngine(room, io);
+ const result = engine.handleRuleCardDealt(bot, bot.cards.at(-1));
+
+ assert.equal(result, null);
+ assert.equal(room.gameState.trumpSuit, 'clubs');
+ assert.equal(room.gameState.currentTrumpDeclaration.playerId, bot.id);
+ assert.equal(room.gameState.currentTrumpDeclaration.count, 1);
+ const declaredCardId = room.gameState.currentTrumpDeclaration.cards[0].id;
+ bot.removeCards([declaredCardId]);
+ assert.equal(
+ room.gameState.toJSON().currentTrumpDeclaration.cards[0].id,
+ declaredCardId,
+ '亮主记录必须保留在公共快照中,不能依赖实体牌仍在玩家手中'
+ );
+ assert.ok(events.some(entry => (
+ entry.event === 'trump_declared'
+ && entry.payload.playerId === bot.id
+ && entry.payload.suit === 'clubs'
+ )));
+});
+
+test('WhoDesigned 埋牌优先造缺且保留主牌和副牌 A', () => {
+ const cards = [
+ card('clubs', '3'),
+ card('diamonds', '4'),
+ card('diamonds', '6'),
+ card('clubs', 'A'),
+ card('hearts', '3'),
+ card('hearts', '4'),
+ card('spades', '2'),
+ card('joker', 'small_joker')
+ ];
+
+ const buried = chooseWhoDesignedCardsToBury(cards, 3, 'spades', '2');
+ assert.equal(buried.length, 3);
+ assert.ok(buried.some(item => item.suit === 'clubs' && item.rank === '3'));
+ assert.ok(buried.every(item => item.suit !== 'spades' && item.suit !== 'joker'));
+ assert.ok(buried.every(item => item.rank !== 'A'));
+});
+
+test('BotService 会从完整墩历史恢复各家的缺门信息', () => {
+ const room = createBotRoom();
+ room.gameState.trumpSuit = 'spades';
+ room.gameState.trumpRank = '2';
+ const plays = [
+ { playerIndex: 0, cards: [card('hearts', '10')] },
+ { playerIndex: 1, cards: [card('hearts', '3')] },
+ { playerIndex: 2, cards: [card('clubs', '4')] },
+ { playerIndex: 3, cards: [card('spades', '5')] }
+ ];
+ room.gameState.playHistory = plays.map(play => ({
+ ...play,
+ playerId: room.players[play.playerIndex].id
+ }));
+
+ const service = new BotService(BotTypes.WHO_DESIGNED);
+ const input = service._buildBotInput(room.gameState, [], 0, room);
+
+ assert.deepEqual(input.emptySuits, [[], [], ['h'], ['h']]);
+});