Compare commits
4 Commits
8d5fb3c511
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d02d718237 | ||
|
|
ffd36cfee1 | ||
|
|
79be58e76a | ||
|
|
2a376e142b |
161
apps/web/e2e/mahjong-multiplayer.spec.ts
Normal file
161
apps/web/e2e/mahjong-multiplayer.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -1379,7 +1379,9 @@ function MahjongLocalView() {
|
||||
}),
|
||||
[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}`} />;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -65,6 +65,7 @@ function TileFace({
|
||||
title={title}
|
||||
data-testid={`mj-tile-${kind}`}
|
||||
data-selected={selected ? 'true' : 'false'}
|
||||
data-playable={onClick ? 'true' : 'false'}
|
||||
style={{
|
||||
width: w,
|
||||
height: h,
|
||||
@@ -212,6 +213,15 @@ function TableView({
|
||||
const turnStep = G.turnStep;
|
||||
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 lastMoveRef = useRef(0);
|
||||
@@ -224,8 +234,10 @@ function TableView({
|
||||
const onTileClick = (tileId: string) => {
|
||||
if (!isMyTurn || turnStep !== 'discard') return;
|
||||
if (riichiMode) {
|
||||
doMove('declareRiichi', [tileId]);
|
||||
doMove('discard', [tileId]);
|
||||
// 立直宣言 + 打出必须成对派发,绕过 250ms 防抖,否则第二发会被丢弃导致卡死
|
||||
lastMoveRef.current = Date.now();
|
||||
onMove('declareRiichi', [tileId]);
|
||||
onMove('discard', [tileId]);
|
||||
setRiichiMode(false);
|
||||
} else {
|
||||
doMove('discard', [tileId]);
|
||||
@@ -257,7 +269,13 @@ function TableView({
|
||||
<div style={topBar}>
|
||||
<span>{['', '東', '南', '西', '北'][G.bakaze]}{G.kyoku}局</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 data-testid="mj-dora">
|
||||
宝牌指示:
|
||||
@@ -349,7 +367,7 @@ function TableView({
|
||||
{isMyTurn && turnStep === 'discard' && (
|
||||
<>
|
||||
<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
|
||||
data-testid="mj-riichi"
|
||||
onClick={() => setRiichiMode((v) => !v)}
|
||||
@@ -385,6 +403,15 @@ function TableView({
|
||||
{turnStep === 'claim' && !myClaim && (
|
||||
<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>
|
||||
|
||||
@@ -501,19 +528,32 @@ function SeatPanel({
|
||||
}
|
||||
|
||||
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(() => {
|
||||
setSecs(8);
|
||||
actedRef.current = false;
|
||||
setSecs(10);
|
||||
const iv = setInterval(() => setSecs((s) => (s > 0 ? s - 1 : 0)), 1000);
|
||||
return () => clearInterval(iv);
|
||||
}, [claim]);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [sig]);
|
||||
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
|
||||
}, [secs]);
|
||||
|
||||
const hasRon = claim.options.some((o) => o.type === 'ron');
|
||||
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 (
|
||||
<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
|
||||
</div>
|
||||
<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') && (
|
||||
<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') && (
|
||||
<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) => (
|
||||
<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('')}
|
||||
</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>
|
||||
@@ -566,6 +606,22 @@ function RoundEndOverlay({
|
||||
{G.lastSummary && (
|
||||
<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) => (
|
||||
<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}符 ·{' '}
|
||||
@@ -582,8 +638,17 @@ function RoundEndOverlay({
|
||||
))}
|
||||
</div>
|
||||
{G.gameOver && G.finalRanking && (
|
||||
<div style={{ fontSize: 12, marginBottom: 10 }}>
|
||||
最终顺位:{G.finalRanking.map((r, i) => `${i + 1}位 ${nameOf(r.player)}(${r.score})`).join(' ')}
|
||||
<div data-testid="mj-final-ranking" style={{ fontSize: 12, marginBottom: 10 }}>
|
||||
<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>
|
||||
)}
|
||||
{isHost && (
|
||||
|
||||
362
docs/mahjong/02-verification-checklist.md
Normal file
362
docs/mahjong/02-verification-checklist.md
Normal 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 | 未实现,无需测试 |
|
||||
@@ -234,13 +234,13 @@ export function kindsFromCounts(counts: Counts34): TileKind[] {
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 牌种列表 → 物理牌列表(用于测试便捷构造)。 */
|
||||
/** 牌种列表 → 物理牌列表(用于测试便捷构造;赤牌自动标记 red)。 */
|
||||
export function tilesFromKinds(kinds: readonly TileKind[]): Tile[] {
|
||||
const used: Record<string, number> = {};
|
||||
return kinds.map((kind) => {
|
||||
const n = used[kind] ?? 0;
|
||||
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 张(含副露时更少)手牌,返回所有能使其成和牌型的牌索引。
|
||||
* 会扣除已锁定在副露中的牌:某牌种已被自己占用 4 张时不可能再摸到/荣和。
|
||||
*/
|
||||
export function waitingKinds(concealed: readonly number[], melds: readonly Meld[] = []): number[] {
|
||||
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[] = [];
|
||||
for (let i = 0; i < 34; i++) {
|
||||
if ((base[i] ?? 0) >= 4) continue;
|
||||
if ((used[i] ?? 0) >= 4) continue;
|
||||
const c = base.slice();
|
||||
c[i] = (c[i] ?? 0) + 1;
|
||||
if (formsWinningShape(c, melds)) out.push(i);
|
||||
|
||||
@@ -8,8 +8,8 @@
|
||||
|
||||
import { describe, expect, test } from 'vitest';
|
||||
import { Mahjong, MAHJONG_INTERNALS } from './mahjong.js';
|
||||
import { buildClaims, resolveClaims, computeWin, seatWindOf } from './mahjong.js';
|
||||
import { tilesFromKinds } from './mahjong-tiles.js';
|
||||
import { buildClaims, resolveClaims, computeWin, seatWindOf, canRon } from './mahjong.js';
|
||||
import { tilesFromKinds, kindToIndex } from './mahjong-tiles.js';
|
||||
|
||||
function rngFactory(seed = 12345) {
|
||||
let s = seed;
|
||||
@@ -170,6 +170,31 @@ describe('mahjong — 和了计算', () => {
|
||||
G.ippatsu = { '0': false, '1': false, '2': false, '3': false };
|
||||
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 — 荒牌流局', () => {
|
||||
@@ -208,3 +233,341 @@ describe('mahjong — 荒牌流局', () => {
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -121,17 +121,32 @@ export interface MahjongState {
|
||||
pendingClaims: PendingClaim[];
|
||||
claimTile: Tile | null;
|
||||
claimDiscarder: PlayerId | null;
|
||||
/** 'normal' = 打牌后鸣牌;'kakan' = 加杠抢杠窗口。 */
|
||||
claimKind: 'normal' | 'kakan';
|
||||
pendingKakan: { player: PlayerId; kind: number } | null;
|
||||
/** 'normal' = 打牌后鸣牌;'kakan' = 加杠抢杠;'nuki' = 三麻抢北。 */
|
||||
claimKind: 'normal' | 'kakan' | 'nuki';
|
||||
pendingKakan: { player: PlayerId; kind: number; redRemoved: number } | null;
|
||||
pendingNuki: { player: PlayerId } | null;
|
||||
/** 鸣牌响应的截止时间(epoch ms),用于掉线自动跳过。 */
|
||||
claimDeadline: number | null;
|
||||
/** 刚鸣牌(碰/明杠)尚未打牌者;此期间不可拔北。 */
|
||||
justCalledBy: PlayerId | null;
|
||||
/** 当前行动者因刚鸣牌而禁止打出的牌(食替)。 */
|
||||
kuikaeForbidden: number[];
|
||||
/** 自弃牌的全部牌种(不清除,含被鸣走者),用于舍张振听。 */
|
||||
furitenKinds: Record<PlayerId, number[]>;
|
||||
/** 各家的杠数(用于四杠散了判定)。 */
|
||||
kanByPlayer: Record<PlayerId, number>;
|
||||
/** playerView 注入:各家手牌张数(公开信息)。 */
|
||||
handCounts?: Record<PlayerId, number>;
|
||||
/** playerView 注入:剩余牌山张数(公开)。 */
|
||||
wallCount?: number;
|
||||
/** playerView 注入:已翻开的宝牌指示牌牌种(公开)。 */
|
||||
doraIndicators?: string[];
|
||||
/** playerView 注入:和了后揭开的里宝牌指示牌牌种(公开)。 */
|
||||
uraIndicators?: string[];
|
||||
/** playerView 注入:本人听牌牌种(公开给本人)。 */
|
||||
myWaits?: string[];
|
||||
/** playerView 注入:本人是否舍张振听。 */
|
||||
myFuriten?: boolean;
|
||||
|
||||
// 立直
|
||||
pendingRiichi: Record<PlayerId, boolean>;
|
||||
@@ -162,7 +177,7 @@ export interface MahjongState {
|
||||
result: RoundResult | null;
|
||||
lastSummary: string | null;
|
||||
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 {
|
||||
@@ -292,8 +307,11 @@ export function computeWin(
|
||||
if (!tsumo) allKinds.push(indexToKind(winTileIdx));
|
||||
const doraIndicators = G.deadWall.slice(0, G.doraCount).map((t) => t.kind);
|
||||
let doraHan = countDora(doraIndicators, allKinds);
|
||||
const aka = G.aka[pid] ?? 0;
|
||||
doraHan += aka;
|
||||
// 赤宝牌:按实际持有的牌重算(手牌 red 标记 + 副露 redCount),
|
||||
// 不能用跑来跑去的计数器(打出的赤牌不会归还,会高估)。
|
||||
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]) {
|
||||
const uraIndicators = G.deadWall.slice(9, 9 + G.doraCount).map((t) => t.kind);
|
||||
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 (G.tempFuriten[pid]) return false;
|
||||
if (G.riichiFuriten[pid]) return false;
|
||||
const myDiscards = (G.discards[pid] ?? []).map((t) => kindToIndex(t.kind));
|
||||
if (isDiscardFuriten(waits, myDiscards)) return false;
|
||||
// 舍张振听:含被鸣走的自弃牌(furitenKinds 只增不减)
|
||||
const myKinds = G.furitenKinds?.[pid] ?? [];
|
||||
if (isDiscardFuriten(waits, myKinds)) return false;
|
||||
// 役种校验:构造一次和了(houtei 取决于牌山是否摸尽)
|
||||
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 deadWall = deck.splice(deck.length - 14, 14);
|
||||
G.wall = deck;
|
||||
@@ -460,7 +479,12 @@ function freshRoundState(G: MahjongState, random: () => number): void {
|
||||
G.claimDiscarder = null;
|
||||
G.claimKind = 'normal';
|
||||
G.pendingKakan = null;
|
||||
G.pendingNuki = null;
|
||||
G.claimDeadline = null;
|
||||
G.justCalledBy = null;
|
||||
G.kuikaeForbidden = [];
|
||||
G.furitenKinds = {};
|
||||
G.kanByPlayer = {};
|
||||
G.result = null;
|
||||
G.lastSummary = null;
|
||||
|
||||
@@ -478,6 +502,8 @@ function freshRoundState(G: MahjongState, random: () => number): void {
|
||||
G.tempFuriten[pid] = false;
|
||||
G.riichiFuriten[pid] = false;
|
||||
G.nagashiEligible[pid] = true;
|
||||
G.furitenKinds[pid] = [];
|
||||
G.kanByPlayer[pid] = 0;
|
||||
}
|
||||
|
||||
// 发牌:每人 13 张(原子完成)
|
||||
@@ -501,6 +527,7 @@ function drawForCurrent(G: MahjongState): void {
|
||||
G.tempFuriten[pid] = false;
|
||||
G.ippatsu[pid] = false;
|
||||
G.kuikaeForbidden = [];
|
||||
G.justCalledBy = null;
|
||||
if (G.wall.length === 0) {
|
||||
settleExhaustiveDraw(G);
|
||||
return;
|
||||
@@ -535,24 +562,8 @@ function applyWin(G: MahjongState, win: WinInfo): Record<PlayerId, number> {
|
||||
});
|
||||
// 直接使用 win.points(已在 computeWin 中含本场),按支付结构拆分
|
||||
if (win.fromPlayer === null) {
|
||||
if (win.player === G.dealer) {
|
||||
const each = score.fromEach ?? 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;
|
||||
}
|
||||
}
|
||||
const d = distributeTsumo(G, win.player, score.fromDealer ?? 0, score.fromEach ?? 0);
|
||||
for (const pid of seatOrder(G.mode)) deltas[pid] = d[pid] ?? 0;
|
||||
} else {
|
||||
deltas[win.fromPlayer] = -(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);
|
||||
}
|
||||
|
||||
/** 荒牌流局:听牌罚符 + 连庄判定。 */
|
||||
/** 荒牌流局:流局满贯 / 听牌罚符 + 连庄判定。 */
|
||||
function settleExhaustiveDraw(G: MahjongState): void {
|
||||
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 noten: PlayerId[] = [];
|
||||
for (const pid of order) {
|
||||
@@ -669,6 +727,16 @@ function maybeFinishGame(G: MahjongState): void {
|
||||
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→终局
|
||||
if (G.kyoku > 4) {
|
||||
if (G.bakaze === 1) {
|
||||
@@ -696,24 +764,45 @@ function maybeFinishGame(G: MahjongState): void {
|
||||
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 {
|
||||
const order = seatOrder(G.mode);
|
||||
const uma = UMA_TABLE[G.mode];
|
||||
const okaTotal = ((G.returnScore - G.startScore) * G.numPlayers) / 1000; // 头名赏(全部归一位)
|
||||
G.finalRanking = order
|
||||
.slice()
|
||||
.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 {
|
||||
G.pendingClaims = [];
|
||||
G.claimTile = null;
|
||||
G.claimDiscarder = null;
|
||||
G.claimKind = 'normal';
|
||||
G.pendingKakan = null;
|
||||
G.pendingNuki = null;
|
||||
G.claimDeadline = null;
|
||||
}
|
||||
|
||||
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.claimDiscarder = discarder;
|
||||
G.claimKind = 'normal';
|
||||
G.claimDeadline = Date.now() + CLAIM_TIMEOUT_MS;
|
||||
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 {
|
||||
const kind = kindToIndex(tile.kind);
|
||||
const removed = removeTileByKind(G, pid, kind, 2);
|
||||
@@ -738,6 +856,7 @@ function executePon(G: MahjongState, pid: PlayerId, tile: Tile): void {
|
||||
G.drawnTile = null;
|
||||
G.turnStep = 'discard';
|
||||
G.noCallsYet = false;
|
||||
G.justCalledBy = pid;
|
||||
G.kuikaeForbidden = kuikaeForbiddenDiscards(G.melds[pid]![G.melds[pid]!.length - 1]!);
|
||||
for (const p of seatOrder(G.mode)) G.ippatsu[p] = false;
|
||||
clearClaimWindow(G);
|
||||
@@ -759,6 +878,7 @@ function executeChi(G: MahjongState, pid: PlayerId, tile: Tile, useTiles: number
|
||||
G.drawnTile = null;
|
||||
G.turnStep = 'discard';
|
||||
G.noCallsYet = false;
|
||||
G.justCalledBy = pid;
|
||||
G.kuikaeForbidden = kuikaeForbiddenDiscards(G.melds[pid]![G.melds[pid]!.length - 1]!);
|
||||
for (const p of seatOrder(G.mode)) G.ippatsu[p] = false;
|
||||
clearClaimWindow(G);
|
||||
@@ -793,8 +913,14 @@ function executeKan(G: MahjongState, pid: PlayerId, tile: Tile): void {
|
||||
});
|
||||
G.turn = pid;
|
||||
G.noCallsYet = false;
|
||||
G.justCalledBy = pid;
|
||||
for (const p of seatOrder(G.mode)) G.ippatsu[p] = false;
|
||||
clearClaimWindow(G);
|
||||
G.kanByPlayer[pid] = (G.kanByPlayer[pid] ?? 0) + 1;
|
||||
if (fourKanAbort(G)) {
|
||||
settleAbort(G, '四杠散了');
|
||||
return;
|
||||
}
|
||||
flipKanDora(G);
|
||||
drawRinshan(G);
|
||||
}
|
||||
@@ -849,7 +975,12 @@ export const Mahjong: TTSGame<MahjongState> = {
|
||||
claimDiscarder: null,
|
||||
claimKind: 'normal',
|
||||
pendingKakan: null,
|
||||
pendingNuki: null,
|
||||
claimDeadline: null,
|
||||
justCalledBy: null,
|
||||
kuikaeForbidden: [],
|
||||
furitenKinds: {},
|
||||
kanByPlayer: {},
|
||||
pendingRiichi: {},
|
||||
riichi: {},
|
||||
doubleRiichi: {},
|
||||
@@ -901,11 +1032,11 @@ export const Mahjong: TTSGame<MahjongState> = {
|
||||
moves: {
|
||||
// 房主开局
|
||||
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 (G.started) return;
|
||||
G.started = true;
|
||||
freshRoundState(G, random);
|
||||
freshRoundState(G);
|
||||
drawForCurrent(G);
|
||||
events?.endPhase?.(); // → playing
|
||||
},
|
||||
@@ -942,6 +1073,10 @@ export const Mahjong: TTSGame<MahjongState> = {
|
||||
if (pid !== G.turn || G.turnStep !== 'discard' || !tileId) return;
|
||||
const tile = G.hands[pid]?.find((t) => t.id === tileId);
|
||||
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;
|
||||
removeTileById(G, pid, tileId);
|
||||
@@ -953,6 +1088,8 @@ export const Mahjong: TTSGame<MahjongState> = {
|
||||
}
|
||||
|
||||
G.discards[pid]!.push(tile);
|
||||
G.furitenKinds[pid] = [...(G.furitenKinds[pid] ?? []), kindToIndex(tile.kind)];
|
||||
G.justCalledBy = null;
|
||||
G.lastDiscard = { player: pid, tile };
|
||||
G.drawnTile = null;
|
||||
G.drawnFromRinshan = false;
|
||||
@@ -995,6 +1132,13 @@ export const Mahjong: TTSGame<MahjongState> = {
|
||||
const claim = G.pendingClaims.find((c) => c.player === pid);
|
||||
if (!claim) 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 };
|
||||
|
||||
// 放弃荣和 → 临时振听(立直后则永久立直振听)
|
||||
@@ -1006,56 +1150,26 @@ export const Mahjong: TTSGame<MahjongState> = {
|
||||
}
|
||||
|
||||
if (!G.pendingClaims.every((c) => c.response !== null)) return;
|
||||
resolveClaimWindow(G);
|
||||
},
|
||||
ignoreStaleStateID: true,
|
||||
},
|
||||
|
||||
// ---- 加杠抢杠窗口 ----
|
||||
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;
|
||||
// 鸣牌响应超时(任意客户端可驱动):到点后把未响应者视为跳过,避免掉线卡死
|
||||
claimTimeout: {
|
||||
move: ({ G }: { G: MahjongState }) => {
|
||||
if (G.turnStep !== 'claim' || !G.claimDeadline) return;
|
||||
if (Date.now() < G.claimDeadline) return;
|
||||
for (const c of G.pendingClaims) {
|
||||
if (c.response === null) {
|
||||
if (c.options.some((o) => o.type === 'ron')) {
|
||||
G.tempFuriten[c.player] = true;
|
||||
if (G.riichi[c.player]) G.riichiFuriten[c.player] = true;
|
||||
}
|
||||
c.response = { type: 'pass' };
|
||||
}
|
||||
if (kakan) finishKakan(G, kakan.player);
|
||||
else clearClaimWindow(G);
|
||||
return;
|
||||
}
|
||||
|
||||
// ---- 普通鸣牌窗口 ----
|
||||
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);
|
||||
resolveClaimWindow(G);
|
||||
},
|
||||
ignoreStaleStateID: true,
|
||||
},
|
||||
@@ -1066,10 +1180,38 @@ export const Mahjong: TTSGame<MahjongState> = {
|
||||
const pid = playerID ?? G.turn;
|
||||
if (pid !== G.turn || G.turnStep !== 'discard' || kind === undefined) return;
|
||||
if (handCountOfKind(G, pid, kind) < 4) return;
|
||||
// TODO: 立直后暗杠需校验不改变听牌
|
||||
const removed = removeTileByKind(G, pid, kind, 4);
|
||||
G.melds[pid]!.push({ type: 'ankan', tiles: [kind, kind, kind, kind], from: 'self', redCount: removed.filter((t) => t.red).length });
|
||||
// 立直后暗杠:不得改变听牌(比较暗杠前后的 waitingKinds)
|
||||
if (G.riichi[pid]) {
|
||||
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.kanByPlayer[pid] = (G.kanByPlayer[pid] ?? 0) + 1;
|
||||
if (fourKanAbort(G)) {
|
||||
settleAbort(G, '四杠散了');
|
||||
return;
|
||||
}
|
||||
flipKanDora(G);
|
||||
drawRinshan(G);
|
||||
},
|
||||
@@ -1085,8 +1227,9 @@ export const Mahjong: TTSGame<MahjongState> = {
|
||||
if (!meld) return;
|
||||
if (handCountOfKind(G, pid, kind) < 1) return;
|
||||
const removed = removeTileByKind(G, pid, kind, 1);
|
||||
const redRemoved = removed.filter((t) => t.red).length;
|
||||
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);
|
||||
@@ -1100,7 +1243,8 @@ export const Mahjong: TTSGame<MahjongState> = {
|
||||
G.claimTile = { id: `kakan-${kind}`, kind: indexToKind(kind) };
|
||||
G.claimDiscarder = pid;
|
||||
G.claimKind = 'kakan';
|
||||
G.pendingKakan = { player: pid, kind };
|
||||
G.pendingKakan = { player: pid, kind, redRemoved };
|
||||
G.claimDeadline = Date.now() + CLAIM_TIMEOUT_MS;
|
||||
G.turnStep = 'claim';
|
||||
return;
|
||||
}
|
||||
@@ -1109,19 +1253,35 @@ export const Mahjong: TTSGame<MahjongState> = {
|
||||
ignoreStaleStateID: true,
|
||||
},
|
||||
|
||||
// 三麻拔北
|
||||
// 三麻拔北(可被抢北)
|
||||
nuki: {
|
||||
move: ({ G, playerID }: { G: MahjongState; playerID?: string }) => {
|
||||
if (G.mode !== 3) return;
|
||||
const pid = playerID ?? G.turn;
|
||||
if (pid !== G.turn || G.turnStep !== 'discard') return;
|
||||
if (G.justCalledBy === pid) return; // 碰/明杠后不可立即拔北
|
||||
const northKind = 30; // 4z
|
||||
if (handCountOfKind(G, pid, northKind) < 1) return;
|
||||
// 简化:拔北后不可立即(此处要求是自摸后)
|
||||
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,
|
||||
},
|
||||
@@ -1143,13 +1303,13 @@ export const Mahjong: TTSGame<MahjongState> = {
|
||||
|
||||
// 房主开下一局
|
||||
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 (G.gameOver) {
|
||||
events?.endPhase?.(); // → gameEnd
|
||||
return;
|
||||
}
|
||||
freshRoundState(G, random);
|
||||
freshRoundState(G);
|
||||
drawForCurrent(G);
|
||||
events?.endPhase?.(); // → playing
|
||||
},
|
||||
@@ -1187,21 +1347,38 @@ export const Mahjong: TTSGame<MahjongState> = {
|
||||
for (const p of Object.keys(hands)) handCounts[p] = hands[p]!.length;
|
||||
const wallCount = (state.wall ?? []).length;
|
||||
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 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 {
|
||||
...state,
|
||||
hands: hidden,
|
||||
myHand,
|
||||
myWaits,
|
||||
myFuriten,
|
||||
handCounts,
|
||||
wallCount,
|
||||
doraIndicators,
|
||||
uraIndicators,
|
||||
// SECRET 剥离
|
||||
wall: [],
|
||||
deadWall: [],
|
||||
aka: {},
|
||||
pendingClaims,
|
||||
pendingKakan: null,
|
||||
pendingNuki: null,
|
||||
melds,
|
||||
kuikaeForbidden: viewer === state.turn ? (state.kuikaeForbidden ?? []) : [],
|
||||
drawnTile: viewer === state.turn ? state.drawnTile : null,
|
||||
@@ -1214,16 +1391,155 @@ function removeDiscard(G: MahjongState, discarder: PlayerId, tile: Tile): void {
|
||||
if (!pond) return;
|
||||
const i = pond.findIndex((t) => t.id === tile.id);
|
||||
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 {
|
||||
clearClaimWindow(G);
|
||||
G.turn = pid;
|
||||
G.noCallsYet = false;
|
||||
G.kanByPlayer[pid] = (G.kanByPlayer[pid] ?? 0) + 1;
|
||||
if (fourKanAbort(G)) {
|
||||
settleAbort(G, '四杠散了');
|
||||
return;
|
||||
}
|
||||
flipKanDora(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 const MAHJONG_HELPERS = {
|
||||
@@ -1248,4 +1564,9 @@ export const MAHJONG_INTERNALS = {
|
||||
settleWin,
|
||||
maybeFinishGame,
|
||||
applyWin,
|
||||
resolveClaimWindow,
|
||||
checkAbortiveDraws,
|
||||
fourKanAbort,
|
||||
finalizeRanking,
|
||||
CLAIM_TIMEOUT_MS,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user