Compare commits

...

4 Commits

Author SHA1 Message Date
e2hang
d02d718237 docs(mahjong): 依据实跑重核测试清单(v4)
- 头部测试概要改为准确值:麻将单测 78(tiles 16 / yaku 33 / game 29)、引擎 329、E2E ✅
- 新增「Q. 测试清单」:逐组列出 78 条用例 + 联机 E2E + 未纳入自动化项
- 修正过时项:O4 由「未做 4 端 E2E」→ ✅ E2E;移除已完成的后续优先项(仅剩 R-04 观战/AI)
2026-10-03 20:15:11 +08:00
e2hang
ffd36cfee1 feat(mahjong): 修复 P2(拔北限制/抢杠赤牌/顺位马/听牌振听提示/立直棒)+ 联机 E2E
- R-01 三麻:碰/明杠后未打牌前不可拔北(justCalledBy)
- R-02 加杠被抢撤销为碰时扣回第 4 张牌的赤宝牌计数
- R-05 终局顺位马 + 头名赏(四麻 +15/+5/-5/-15 赏+20;三麻 +15/0/-15 赏+15),结算面板展示
- R-06 playerView 暴露 myWaits/myFuriten,UI 显示听牌与振听
- D9/N8 立直棒可视化(顶栏按供托数画棍)+ 自己立直提示
- R-07 新增 4 端联机 E2E(建房→开局→隐藏信息→轮转打牌),Playwright 通过
- 保留合理简化:R-03 开牌/宝牌物理位置、C6 自摸牌显示、E15 杠宝时机、F14 人和(有意)、O5 客户端驱动超时
- 测试:engine 329 通过(新增 P2 用例 5 条);engine/web tsc 干净;清单更新至 v3
2026-10-03 15:59:28 +08:00
e2hang
79be58e76a feat(mahjong): 补齐 P0+P1 规则(摸切/鸣牌超时/途中流局/流局满贯/抢北/里宝展示等)
P0
- 立直后强制摸切(宣言回合可打任意牌,之后只能打摸到的牌)
- 鸣牌响应超时:新增 claimDeadline + claimTimeout move + UI 全局倒计时,掉线不再卡死

P1
- 途中流局:四风连打 / 四家立直 / 三家和了 / 四杠散了
- 流局满贯:弃牌全幺九且未被鸣 → 按满贯自摸(不收供托/本场,不算不听罚符)
- 和了终局/听牌终局:All Last 亲家一位、达返点且连庄 → 终局
- 立直后暗杠「听牌不变」校验(并修正 waitingKinds 扣除副露已占用的牌)
- 三麻抢北:对拔北开荣和窗口,按抢杠计
- 里宝牌/杠宝揭示:和了后 playerView 公开 uraIndicators 并在结算面板展示
- 振听边界:被鸣走的自弃牌仍计入舍张振听(furitenKinds 只增不减)

验证
- engine 324 通过(新增 P0/P1 用例 11 条);engine/web tsc 干净;web vite build 通过
- 浏览器冒烟:开局/打牌/换座不重置/鸣牌窗口;故意不响应等待 >12s 自动跳过并推进;无 JS 异常
- 更新 docs/mahjong/02-verification-checklist.md 至 v2(P0+P1 全完成)
2026-10-03 15:03:59 +08:00
e2hang
2a376e142b fix(mahjong): 修复无法开局/立直卡死/本地换座重置/赤宝牌高估 + UI 冒烟
- engine: boardgame.io 注入的 random 是插件对象(非函数),freshRoundState 传入导致
  TypeError「random is not a function」,开局即失败 → 改用 Math.random(与 bridge 一致)
- engine: 赤宝牌改为按实际持有重算(手牌 red + 副露 redCount);旧实现用累计计数器,
  打出的赤 5 不归还导致和了时高估番/点
- engine: respond 校验「吃」的两张手牌必须匹配某个合法选项,防止客户端传错牌破坏状态
- ui: 立直「宣言 + 打出」绕过 250ms 防抖(原第二发被丢弃 → 玩家卡死无法出牌)
- ui: ClaimOverlay 倒计时依赖改为稳定签名 + 单次触发,避免 playerView 新对象导致反复重置/重复 pass
- ui: 本地换座位不再 remount(key 去掉 seat),改由 boardgame.io updatePlayerID 原地切换,
  避免切换视角时 local master 被销毁、整局重置
- ui: 无自摸牌(如碰/吃后)禁用「自摸」按钮
- test: 补赤宝牌回归、applyWin 点数守恒、整手随机打牌至荒牌流局;engine 313 全通过
2026-10-03 14:43:09 +08:00
8 changed files with 1387 additions and 110 deletions

View File

