Compare commits
2 Commits
8d5fb3c511
...
79be58e76a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
79be58e76a | ||
|
|
2a376e142b |
@@ -1379,7 +1379,9 @@ function MahjongLocalView() {
|
|||||||
}),
|
}),
|
||||||
[mode],
|
[mode],
|
||||||
);
|
);
|
||||||
return <LocalClient matchID="local" playerID={seat} key={`mj-local-${mode}-${seat}`} />;
|
// 注意:key 只随人数变化——切换座位时让 boardgame.io 原地 updatePlayerID,
|
||||||
|
// 若把 seat 放进 key 会导致组件重挂载、local master 被销毁、整局重置。
|
||||||
|
return <LocalClient matchID="local" playerID={seat} key={`mj-local-${mode}`} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -212,6 +212,15 @@ function TableView({
|
|||||||
const turnStep = G.turnStep;
|
const turnStep = G.turnStep;
|
||||||
const myClaim: PendingClaim | undefined = (G.pendingClaims ?? []).find((c) => c.player === me);
|
const myClaim: PendingClaim | undefined = (G.pendingClaims ?? []).find((c) => c.player === me);
|
||||||
|
|
||||||
|
// 全局鸣牌超时:任意客户端在截止后驱动 claimTimeout,避免鸣牌者掉线导致整局卡死。
|
||||||
|
useEffect(() => {
|
||||||
|
if (turnStep !== 'claim' || !G.claimDeadline) return;
|
||||||
|
const ms = Math.max(0, G.claimDeadline - Date.now()) + 300;
|
||||||
|
const t = setTimeout(() => onMove('claimTimeout', []), ms);
|
||||||
|
return () => clearTimeout(t);
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [turnStep, G.claimDeadline, (G.pendingClaims ?? []).length]);
|
||||||
|
|
||||||
// 立直模式:点击「立直」后选一张牌同时宣言 + 打出
|
// 立直模式:点击「立直」后选一张牌同时宣言 + 打出
|
||||||
const [riichiMode, setRiichiMode] = useState(false);
|
const [riichiMode, setRiichiMode] = useState(false);
|
||||||
const lastMoveRef = useRef(0);
|
const lastMoveRef = useRef(0);
|
||||||
@@ -224,8 +233,10 @@ function TableView({
|
|||||||
const onTileClick = (tileId: string) => {
|
const onTileClick = (tileId: string) => {
|
||||||
if (!isMyTurn || turnStep !== 'discard') return;
|
if (!isMyTurn || turnStep !== 'discard') return;
|
||||||
if (riichiMode) {
|
if (riichiMode) {
|
||||||
doMove('declareRiichi', [tileId]);
|
// 立直宣言 + 打出必须成对派发,绕过 250ms 防抖,否则第二发会被丢弃导致卡死
|
||||||
doMove('discard', [tileId]);
|
lastMoveRef.current = Date.now();
|
||||||
|
onMove('declareRiichi', [tileId]);
|
||||||
|
onMove('discard', [tileId]);
|
||||||
setRiichiMode(false);
|
setRiichiMode(false);
|
||||||
} else {
|
} else {
|
||||||
doMove('discard', [tileId]);
|
doMove('discard', [tileId]);
|
||||||
@@ -349,7 +360,7 @@ function TableView({
|
|||||||
{isMyTurn && turnStep === 'discard' && (
|
{isMyTurn && turnStep === 'discard' && (
|
||||||
<>
|
<>
|
||||||
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', justifyContent: 'flex-end' }}>
|
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', justifyContent: 'flex-end' }}>
|
||||||
<button data-testid="mj-tsumo" onClick={() => doMove('tsumo', [])} style={btn('#b71c1c')}>自摸</button>
|
<button data-testid="mj-tsumo" onClick={() => doMove('tsumo', [])} disabled={!G.drawnTile} style={btn('#b71c1c', !G.drawnTile)}>自摸</button>
|
||||||
<button
|
<button
|
||||||
data-testid="mj-riichi"
|
data-testid="mj-riichi"
|
||||||
onClick={() => setRiichiMode((v) => !v)}
|
onClick={() => setRiichiMode((v) => !v)}
|
||||||
@@ -385,6 +396,9 @@ function TableView({
|
|||||||
{turnStep === 'claim' && !myClaim && (
|
{turnStep === 'claim' && !myClaim && (
|
||||||
<div style={{ fontSize: 12, color: '#aaa' }}>等待鸣牌响应…</div>
|
<div style={{ fontSize: 12, color: '#aaa' }}>等待鸣牌响应…</div>
|
||||||
)}
|
)}
|
||||||
|
{G.riichi?.[me] && (
|
||||||
|
<div data-testid="mj-my-riichi" style={{ fontSize: 12, color: '#f66' }}>🔴 已立直(只能摸切)</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -501,19 +515,32 @@ function SeatPanel({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function ClaimOverlay({ claim, onMove, claimTile }: { claim: PendingClaim; onMove: (n: string, a: any[]) => void; claimTile: any }) {
|
function ClaimOverlay({ claim, onMove, claimTile }: { claim: PendingClaim; onMove: (n: string, a: any[]) => void; claimTile: any }) {
|
||||||
const [secs, setSecs] = useState(8);
|
const [secs, setSecs] = useState(10);
|
||||||
|
// 用稳定的选项签名作依赖,避免 playerView 每次返回新对象导致倒计时被反复重置
|
||||||
|
const sig = `${claim.player}:${claim.options.map((o) => o.type + (o.useTiles?.join('-') ?? '')).join(',')}`;
|
||||||
|
const actedRef = useRef(false);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setSecs(8);
|
actedRef.current = false;
|
||||||
|
setSecs(10);
|
||||||
const iv = setInterval(() => setSecs((s) => (s > 0 ? s - 1 : 0)), 1000);
|
const iv = setInterval(() => setSecs((s) => (s > 0 ? s - 1 : 0)), 1000);
|
||||||
return () => clearInterval(iv);
|
return () => clearInterval(iv);
|
||||||
}, [claim]);
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [sig]);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (secs === 0) onMove('respond', ['pass']);
|
if (secs === 0 && !actedRef.current) {
|
||||||
|
actedRef.current = true;
|
||||||
|
onMove('respond', ['pass']);
|
||||||
|
}
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [secs]);
|
}, [secs]);
|
||||||
|
|
||||||
const hasRon = claim.options.some((o) => o.type === 'ron');
|
const hasRon = claim.options.some((o) => o.type === 'ron');
|
||||||
const chiOptions = claim.options.filter((o) => o.type === 'chi');
|
const chiOptions = claim.options.filter((o) => o.type === 'chi');
|
||||||
|
const act = (type: string, useTiles?: number[]) => {
|
||||||
|
if (actedRef.current) return;
|
||||||
|
actedRef.current = true;
|
||||||
|
onMove('respond', useTiles ? [type, useTiles] : [type]);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div data-testid="mj-claim" style={{ position: 'absolute', inset: 0, background: 'rgba(0,0,0,0.5)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 20 }}>
|
<div data-testid="mj-claim" style={{ position: 'absolute', inset: 0, background: 'rgba(0,0,0,0.5)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 20 }}>
|
||||||
@@ -522,19 +549,19 @@ function ClaimOverlay({ claim, onMove, claimTile }: { claim: PendingClaim; onMov
|
|||||||
可鸣牌 {claimTile ? `(${tileLabel(claimTile.kind)})` : ''} · 自动跳过 {secs}s
|
可鸣牌 {claimTile ? `(${tileLabel(claimTile.kind)})` : ''} · 自动跳过 {secs}s
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', justifyContent: 'center' }}>
|
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', justifyContent: 'center' }}>
|
||||||
{hasRon && <button data-testid="mj-claim-ron" onClick={() => onMove('respond', ['ron'])} style={btn('#b71c1c')}>荣和</button>}
|
{hasRon && <button data-testid="mj-claim-ron" onClick={() => act('ron')} style={btn('#b71c1c')}>荣和</button>}
|
||||||
{claim.options.some((o) => o.type === 'pon') && (
|
{claim.options.some((o) => o.type === 'pon') && (
|
||||||
<button data-testid="mj-claim-pon" onClick={() => onMove('respond', ['pon'])} style={btn('#1565c0')}>碰</button>
|
<button data-testid="mj-claim-pon" onClick={() => act('pon')} style={btn('#1565c0')}>碰</button>
|
||||||
)}
|
)}
|
||||||
{claim.options.some((o) => o.type === 'kan') && (
|
{claim.options.some((o) => o.type === 'kan') && (
|
||||||
<button data-testid="mj-claim-kan" onClick={() => onMove('respond', ['kan'])} style={btn('#4527a0')}>杠</button>
|
<button data-testid="mj-claim-kan" onClick={() => act('kan')} style={btn('#4527a0')}>杠</button>
|
||||||
)}
|
)}
|
||||||
{chiOptions.map((o, i) => (
|
{chiOptions.map((o, i) => (
|
||||||
<button key={i} onClick={() => onMove('respond', ['chi', o.useTiles])} style={btn('#2e7d32')}>
|
<button key={i} onClick={() => act('chi', o.useTiles)} style={btn('#2e7d32')}>
|
||||||
吃 {o.useTiles?.map((k) => tileLabel(kindToIndexToKind(k))).join('')}
|
吃 {o.useTiles?.map((k) => tileLabel(kindToIndexToKind(k))).join('')}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
<button data-testid="mj-claim-pass" onClick={() => onMove('respond', ['pass'])} style={btn('#555')}>跳过</button>
|
<button data-testid="mj-claim-pass" onClick={() => act('pass')} style={btn('#555')}>跳过</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -566,6 +593,22 @@ function RoundEndOverlay({
|
|||||||
{G.lastSummary && (
|
{G.lastSummary && (
|
||||||
<div style={{ padding: 8, background: '#1a3050', borderRadius: 6, fontSize: 13, marginBottom: 10 }}>{G.lastSummary}</div>
|
<div style={{ padding: 8, background: '#1a3050', borderRadius: 6, fontSize: 13, marginBottom: 10 }}>{G.lastSummary}</div>
|
||||||
)}
|
)}
|
||||||
|
{(G.doraIndicators?.length || G.uraIndicators?.length) ? (
|
||||||
|
<div data-testid="mj-dora-reveal" style={{ display: 'flex', gap: 12, alignItems: 'center', flexWrap: 'wrap', marginBottom: 8, fontSize: 12 }}>
|
||||||
|
<span>表宝牌指示:</span>
|
||||||
|
{(G.doraIndicators ?? []).map((k, i) => (
|
||||||
|
<TileFace key={`d${i}`} kind={k} size="tiny" />
|
||||||
|
))}
|
||||||
|
{G.uraIndicators && G.uraIndicators.length > 0 && (
|
||||||
|
<>
|
||||||
|
<span style={{ marginLeft: 8 }}>里宝牌指示:</span>
|
||||||
|
{G.uraIndicators.map((k, i) => (
|
||||||
|
<TileFace key={`u${i}`} kind={k} size="tiny" />
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
{G.result?.wins?.map((w, i) => (
|
{G.result?.wins?.map((w, i) => (
|
||||||
<div key={i} style={{ fontSize: 12, marginBottom: 6 }}>
|
<div key={i} style={{ fontSize: 12, marginBottom: 6 }}>
|
||||||
{nameOf(w.player)} {w.fromPlayer === null ? '自摸' : `荣和(${nameOf(w.fromPlayer)} 放铳)`}:{w.isYakuman ? `役满×${w.yakumanMultiplier}` : `${w.han}番`} {w.fu}符 ·{' '}
|
{nameOf(w.player)} {w.fromPlayer === null ? '自摸' : `荣和(${nameOf(w.fromPlayer)} 放铳)`}:{w.isYakuman ? `役满×${w.yakumanMultiplier}` : `${w.han}番`} {w.fu}符 ·{' '}
|
||||||
|
|||||||
277
docs/mahjong/02-verification-checklist.md
Normal file
277
docs/mahjong/02-verification-checklist.md
Normal file
@@ -0,0 +1,277 @@
|
|||||||
|
# 立直麻将(Mahjong)实现验证清单
|
||||||
|
|
||||||
|
> 依据:`docs/mahjong/00-rules.md`(雀魂标准,四麻+三麻,半庄战)。
|
||||||
|
> 目的:供人工审核实现与规则的一致性。逐条列出**期望行为 / 实现状态 / 证据 / 风险**。
|
||||||
|
> **版本**:v2(2026-10-03,P0+P1 修复完成)。v1 快照见 git 历史。
|
||||||
|
> 代码:`packages/engine/src/games/mahjong*.ts`、`apps/web/src/MahjongBoard.tsx`。
|
||||||
|
|
||||||
|
## 图例
|
||||||
|
|
||||||
|
**级别(重要度)**:**P0** 核心(缺失/错误会导致对局不成立或算错分)|**P1** 重要|**P2** 次要/边角
|
||||||
|
|
||||||
|
**实现状态**:✅ 已实现且有单测 | 🟩 已实现(无专门单测/间接覆盖)| 🧪 已实现且浏览器冒烟实测 | ⚠️ 部分/简化/有边界问题 | ❌ 未实现
|
||||||
|
|
||||||
|
**测试**:`mahjong-tiles.test.ts`(16) · `mahjong-yaku.test.ts`(33) · `mahjong.test.ts`(24);引擎合计 **324 通过**。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 本轮(v2)修复总览:P0 + P1 全部完成
|
||||||
|
|
||||||
|
| 编号 | 级别 | 修复内容 | 状态 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| P-01 | P0 | 立直后强制**摸切**(宣言回合可打任意牌,之后只能打摸到的牌) | ✅ 单测 |
|
||||||
|
| P-02 | P0 | **鸣牌响应超时**:新增 `claimDeadline` + `claimTimeout` move + UI 全局倒计时,掉线也不卡死 | ✅ 单测 + 🧪 冒烟 |
|
||||||
|
| P-03 | P1 | 途中流局:四风连打 / 四家立直 / 三家和了 / 四杠散了 | ✅ 单测 |
|
||||||
|
| P-04 | P1 | 流局满贯(弃牌全幺九且未被鸣 → 按满贯自摸,不收供托/本场) | ✅ 单测 |
|
||||||
|
| P-05 | P1 | 和了终局/听牌终局(All Last 亲家一位且达返点 + 连庄 → 终局) | ✅ 单测 |
|
||||||
|
| P-06 | P1 | 立直后暗杠**听牌不变**校验 | ✅ 单测 |
|
||||||
|
| P-07 | P1 | 三麻抢北(对拔北开荣和窗口,按抢杠计) | ✅ 单测 |
|
||||||
|
| P-08 | P1 | 里宝牌/杠宝 UI 揭示(和了后 `uraIndicators` 公开并在结算面板展示) | ✅ 单测 + UI |
|
||||||
|
| P-09 | P1 | 食替「筋」规则核对(现物 + 筋,含 123/789 边界) | ✅ 已覆盖 |
|
||||||
|
| P-10 | P1 | 振听边界:被鸣走的自弃牌仍计入舍张振听(`furitenKinds` 只增不减) | ✅ 单测 |
|
||||||
|
|
||||||
|
> 另修正 `waitingKinds`:扣除已锁定在副露中的牌(某牌种自占 4 张时不再作为听牌)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## A. 牌具与牌组
|
||||||
|
|
||||||
|
| 编号 | 级别 | 检查项 | 期望 | 状态 |
|
||||||
|
| --- | --- | --- | --- | --- |
|
||||||
|
| A1 | P0 | 四麻牌组 | 34 种 × 4 = 136 张 | ✅ |
|
||||||
|
| A2 | P0 | 三麻牌组 | 去 2m~8m,108 张 | ✅ |
|
||||||
|
| A3 | P0 | 四麻赤宝牌 | 红 5m/5p/5s 各 1(3 枚) | ✅ |
|
||||||
|
| A4 | P0 | 三麻赤宝牌 | 红 5p/5s 各 1(2 枚) | ✅ |
|
||||||
|
| A5 | P0 | 牌种索引与赤牌归一 | `0m→5m`;`red` 单独保留 | ✅ |
|
||||||
|
| A6 | P1 | 宝牌指示映射 | 数牌 9→1;风循环;三元循环 | ✅ |
|
||||||
|
| A7 | P2 | 手牌排序 | m→p→s→z,各 1..9 | ✅ |
|
||||||
|
|
||||||
|
## B. 牌山 / 王牌 / 指示牌 / 岭上 / 里宝
|
||||||
|
|
||||||
|
| 编号 | 级别 | 检查项 | 期望 | 状态 |
|
||||||
|
| --- | --- | --- | --- | --- |
|
||||||
|
| B1 | P0 | 王牌 | 14 张不参与摸牌 | ✅ |
|
||||||
|
| B2 | P0 | 开局表宝牌指示牌 | 开局翻 1 张 | 🧪 |
|
||||||
|
| B3 | P0 | 杠宝牌 | 每次杠翻新指示牌(≤5) | 🟩 |
|
||||||
|
| B4 | P0 | 岭上牌 | 4 张,杠/拔北后补摸 | 🟩 |
|
||||||
|
| B5 | P1 | 里宝牌 | 立直和了按里指示加番;和了后 UI 揭示 | ✅ 计分 + UI |
|
||||||
|
| B6 | P0 | 牌山摸尽 | 荒牌流局 | ✅ |
|
||||||
|
| B7 | P2 | 指示牌/岭上物理位置 | 与真实砌墙一致 | ⚠️ 简化布局 |
|
||||||
|
| B8 | P1 | 杠宝翻开时机 | 抢杠则不翻 | 🟩 |
|
||||||
|
|
||||||
|
## C. 配牌与摸打
|
||||||
|
|
||||||
|
| 编号 | 级别 | 检查项 | 期望 | 状态 |
|
||||||
|
| --- | --- | --- | --- | --- |
|
||||||
|
| C1 | P0 | 发牌 | 每人 13 张,原子完成 | ✅ |
|
||||||
|
| C2 | P0 | 庄家先摸 | 起手 14 张 | ✅ |
|
||||||
|
| C3 | P0 | 摸打循环 | 打牌 → 下家摸牌 | ✅ |
|
||||||
|
| C4 | P0 | 出牌合法性 | 只能打自己手牌 | 🟩 |
|
||||||
|
| C5 | P1 | 摸牌并入 | 摸到的牌进手牌 | ✅ |
|
||||||
|
| C6 | P2 | 自摸牌提示 | 显示「摸到」 | 🧪(手牌行内亦有,UX 小事) |
|
||||||
|
|
||||||
|
## D. 立直
|
||||||
|
|
||||||
|
| 编号 | 级别 | 检查项 | 期望 | 状态 |
|
||||||
|
| --- | --- | --- | --- | --- |
|
||||||
|
| D1 | P0 | 立直条件 | 门清 + 听牌 + ≥1000 | 🟩 |
|
||||||
|
| D2 | P0 | 立直供托 | 支付 1000 计供托 | 🟩 |
|
||||||
|
| D3 | P0 | **立直后摸切** | 立直后只能打刚摸的牌 | ✅ |
|
||||||
|
| D4 | P0 | 一发 | 立直后一巡内和了且无鸣牌 | 🟩 |
|
||||||
|
| D5 | P1 | 双立直 | 第一巡立直(2 番) | 🟩 |
|
||||||
|
| D6 | P1 | 立直后暗杠 | 仅在不改变听牌时可暗杠 | ✅ |
|
||||||
|
| D7 | P1 | 四家立直流局 | → 途中流局 | ✅ |
|
||||||
|
| D8 | P1 | 里宝牌 | 立直和了加算里宝 | ✅ |
|
||||||
|
| D9 | P2 | 立直棒可视化 | 桌面立直棒 | ⚠️ 仅数字 + 自己立直提示 |
|
||||||
|
|
||||||
|
## E. 鸣牌
|
||||||
|
|
||||||
|
| 编号 | 级别 | 检查项 | 期望 | 状态 |
|
||||||
|
| --- | --- | --- | --- | --- |
|
||||||
|
| E1 | P0 | 吃 | 仅下家(四麻) | ✅🧪 |
|
||||||
|
| E2 | P0 | 碰 | 任意家 | 🟩 |
|
||||||
|
| E3 | P0 | 大明杠 | 3 手牌 + 弃牌 | 🟩 |
|
||||||
|
| E4 | P0 | 暗杠 | 手持 4 张 | 🟩 |
|
||||||
|
| E5 | P0 | 加杠 | 已碰 + 第 4 张,含抢杠窗口 | 🟩 |
|
||||||
|
| E6 | P0 | 三麻不可吃 | 无吃选项 | 🟩 |
|
||||||
|
| E7 | P0 | 食替禁止 | 现物 + 筋 | ✅ |
|
||||||
|
| E8 | P0 | 鸣牌优先级 | 荣 > 碰/杠 > 吃 | ✅ |
|
||||||
|
| E9 | P0 | 一炮多响(头跳) | 最近者 | ✅ |
|
||||||
|
| E10 | P1 | 三家和了 | → 流局 | ✅ |
|
||||||
|
| E11 | P1 | 四杠散了 | → 流局 | ✅ |
|
||||||
|
| E12 | P1 | 三麻拔北 | 亮北 +1 宝、岭上 | 🟩 |
|
||||||
|
| E13 | P1 | 抢北 | 对拔北荣和 | ✅ |
|
||||||
|
| E14 | P2 | 碰后不可立即拔北 | 拔北近似鸣牌 | ❌ 未限制 |
|
||||||
|
| E15 | P2 | 明杠/加杠的宝牌与岭上顺序 | 符合规则 | 🟩 |
|
||||||
|
|
||||||
|
## F. 和了判定
|
||||||
|
|
||||||
|
| 编号 | 级别 | 检查项 | 期望 | 状态 |
|
||||||
|
| --- | --- | --- | --- | --- |
|
||||||
|
| F1 | P0 | 自摸 | 三家付点 | ✅ |
|
||||||
|
| F2 | P0 | 荣和 | 放铳者付 | 🟩 |
|
||||||
|
| F3 | P0 | **无役不能和** | 番=0 不能和(宝牌不算役) | ✅ |
|
||||||
|
| F4 | P0 | 宝牌不计入役 | 无役即使有宝牌也不能和 | ✅ |
|
||||||
|
| F5 | P0 | 振听·舍张 | 含被鸣走的自弃牌 | ✅ |
|
||||||
|
| F6 | P0 | 振听·同巡 | 本轮放过 → 本轮不能再荣和 | 🟩 |
|
||||||
|
| F7 | P0 | 振听·立直后 | 永久振听 | 🟩 |
|
||||||
|
| F8 | P1 | 一发和了 | +1 番 | 🟩 |
|
||||||
|
| F9 | P0 | 岭上开花 | 杠/拔北后岭上自摸 | 🟩 |
|
||||||
|
| F10 | P0 | 抢杠 | 抢加杠荣和 | 🟩 |
|
||||||
|
| F11 | P0 | 海底摸月 | 牌山最后自摸 | 🟩 |
|
||||||
|
| F12 | P0 | 河底捞鱼 | 牌山最后弃牌荣和 | 🟩 |
|
||||||
|
| F13 | P0 | 天和 / 地和 | 役满 | 🟩 |
|
||||||
|
| F14 | P2 | 人和 | 古役 | ❌ 有意不做 |
|
||||||
|
| F15 | P1 | 振听边界(被鸣走牌) | 仍计入 | ✅ |
|
||||||
|
|
||||||
|
## G. 宝牌
|
||||||
|
|
||||||
|
| 编号 | 级别 | 检查项 | 期望 | 状态 |
|
||||||
|
| --- | --- | --- | --- | --- |
|
||||||
|
| G1 | P0 | 表宝牌 | 每张 +1 番 | ✅ |
|
||||||
|
| G2 | P0 | 赤宝牌 | 按实际持有(含副露) | ✅ |
|
||||||
|
| G3 | P1 | 里宝牌 | 立直和了 + 里指示 | ✅ 计分 + UI |
|
||||||
|
| G4 | P0 | 杠宝牌 | 每杠翻新 | 🟩 |
|
||||||
|
| G5 | P1 | 拔北宝牌 | 每张拔出北 +1 | 🟩 |
|
||||||
|
| G6 | P0 | 宝牌不是役 | — | ✅ |
|
||||||
|
|
||||||
|
## H. 役种(按番数)
|
||||||
|
|
||||||
|
> ✅ 有单测;🟩 已实现未单测;❌ 未实现。
|
||||||
|
|
||||||
|
| 番 | 役种 | 状态 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 1 番 | 立直 / 一发 / 门前清自摸和 / 平和 / 断幺九(有食断)/ 一杯口 / 役牌(含连风)/ 岭上 / 抢杠 / 海底 / 河底 | ✅ 平和·断幺·一杯口·役牌·门前清自摸;🟩 立直·一发·岭上·抢杠·海底·河底 |
|
||||||
|
| 2 番 | 双立直 / 三色同顺 / 一气通贯 / 混全带 / 七对子 / 对对和 / 三暗刻 / 三色同刻 / 三杠子 / 混老头 / 小三元 | ✅ 除双立直·三杠子为 🟩 |
|
||||||
|
| 3 番 | 二杯口 / 纯全带 / 混一色 | ✅ |
|
||||||
|
| 6 番 | 清一色 | ✅ |
|
||||||
|
| 役满 | 国士 / 四暗刻 / 大三元 / 小四喜 / 大四喜(双) / 字一色 / 绿一色 / 清老头 / 九莲 / 纯正九莲(双) / 四杠子 / 天和 / 地和 / 国士十三面(双) / 四暗刻单骑(双) / 累计役满 | ✅ 国士·四暗刻·大三元·字一色·九莲·纯正·国士十三面·四暗刻单骑;🟩 其余 |
|
||||||
|
| 未实现 | 人和 / 古役(有意不做)/ 抢北已补 | ❌ 仅人和·古役 |
|
||||||
|
|
||||||
|
## I. 符计算
|
||||||
|
|
||||||
|
| 编号 | 级别 | 检查项 | 期望 | 状态 |
|
||||||
|
| --- | --- | --- | --- | --- |
|
||||||
|
| I1 | P0 | 副底 | 20 符 | ✅ |
|
||||||
|
| I2 | P0 | 门前清荣和 | +10 | ✅ |
|
||||||
|
| I3 | P0 | 自摸 | +2 | 🟩 |
|
||||||
|
| I4 | P0 | 雀头 | 三元/自风/场风 +2,连风 +4 | 🟩 |
|
||||||
|
| I5 | P0 | 刻/杠符 | 分档(暗杠幺九 +32) | ✅ |
|
||||||
|
| I6 | P0 | 听牌形 | 嵌张/单骑/边张 +2 | 🟩 |
|
||||||
|
| I7 | P0 | 平和自摸 | 20 符 | ✅ |
|
||||||
|
| I8 | P0 | 七对子 | 25 符 | 🟩 |
|
||||||
|
| I9 | P0 | 最低符 | 不足 30 进位 | 🟩 |
|
||||||
|
| I10 | P0 | 进位 | 到 10 位 | 🟩 |
|
||||||
|
|
||||||
|
## J. 点数计算
|
||||||
|
|
||||||
|
| 编号 | 级别 | 检查项 | 期望 | 状态 |
|
||||||
|
| --- | --- | --- | --- | --- |
|
||||||
|
| J1 | P0 | 基本点 | 符 × 2^(2+番) | ✅ |
|
||||||
|
| J2 | P0 | 满贯判定 | 5 番 / 4番40符 / 3番70符 | ✅ |
|
||||||
|
| J3 | P0 | 跳满/倍满/三倍满/役满 | 6/8/11/13 档 | ✅ |
|
||||||
|
| J4 | P0 | 切上满贯 | 不采用 | ✅ |
|
||||||
|
| J5 | P0 | 亲/子荣和 | ×6 / ×4 | ✅ |
|
||||||
|
| J6 | P0 | 自摸分配 | 亲平摊;子庄2倍闲1倍 | ✅ |
|
||||||
|
| J7 | P0 | 本场 | 300 / 200 | ✅ |
|
||||||
|
| J8 | P0 | 供托 | 1000 归和了者 | 🟩 |
|
||||||
|
| J9 | P0 | 三麻自摸损 | 少收一名闲家 | ✅ |
|
||||||
|
| J10 | P0 | 取整 | 向上到 100 | ✅ |
|
||||||
|
| J11 | P0 | 点数守恒 | 无供托零和 | ✅ |
|
||||||
|
| J12 | P1 | 击飞 | < 0 终局(=0 继续) | 🟩 |
|
||||||
|
|
||||||
|
## K. 流局
|
||||||
|
|
||||||
|
| 编号 | 级别 | 检查项 | 期望 | 状态 |
|
||||||
|
| --- | --- | --- | --- | --- |
|
||||||
|
| K1 | P0 | 荒牌流局 | 牌山摸尽 | ✅ |
|
||||||
|
| K2 | P0 | 听牌罚符 | 3000 / 2000 按听牌家数 | ✅ |
|
||||||
|
| K3 | P0 | 连庄 | 亲听牌连庄 | ✅ |
|
||||||
|
| K4 | P1 | 九种九牌 | 首巡 ≥9 种幺九字牌可宣言 | 🟩 |
|
||||||
|
| K5 | P1 | 四风连打 | → 流局 | ✅ |
|
||||||
|
| K6 | P1 | 四家立直 | → 流局 | ✅ |
|
||||||
|
| K7 | P1 | 三家和了 | → 流局 | ✅ |
|
||||||
|
| K8 | P2 | 四杠散了 | → 流局 | ✅ |
|
||||||
|
| K9 | P1 | 流局满贯 | 弃牌全幺九未被鸣 → 满贯自摸 | ✅ |
|
||||||
|
|
||||||
|
## L. 连庄 / 进庄 / 终局 / 西入
|
||||||
|
|
||||||
|
| 编号 | 级别 | 检查项 | 期望 | 状态 |
|
||||||
|
| --- | --- | --- | --- | --- |
|
||||||
|
| L1 | P0 | 和了连庄/下庄 | 亲和了连庄 | 🟩 |
|
||||||
|
| L2 | P0 | 听牌连庄 | — | ✅ |
|
||||||
|
| L3 | P0 | 场次推进 | 东南半庄 4 局一场风 | 🟩 |
|
||||||
|
| L4 | P1 | 和了终局/听牌终局 | All Last 亲一位+达返点+连庄 → 终局 | ✅ |
|
||||||
|
| L5 | P1 | 西入 | 南 4 无人达返点 → 西入 | 🟩 |
|
||||||
|
| L6 | P0 | 击飞终局 | — | 🟩 |
|
||||||
|
| L7 | P2 | 顺位马 / 头名赏 | 段位点结算 | ❌ 仅场内点数 |
|
||||||
|
|
||||||
|
## M. 三麻特有
|
||||||
|
|
||||||
|
| 编号 | 级别 | 检查项 | 期望 | 状态 |
|
||||||
|
| --- | --- | --- | --- | --- |
|
||||||
|
| M1 | P0 | 去牌 / 无吃 | — | ✅ |
|
||||||
|
| M2 | P0 | 起点/返点 | 35000 / 40000 | 🟩 |
|
||||||
|
| M3 | P0 | 自摸损 | — | ✅ |
|
||||||
|
| M4 | P1 | 拔北 | — | 🟩 |
|
||||||
|
| M5 | P1 | 拔北后岭上 | 算岭上开花 | 🟩 |
|
||||||
|
| M6 | P1 | 北作面子=客风 | 非役 | 🟩 |
|
||||||
|
| M7 | P1 | 无四风连打/四家立直 | 自动不适用 | 🟩 |
|
||||||
|
| M8 | P2 | 抢北 | 对拔北荣和 | ✅ |
|
||||||
|
|
||||||
|
## N. UI / 交互
|
||||||
|
|
||||||
|
| 编号 | 级别 | 检查项 | 期望 | 状态 |
|
||||||
|
| --- | --- | --- | --- | --- |
|
||||||
|
| N1 | P0 | 牌桌渲染 | 手牌/牌河/副露/宝牌/剩余/点数/场况 | 🧪 |
|
||||||
|
| N2 | P0 | 出牌 | 点击手牌、轮转 | 🧪 |
|
||||||
|
| N3 | P0 | 鸣牌窗口 | 荣/碰/杠/吃/跳过 + 倒计时 | 🧪 |
|
||||||
|
| N4 | P1 | 立直操作 | 宣言 + 选牌打出 | 🟩 |
|
||||||
|
| N5 | P1 | 操作按钮 | 自摸/暗杠/加杠/拔北/九种九牌 | 🟩 |
|
||||||
|
| N6 | P1 | 结算面板 | 役种/番符/点数/顺位/下一局 | 🟩 |
|
||||||
|
| N7 | P2 | 里宝牌展示 | 和了时揭开 | ✅ |
|
||||||
|
| N8 | P2 | 立直棒/自己立直提示 | — | ⚠️ 数字 + 自己立直提示;桌面立直棒未画 |
|
||||||
|
| N9 | P2 | 听牌/振听提示 | — | ❌ |
|
||||||
|
| N10 | P1 | 鸣牌超时对“非本人”推进 | 掉线也能推进 | ✅🧪(任一客户端在线即可驱动) |
|
||||||
|
|
||||||
|
## O. 联机 / 隐藏信息
|
||||||
|
|
||||||
|
| 编号 | 级别 | 检查项 | 期望 | 状态 |
|
||||||
|
| --- | --- | --- | --- | --- |
|
||||||
|
| O1 | P0 | playerView 隐藏 | 他家手牌/牌山/里宝隐藏 | 🟩 |
|
||||||
|
| O2 | P0 | 公开信息 | handCounts/wallCount/doraIndicators | 🧪 |
|
||||||
|
| O3 | P1 | 本地换座位不重置 | updatePlayerID 原地切换 | 🧪 |
|
||||||
|
| O4 | P1 | 联机 4 端对局 | SocketIO 正常同步 | 🟩 未做完整 4 端 E2E |
|
||||||
|
| O5 | P2 | 掉线玩家鸣牌超时 | 服务端推进 | 🟩(客户端驱动;全部离线则仍会停) |
|
||||||
|
|
||||||
|
## P. 已修复与剩余风险
|
||||||
|
|
||||||
|
**已修复(P0/P1)**:P-01 摸切、P-02 鸣牌超时、P-03 途中流局×4、P-04 流局满贯、P-05 和了终局、P-06 立直后暗杠校验、P-07 抢北、P-08 里宝/杠宝展示、P-09 食替筋、P-10 振听边界。
|
||||||
|
|
||||||
|
**仍存在(均为 P2 或依赖人工配置)**:
|
||||||
|
|
||||||
|
| 编号 | 级别 | 问题 | 影响 | 说明 |
|
||||||
|
| --- | --- | --- | --- | --- |
|
||||||
|
| R-01 | P2 | 碰后不可立即拔北未限制 | 三麻细节 | 可与拔北 move 中加“刚鸣牌”标记 |
|
||||||
|
| R-02 | P2 | 加杠被抢后第 4 张牌未归还手牌 | 抢杠后本局立即结束,暂无实际影响 | 若后续支持“抢杠后继续”需修 |
|
||||||
|
| R-03 | P2 | 掷骰/开牌位置、宝牌物理位置简化 | 与真实砌墙无关紧要 | 保持简化 |
|
||||||
|
| R-04 | P2 | 无观战、无 AI 补位 | 需满员开局 | 后续独立阶段 |
|
||||||
|
| R-05 | P2 | 段位马点/头名赏未计算 | 仅显示场内点数 | 终局结算时补 |
|
||||||
|
| R-06 | P2 | 听牌/振听无 UI 提示 | 体验 | 可选 |
|
||||||
|
| R-07 | P1 | 联机 4 端 E2E 未做 | 缺回归 | 建议后续补 Playwright |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 本轮浏览器冒烟实测(v2,均已通过、无 JS 异常)
|
||||||
|
|
||||||
|
- 开局(修复后正常)→ 打牌 → 轮转
|
||||||
|
- 切换座位不重置
|
||||||
|
- 鸣牌窗口出现;**故意不响应,等待 >12s 后自动跳过并推进**(验证 P0-02 / N10)
|
||||||
|
- 连续多回合打牌正常(牌河累加、宝牌指示显示、点数显示)
|
||||||
|
- 注入 `window.onerror` 捕获:全程 `[]`
|
||||||
|
|
||||||
|
## 建议的后续优先项
|
||||||
|
|
||||||
|
1. **R-07 联机 4 端 E2E**(回归保障)。
|
||||||
|
2. R-01/R-02 两个三麻/抢杠细节。
|
||||||
|
3. R-05 终局马点/头名赏(若需要段位结算)。
|
||||||
@@ -234,13 +234,13 @@ export function kindsFromCounts(counts: Counts34): TileKind[] {
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 牌种列表 → 物理牌列表(用于测试便捷构造)。 */
|
/** 牌种列表 → 物理牌列表(用于测试便捷构造;赤牌自动标记 red)。 */
|
||||||
export function tilesFromKinds(kinds: readonly TileKind[]): Tile[] {
|
export function tilesFromKinds(kinds: readonly TileKind[]): Tile[] {
|
||||||
const used: Record<string, number> = {};
|
const used: Record<string, number> = {};
|
||||||
return kinds.map((kind) => {
|
return kinds.map((kind) => {
|
||||||
const n = used[kind] ?? 0;
|
const n = used[kind] ?? 0;
|
||||||
used[kind] = n + 1;
|
used[kind] = n + 1;
|
||||||
return { id: `${kind}-${n}`, kind };
|
return { id: `${kind}-${n}`, kind, ...(isRedKind(kind) ? { red: true } : {}) };
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -866,12 +866,15 @@ export function formsWinningShape(counts: number[], melds: readonly Meld[]): boo
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 听牌种类:13 张(含副露时更少)手牌,返回所有能使其成和牌型的牌索引。
|
* 听牌种类:13 张(含副露时更少)手牌,返回所有能使其成和牌型的牌索引。
|
||||||
|
* 会扣除已锁定在副露中的牌:某牌种已被自己占用 4 张时不可能再摸到/荣和。
|
||||||
*/
|
*/
|
||||||
export function waitingKinds(concealed: readonly number[], melds: readonly Meld[] = []): number[] {
|
export function waitingKinds(concealed: readonly number[], melds: readonly Meld[] = []): number[] {
|
||||||
const base = countsFromIndices(concealed);
|
const base = countsFromIndices(concealed);
|
||||||
|
const used = base.slice();
|
||||||
|
for (const m of melds) for (const k of m.tiles) used[k] = (used[k] ?? 0) + 1;
|
||||||
const out: number[] = [];
|
const out: number[] = [];
|
||||||
for (let i = 0; i < 34; i++) {
|
for (let i = 0; i < 34; i++) {
|
||||||
if ((base[i] ?? 0) >= 4) continue;
|
if ((used[i] ?? 0) >= 4) continue;
|
||||||
const c = base.slice();
|
const c = base.slice();
|
||||||
c[i] = (c[i] ?? 0) + 1;
|
c[i] = (c[i] ?? 0) + 1;
|
||||||
if (formsWinningShape(c, melds)) out.push(i);
|
if (formsWinningShape(c, melds)) out.push(i);
|
||||||
|
|||||||
@@ -8,8 +8,8 @@
|
|||||||
|
|
||||||
import { describe, expect, test } from 'vitest';
|
import { describe, expect, test } from 'vitest';
|
||||||
import { Mahjong, MAHJONG_INTERNALS } from './mahjong.js';
|
import { Mahjong, MAHJONG_INTERNALS } from './mahjong.js';
|
||||||
import { buildClaims, resolveClaims, computeWin, seatWindOf } from './mahjong.js';
|
import { buildClaims, resolveClaims, computeWin, seatWindOf, canRon } from './mahjong.js';
|
||||||
import { tilesFromKinds } from './mahjong-tiles.js';
|
import { tilesFromKinds, kindToIndex } from './mahjong-tiles.js';
|
||||||
|
|
||||||
function rngFactory(seed = 12345) {
|
function rngFactory(seed = 12345) {
|
||||||
let s = seed;
|
let s = seed;
|
||||||
@@ -170,6 +170,31 @@ describe('mahjong — 和了计算', () => {
|
|||||||
G.ippatsu = { '0': false, '1': false, '2': false, '3': false };
|
G.ippatsu = { '0': false, '1': false, '2': false, '3': false };
|
||||||
expect(computeWin(G, '1', 5 /* 6m */, false)).toBeNull();
|
expect(computeWin(G, '1', 5 /* 6m */, false)).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('赤宝牌按实际持有重算:打出的赤牌不计、手中赤 5 计 1', () => {
|
||||||
|
// 过期计数器(旧 bug:打出的赤 5 不归还,导致高估)不应被使用
|
||||||
|
const stale = winState();
|
||||||
|
stale.aka['1'] = 3;
|
||||||
|
expect(computeWin(stale, '1', 2, true)!.doraHan).toBe(0);
|
||||||
|
|
||||||
|
// 手牌含红 5m('0m' 归一为 5m,构成 456m)
|
||||||
|
const withRed = winState();
|
||||||
|
withRed.hands['1'] = tilesFromKinds([
|
||||||
|
'2m', '3m', '4m', '4m', '0m', '6m', '5p', '6p', '7p', '6s', '7s', '8s', '5s', '5s',
|
||||||
|
]);
|
||||||
|
const win = computeWin(withRed, '1', 1 /* 2m */, true)!;
|
||||||
|
expect(win.yaku.map((y) => y.name)).toEqual(expect.arrayContaining(['断幺九', '平和']));
|
||||||
|
expect(win.doraHan).toBe(1); // 仅赤宝牌
|
||||||
|
});
|
||||||
|
|
||||||
|
test('applyWin 点数守恒(自摸总额 = 三家付出之和)', () => {
|
||||||
|
const G = winState();
|
||||||
|
const win = computeWin(G, '1', 2, true)!;
|
||||||
|
const deltas = MAHJONG_INTERNALS.applyWin(G, win);
|
||||||
|
const sum = Object.values(deltas).reduce((a: number, b: any) => a + b, 0);
|
||||||
|
expect(sum).toBe(0); // 无供托时零和
|
||||||
|
expect(G.scores['1']).toBe(25000 + deltas['1']);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('mahjong — 荒牌流局', () => {
|
describe('mahjong — 荒牌流局', () => {
|
||||||
@@ -208,3 +233,247 @@ describe('mahjong — 荒牌流局', () => {
|
|||||||
expect(G.kyoku).toBe(1);
|
expect(G.kyoku).toBe(1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('mahjong — 整手模拟(冒烟)', () => {
|
||||||
|
test('随机打牌直至荒牌流局:不抛异常、点数守恒、可再开下一局', () => {
|
||||||
|
const G = setup(4);
|
||||||
|
startGame(G);
|
||||||
|
const discard = moveFn('discard');
|
||||||
|
const respond = moveFn('respond');
|
||||||
|
let guard = 0;
|
||||||
|
while (!G.result && guard++ < 1000) {
|
||||||
|
if (G.turnStep === 'discard' && G.drawnTile) {
|
||||||
|
const hand = G.hands[G.turn];
|
||||||
|
const tile = hand.find((t: any) => !G.kuikaeForbidden.includes(kindToIndex(t.kind)));
|
||||||
|
if (!tile) break;
|
||||||
|
discard({ G, playerID: G.turn }, tile.id);
|
||||||
|
} else if (G.turnStep === 'claim') {
|
||||||
|
for (const c of G.pendingClaims) {
|
||||||
|
if (!c.response) respond({ G, playerID: c.player }, 'pass');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
expect(G.result).not.toBeNull();
|
||||||
|
// 只打不和:正常为荒牌流局(极小概率触发四风连打等途中流局)
|
||||||
|
expect(['draw', 'abort']).toContain(G.result!.type);
|
||||||
|
if (G.result!.type === 'draw') expect(G.wall.length).toBe(0);
|
||||||
|
const sum = Object.values(G.scores).reduce((a: number, b: any) => a + b, 0);
|
||||||
|
expect(sum).toBe(25000 * 4); // 听牌罚符零和,总分不变
|
||||||
|
// 记录应包含十几次打牌
|
||||||
|
expect(G.discardCount).toBeGreaterThan(30);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// P0 / P1 规则补全的验证
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
const T = (k: string, i = 0) => ({ id: `${k}-${i}`, kind: k });
|
||||||
|
|
||||||
|
describe('mahjong — P0-01 立直后摸切', () => {
|
||||||
|
test('立直成立后不能打非摸到的牌,只能摸切', () => {
|
||||||
|
const G = setup(4);
|
||||||
|
startGame(G);
|
||||||
|
G.riichi['0'] = true;
|
||||||
|
G.pendingRiichi['0'] = false;
|
||||||
|
G.turn = '0';
|
||||||
|
G.turnStep = 'discard';
|
||||||
|
const drawn = G.drawnTile!;
|
||||||
|
const other = G.hands['0'].find((t: any) => t.id !== drawn.id)!;
|
||||||
|
const before = G.discards['0'].length;
|
||||||
|
moveFn('discard')({ G, playerID: '0' }, other.id);
|
||||||
|
expect(G.discards['0'].length).toBe(before); // 被拒绝
|
||||||
|
moveFn('discard')({ G, playerID: '0' }, drawn.id);
|
||||||
|
expect(G.discards['0'].some((t: any) => t.id === drawn.id)).toBe(true); // 摸切成功
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('mahjong — P0-02 鸣牌超时', () => {
|
||||||
|
test('未到点不生效;到点后自动跳过并推进', () => {
|
||||||
|
const G = setup(4);
|
||||||
|
startGame(G);
|
||||||
|
G.turn = '0';
|
||||||
|
G.turnStep = 'claim';
|
||||||
|
G.claimKind = 'normal';
|
||||||
|
G.claimTile = T('1m');
|
||||||
|
G.claimDiscarder = '0';
|
||||||
|
G.pendingClaims = [{ player: '1', options: [{ type: 'pon' }], response: null }];
|
||||||
|
G.claimDeadline = Date.now() + 10000;
|
||||||
|
const fn = moveFn('claimTimeout');
|
||||||
|
fn({ G });
|
||||||
|
expect(G.turnStep).toBe('claim'); // 未到点
|
||||||
|
G.claimDeadline = Date.now() - 1;
|
||||||
|
fn({ G });
|
||||||
|
expect(G.turnStep).toBe('discard'); // 已跳过 → 推进
|
||||||
|
expect(G.turn).toBe('1');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('mahjong — P1-03 途中流局', () => {
|
||||||
|
test('四家立直 / 四风连打', () => {
|
||||||
|
const G = setup(4);
|
||||||
|
startGame(G);
|
||||||
|
G.riichi = { '0': true, '1': true, '2': true, '3': true };
|
||||||
|
expect(MAHJONG_INTERNALS.checkAbortiveDraws(G)).toBe('四家立直');
|
||||||
|
|
||||||
|
const G2 = setup(4);
|
||||||
|
startGame(G2);
|
||||||
|
G2.noCallsYet = true;
|
||||||
|
G2.discardCount = 4;
|
||||||
|
G2.riichi = { '0': false, '1': false, '2': false, '3': false };
|
||||||
|
G2.discards = { '0': [T('1z')], '1': [T('1z')], '2': [T('1z')], '3': [T('1z')] };
|
||||||
|
expect(MAHJONG_INTERNALS.checkAbortiveDraws(G2)).toBe('四风连打');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('三家和了 → 流局', () => {
|
||||||
|
const G = setup(4);
|
||||||
|
startGame(G);
|
||||||
|
G.turn = '0';
|
||||||
|
G.turnStep = 'claim';
|
||||||
|
G.claimKind = 'normal';
|
||||||
|
G.claimDiscarder = '0';
|
||||||
|
G.claimTile = T('1m');
|
||||||
|
G.pendingClaims = ['1', '2', '3'].map((p) => ({
|
||||||
|
player: p,
|
||||||
|
options: [{ type: 'ron' }],
|
||||||
|
response: { type: 'ron' },
|
||||||
|
}));
|
||||||
|
MAHJONG_INTERNALS.resolveClaimWindow(G);
|
||||||
|
expect(G.result?.type).toBe('abort');
|
||||||
|
expect(G.result?.reason).toBe('三家和了');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('四杠散了判定', () => {
|
||||||
|
const G = setup(4);
|
||||||
|
G.kanByPlayer = { '0': 1, '1': 1, '2': 1, '3': 1 };
|
||||||
|
expect(MAHJONG_INTERNALS.fourKanAbort(G)).toBe(true);
|
||||||
|
G.kanByPlayer = { '0': 4, '1': 0, '2': 0, '3': 0 };
|
||||||
|
expect(MAHJONG_INTERNALS.fourKanAbort(G)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('mahjong — P1-04 流局满贯', () => {
|
||||||
|
test('弃牌全幺九未被鸣 → 按满贯自摸结算,不收供托', () => {
|
||||||
|
const G = setup(4);
|
||||||
|
startGame(G);
|
||||||
|
G.wall = [];
|
||||||
|
G.dealer = '0';
|
||||||
|
G.kyoutaku = 2; // 有供托也不该被收走
|
||||||
|
G.nagashiEligible = { '0': true, '1': false, '2': false, '3': false };
|
||||||
|
const before = { ...G.scores };
|
||||||
|
MAHJONG_INTERNALS.settleExhaustiveDraw(G);
|
||||||
|
expect(G.result?.reason).toBe('流局满贯');
|
||||||
|
// 亲家满贯自摸:每家付 4000,共 +12000
|
||||||
|
expect(G.scores['0']).toBe(before['0'] + 12000);
|
||||||
|
expect(G.scores['1']).toBe(before['1'] - 4000);
|
||||||
|
expect(G.kyoutaku).toBe(2); // 供托未被收走
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('mahjong — P1-05 和了终局', () => {
|
||||||
|
test('All Last 亲家一位且达返点 + 连庄 → 终局', () => {
|
||||||
|
const G = setup(4);
|
||||||
|
startGame(G);
|
||||||
|
G.bakaze = 2;
|
||||||
|
G.kyoku = 4;
|
||||||
|
G.dealer = '0';
|
||||||
|
G.honba = 1;
|
||||||
|
G.scores = { '0': 35000, '1': 25000, '2': 20000, '3': 20000 };
|
||||||
|
G.result = { type: 'tsumo', reason: '自摸', wins: [], deltas: {}, tenpai: [], noten: [], dealerRepeats: true };
|
||||||
|
MAHJONG_INTERNALS.maybeFinishGame(G);
|
||||||
|
expect(G.gameOver).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('mahjong — P1-06 立直后暗杠校验', () => {
|
||||||
|
test('改变听牌的暗杠被拒绝,保持不变则允许', () => {
|
||||||
|
const G = setup(4);
|
||||||
|
startGame(G);
|
||||||
|
G.turn = '0';
|
||||||
|
G.turnStep = 'discard';
|
||||||
|
G.riichi['0'] = true;
|
||||||
|
G.dealer = '0';
|
||||||
|
G.melds['0'] = [];
|
||||||
|
G.deadWall = tilesFromKinds(Array(14).fill('1z'));
|
||||||
|
// 1111m 23m 456m 789m 99p(听 1m/4m);杠 1m 后 1m 不可再和 → 听牌改变
|
||||||
|
G.hands['0'] = tilesFromKinds(['1m', '1m', '1m', '1m', '2m', '3m', '4m', '5m', '6m', '7m', '8m', '9m', '9p', '9p']);
|
||||||
|
G.drawnTile = G.hands['0'][0];
|
||||||
|
moveFn('ankan')({ G, playerID: '0' }, kindToIndex('1m'));
|
||||||
|
expect(G.melds['0'].length).toBe(0); // 被拒绝
|
||||||
|
|
||||||
|
// 杠 1z 不改变 9p/5s 双碰听 → 允许
|
||||||
|
const G2 = setup(4);
|
||||||
|
startGame(G2);
|
||||||
|
G2.turn = '0';
|
||||||
|
G2.turnStep = 'discard';
|
||||||
|
G2.riichi['0'] = true;
|
||||||
|
G2.melds['0'] = [];
|
||||||
|
G2.deadWall = tilesFromKinds(Array(14).fill('9s'));
|
||||||
|
G2.hands['0'] = tilesFromKinds(['1z', '1z', '1z', '1z', '2m', '3m', '4m', '5m', '6m', '7m', '9p', '9p', '5s', '5s']);
|
||||||
|
G2.drawnTile = G2.hands['0'][0];
|
||||||
|
moveFn('ankan')({ G: G2, playerID: '0' }, kindToIndex('1z'));
|
||||||
|
expect(G2.melds['0'].length).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('mahjong — P1-07 三麻抢北', () => {
|
||||||
|
test('对拔北开启抢北荣和窗口', () => {
|
||||||
|
const G = setup(3);
|
||||||
|
startGame(G);
|
||||||
|
G.turn = '0';
|
||||||
|
G.turnStep = 'discard';
|
||||||
|
G.melds = { '0': [], '1': [], '2': [] };
|
||||||
|
G.riichi = { '0': false, '1': false, '2': false };
|
||||||
|
G.pendingRiichi = { '0': false, '1': false, '2': false };
|
||||||
|
G.tempFuriten = { '0': false, '1': false, '2': false };
|
||||||
|
G.riichiFuriten = { '0': false, '1': false, '2': false };
|
||||||
|
G.furitenKinds = { '0': [], '1': [], '2': [] };
|
||||||
|
G.discards = { '0': [], '1': [], '2': [] };
|
||||||
|
G.aka = { '0': 0, '1': 0, '2': 0 };
|
||||||
|
G.nukiDora = { '0': 0, '1': 0, '2': 0 };
|
||||||
|
G.deadWall = tilesFromKinds(Array(14).fill('1z'));
|
||||||
|
G.dealer = '0';
|
||||||
|
G.bakaze = 1;
|
||||||
|
// P0 有北;P1 国士听北(4z)
|
||||||
|
G.hands['0'] = tilesFromKinds(['1m', '9m', '1p', '2p', '3p', '4p', '5p', '6p', '7p', '8p', '9p', '1s', '2s', '4z']);
|
||||||
|
G.hands['1'] = tilesFromKinds(['1m', '9m', '1p', '9p', '1s', '9s', '1z', '2z', '3z', '5z', '6z', '7z', '1m']);
|
||||||
|
moveFn('nuki')({ G, playerID: '0' });
|
||||||
|
expect(G.claimKind).toBe('nuki');
|
||||||
|
expect(G.pendingClaims.some((c: any) => c.player === '1' && c.options.some((o: any) => o.type === 'ron'))).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('mahjong — P1-10 振听边界(被鸣走的自弃牌)', () => {
|
||||||
|
test('自弃牌被鸣走后仍计入舍张振听', () => {
|
||||||
|
const G = setup(4);
|
||||||
|
startGame(G);
|
||||||
|
G.dealer = '0';
|
||||||
|
G.melds = { '0': [], '1': [], '2': [], '3': [] };
|
||||||
|
G.hands['1'] = tilesFromKinds(['1m', '2m', '3m', '4m', '5m', '6m', '7m', '8m', '9m', '1p', '2p', '3p', '5s']);
|
||||||
|
G.riichi = { '0': false, '1': false, '2': false, '3': false };
|
||||||
|
G.pendingRiichi = { '0': false, '1': false, '2': false, '3': false };
|
||||||
|
G.tempFuriten = { '0': false, '1': false, '2': false, '3': false };
|
||||||
|
G.riichiFuriten = { '0': false, '1': false, '2': false, '3': false };
|
||||||
|
G.discards = { '0': [], '1': [], '2': [], '3': [] };
|
||||||
|
G.deadWall = tilesFromKinds(Array(14).fill('1z'));
|
||||||
|
G.bakaze = 1;
|
||||||
|
G.furitenKinds = { '0': [], '1': [kindToIndex('5s')], '2': [], '3': [] };
|
||||||
|
expect(canRon(G, '1', kindToIndex('5s'))).toBe(false);
|
||||||
|
G.furitenKinds['1'] = [];
|
||||||
|
expect(canRon(G, '1', kindToIndex('5s'))).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('mahjong — P1-08 里宝牌揭示', () => {
|
||||||
|
test('和了前隐藏,和了后公开 uraIndicators', () => {
|
||||||
|
const G = setup(4);
|
||||||
|
startGame(G);
|
||||||
|
const before = Mahjong.playerView(G, '0');
|
||||||
|
expect(before.uraIndicators ?? []).toEqual([]);
|
||||||
|
G.result = { type: 'tsumo', reason: '自摸', wins: [], deltas: {}, tenpai: [], noten: [], dealerRepeats: false };
|
||||||
|
const after = Mahjong.playerView(G, '0');
|
||||||
|
expect(after.uraIndicators?.length).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -121,17 +121,26 @@ export interface MahjongState {
|
|||||||
pendingClaims: PendingClaim[];
|
pendingClaims: PendingClaim[];
|
||||||
claimTile: Tile | null;
|
claimTile: Tile | null;
|
||||||
claimDiscarder: PlayerId | null;
|
claimDiscarder: PlayerId | null;
|
||||||
/** 'normal' = 打牌后鸣牌;'kakan' = 加杠抢杠窗口。 */
|
/** 'normal' = 打牌后鸣牌;'kakan' = 加杠抢杠;'nuki' = 三麻抢北。 */
|
||||||
claimKind: 'normal' | 'kakan';
|
claimKind: 'normal' | 'kakan' | 'nuki';
|
||||||
pendingKakan: { player: PlayerId; kind: number } | null;
|
pendingKakan: { player: PlayerId; kind: number } | null;
|
||||||
|
pendingNuki: { player: PlayerId } | null;
|
||||||
|
/** 鸣牌响应的截止时间(epoch ms),用于掉线自动跳过。 */
|
||||||
|
claimDeadline: number | null;
|
||||||
/** 当前行动者因刚鸣牌而禁止打出的牌(食替)。 */
|
/** 当前行动者因刚鸣牌而禁止打出的牌(食替)。 */
|
||||||
kuikaeForbidden: number[];
|
kuikaeForbidden: number[];
|
||||||
|
/** 自弃牌的全部牌种(不清除,含被鸣走者),用于舍张振听。 */
|
||||||
|
furitenKinds: Record<PlayerId, number[]>;
|
||||||
|
/** 各家的杠数(用于四杠散了判定)。 */
|
||||||
|
kanByPlayer: Record<PlayerId, number>;
|
||||||
/** playerView 注入:各家手牌张数(公开信息)。 */
|
/** playerView 注入:各家手牌张数(公开信息)。 */
|
||||||
handCounts?: Record<PlayerId, number>;
|
handCounts?: Record<PlayerId, number>;
|
||||||
/** playerView 注入:剩余牌山张数(公开)。 */
|
/** playerView 注入:剩余牌山张数(公开)。 */
|
||||||
wallCount?: number;
|
wallCount?: number;
|
||||||
/** playerView 注入:已翻开的宝牌指示牌牌种(公开)。 */
|
/** playerView 注入:已翻开的宝牌指示牌牌种(公开)。 */
|
||||||
doraIndicators?: string[];
|
doraIndicators?: string[];
|
||||||
|
/** playerView 注入:和了后揭开的里宝牌指示牌牌种(公开)。 */
|
||||||
|
uraIndicators?: string[];
|
||||||
|
|
||||||
// 立直
|
// 立直
|
||||||
pendingRiichi: Record<PlayerId, boolean>;
|
pendingRiichi: Record<PlayerId, boolean>;
|
||||||
@@ -292,8 +301,11 @@ export function computeWin(
|
|||||||
if (!tsumo) allKinds.push(indexToKind(winTileIdx));
|
if (!tsumo) allKinds.push(indexToKind(winTileIdx));
|
||||||
const doraIndicators = G.deadWall.slice(0, G.doraCount).map((t) => t.kind);
|
const doraIndicators = G.deadWall.slice(0, G.doraCount).map((t) => t.kind);
|
||||||
let doraHan = countDora(doraIndicators, allKinds);
|
let doraHan = countDora(doraIndicators, allKinds);
|
||||||
const aka = G.aka[pid] ?? 0;
|
// 赤宝牌:按实际持有的牌重算(手牌 red 标记 + 副露 redCount),
|
||||||
doraHan += aka;
|
// 不能用跑来跑去的计数器(打出的赤牌不会归还,会高估)。
|
||||||
|
const akaFromHand = (G.hands[pid] ?? []).filter((t) => t.red).length;
|
||||||
|
const akaFromMelds = (G.melds[pid] ?? []).reduce((a, m) => a + (m.redCount ?? 0), 0);
|
||||||
|
doraHan += akaFromHand + akaFromMelds;
|
||||||
if (G.riichi[pid] || G.pendingRiichi[pid]) {
|
if (G.riichi[pid] || G.pendingRiichi[pid]) {
|
||||||
const uraIndicators = G.deadWall.slice(9, 9 + G.doraCount).map((t) => t.kind);
|
const uraIndicators = G.deadWall.slice(9, 9 + G.doraCount).map((t) => t.kind);
|
||||||
doraHan += countDora(uraIndicators, allKinds);
|
doraHan += countDora(uraIndicators, allKinds);
|
||||||
@@ -341,8 +353,9 @@ export function canRon(G: MahjongState, pid: PlayerId, winTileIdx: number): bool
|
|||||||
if (!waits.includes(winTileIdx)) return false;
|
if (!waits.includes(winTileIdx)) return false;
|
||||||
if (G.tempFuriten[pid]) return false;
|
if (G.tempFuriten[pid]) return false;
|
||||||
if (G.riichiFuriten[pid]) return false;
|
if (G.riichiFuriten[pid]) return false;
|
||||||
const myDiscards = (G.discards[pid] ?? []).map((t) => kindToIndex(t.kind));
|
// 舍张振听:含被鸣走的自弃牌(furitenKinds 只增不减)
|
||||||
if (isDiscardFuriten(waits, myDiscards)) return false;
|
const myKinds = G.furitenKinds?.[pid] ?? [];
|
||||||
|
if (isDiscardFuriten(waits, myKinds)) return false;
|
||||||
// 役种校验:构造一次和了(houtei 取决于牌山是否摸尽)
|
// 役种校验:构造一次和了(houtei 取决于牌山是否摸尽)
|
||||||
return computeWin(G, pid, winTileIdx, false, { houtei: G.wall.length === 0 }) !== null;
|
return computeWin(G, pid, winTileIdx, false, { houtei: G.wall.length === 0 }) !== null;
|
||||||
}
|
}
|
||||||
@@ -431,7 +444,7 @@ export function resolveClaims(
|
|||||||
// 局流程
|
// 局流程
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
function freshRoundState(G: MahjongState, random: () => number): void {
|
function freshRoundState(G: MahjongState, random: () => number = Math.random): void {
|
||||||
const deck = shuffle(makeDeck(G.mode), random);
|
const deck = shuffle(makeDeck(G.mode), random);
|
||||||
const deadWall = deck.splice(deck.length - 14, 14);
|
const deadWall = deck.splice(deck.length - 14, 14);
|
||||||
G.wall = deck;
|
G.wall = deck;
|
||||||
@@ -460,7 +473,11 @@ function freshRoundState(G: MahjongState, random: () => number): void {
|
|||||||
G.claimDiscarder = null;
|
G.claimDiscarder = null;
|
||||||
G.claimKind = 'normal';
|
G.claimKind = 'normal';
|
||||||
G.pendingKakan = null;
|
G.pendingKakan = null;
|
||||||
|
G.pendingNuki = null;
|
||||||
|
G.claimDeadline = null;
|
||||||
G.kuikaeForbidden = [];
|
G.kuikaeForbidden = [];
|
||||||
|
G.furitenKinds = {};
|
||||||
|
G.kanByPlayer = {};
|
||||||
G.result = null;
|
G.result = null;
|
||||||
G.lastSummary = null;
|
G.lastSummary = null;
|
||||||
|
|
||||||
@@ -478,6 +495,8 @@ function freshRoundState(G: MahjongState, random: () => number): void {
|
|||||||
G.tempFuriten[pid] = false;
|
G.tempFuriten[pid] = false;
|
||||||
G.riichiFuriten[pid] = false;
|
G.riichiFuriten[pid] = false;
|
||||||
G.nagashiEligible[pid] = true;
|
G.nagashiEligible[pid] = true;
|
||||||
|
G.furitenKinds[pid] = [];
|
||||||
|
G.kanByPlayer[pid] = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 发牌:每人 13 张(原子完成)
|
// 发牌:每人 13 张(原子完成)
|
||||||
@@ -535,24 +554,8 @@ function applyWin(G: MahjongState, win: WinInfo): Record<PlayerId, number> {
|
|||||||
});
|
});
|
||||||
// 直接使用 win.points(已在 computeWin 中含本场),按支付结构拆分
|
// 直接使用 win.points(已在 computeWin 中含本场),按支付结构拆分
|
||||||
if (win.fromPlayer === null) {
|
if (win.fromPlayer === null) {
|
||||||
if (win.player === G.dealer) {
|
const d = distributeTsumo(G, win.player, score.fromDealer ?? 0, score.fromEach ?? 0);
|
||||||
const each = score.fromEach ?? 0;
|
for (const pid of seatOrder(G.mode)) deltas[pid] = d[pid] ?? 0;
|
||||||
for (const pid of seatOrder(G.mode)) {
|
|
||||||
if (pid !== win.player) {
|
|
||||||
deltas[pid] = -each;
|
|
||||||
deltas[win.player] = (deltas[win.player] ?? 0) + each;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
const fromDealer = score.fromDealer ?? 0;
|
|
||||||
const fromEach = score.fromEach ?? 0;
|
|
||||||
for (const pid of seatOrder(G.mode)) {
|
|
||||||
if (pid === win.player) continue;
|
|
||||||
const pay = pid === G.dealer ? fromDealer : fromEach;
|
|
||||||
deltas[pid] = -pay;
|
|
||||||
deltas[win.player] = (deltas[win.player] ?? 0) + pay;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
deltas[win.fromPlayer] = -(score.fromRon ?? win.points);
|
deltas[win.fromPlayer] = -(score.fromRon ?? win.points);
|
||||||
deltas[win.player] = score.fromRon ?? win.points;
|
deltas[win.player] = score.fromRon ?? win.points;
|
||||||
@@ -600,9 +603,56 @@ function settleWin(G: MahjongState, win: WinInfo, type: 'tsumo' | 'ron'): void {
|
|||||||
maybeFinishGame(G);
|
maybeFinishGame(G);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 荒牌流局:听牌罚符 + 连庄判定。 */
|
/** 荒牌流局:流局满贯 / 听牌罚符 + 连庄判定。 */
|
||||||
function settleExhaustiveDraw(G: MahjongState): void {
|
function settleExhaustiveDraw(G: MahjongState): void {
|
||||||
const order = seatOrder(G.mode);
|
const order = seatOrder(G.mode);
|
||||||
|
|
||||||
|
// ---- 流局满贯:弃牌全幺九且未被鸣(按满贯自摸结算,不收供托/本场,不再算不听罚符) ----
|
||||||
|
const nagashi = order.filter((pid) => G.nagashiEligible[pid]);
|
||||||
|
if (nagashi.length > 0) {
|
||||||
|
const deltas: Record<PlayerId, number> = {};
|
||||||
|
for (const pid of order) deltas[pid] = 0;
|
||||||
|
for (const pid of nagashi) {
|
||||||
|
const sc = computeScore({
|
||||||
|
han: 5,
|
||||||
|
fu: 30,
|
||||||
|
yakumanMultiplier: 0,
|
||||||
|
isDealer: pid === G.dealer,
|
||||||
|
tsumo: true,
|
||||||
|
honba: 0,
|
||||||
|
mode: G.mode,
|
||||||
|
});
|
||||||
|
const d = distributeTsumo(G, pid, sc.fromDealer ?? 0, sc.fromEach ?? 0);
|
||||||
|
for (const p of order) deltas[p] = (deltas[p] ?? 0) + (d[p] ?? 0);
|
||||||
|
}
|
||||||
|
for (const pid of order) G.scores[pid] = (G.scores[pid] ?? 0) + (deltas[pid] ?? 0);
|
||||||
|
|
||||||
|
const tenpai: PlayerId[] = [];
|
||||||
|
const noten: PlayerId[] = [];
|
||||||
|
for (const pid of order) {
|
||||||
|
if (waitingKinds(handIndices(G, pid), G.melds[pid] ?? []).length > 0) tenpai.push(pid);
|
||||||
|
else noten.push(pid);
|
||||||
|
}
|
||||||
|
G.honba += 1;
|
||||||
|
const dealerTenpai = tenpai.includes(G.dealer);
|
||||||
|
if (!dealerTenpai) {
|
||||||
|
G.dealer = nextSeat(G.mode, G.dealer);
|
||||||
|
G.kyoku += 1;
|
||||||
|
}
|
||||||
|
G.result = {
|
||||||
|
type: 'draw',
|
||||||
|
reason: '流局满贯',
|
||||||
|
wins: [],
|
||||||
|
deltas,
|
||||||
|
tenpai,
|
||||||
|
noten,
|
||||||
|
dealerRepeats: dealerTenpai,
|
||||||
|
};
|
||||||
|
G.lastSummary = `流局满贯:${nagashi.join(',')}`;
|
||||||
|
maybeFinishGame(G);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const tenpai: PlayerId[] = [];
|
const tenpai: PlayerId[] = [];
|
||||||
const noten: PlayerId[] = [];
|
const noten: PlayerId[] = [];
|
||||||
for (const pid of order) {
|
for (const pid of order) {
|
||||||
@@ -669,6 +719,16 @@ function maybeFinishGame(G: MahjongState): void {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 和了终局 / 听牌终局:All Last(南 4)亲家为一位、达返点、且本局连庄(亲和了或亲听牌流局)→ 结束
|
||||||
|
if (G.bakaze === 2 && G.kyoku === 4 && G.result?.dealerRepeats) {
|
||||||
|
const top = order.slice().sort((a, b) => (G.scores[b] ?? 0) - (G.scores[a] ?? 0))[0];
|
||||||
|
if (top === G.dealer && (G.scores[G.dealer] ?? 0) >= G.returnScore) {
|
||||||
|
G.gameOver = true;
|
||||||
|
finalizeRanking(G);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 当前场风 4 局结束(kyoku 超 4)→ 东4→南场;南4→西入/终局;西4→终局
|
// 当前场风 4 局结束(kyoku 超 4)→ 东4→南场;南4→西入/终局;西4→终局
|
||||||
if (G.kyoku > 4) {
|
if (G.kyoku > 4) {
|
||||||
if (G.bakaze === 1) {
|
if (G.bakaze === 1) {
|
||||||
@@ -708,12 +768,17 @@ function finalizeRanking(G: MahjongState): void {
|
|||||||
// 鸣牌执行
|
// 鸣牌执行
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
|
/** 鸣牌响应超时(毫秒)。 */
|
||||||
|
export const CLAIM_TIMEOUT_MS = 12000;
|
||||||
|
|
||||||
function clearClaimWindow(G: MahjongState): void {
|
function clearClaimWindow(G: MahjongState): void {
|
||||||
G.pendingClaims = [];
|
G.pendingClaims = [];
|
||||||
G.claimTile = null;
|
G.claimTile = null;
|
||||||
G.claimDiscarder = null;
|
G.claimDiscarder = null;
|
||||||
G.claimKind = 'normal';
|
G.claimKind = 'normal';
|
||||||
G.pendingKakan = null;
|
G.pendingKakan = null;
|
||||||
|
G.pendingNuki = null;
|
||||||
|
G.claimDeadline = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function openClaimWindow(G: MahjongState, discarder: PlayerId, tile: Tile): void {
|
function openClaimWindow(G: MahjongState, discarder: PlayerId, tile: Tile): void {
|
||||||
@@ -727,9 +792,38 @@ function openClaimWindow(G: MahjongState, discarder: PlayerId, tile: Tile): void
|
|||||||
G.claimTile = tile;
|
G.claimTile = tile;
|
||||||
G.claimDiscarder = discarder;
|
G.claimDiscarder = discarder;
|
||||||
G.claimKind = 'normal';
|
G.claimKind = 'normal';
|
||||||
|
G.claimDeadline = Date.now() + CLAIM_TIMEOUT_MS;
|
||||||
G.turnStep = 'claim';
|
G.turnStep = 'claim';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 途中流局检查(在鸣牌窗口以「无人鸣牌」结束后调用)。
|
||||||
|
* 返回流局原因或 null。
|
||||||
|
*/
|
||||||
|
function checkAbortiveDraws(G: MahjongState): string | null {
|
||||||
|
const order = seatOrder(G.mode);
|
||||||
|
// 四家立直(仅四麻;三麻无此系)
|
||||||
|
if (G.mode === 4 && order.every((pid) => G.riichi[pid])) return '四家立直';
|
||||||
|
// 四风连打:首巡四家打出同一风牌且无鸣牌
|
||||||
|
if (G.mode === 4 && G.noCallsYet && G.discardCount === G.numPlayers) {
|
||||||
|
const firsts = order.map((pid) => G.discards[pid]?.[0]);
|
||||||
|
if (firsts.every((t) => t !== undefined)) {
|
||||||
|
const kinds = firsts.map((t) => kindToIndex(t!.kind));
|
||||||
|
const isWind = (k: number) => k >= 27 && k <= 30;
|
||||||
|
if (kinds.every(isWind) && kinds.every((k) => k === kinds[0])) return '四风连打';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 四杠散了:4 个杠分散于不同玩家 → 途中流局。 */
|
||||||
|
function fourKanAbort(G: MahjongState): boolean {
|
||||||
|
const order = seatOrder(G.mode);
|
||||||
|
const total = order.reduce((a, pid) => a + (G.kanByPlayer[pid] ?? 0), 0);
|
||||||
|
const players = order.filter((pid) => (G.kanByPlayer[pid] ?? 0) > 0).length;
|
||||||
|
return total >= 4 && players > 1;
|
||||||
|
}
|
||||||
|
|
||||||
function executePon(G: MahjongState, pid: PlayerId, tile: Tile): void {
|
function executePon(G: MahjongState, pid: PlayerId, tile: Tile): void {
|
||||||
const kind = kindToIndex(tile.kind);
|
const kind = kindToIndex(tile.kind);
|
||||||
const removed = removeTileByKind(G, pid, kind, 2);
|
const removed = removeTileByKind(G, pid, kind, 2);
|
||||||
@@ -795,6 +889,11 @@ function executeKan(G: MahjongState, pid: PlayerId, tile: Tile): void {
|
|||||||
G.noCallsYet = false;
|
G.noCallsYet = false;
|
||||||
for (const p of seatOrder(G.mode)) G.ippatsu[p] = false;
|
for (const p of seatOrder(G.mode)) G.ippatsu[p] = false;
|
||||||
clearClaimWindow(G);
|
clearClaimWindow(G);
|
||||||
|
G.kanByPlayer[pid] = (G.kanByPlayer[pid] ?? 0) + 1;
|
||||||
|
if (fourKanAbort(G)) {
|
||||||
|
settleAbort(G, '四杠散了');
|
||||||
|
return;
|
||||||
|
}
|
||||||
flipKanDora(G);
|
flipKanDora(G);
|
||||||
drawRinshan(G);
|
drawRinshan(G);
|
||||||
}
|
}
|
||||||
@@ -849,7 +948,11 @@ export const Mahjong: TTSGame<MahjongState> = {
|
|||||||
claimDiscarder: null,
|
claimDiscarder: null,
|
||||||
claimKind: 'normal',
|
claimKind: 'normal',
|
||||||
pendingKakan: null,
|
pendingKakan: null,
|
||||||
|
pendingNuki: null,
|
||||||
|
claimDeadline: null,
|
||||||
kuikaeForbidden: [],
|
kuikaeForbidden: [],
|
||||||
|
furitenKinds: {},
|
||||||
|
kanByPlayer: {},
|
||||||
pendingRiichi: {},
|
pendingRiichi: {},
|
||||||
riichi: {},
|
riichi: {},
|
||||||
doubleRiichi: {},
|
doubleRiichi: {},
|
||||||
@@ -901,11 +1004,11 @@ export const Mahjong: TTSGame<MahjongState> = {
|
|||||||
moves: {
|
moves: {
|
||||||
// 房主开局
|
// 房主开局
|
||||||
startGame: {
|
startGame: {
|
||||||
move: ({ G, events, playerID, random }: { G: MahjongState; events?: any; playerID?: string; random: any }) => {
|
move: ({ G, events, playerID }: { G: MahjongState; events?: any; playerID?: string }) => {
|
||||||
if (playerID !== '0') return;
|
if (playerID !== '0') return;
|
||||||
if (G.started) return;
|
if (G.started) return;
|
||||||
G.started = true;
|
G.started = true;
|
||||||
freshRoundState(G, random);
|
freshRoundState(G);
|
||||||
drawForCurrent(G);
|
drawForCurrent(G);
|
||||||
events?.endPhase?.(); // → playing
|
events?.endPhase?.(); // → playing
|
||||||
},
|
},
|
||||||
@@ -942,6 +1045,10 @@ export const Mahjong: TTSGame<MahjongState> = {
|
|||||||
if (pid !== G.turn || G.turnStep !== 'discard' || !tileId) return;
|
if (pid !== G.turn || G.turnStep !== 'discard' || !tileId) return;
|
||||||
const tile = G.hands[pid]?.find((t) => t.id === tileId);
|
const tile = G.hands[pid]?.find((t) => t.id === tileId);
|
||||||
if (!tile) return;
|
if (!tile) return;
|
||||||
|
// 立直后(宣言回合之后)只能摸切
|
||||||
|
if (G.riichi[pid] && !G.pendingRiichi[pid]) {
|
||||||
|
if (!G.drawnTile || tile.id !== G.drawnTile.id) return;
|
||||||
|
}
|
||||||
// 食替禁止
|
// 食替禁止
|
||||||
if (G.kuikaeForbidden.includes(kindToIndex(tile.kind))) return;
|
if (G.kuikaeForbidden.includes(kindToIndex(tile.kind))) return;
|
||||||
removeTileById(G, pid, tileId);
|
removeTileById(G, pid, tileId);
|
||||||
@@ -953,6 +1060,7 @@ export const Mahjong: TTSGame<MahjongState> = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
G.discards[pid]!.push(tile);
|
G.discards[pid]!.push(tile);
|
||||||
|
G.furitenKinds[pid] = [...(G.furitenKinds[pid] ?? []), kindToIndex(tile.kind)];
|
||||||
G.lastDiscard = { player: pid, tile };
|
G.lastDiscard = { player: pid, tile };
|
||||||
G.drawnTile = null;
|
G.drawnTile = null;
|
||||||
G.drawnFromRinshan = false;
|
G.drawnFromRinshan = false;
|
||||||
@@ -995,6 +1103,13 @@ export const Mahjong: TTSGame<MahjongState> = {
|
|||||||
const claim = G.pendingClaims.find((c) => c.player === pid);
|
const claim = G.pendingClaims.find((c) => c.player === pid);
|
||||||
if (!claim) return;
|
if (!claim) return;
|
||||||
if (type && type !== 'pass' && !claim.options.some((o) => o.type === type)) return;
|
if (type && type !== 'pass' && !claim.options.some((o) => o.type === type)) return;
|
||||||
|
// 吃的具体两张手牌必须与某个合法选项一致,防止客户端传错牌破坏状态
|
||||||
|
if (type === 'chi') {
|
||||||
|
const ok = !!useTiles && claim.options.some(
|
||||||
|
(o) => o.type === 'chi' && o.useTiles && o.useTiles[0] === useTiles[0] && o.useTiles[1] === useTiles[1],
|
||||||
|
);
|
||||||
|
if (!ok) return;
|
||||||
|
}
|
||||||
claim.response = type === 'pass' || !type ? { type: 'pass' } : { type, useTiles };
|
claim.response = type === 'pass' || !type ? { type: 'pass' } : { type, useTiles };
|
||||||
|
|
||||||
// 放弃荣和 → 临时振听(立直后则永久立直振听)
|
// 放弃荣和 → 临时振听(立直后则永久立直振听)
|
||||||
@@ -1006,56 +1121,26 @@ export const Mahjong: TTSGame<MahjongState> = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!G.pendingClaims.every((c) => c.response !== null)) return;
|
if (!G.pendingClaims.every((c) => c.response !== null)) return;
|
||||||
|
resolveClaimWindow(G);
|
||||||
|
},
|
||||||
|
ignoreStaleStateID: true,
|
||||||
|
},
|
||||||
|
|
||||||
// ---- 加杠抢杠窗口 ----
|
// 鸣牌响应超时(任意客户端可驱动):到点后把未响应者视为跳过,避免掉线卡死
|
||||||
if (G.claimKind === 'kakan') {
|
claimTimeout: {
|
||||||
const kakan = G.pendingKakan;
|
move: ({ G }: { G: MahjongState }) => {
|
||||||
const kind = G.claimTile ? kindToIndex(G.claimTile.kind) : -1;
|
if (G.turnStep !== 'claim' || !G.claimDeadline) return;
|
||||||
const res = resolveClaims(G.pendingClaims, G.claimDiscarder!, G.mode);
|
if (Date.now() < G.claimDeadline) return;
|
||||||
if (res.kind === 'ron' && kakan) {
|
for (const c of G.pendingClaims) {
|
||||||
const win = computeWin(G, res.player, kind, false, { chankan: true });
|
if (c.response === null) {
|
||||||
if (win) {
|
if (c.options.some((o) => o.type === 'ron')) {
|
||||||
// 撤销加杠
|
G.tempFuriten[c.player] = true;
|
||||||
const meld = G.melds[kakan.player]!.find((m) => m.type === 'kakan' && m.tiles[0] === kind);
|
if (G.riichi[c.player]) G.riichiFuriten[c.player] = true;
|
||||||
if (meld) meld.type = 'pon';
|
|
||||||
G.turn = kakan.player; // 放铳者为加杠者
|
|
||||||
clearClaimWindow(G);
|
|
||||||
settleWin(G, win, 'ron');
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
c.response = { type: 'pass' };
|
||||||
}
|
}
|
||||||
if (kakan) finishKakan(G, kakan.player);
|
|
||||||
else clearClaimWindow(G);
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
resolveClaimWindow(G);
|
||||||
// ---- 普通鸣牌窗口 ----
|
|
||||||
const discarder = G.claimDiscarder!;
|
|
||||||
const tile = G.claimTile!;
|
|
||||||
const res = resolveClaims(G.pendingClaims, discarder, G.mode);
|
|
||||||
if (res.kind === 'ron') {
|
|
||||||
const win = computeWin(G, res.player, kindToIndex(tile.kind), false, { houtei: G.wall.length === 0 });
|
|
||||||
if (win) {
|
|
||||||
removeDiscard(G, discarder, tile);
|
|
||||||
clearClaimWindow(G);
|
|
||||||
settleWin(G, win, 'ron');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
} else if (res.kind === 'pon') {
|
|
||||||
removeDiscard(G, discarder, tile);
|
|
||||||
executePon(G, res.player, tile);
|
|
||||||
return;
|
|
||||||
} else if (res.kind === 'kan') {
|
|
||||||
removeDiscard(G, discarder, tile);
|
|
||||||
executeKan(G, res.player, tile);
|
|
||||||
return;
|
|
||||||
} else if (res.kind === 'chi') {
|
|
||||||
removeDiscard(G, discarder, tile);
|
|
||||||
executeChi(G, res.player, tile, res.useTiles);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
clearClaimWindow(G);
|
|
||||||
advanceTurn(G);
|
|
||||||
},
|
},
|
||||||
ignoreStaleStateID: true,
|
ignoreStaleStateID: true,
|
||||||
},
|
},
|
||||||
@@ -1066,10 +1151,38 @@ export const Mahjong: TTSGame<MahjongState> = {
|
|||||||
const pid = playerID ?? G.turn;
|
const pid = playerID ?? G.turn;
|
||||||
if (pid !== G.turn || G.turnStep !== 'discard' || kind === undefined) return;
|
if (pid !== G.turn || G.turnStep !== 'discard' || kind === undefined) return;
|
||||||
if (handCountOfKind(G, pid, kind) < 4) return;
|
if (handCountOfKind(G, pid, kind) < 4) return;
|
||||||
// TODO: 立直后暗杠需校验不改变听牌
|
// 立直后暗杠:不得改变听牌(比较暗杠前后的 waitingKinds)
|
||||||
const removed = removeTileByKind(G, pid, kind, 4);
|
if (G.riichi[pid]) {
|
||||||
G.melds[pid]!.push({ type: 'ankan', tiles: [kind, kind, kind, kind], from: 'self', redCount: removed.filter((t) => t.red).length });
|
const hand = handIndices(G, pid);
|
||||||
|
const before = hand.slice();
|
||||||
|
if (G.drawnTile) {
|
||||||
|
const di = before.indexOf(kindToIndex(G.drawnTile.kind));
|
||||||
|
if (di >= 0) before.splice(di, 1);
|
||||||
|
}
|
||||||
|
const after = hand.slice();
|
||||||
|
let removed = 0;
|
||||||
|
for (let i = after.length - 1; i >= 0 && removed < 4; i--) {
|
||||||
|
if (after[i] === kind) {
|
||||||
|
after.splice(i, 1);
|
||||||
|
removed += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const meldsAfter: Meld[] = [
|
||||||
|
...(G.melds[pid] ?? []),
|
||||||
|
{ type: 'ankan', tiles: [kind, kind, kind, kind], from: 'self' },
|
||||||
|
];
|
||||||
|
const wb = [...new Set(waitingKinds(before, G.melds[pid] ?? []))].sort((a, b) => a - b).join(',');
|
||||||
|
const wa = [...new Set(waitingKinds(after, meldsAfter))].sort((a, b) => a - b).join(',');
|
||||||
|
if (wb !== wa) return;
|
||||||
|
}
|
||||||
|
const removedTiles = removeTileByKind(G, pid, kind, 4);
|
||||||
|
G.melds[pid]!.push({ type: 'ankan', tiles: [kind, kind, kind, kind], from: 'self', redCount: removedTiles.filter((t) => t.red).length });
|
||||||
G.noCallsYet = false;
|
G.noCallsYet = false;
|
||||||
|
G.kanByPlayer[pid] = (G.kanByPlayer[pid] ?? 0) + 1;
|
||||||
|
if (fourKanAbort(G)) {
|
||||||
|
settleAbort(G, '四杠散了');
|
||||||
|
return;
|
||||||
|
}
|
||||||
flipKanDora(G);
|
flipKanDora(G);
|
||||||
drawRinshan(G);
|
drawRinshan(G);
|
||||||
},
|
},
|
||||||
@@ -1101,6 +1214,7 @@ export const Mahjong: TTSGame<MahjongState> = {
|
|||||||
G.claimDiscarder = pid;
|
G.claimDiscarder = pid;
|
||||||
G.claimKind = 'kakan';
|
G.claimKind = 'kakan';
|
||||||
G.pendingKakan = { player: pid, kind };
|
G.pendingKakan = { player: pid, kind };
|
||||||
|
G.claimDeadline = Date.now() + CLAIM_TIMEOUT_MS;
|
||||||
G.turnStep = 'claim';
|
G.turnStep = 'claim';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1109,7 +1223,7 @@ export const Mahjong: TTSGame<MahjongState> = {
|
|||||||
ignoreStaleStateID: true,
|
ignoreStaleStateID: true,
|
||||||
},
|
},
|
||||||
|
|
||||||
// 三麻拔北
|
// 三麻拔北(可被抢北)
|
||||||
nuki: {
|
nuki: {
|
||||||
move: ({ G, playerID }: { G: MahjongState; playerID?: string }) => {
|
move: ({ G, playerID }: { G: MahjongState; playerID?: string }) => {
|
||||||
if (G.mode !== 3) return;
|
if (G.mode !== 3) return;
|
||||||
@@ -1117,11 +1231,26 @@ export const Mahjong: TTSGame<MahjongState> = {
|
|||||||
if (pid !== G.turn || G.turnStep !== 'discard') return;
|
if (pid !== G.turn || G.turnStep !== 'discard') return;
|
||||||
const northKind = 30; // 4z
|
const northKind = 30; // 4z
|
||||||
if (handCountOfKind(G, pid, northKind) < 1) return;
|
if (handCountOfKind(G, pid, northKind) < 1) return;
|
||||||
// 简化:拔北后不可立即(此处要求是自摸后)
|
|
||||||
removeTileByKind(G, pid, northKind, 1);
|
removeTileByKind(G, pid, northKind, 1);
|
||||||
G.nukiDora[pid] = (G.nukiDora[pid] ?? 0) + 1;
|
|
||||||
G.noCallsYet = false;
|
// 抢北窗口:仅荣和(抢北按抢杠计)
|
||||||
drawRinshan(G);
|
const order = seatOrder(G.mode);
|
||||||
|
const claims: PendingClaim[] = [];
|
||||||
|
for (const p of order) {
|
||||||
|
if (p === pid) continue;
|
||||||
|
if (canRon(G, p, northKind)) claims.push({ player: p, options: [{ type: 'ron' }], response: null });
|
||||||
|
}
|
||||||
|
if (claims.length > 0) {
|
||||||
|
G.pendingClaims = claims;
|
||||||
|
G.claimTile = { id: `nuki-${northKind}`, kind: indexToKind(northKind) };
|
||||||
|
G.claimDiscarder = pid;
|
||||||
|
G.claimKind = 'nuki';
|
||||||
|
G.pendingNuki = { player: pid };
|
||||||
|
G.claimDeadline = Date.now() + CLAIM_TIMEOUT_MS;
|
||||||
|
G.turnStep = 'claim';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
finishNuki(G, pid);
|
||||||
},
|
},
|
||||||
ignoreStaleStateID: true,
|
ignoreStaleStateID: true,
|
||||||
},
|
},
|
||||||
@@ -1143,13 +1272,13 @@ export const Mahjong: TTSGame<MahjongState> = {
|
|||||||
|
|
||||||
// 房主开下一局
|
// 房主开下一局
|
||||||
nextRound: {
|
nextRound: {
|
||||||
move: ({ G, events, playerID, random }: { G: MahjongState; events?: any; playerID?: string; random: any }) => {
|
move: ({ G, events, playerID }: { G: MahjongState; events?: any; playerID?: string }) => {
|
||||||
if (playerID !== '0') return;
|
if (playerID !== '0') return;
|
||||||
if (G.gameOver) {
|
if (G.gameOver) {
|
||||||
events?.endPhase?.(); // → gameEnd
|
events?.endPhase?.(); // → gameEnd
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
freshRoundState(G, random);
|
freshRoundState(G);
|
||||||
drawForCurrent(G);
|
drawForCurrent(G);
|
||||||
events?.endPhase?.(); // → playing
|
events?.endPhase?.(); // → playing
|
||||||
},
|
},
|
||||||
@@ -1187,6 +1316,10 @@ export const Mahjong: TTSGame<MahjongState> = {
|
|||||||
for (const p of Object.keys(hands)) handCounts[p] = hands[p]!.length;
|
for (const p of Object.keys(hands)) handCounts[p] = hands[p]!.length;
|
||||||
const wallCount = (state.wall ?? []).length;
|
const wallCount = (state.wall ?? []).length;
|
||||||
const doraIndicators = (state.deadWall ?? []).slice(0, state.doraCount ?? 1).map((t) => t.kind);
|
const doraIndicators = (state.deadWall ?? []).slice(0, state.doraCount ?? 1).map((t) => t.kind);
|
||||||
|
// 和了结算后揭开里宝牌指示牌(公开)
|
||||||
|
const uraIndicators = state.result
|
||||||
|
? (state.deadWall ?? []).slice(9, 9 + (state.doraCount ?? 1)).map((t) => t.kind)
|
||||||
|
: [];
|
||||||
// 只暴露请求者自己的鸣牌响应选项
|
// 只暴露请求者自己的鸣牌响应选项
|
||||||
const pendingClaims = (state.pendingClaims ?? []).filter((c) => c.player === viewer);
|
const pendingClaims = (state.pendingClaims ?? []).filter((c) => c.player === viewer);
|
||||||
return {
|
return {
|
||||||
@@ -1196,12 +1329,14 @@ export const Mahjong: TTSGame<MahjongState> = {
|
|||||||
handCounts,
|
handCounts,
|
||||||
wallCount,
|
wallCount,
|
||||||
doraIndicators,
|
doraIndicators,
|
||||||
|
uraIndicators,
|
||||||
// SECRET 剥离
|
// SECRET 剥离
|
||||||
wall: [],
|
wall: [],
|
||||||
deadWall: [],
|
deadWall: [],
|
||||||
aka: {},
|
aka: {},
|
||||||
pendingClaims,
|
pendingClaims,
|
||||||
pendingKakan: null,
|
pendingKakan: null,
|
||||||
|
pendingNuki: null,
|
||||||
melds,
|
melds,
|
||||||
kuikaeForbidden: viewer === state.turn ? (state.kuikaeForbidden ?? []) : [],
|
kuikaeForbidden: viewer === state.turn ? (state.kuikaeForbidden ?? []) : [],
|
||||||
drawnTile: viewer === state.turn ? state.drawnTile : null,
|
drawnTile: viewer === state.turn ? state.drawnTile : null,
|
||||||
@@ -1214,16 +1349,151 @@ function removeDiscard(G: MahjongState, discarder: PlayerId, tile: Tile): void {
|
|||||||
if (!pond) return;
|
if (!pond) return;
|
||||||
const i = pond.findIndex((t) => t.id === tile.id);
|
const i = pond.findIndex((t) => t.id === tile.id);
|
||||||
if (i >= 0) pond.splice(i, 1);
|
if (i >= 0) pond.splice(i, 1);
|
||||||
|
// 弃牌被鸣 → 该家的流局满贯资格失效
|
||||||
|
G.nagashiEligible[discarder] = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 自摸点数分配(亲家自摸各家平分;子家自摸庄付2倍、闲付1倍)。 */
|
||||||
|
function distributeTsumo(
|
||||||
|
G: MahjongState,
|
||||||
|
winner: PlayerId,
|
||||||
|
fromDealer: number,
|
||||||
|
fromEach: number,
|
||||||
|
): Record<PlayerId, number> {
|
||||||
|
const deltas: Record<PlayerId, number> = {};
|
||||||
|
for (const pid of seatOrder(G.mode)) deltas[pid] = 0;
|
||||||
|
if (winner === G.dealer) {
|
||||||
|
for (const pid of seatOrder(G.mode)) {
|
||||||
|
if (pid === winner) continue;
|
||||||
|
deltas[pid] = -fromEach;
|
||||||
|
deltas[winner] = (deltas[winner] ?? 0) + fromEach;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for (const pid of seatOrder(G.mode)) {
|
||||||
|
if (pid === winner) continue;
|
||||||
|
const pay = pid === G.dealer ? fromDealer : fromEach;
|
||||||
|
deltas[pid] = -pay;
|
||||||
|
deltas[winner] = (deltas[winner] ?? 0) + pay;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return deltas;
|
||||||
}
|
}
|
||||||
|
|
||||||
function finishKakan(G: MahjongState, pid: PlayerId): void {
|
function finishKakan(G: MahjongState, pid: PlayerId): void {
|
||||||
clearClaimWindow(G);
|
clearClaimWindow(G);
|
||||||
G.turn = pid;
|
G.turn = pid;
|
||||||
G.noCallsYet = false;
|
G.noCallsYet = false;
|
||||||
|
G.kanByPlayer[pid] = (G.kanByPlayer[pid] ?? 0) + 1;
|
||||||
|
if (fourKanAbort(G)) {
|
||||||
|
settleAbort(G, '四杠散了');
|
||||||
|
return;
|
||||||
|
}
|
||||||
flipKanDora(G);
|
flipKanDora(G);
|
||||||
drawRinshan(G);
|
drawRinshan(G);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function finishNuki(G: MahjongState, pid: PlayerId): void {
|
||||||
|
clearClaimWindow(G);
|
||||||
|
G.turn = pid;
|
||||||
|
G.noCallsYet = false;
|
||||||
|
G.nukiDora[pid] = (G.nukiDora[pid] ?? 0) + 1;
|
||||||
|
// 三麻:拔北计入杠数(四杠散了)
|
||||||
|
G.kanByPlayer[pid] = (G.kanByPlayer[pid] ?? 0) + 1;
|
||||||
|
if (fourKanAbort(G)) {
|
||||||
|
settleAbort(G, '四杠散了');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
drawRinshan(G);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 所有响应就绪后统一结算鸣牌窗口(respond / claimTimeout 共用)。 */
|
||||||
|
function resolveClaimWindow(G: MahjongState): void {
|
||||||
|
if (G.turnStep !== 'claim') return;
|
||||||
|
|
||||||
|
// ---- 加杠抢杠 ----
|
||||||
|
if (G.claimKind === 'kakan') {
|
||||||
|
const kakan = G.pendingKakan;
|
||||||
|
const kind = G.claimTile ? kindToIndex(G.claimTile.kind) : -1;
|
||||||
|
const res = resolveClaims(G.pendingClaims, G.claimDiscarder!, G.mode);
|
||||||
|
if (res.kind === 'ron' && kakan) {
|
||||||
|
const win = computeWin(G, res.player, kind, false, { chankan: true });
|
||||||
|
if (win) {
|
||||||
|
const meld = G.melds[kakan.player]!.find((m) => m.type === 'kakan' && m.tiles[0] === kind);
|
||||||
|
if (meld) meld.type = 'pon';
|
||||||
|
G.turn = kakan.player;
|
||||||
|
clearClaimWindow(G);
|
||||||
|
settleWin(G, win, 'ron');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (kakan) finishKakan(G, kakan.player);
|
||||||
|
else clearClaimWindow(G);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 三麻抢北 ----
|
||||||
|
if (G.claimKind === 'nuki') {
|
||||||
|
const nuki = G.pendingNuki;
|
||||||
|
const res = resolveClaims(G.pendingClaims, G.claimDiscarder!, G.mode);
|
||||||
|
if (res.kind === 'ron' && nuki) {
|
||||||
|
const win = computeWin(G, res.player, 30 /* 4z 北 */, false, { chankan: true });
|
||||||
|
if (win) {
|
||||||
|
G.turn = nuki.player;
|
||||||
|
clearClaimWindow(G);
|
||||||
|
settleWin(G, win, 'ron');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (nuki) finishNuki(G, nuki.player);
|
||||||
|
else clearClaimWindow(G);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 普通鸣牌窗口 ----
|
||||||
|
const discarder = G.claimDiscarder!;
|
||||||
|
const tile = G.claimTile!;
|
||||||
|
|
||||||
|
// 三家和了 → 途中流局(四麻)
|
||||||
|
const ronCount = G.pendingClaims.filter((c) => c.response?.type === 'ron').length;
|
||||||
|
if (G.mode === 4 && ronCount >= 3) {
|
||||||
|
clearClaimWindow(G);
|
||||||
|
settleAbort(G, '三家和了');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = resolveClaims(G.pendingClaims, discarder, G.mode);
|
||||||
|
if (res.kind === 'ron') {
|
||||||
|
const win = computeWin(G, res.player, kindToIndex(tile.kind), false, { houtei: G.wall.length === 0 });
|
||||||
|
if (win) {
|
||||||
|
removeDiscard(G, discarder, tile);
|
||||||
|
clearClaimWindow(G);
|
||||||
|
settleWin(G, win, 'ron');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} else if (res.kind === 'pon') {
|
||||||
|
removeDiscard(G, discarder, tile);
|
||||||
|
executePon(G, res.player, tile);
|
||||||
|
return;
|
||||||
|
} else if (res.kind === 'kan') {
|
||||||
|
removeDiscard(G, discarder, tile);
|
||||||
|
executeKan(G, res.player, tile);
|
||||||
|
return;
|
||||||
|
} else if (res.kind === 'chi') {
|
||||||
|
removeDiscard(G, discarder, tile);
|
||||||
|
executeChi(G, res.player, tile, res.useTiles);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 无人鸣牌 → 途中流局检查(四家立直 / 四风连打)
|
||||||
|
const abort = checkAbortiveDraws(G);
|
||||||
|
clearClaimWindow(G);
|
||||||
|
if (abort) {
|
||||||
|
settleAbort(G, abort);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
advanceTurn(G);
|
||||||
|
}
|
||||||
|
|
||||||
export default Mahjong;
|
export default Mahjong;
|
||||||
|
|
||||||
export const MAHJONG_HELPERS = {
|
export const MAHJONG_HELPERS = {
|
||||||
@@ -1248,4 +1518,8 @@ export const MAHJONG_INTERNALS = {
|
|||||||
settleWin,
|
settleWin,
|
||||||
maybeFinishGame,
|
maybeFinishGame,
|
||||||
applyWin,
|
applyWin,
|
||||||
|
resolveClaimWindow,
|
||||||
|
checkAbortiveDraws,
|
||||||
|
fourKanAbort,
|
||||||
|
CLAIM_TIMEOUT_MS,
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user