@@ -0,0 +1,161 @@
/**
* 立直麻将 — 联机 E2E(真实 UI + server + engine,4 端)。
*
* 覆盖:
* 1. 4 人注册 → 建房(4 座)→ 加入 → 房主开始对局。
* 2. 进入 playing:每人只见己方手牌;对手座位只显示牌背(无真实牌泄漏)。
* 3. 当前行动者打出第一张可点牌,轮转推进;牌河累积。
* 4. 鸣牌窗口出现时自动跳过。
*
* 参考:apps/web/e2e/bridge-multiplayer.spec.ts。
*/
import { test, expect, type Browser, type Page, type BrowserContext } from '@playwright/test';
interface Seat {
ctx: BrowserContext;
page: Page;
pid: string;
name: string;
}
async function authUser(suffix: string): Promise<{ token: string; username: string }> {
const uname = `mj${suffix}${Math.floor(Math.random() * 1e6)}`.slice(0, 18);
const pwd = 'pw123456';
const base = process.env.TTS_API_BASE ?? 'http://localhost:8000';
const reg = await fetch(`${base}/api/auth/register`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: uname, password: pwd }),
});
if (reg.ok) {
const j = (await reg.json()) as { token: string; username: string };
return { token: j.token, username: j.username };
}
const login = await fetch(`${base}/api/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: uname, password: pwd }),
});
const j = (await login.json()) as { token: string; username: string };
return { token: j.token, username: j.username };
}
async function newSeat(browser: Browser, pid: string, auth: { token: string; username: string }): Promise<Seat> {
const ctx = await browser.newContext();
const page = await ctx.newPage();
await page.addInitScript(
([token, username]) => {
localStorage.setItem('tts-like.token', token);
localStorage.setItem('tts-like.username', username);
},
[auth.token, auth.username] as [string, string],
);
await page.goto('/');
page.on('console', (m) => console.log(`[browser:${pid}] ${m.text()}`));
await page.getByTestId('game-select').selectOption('mahjong');
await page.getByTestId('mode-online').click();
await expect(page.getByTestId('lobby-room-list')).toBeVisible({ timeout: 15_000 });
return { ctx, page, pid, name: auth.username };
}
/** 当前可出牌的 seat(本人回合且手牌可点)。 */
async function discardActor(seats: Seat[]): Promise<Seat | null> {
for (const s of seats) {
const n = await s.page.locator('[data-testid=mj-myhand] [data-playable="true"]').count().catch(() => 0);
if (n > 0) return s;
}
return null;
}
/** 任意 seat 出现鸣牌窗口就跳过。 */
async function passClaims(seats: Seat[]): Promise<void> {
for (const s of seats) {
const btn = s.page.getByTestId('mj-claim-pass');
if ((await btn.count()) > 0 && (await btn.isVisible().catch(() => false))) {
await btn.click().catch(() => {});
}
}
}
test.describe('立直麻将 — 4 人联机 端到端', () => {
test('建房 → 开局 → 隐藏信息 → 轮转打牌', async ({ browser }) => {
const auths = [
await authUser('a'),
await authUser('b'),
await authUser('c'),
await authUser('d'),
];
const seats = [
await newSeat(browser, '0', auths[0]!),
await newSeat(browser, '1', auths[1]!),
await newSeat(browser, '2', auths[2]!),
await newSeat(browser, '3', auths[3]!),
];
// ===== 1. P0 建房(mahjong 可变人数,显式选 4 座) =====
await expect(async () => {
const btn = seats[0]!.page.getByTestId('lobby-create-room');
await expect(btn).toBeEnabled({ timeout: 2_000 });
const seatsSel = seats[0]!.page.getByTestId('lobby-create-seats');
if ((await seatsSel.count()) > 0) await seatsSel.selectOption('4').catch(() => {});
await btn.click();
}).toPass({ timeout: 15_000 });
await expect(seats[0]!.page.getByTestId('mj-waiting')).toBeVisible({ timeout: 15_000 });
const roomText = await seats[0]!.page.getByTestId('lobby-current-room').innerText();
const mid = roomText.match(/在房间\s+(\S+)\s+作为/)?.[1] ?? '';
// ===== 2. 其余 3 家加入 =====
for (const seat of seats.slice(1)) {
await expect(async () => {
await seat.page.getByTestId('lobby-refresh').click();
const btn = seat.page.getByTestId(`lobby-join-${seat.pid}-${mid}`);
expect(await btn.count()).toBeGreaterThan(0);
}).toPass({ timeout: 15_000 });
await seat.page.getByTestId(`lobby-join-${seat.pid}-${mid}`).click();
await expect(seat.page.getByTestId('mj-waiting')).toBeVisible({ timeout: 15_000 });
}
// ===== 3. 房主开局 =====
await expect(async () => {
const btn = seats[0]!.page.getByTestId('mj-start');
await expect(btn).toBeEnabled({ timeout: 2_000 });
await btn.click();
}).toPass({ timeout: 15_000 });
await expect(async () => {
for (const seat of seats) {
await expect(seat.page.getByTestId('mj-myhand')).toBeVisible({ timeout: 5_000 });
}
}).toPass({ timeout: 15_000 });
// ===== 4. 隐藏信息:各见己方手牌;对手座位无真实牌泄漏 =====
for (const seat of seats) {
const selfTiles = await seat.page.locator('[data-testid=mj-myhand] [data-testid^=mj-tile-]').count();
expect(selfTiles).toBeGreaterThanOrEqual(13);
for (const other of seats.filter((s) => s.pid !== seat.pid)) {
const leaked = await seat.page.locator(`[data-testid=mj-seat-${other.pid}] [data-testid^=mj-tile-]`).count();
expect(leaked).toBe(0);
}
}
// ===== 5. 连续打牌若干轮(当前行动者打出第一张可点牌;有鸣牌窗口则跳过) =====
for (let i = 0; i < 6; i++) {
await passClaims(seats);
await expect(async () => {
const actor = await discardActor(seats);
expect(actor).not.toBeNull();
}).toPass({ timeout: 15_000 });
const actor = await discardActor(seats);
if (!actor) throw new Error(`第 ${i + 1} 手:没有可出牌的玩家`);
await actor.page.locator('[data-testid=mj-myhand] [data-playable="true"]').first().click();
await seats[0]!.page.waitForTimeout(500);
}
// 牌河累积(被合理的鸣牌消耗后仍应 >= 3)
const totalRiver = await seats[0]!.page.locator('[data-testid^=mj-river-] [data-testid^=mj-tile-]').count();
expect(totalRiver).toBeGreaterThanOrEqual(3);
console.log('mahjong matchID:', mid);
});
});

View File

@@ -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}`} />;
} }
/** /**

View File

@@ -65,6 +65,7 @@ function TileFace({
title={title} title={title}
data-testid={`mj-tile-${kind}`} data-testid={`mj-tile-${kind}`}
data-selected={selected ? 'true' : 'false'} data-selected={selected ? 'true' : 'false'}
data-playable={onClick ? 'true' : 'false'}
style={{ style={{
width: w, width: w,
height: h, height: h,
@@ -212,6 +213,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 +234,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]);
@@ -257,7 +269,13 @@ function TableView({
<div style={topBar}> <div style={topBar}>
<span>{['', '東', '南', '西', '北'][G.bakaze]}{G.kyoku}局</span> <span>{['', '東', '南', '西', '北'][G.bakaze]}{G.kyoku}局</span>
<span>本场 <b style={{ color: '#fc6' }}>{G.honba}</b></span> <span>本场 <b style={{ color: '#fc6' }}>{G.honba}</b></span>
<span>供托 <b style={{ color: '#fc6' }}>{G.kyoutaku}</b></span> <span style={{ display: 'inline-flex', alignItems: 'center', gap: 3 }}>
供托{' '}
{Array.from({ length: G.kyoutaku }).map((_, i) => (
<span key={i} data-testid="mj-riichi-stick" style={{ display: 'inline-block', width: 4, height: 16, background: '#e6c200', borderRadius: 2 }} />
))}
{G.kyoutaku === 0 && <span style={{ color: '#666' }}>—</span>}
</span>
<span>剩余 <b style={{ color: '#8cf' }}>{G.wallCount ?? '?'}</b> 张</span> <span>剩余 <b style={{ color: '#8cf' }}>{G.wallCount ?? '?'}</b> 张</span>
<span data-testid="mj-dora"> <span data-testid="mj-dora">
宝牌指示: 宝牌指示:
@@ -349,7 +367,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 +403,15 @@ 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>
)}
{G.myWaits && G.myWaits.length > 0 && (
<div data-testid="mj-my-waits" style={{ fontSize: 12, color: G.myFuriten ? '#f88' : '#8f8' }}>
听:{G.myWaits.map((k) => tileLabel(k)).join(' ')}
{G.myFuriten ? '(振听·仅可自摸)' : ''}
</div>
)}
</div> </div>
</div> </div>
@@ -501,19 +528,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 +562,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 +606,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}符 ·{' '}
@@ -582,8 +638,17 @@ function RoundEndOverlay({
))} ))}
</div> </div>
{G.gameOver && G.finalRanking && ( {G.gameOver && G.finalRanking && (
<div style={{ fontSize: 12, marginBottom: 10 }}> <div data-testid="mj-final-ranking" style={{ fontSize: 12, marginBottom: 10 }}>
最终顺位:{G.finalRanking.map((r, i) => `${i + 1}位 ${nameOf(r.player)}(${r.score})`).join(' ')} <div style={{ color: '#fc6', marginBottom: 4 }}>最终顺位(顺位点 = ウマ + 头名赏)</div>
{G.finalRanking.map((r) => (
<div key={r.player} style={{ display: 'flex', justifyContent: 'space-between', gap: 8 }}>
<span>{r.rank}位 {nameOf(r.player)}</span>
<span>
{r.score}点 · {r.points >= 0 ? '+' : ''}{r.points}
<span style={{ color: '#888', marginLeft: 6 }}>(ウマ{r.uma >= 0 ? '+' : ''}{r.uma} 赏+{r.oka})</span>
</span>
</div>
))}
</div> </div>
)} )}
{isHost && ( {isHost && (

View File

@@ -0,0 +1,362 @@
# 立直麻将(Mahjong)实现验证清单
> 依据:`docs/mahjong/00-rules.md`(雀魂标准,四麻+三麻,半庄战)。
> 目的:供人工审核实现与规则的一致性。逐条列出**期望行为 / 实现状态 / 证据 / 风险**。
> **版本**:v4(2026-10-03,P0+P1+P2 主要项修复完成;测试清单据实重核)。v1~v3 快照见 git 历史。
> 代码:`packages/engine/src/games/mahjong*.ts`、`apps/web/src/MahjongBoard.tsx`。
## 图例
**级别(重要度)**:**P0** 核心(缺失/错误会导致对局不成立或算错分)|**P1** 重要|**P2** 次要/边角
**实现状态**:✅ 已实现且有单测 | 🟩 已实现(无专门单测/间接覆盖)| 🧪 已实现且浏览器冒烟实测 | ⚠️ 部分/简化/有边界问题 | ❌ 未实现
**测试**:麻将单测 **78 条**(`mahjong-tiles.test.ts` 16 · `mahjong-yaku.test.ts` 33 · `mahjong.test.ts` 29),引擎全量 **329 通过**(13 文件);联机 E2E `e2e/mahjong-multiplayer.spec.ts`(4 端)✅。逐条清单见文末 [Q. 测试清单](#q-测试清单test-inventory)。
---
## 本轮(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 张时不再作为听牌)。
---
## v3(P2)修复总览
| 编号 | 级别 | 修复内容 | 状态 |
| --- | --- | --- | --- |
| R-01 | P2 | 三麻:碰/明杠后**未打牌前不可拔北**(`justCalledBy`) | ✅ 单测 |
| R-02 | P2 | 加杠被抢后撤销为碰时,**扣回第 4 张牌的赤宝牌计数** | ✅ 单测 |
| R-05 | P2 | 终局**顺位马 + 头名赏**(四麻 +15/+5/-5/-15 且赏+20;三麻 +15/0/-15 且赏+15) | ✅ 单测 |
| R-06 | P2 | **听牌 / 振听提示**(`playerView` 暴露 `myWaits`/`myFuriten`,UI 显示) | ✅ 单测 + UI |
| D9/N8 | P2 | **立直棒可视化**(顶栏按供托数画棍)+ 自己立直提示 | ✅ UI |
| R-07 | P1 | **联机 4 端 E2E**(建房→开局→隐藏信息→轮转打牌) | ✅ Playwright |
**本轮有意保持不变(合理简化,非遗漏)**:R-03 开牌/宝牌物理位置、C6 自摸牌显示、E15 杠宝时机、F14 人和(雀魂不采用)、O5 掉线超时(客户端驱动已够用)。
**独立阶段(未做)**:R-04 观战 + AI 补位。
---
## 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 正常同步 | ✅ E2E(`mahjong-multiplayer.spec.ts`) |
| O5 | P2 | 掉线玩家鸣牌超时 | 服务端推进 | 🟩(客户端驱动;全部离线则仍会停) |
## P. 已修复与剩余风险
**已修复(P0/P1/P2 主要项)**:
- 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 碰后不可立即拔北、R-02 抢杠撤销红牌计数、R-05 顺位马/头名赏、R-06 听牌/振听提示、D9/N8 立直棒可视化、R-07 联机 4 端 E2E。
**仍存在(均为“有意简化”或“独立阶段”,非缺陷)**:
| 编号 | 级别 | 项目 | 说明 |
| --- | --- | --- | --- |
| R-03 | P2 | 掷骰/开牌位置、宝牌物理位置 | 采用简化布局,不影响玩法 |
| R-04 | — | 观战 + AI 补位 | 独立阶段(已确认 AI 后续再做) |
| S-01 | P2 | 自摸牌在“摸到”与手牌行各显示一次 | 纯 UX,可后续去重 |
| S-02 | P2 | 杠宝翻开时机为“杠成立即翻” | 与真实时序略有差异,无算分影响 |
| S-03 | P2 | 人和(古役)不实现 | 雀魂不采用(有意) |
| S-04 | P2 | 鸣牌超时由客户端驱动 | 任一客户端在线即可推进;全部离线则停 |
---
## 本轮浏览器冒烟实测(v2,均已通过、无 JS 异常)
- 开局(修复后正常)→ 打牌 → 轮转
- 切换座位不重置
- 鸣牌窗口出现;**故意不响应,等待 >12s 后自动跳过并推进**(验证 P0-02 / N10)
- 连续多回合打牌正常(牌河累加、宝牌指示显示、点数显示)
- 注入 `window.onerror` 捕获:全程 `[]`
## 建议的后续优先项
1. **R-04 观战 + AI 补位**(唯一剩余的大阶段,已确认 AI 后续再做)。
2. 观战/补位完成后再补「观战者不可见手牌」的 E2E。
---
## Q. 测试清单(Test Inventory)
> 数据来源:`packages/engine` 实跑(2026-10-03)。命令:`pnpm --filter @tts-like/engine test`。
> 引擎全量 **329 / 13 文件全通过**;下表为麻将相关的 **78 条**逐组明细。
### Q1. `mahjong-tiles.test.ts` — 16 条(牌具/牌组)
| # | 分组 | 用例 |
| --- | --- | --- |
| 1-3 | 四麻牌组 | 136 张 id 唯一 · 每种牌种恰好 4 张 · 红 5m/5p/5s 各 1 |
| 4-5 | 三麻牌组 | 108 张且不含 2m~8m · 红 5p/5s 各 1(无红 5m) |
| 6-8 | 索引↔牌种 | 34 种索引 0..33 一一对应 · 赤牌归一到普通 5 · 幺九/字牌判定 |
| 9-12 | 宝牌指示映射 | 数牌 9→1 循环 · 赤牌按普通 5 计算 · 风牌 東→南→西→北→東 · 三元牌 白→發→中→白 |
| 13-15 | 排序与展示 | `sortTiles` 按 m→p→s→z 且各花色 1..9 · `tileLabel` 中文/数字 · 计数↔牌种互转 |
| 16 | 洗牌 | 注入固定随机源可复现,且不改原数组 |
### Q2. `mahjong-yaku.test.ts` — 33 条(役种/符/点/听牌)
| # | 分组 | 用例 |
| --- | --- | --- |
| 1-2 | 和牌型分解 | 标准型分解数量正确 · 七对子/国士判定 |
| 3-15 | 普通役种(13) | 平和 · 平和+断幺九 · 一杯口 · 二杯口 · 役牌(三元)与副露 · 混一色/清一色 · 混全带/纯全带 · 无役→null · 三色同顺/一气通贯/三色同刻 · 对对和+三暗刻 · 混老头/小三元 · 立直/一发/门清自摸/平和 · 海底/河底/岭上/抢杠 旗标 |
| 16 | 符细节 | 幺九暗杠 +32 符、门清荣和 70 符 |
| 17-22 | 役满(6) | 国士无双 · 国士十三面(双倍) · 大三元 · 四暗刻/四暗刻单骑(双倍) · 字一色 · 九莲宝灯/纯正九莲(双倍) |
| 23 | 宝牌计数 | 指示 3m→宝牌 4m;赤牌归一 |
| 24-28 | 符与点数(5) | 点数基本表(子/亲×荣/自摸) · 满贯封顶且不采用切上满贯 · 役满/双倍役满 · 本场加算(+300/本场) · 三麻自摸损 |
| 29-33 | 听牌/振听/食替 | 单骑听 · 两面听 · 不成和牌型→false · 食替(碰现物 / 吃现物+筋 / 嵌张) · 舍张振听 |
### Q3. `mahjong.test.ts` — 29 条(对局流程 / P0-P1-P2)
| # | 分组 | 用例 |
| --- | --- | --- |
| 1-2 | 配牌与开局 | 四麻:庄 14/闲 13/牌山 69/赤初始化 · 三麻:108 张/每家 35000/无 2m~8m |
| 3 | 打牌推进 | 无人鸣牌时推进下家并摸牌 |
| 4-6 | 鸣牌窗口 | `buildClaims` 检出碰/杠/吃/荣 · 双响**头跳**取最近 · 荣和优先于碰 |
| 7-10 | 和了计算 | 平和+门清自摸点数 · **无役不能和** · 赤宝牌按实际持有重算 · `applyWin` 点数守恒 |
| 11-12 | 荒牌流局 | 听牌罚符 3000 与庄家轮转 · 庄家听牌则连庄 |
| 13 | 整手模拟(冒烟) | 随机打至荒牌流局:不抛异常、点数守恒、可开下一局 |
| 14 | P0-01 | 立直后不能打非摸到的牌,只能摸切 |
| 15 | P0-02 | 鸣牌超时:未到点不生效;到点自动跳过并推进 |
| 16-18 | P1-03 | 四家立直/四风连打 · 三家和了→流局 · 四杠散了判定 |
| 19 | P1-04 | 流局满贯:弃牌全幺九未被鸣→满贯自摸,不收供托 |
| 20 | P1-05 | All Last 亲家一位、达返点且连庄→终局 |
| 21 | P1-06 | 立直后暗杠:改变听牌被拒 / 不变则允许 |
| 22 | P1-07 | 三麻抢北:对拔北开启荣和窗口 |
| 23 | P1-08 | 里宝牌:和了前隐藏,和了后公开 `uraIndicators` |
| 24 | P1-10 | 被鸣走的自弃牌仍计入舍张振听 |
| 25 | R-01 | 碰后 `justCalledBy` 期间拔北被拒,打牌后恢复 |
| 26 | R-02 | 加杠被抢撤销为碰并扣回赤宝牌计数 |
| 27-28 | R-05 | 四麻ウマ +15/+5/−5/−15 且头名赏 +20 · 三麻 +15/0/−15 且 +15 |
| 29 | R-06 | `playerView` 暴露 `myWaits` / `myFuriten` |
### Q4. 联机 E2E(Playwright)
| 用例 | 覆盖 |
| --- | --- |
| `e2e/mahjong-multiplayer.spec.ts` | 4 客户端注册/建房/加入/开局 · 各自仅见己方手牌 · 对手座位无真实牌泄漏 · 连续轮转打牌 · 牌河累积 |
### Q5. 未纳入自动化(人工/简化,非缺陷)
| 项 | 说明 |
| --- | --- |
| 三麻自摸损全表 | 仅抽样(满贯)验证,未穷举 |
| 浏览器冒烟(人工) | 开局/打牌/换座/鸣牌/超时自动跳过,见上节记录 |
| R-03 / S-01~S-04 | 有意简化项,见 [P 节](#p-已修复与剩余风险) |
| R-04 观战/AI | 未实现,无需测试 |

View File

@@ -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 } : {}) };
}); });
} }

View File

@@ -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);

View File

@@ -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,341 @@ 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);
});
});
// ============================================================
// P2 补全的验证
// ============================================================
describe('mahjong — R-01 碰后不可立即拔北', () => {
test('justCalledBy 期间拔北被拒,打牌后恢复', () => {
const G = setup(3);
startGame(G);
G.turn = '0';
G.turnStep = 'discard';
G.melds = { '0': [], '1': [], '2': [] };
G.hands['0'] = tilesFromKinds(['1m', '9m', '1p', '2p', '3p', '4p', '5p', '6p', '7p', '8p', '9p', '1s', '2s', '4z']);
G.hands['1'] = tilesFromKinds(Array(13).fill('9s'));
G.hands['2'] = tilesFromKinds(Array(13).fill('9s'));
G.deadWall = tilesFromKinds(Array(14).fill('1z'));
G.dealer = '0';
G.justCalledBy = '0';
moveFn('nuki')({ G, playerID: '0' });
expect(G.nukiDora['0']).toBe(0); // 被拒
G.justCalledBy = null;
moveFn('nuki')({ G, playerID: '0' });
expect(G.nukiDora['0']).toBe(1); // 成立
});
});
describe('mahjong — R-02 抢杠撤销红牌计数', () => {
test('加杠被抢后撤销为碰并扣回赤宝牌计数', () => {
const G = setup(4);
startGame(G);
G.turn = '0';
G.turnStep = 'discard';
G.dealer = '0';
G.bakaze = 1;
G.deadWall = tilesFromKinds(Array(14).fill('1z'));
const P = kindToIndex('5p');
G.melds = {
'0': [{ type: 'pon', tiles: [P, P, P], from: '1', calledTile: P }],
'1': [], '2': [], '3': [],
};
// P0 手牌含红 5p(第 4 张)
G.hands['0'] = tilesFromKinds(['0p', '1m', '2m', '3m', '4m', '6m', '7m', '8m', '9m', '1s', '2s', '3s', '4s']);
// P1 听 5p(34p 两面),可抢杠
G.hands['1'] = tilesFromKinds(['2m', '3m', '4m', '5m', '6m', '7m', '2s', '3s', '4s', '9s', '9s', '3p', '4p']);
G.hands['2'] = tilesFromKinds(Array(13).fill('1z'));
G.hands['3'] = tilesFromKinds(Array(13).fill('1z'));
moveFn('kakan')({ G, playerID: '0' }, P);
expect(G.claimKind).toBe('kakan');
expect(G.pendingClaims.some((c: any) => c.player === '1')).toBe(true);
moveFn('respond')({ G, playerID: '1' }, 'ron');
expect(G.result?.type).toBe('ron');
const meld = G.melds['0'].find((m: any) => m.tiles[0] === P)!;
expect(meld.type).toBe('pon');
expect(meld.redCount ?? 0).toBe(0); // 赤 1 被扣回
});
});
describe('mahjong — R-05 顺位马/头名赏', () => {
test('四麻:ウマ +15/+5/-5/-15 且头名赏 +20 归一位', () => {
const G = setup(4);
G.scores = { '0': 40000, '1': 30000, '2': 20000, '3': 10000 };
MAHJONG_INTERNALS.finalizeRanking(G);
expect(G.finalRanking![0]).toMatchObject({ player: '0', rank: 1, uma: 15, oka: 20, points: 35 });
expect(G.finalRanking![1]).toMatchObject({ player: '1', rank: 2, uma: 5, oka: 0, points: 5 });
expect(G.finalRanking![3]).toMatchObject({ player: '3', rank: 4, uma: -15, points: -15 });
});
test('三麻:ウマ +15/0/-15 且头名赏 +15', () => {
const G = setup(3);
G.scores = { '0': 45000, '1': 35000, '2': 20000 };
MAHJONG_INTERNALS.finalizeRanking(G);
expect(G.finalRanking![0]).toMatchObject({ player: '0', rank: 1, uma: 15, oka: 15, points: 30 });
expect(G.finalRanking![2]).toMatchObject({ player: '2', rank: 3, uma: -15, points: -15 });
});
});
describe('mahjong — R-06 听牌/振听提示', () => {
test('playerView 暴露 myWaits 与 myFuriten', () => {
const G = setup(4);
startGame(G);
G.turn = '0';
G.dealer = '0';
G.deadWall = tilesFromKinds(Array(14).fill('1z'));
G.melds = { '0': [], '1': [], '2': [], '3': [] };
G.hands['1'] = tilesFromKinds(['1m', '2m', '3m', '4m', '5m', '6m', '7m', '8m', '9m', '1p', '2p', '3p', '5s']);
G.furitenKinds['1'] = [];
const v = Mahjong.playerView(G, '1');
expect(v.myWaits).toEqual(['5s']);
expect(v.myFuriten).toBe(false);
G.furitenKinds['1'] = [kindToIndex('5s')];
expect(Mahjong.playerView(G, '1').myFuriten).toBe(true);
});
});

View File

@@ -121,17 +121,32 @@ 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; redRemoved: number } | null;
pendingNuki: { player: PlayerId } | null;
/** 鸣牌响应的截止时间(epoch ms),用于掉线自动跳过。 */
claimDeadline: number | null;
/** 刚鸣牌(碰/明杠)尚未打牌者;此期间不可拔北。 */
justCalledBy: PlayerId | 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[];
/** playerView 注入:本人听牌牌种(公开给本人)。 */
myWaits?: string[];
/** playerView 注入:本人是否舍张振听。 */
myFuriten?: boolean;
// 立直 // 立直
pendingRiichi: Record<PlayerId, boolean>; pendingRiichi: Record<PlayerId, boolean>;
@@ -162,7 +177,7 @@ export interface MahjongState {
result: RoundResult | null; result: RoundResult | null;
lastSummary: string | null; lastSummary: string | null;
gameOver: boolean; gameOver: boolean;
finalRanking: { player: PlayerId; score: number }[] | null; finalRanking: { player: PlayerId; score: number; rank: number; uma: number; oka: number; points: number }[] | null;
} }
export interface MahjongSetupData { export interface MahjongSetupData {
@@ -292,8 +307,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 +359,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 +450,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 +479,12 @@ 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.justCalledBy = null;
G.kuikaeForbidden = []; G.kuikaeForbidden = [];
G.furitenKinds = {};
G.kanByPlayer = {};
G.result = null; G.result = null;
G.lastSummary = null; G.lastSummary = null;
@@ -478,6 +502,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 张(原子完成)
@@ -501,6 +527,7 @@ function drawForCurrent(G: MahjongState): void {
G.tempFuriten[pid] = false; G.tempFuriten[pid] = false;
G.ippatsu[pid] = false; G.ippatsu[pid] = false;
G.kuikaeForbidden = []; G.kuikaeForbidden = [];
G.justCalledBy = null;
if (G.wall.length === 0) { if (G.wall.length === 0) {
settleExhaustiveDraw(G); settleExhaustiveDraw(G);
return; return;
@@ -535,24 +562,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 +611,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 +727,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) {
@@ -696,24 +764,45 @@ function maybeFinishGame(G: MahjongState): void {
if (G.gameOver) finalizeRanking(G); if (G.gameOver) finalizeRanking(G);
} }
/** 顺位马(ウマ):四麻 +15/+5/-5/-15;三麻 +15/0/-15。 */
const UMA_TABLE: Record<MahjongMode, number[]> = { 4: [15, 5, -5, -15], 3: [15, 0, -15] };
function finalizeRanking(G: MahjongState): void { function finalizeRanking(G: MahjongState): void {
const order = seatOrder(G.mode); const order = seatOrder(G.mode);
const uma = UMA_TABLE[G.mode];
const okaTotal = ((G.returnScore - G.startScore) * G.numPlayers) / 1000; // 头名赏(全部归一位)
G.finalRanking = order G.finalRanking = order
.slice() .slice()
.sort((a, b) => (G.scores[b] ?? 0) - (G.scores[a] ?? 0)) .sort((a, b) => (G.scores[b] ?? 0) - (G.scores[a] ?? 0))
.map((pid) => ({ player: pid, score: G.scores[pid] ?? 0 })); .map((pid, idx) => {
const u = uma[idx] ?? 0;
const oka = idx === 0 ? okaTotal : 0;
return {
player: pid,
score: G.scores[pid] ?? 0,
rank: idx + 1,
uma: u,
oka,
points: u + oka,
};
});
} }
// ============================================================ // ============================================================
// 鸣牌执行 // 鸣牌执行
// ============================================================ // ============================================================
/** 鸣牌响应超时(毫秒)。 */
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 +816,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);
@@ -738,6 +856,7 @@ function executePon(G: MahjongState, pid: PlayerId, tile: Tile): void {
G.drawnTile = null; G.drawnTile = null;
G.turnStep = 'discard'; G.turnStep = 'discard';
G.noCallsYet = false; G.noCallsYet = false;
G.justCalledBy = pid;
G.kuikaeForbidden = kuikaeForbiddenDiscards(G.melds[pid]![G.melds[pid]!.length - 1]!); G.kuikaeForbidden = kuikaeForbiddenDiscards(G.melds[pid]![G.melds[pid]!.length - 1]!);
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);
@@ -759,6 +878,7 @@ function executeChi(G: MahjongState, pid: PlayerId, tile: Tile, useTiles: number
G.drawnTile = null; G.drawnTile = null;
G.turnStep = 'discard'; G.turnStep = 'discard';
G.noCallsYet = false; G.noCallsYet = false;
G.justCalledBy = pid;
G.kuikaeForbidden = kuikaeForbiddenDiscards(G.melds[pid]![G.melds[pid]!.length - 1]!); G.kuikaeForbidden = kuikaeForbiddenDiscards(G.melds[pid]![G.melds[pid]!.length - 1]!);
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);
@@ -793,8 +913,14 @@ function executeKan(G: MahjongState, pid: PlayerId, tile: Tile): void {
}); });
G.turn = pid; G.turn = pid;
G.noCallsYet = false; G.noCallsYet = false;
G.justCalledBy = pid;
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 +975,12 @@ export const Mahjong: TTSGame<MahjongState> = {
claimDiscarder: null, claimDiscarder: null,
claimKind: 'normal', claimKind: 'normal',
pendingKakan: null, pendingKakan: null,
pendingNuki: null,
claimDeadline: null,
justCalledBy: null,
kuikaeForbidden: [], kuikaeForbidden: [],
furitenKinds: {},
kanByPlayer: {},
pendingRiichi: {}, pendingRiichi: {},
riichi: {}, riichi: {},
doubleRiichi: {}, doubleRiichi: {},
@@ -901,11 +1032,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 +1073,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 +1088,8 @@ 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.justCalledBy = null;
G.lastDiscard = { player: pid, tile }; G.lastDiscard = { player: pid, tile };
G.drawnTile = null; G.drawnTile = null;
G.drawnFromRinshan = false; G.drawnFromRinshan = false;
@@ -995,6 +1132,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 +1150,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 +1180,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);
}, },
@@ -1085,8 +1227,9 @@ export const Mahjong: TTSGame<MahjongState> = {
if (!meld) return; if (!meld) return;
if (handCountOfKind(G, pid, kind) < 1) return; if (handCountOfKind(G, pid, kind) < 1) return;
const removed = removeTileByKind(G, pid, kind, 1); const removed = removeTileByKind(G, pid, kind, 1);
const redRemoved = removed.filter((t) => t.red).length;
meld.type = 'kakan'; meld.type = 'kakan';
meld.redCount = (meld.redCount ?? 0) + removed.filter((t) => t.red).length; meld.redCount = (meld.redCount ?? 0) + redRemoved;
// 抢杠窗口:仅荣和 // 抢杠窗口:仅荣和
const order = seatOrder(G.mode); const order = seatOrder(G.mode);
@@ -1100,7 +1243,8 @@ export const Mahjong: TTSGame<MahjongState> = {
G.claimTile = { id: `kakan-${kind}`, kind: indexToKind(kind) }; G.claimTile = { id: `kakan-${kind}`, kind: indexToKind(kind) };
G.claimDiscarder = pid; G.claimDiscarder = pid;
G.claimKind = 'kakan'; G.claimKind = 'kakan';
G.pendingKakan = { player: pid, kind }; G.pendingKakan = { player: pid, kind, redRemoved };
G.claimDeadline = Date.now() + CLAIM_TIMEOUT_MS;
G.turnStep = 'claim'; G.turnStep = 'claim';
return; return;
} }
@@ -1109,19 +1253,35 @@ 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;
const pid = playerID ?? G.turn; const pid = playerID ?? G.turn;
if (pid !== G.turn || G.turnStep !== 'discard') return; if (pid !== G.turn || G.turnStep !== 'discard') return;
if (G.justCalledBy === pid) 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 +1303,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,21 +1347,38 @@ 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);
// 本人听牌 / 舍张振听提示
const viewerHand = hands[viewer] ?? [];
const viewerConcealed = viewerHand.map((t) => kindToIndex(t.kind));
if (viewer === state.turn && state.drawnTile) {
const di = viewerConcealed.indexOf(kindToIndex(state.drawnTile.kind));
if (di >= 0) viewerConcealed.splice(di, 1);
}
const myWaits = waitingKinds(viewerConcealed, state.melds?.[viewer] ?? []).map((i) => indexToKind(i));
const myFuriten = isDiscardFuriten(myWaits.map((k) => kindToIndex(k)), state.furitenKinds?.[viewer] ?? []);
return { return {
...state, ...state,
hands: hidden, hands: hidden,
myHand, myHand,
myWaits,
myFuriten,
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 +1391,155 @@ 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';
// 撤销加杠:第 4 张牌被和了者取走,扣回其赤宝牌计数
meld.redCount = Math.max(0, (meld.redCount ?? 0) - kakan.redRemoved);
}
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 +1564,9 @@ export const MAHJONG_INTERNALS = {
settleWin, settleWin,
maybeFinishGame, maybeFinishGame,
applyWin, applyWin,
resolveClaimWindow,
checkAbortiveDraws,
fourKanAbort,
finalizeRanking,
CLAIM_TIMEOUT_MS,
}; };