Compare commits

...

2 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
5 changed files with 440 additions and 31 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

@@ -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,
@@ -268,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">
宝牌指示: 宝牌指示:
@@ -399,6 +406,12 @@ function TableView({
{G.riichi?.[me] && ( {G.riichi?.[me] && (
<div data-testid="mj-my-riichi" style={{ fontSize: 12, color: '#f66' }}>🔴 已立直(只能摸切)</div> <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>
@@ -625,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

@@ -2,7 +2,7 @@
> 依据:`docs/mahjong/00-rules.md`(雀魂标准,四麻+三麻,半庄战)。 > 依据:`docs/mahjong/00-rules.md`(雀魂标准,四麻+三麻,半庄战)。
> 目的:供人工审核实现与规则的一致性。逐条列出**期望行为 / 实现状态 / 证据 / 风险**。 > 目的:供人工审核实现与规则的一致性。逐条列出**期望行为 / 实现状态 / 证据 / 风险**。
> **版本**:v2(2026-10-03,P0+P1 修复完成)。v1 快照见 git 历史。 > **版本**:v4(2026-10-03,P0+P1+P2 主要项修复完成;测试清单据实重核)。v1~v3 快照见 git 历史。
> 代码:`packages/engine/src/games/mahjong*.ts`、`apps/web/src/MahjongBoard.tsx`。 > 代码:`packages/engine/src/games/mahjong*.ts`、`apps/web/src/MahjongBoard.tsx`。
## 图例 ## 图例
@@ -11,7 +11,7 @@
**实现状态**:✅ 已实现且有单测 | 🟩 已实现(无专门单测/间接覆盖)| 🧪 已实现且浏览器冒烟实测 | ⚠️ 部分/简化/有边界问题 | ❌ 未实现 **实现状态**:✅ 已实现且有单测 | 🟩 已实现(无专门单测/间接覆盖)| 🧪 已实现且浏览器冒烟实测 | ⚠️ 部分/简化/有边界问题 | ❌ 未实现
**测试**:`mahjong-tiles.test.ts`(16) · `mahjong-yaku.test.ts`(33) · `mahjong.test.ts`(24);引擎合计 **324 通过**。 **测试**:麻将单测 **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)。
--- ---
@@ -34,6 +34,22 @@
--- ---
## 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. 牌具与牌组 ## A. 牌具与牌组
| 编号 | 级别 | 检查项 | 期望 | 状态 | | 编号 | 级别 | 检查项 | 期望 | 状态 |
@@ -82,7 +98,7 @@
| D6 | P1 | 立直后暗杠 | 仅在不改变听牌时可暗杠 | ✅ | | D6 | P1 | 立直后暗杠 | 仅在不改变听牌时可暗杠 | ✅ |
| D7 | P1 | 四家立直流局 | → 途中流局 | ✅ | | D7 | P1 | 四家立直流局 | → 途中流局 | ✅ |
| D8 | P1 | 里宝牌 | 立直和了加算里宝 | ✅ | | D8 | P1 | 里宝牌 | 立直和了加算里宝 | ✅ |
| D9 | P2 | 立直棒可视化 | 桌面立直棒 | ⚠️ 仅数字 + 自己立直提示 | | D9 | P2 | 立直棒可视化 | 桌面立直棒 | ✅ 顶栏棍形显示 + 自己立直提示 |
## E. 鸣牌 ## E. 鸣牌
@@ -101,7 +117,7 @@
| E11 | P1 | 四杠散了 | → 流局 | ✅ | | E11 | P1 | 四杠散了 | → 流局 | ✅ |
| E12 | P1 | 三麻拔北 | 亮北 +1 宝、岭上 | 🟩 | | E12 | P1 | 三麻拔北 | 亮北 +1 宝、岭上 | 🟩 |
| E13 | P1 | 抢北 | 对拔北荣和 | ✅ | | E13 | P1 | 抢北 | 对拔北荣和 | ✅ |
| E14 | P2 | 碰后不可立即拔北 | 拔北近似鸣牌 | ❌ 未限制 | | E14 | P2 | 碰后不可立即拔北 | 拔北近似鸣牌 | ✅ |
| E15 | P2 | 明杠/加杠的宝牌与岭上顺序 | 符合规则 | 🟩 | | E15 | P2 | 明杠/加杠的宝牌与岭上顺序 | 符合规则 | 🟩 |
## F. 和了判定 ## F. 和了判定
@@ -204,7 +220,7 @@
| L4 | P1 | 和了终局/听牌终局 | All Last 亲一位+达返点+连庄 → 终局 | ✅ | | L4 | P1 | 和了终局/听牌终局 | All Last 亲一位+达返点+连庄 → 终局 | ✅ |
| L5 | P1 | 西入 | 南 4 无人达返点 → 西入 | 🟩 | | L5 | P1 | 西入 | 南 4 无人达返点 → 西入 | 🟩 |
| L6 | P0 | 击飞终局 | — | 🟩 | | L6 | P0 | 击飞终局 | — | 🟩 |
| L7 | P2 | 顺位马 / 头名赏 | 段位点结算 | ❌ 仅场内点数 | | L7 | P2 | 顺位马 / 头名赏 | 段位点结算 | ✅ 终局计算 + 结算面板显示 |
## M. 三麻特有 ## M. 三麻特有
@@ -230,8 +246,8 @@
| N5 | P1 | 操作按钮 | 自摸/暗杠/加杠/拔北/九种九牌 | 🟩 | | N5 | P1 | 操作按钮 | 自摸/暗杠/加杠/拔北/九种九牌 | 🟩 |
| N6 | P1 | 结算面板 | 役种/番符/点数/顺位/下一局 | 🟩 | | N6 | P1 | 结算面板 | 役种/番符/点数/顺位/下一局 | 🟩 |
| N7 | P2 | 里宝牌展示 | 和了时揭开 | ✅ | | N7 | P2 | 里宝牌展示 | 和了时揭开 | ✅ |
| N8 | P2 | 立直棒/自己立直提示 | — | ⚠️ 数字 + 自己立直提示;桌面立直棒未画 | | N8 | P2 | 立直棒/自己立直提示 | — | ✅ 棍形供托 + 自己立直提示 |
| N9 | P2 | 听牌/振听提示 | — | ❌ | | N9 | P2 | 听牌/振听提示 | — | ✅ |
| N10 | P1 | 鸣牌超时对“非本人”推进 | 掉线也能推进 | ✅🧪(任一客户端在线即可驱动) | | N10 | P1 | 鸣牌超时对“非本人”推进 | 掉线也能推进 | ✅🧪(任一客户端在线即可驱动) |
## O. 联机 / 隐藏信息 ## O. 联机 / 隐藏信息
@@ -241,24 +257,25 @@
| O1 | P0 | playerView 隐藏 | 他家手牌/牌山/里宝隐藏 | 🟩 | | O1 | P0 | playerView 隐藏 | 他家手牌/牌山/里宝隐藏 | 🟩 |
| O2 | P0 | 公开信息 | handCounts/wallCount/doraIndicators | 🧪 | | O2 | P0 | 公开信息 | handCounts/wallCount/doraIndicators | 🧪 |
| O3 | P1 | 本地换座位不重置 | updatePlayerID 原地切换 | 🧪 | | O3 | P1 | 本地换座位不重置 | updatePlayerID 原地切换 | 🧪 |
| O4 | P1 | 联机 4 端对局 | SocketIO 正常同步 | 🟩 未做完整 4 端 E2E | | O4 | P1 | 联机 4 端对局 | SocketIO 正常同步 | ✅ E2E(`mahjong-multiplayer.spec.ts`) |
| O5 | P2 | 掉线玩家鸣牌超时 | 服务端推进 | 🟩(客户端驱动;全部离线则仍会停) | | O5 | P2 | 掉线玩家鸣牌超时 | 服务端推进 | 🟩(客户端驱动;全部离线则仍会停) |
## P. 已修复与剩余风险 ## P. 已修复与剩余风险
**已修复(P0/P1)**:P-01 摸切、P-02 鸣牌超时、P-03 途中流局×4、P-04 流局满贯、P-05 和了终局、P-06 立直后暗杠校验、P-07 抢北、P-08 里宝/杠宝展示、P-09 食替筋、P-10 振听边界。 **已修复(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。
**仍存在(均为 P2 或依赖人工配置)**: **仍存在(均为“有意简化”或“独立阶段”,非缺陷)**:
| 编号 | 级别 | 问题 | 影响 | 说明 | | 编号 | 级别 | 项目 | 说明 |
| --- | --- | --- | --- | --- | | --- | --- | --- | --- |
| R-01 | P2 | 碰后不可立即拔北未限制 | 三麻细节 | 可与拔北 move 中加“刚鸣牌”标记 | | R-03 | P2 | 掷骰/开牌位置、宝牌物理位置 | 采用简化布局,不影响玩法 |
| R-02 | P2 | 加杠被抢后第 4 张牌未归还手牌 | 抢杠后本局立即结束,暂无实际影响 | 若后续支持“抢杠后继续”需修 | | R-04 | — | 观战 + AI 补位 | 独立阶段(已确认 AI 后续再做) |
| R-03 | P2 | 掷骰/开牌位置、宝牌物理位置简化 | 与真实砌墙无关紧要 | 保持简化 | | S-01 | P2 | 自摸牌在“摸到”与手牌行各显示一次 | 纯 UX,可后续去重 |
| R-04 | P2 | 无观战、无 AI 补位 | 需满员开局 | 后续独立阶段 | | S-02 | P2 | 杠宝翻开时机为“杠成立即翻” | 与真实时序略有差异,无算分影响 |
| R-05 | P2 | 段位马点/头名赏未计算 | 仅显示场内点数 | 终局结算时补 | | S-03 | P2 | 人和(古役)不实现 | 雀魂不采用(有意) |
| R-06 | P2 | 听牌/振听无 UI 提示 | 体验 | 可选 | | S-04 | P2 | 鸣牌超时由客户端驱动 | 任一客户端在线即可推进;全部离线则停 |
| R-07 | P1 | 联机 4 端 E2E 未做 | 缺回归 | 建议后续补 Playwright |
--- ---
@@ -272,6 +289,74 @@
## 建议的后续优先项 ## 建议的后续优先项
1. **R-07 联机 4 端 E2E**(回归保障)。 1. **R-04 观战 + AI 补位**(唯一剩余的大阶段,已确认 AI 后续再做)。
2. R-01/R-02 两个三麻/抢杠细节。 2. 观战/补位完成后再补「观战者不可见手牌」的 E2E。
3. R-05 终局马点/头名赏(若需要段位结算)。
---
## 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

@@ -477,3 +477,97 @@ describe('mahjong — P1-08 里宝牌揭示', () => {
expect(after.uraIndicators?.length).toBe(1); 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

@@ -123,10 +123,12 @@ export interface MahjongState {
claimDiscarder: PlayerId | null; claimDiscarder: PlayerId | null;
/** 'normal' = 打牌后鸣牌;'kakan' = 加杠抢杠;'nuki' = 三麻抢北。 */ /** 'normal' = 打牌后鸣牌;'kakan' = 加杠抢杠;'nuki' = 三麻抢北。 */
claimKind: 'normal' | 'kakan' | 'nuki'; claimKind: 'normal' | 'kakan' | 'nuki';
pendingKakan: { player: PlayerId; kind: number } | null; pendingKakan: { player: PlayerId; kind: number; redRemoved: number } | null;
pendingNuki: { player: PlayerId } | null; pendingNuki: { player: PlayerId } | null;
/** 鸣牌响应的截止时间(epoch ms),用于掉线自动跳过。 */ /** 鸣牌响应的截止时间(epoch ms),用于掉线自动跳过。 */
claimDeadline: number | null; claimDeadline: number | null;
/** 刚鸣牌(碰/明杠)尚未打牌者;此期间不可拔北。 */
justCalledBy: PlayerId | null;
/** 当前行动者因刚鸣牌而禁止打出的牌(食替)。 */ /** 当前行动者因刚鸣牌而禁止打出的牌(食替)。 */
kuikaeForbidden: number[]; kuikaeForbidden: number[];
/** 自弃牌的全部牌种(不清除,含被鸣走者),用于舍张振听。 */ /** 自弃牌的全部牌种(不清除,含被鸣走者),用于舍张振听。 */
@@ -141,6 +143,10 @@ export interface MahjongState {
doraIndicators?: string[]; doraIndicators?: string[];
/** playerView 注入:和了后揭开的里宝牌指示牌牌种(公开)。 */ /** playerView 注入:和了后揭开的里宝牌指示牌牌种(公开)。 */
uraIndicators?: string[]; uraIndicators?: string[];
/** playerView 注入:本人听牌牌种(公开给本人)。 */
myWaits?: string[];
/** playerView 注入:本人是否舍张振听。 */
myFuriten?: boolean;
// 立直 // 立直
pendingRiichi: Record<PlayerId, boolean>; pendingRiichi: Record<PlayerId, boolean>;
@@ -171,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 {
@@ -475,6 +481,7 @@ function freshRoundState(G: MahjongState, random: () => number = Math.random): v
G.pendingKakan = null; G.pendingKakan = null;
G.pendingNuki = null; G.pendingNuki = null;
G.claimDeadline = null; G.claimDeadline = null;
G.justCalledBy = null;
G.kuikaeForbidden = []; G.kuikaeForbidden = [];
G.furitenKinds = {}; G.furitenKinds = {};
G.kanByPlayer = {}; G.kanByPlayer = {};
@@ -520,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;
@@ -756,12 +764,28 @@ 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,
};
});
} }
// ============================================================ // ============================================================
@@ -832,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);
@@ -853,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);
@@ -887,6 +913,7 @@ 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; G.kanByPlayer[pid] = (G.kanByPlayer[pid] ?? 0) + 1;
@@ -950,6 +977,7 @@ export const Mahjong: TTSGame<MahjongState> = {
pendingKakan: null, pendingKakan: null,
pendingNuki: null, pendingNuki: null,
claimDeadline: null, claimDeadline: null,
justCalledBy: null,
kuikaeForbidden: [], kuikaeForbidden: [],
furitenKinds: {}, furitenKinds: {},
kanByPlayer: {}, kanByPlayer: {},
@@ -1061,6 +1089,7 @@ export const Mahjong: TTSGame<MahjongState> = {
G.discards[pid]!.push(tile); G.discards[pid]!.push(tile);
G.furitenKinds[pid] = [...(G.furitenKinds[pid] ?? []), kindToIndex(tile.kind)]; G.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;
@@ -1198,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);
@@ -1213,7 +1243,7 @@ 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.claimDeadline = Date.now() + CLAIM_TIMEOUT_MS;
G.turnStep = 'claim'; G.turnStep = 'claim';
return; return;
@@ -1229,6 +1259,7 @@ export const Mahjong: TTSGame<MahjongState> = {
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);
@@ -1322,10 +1353,21 @@ export const Mahjong: TTSGame<MahjongState> = {
: []; : [];
// 只暴露请求者自己的鸣牌响应选项 // 只暴露请求者自己的鸣牌响应选项
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,
@@ -1419,7 +1461,11 @@ function resolveClaimWindow(G: MahjongState): void {
const win = computeWin(G, res.player, kind, false, { chankan: true }); const win = computeWin(G, res.player, kind, false, { chankan: true });
if (win) { if (win) {
const meld = G.melds[kakan.player]!.find((m) => m.type === 'kakan' && m.tiles[0] === kind); const meld = G.melds[kakan.player]!.find((m) => m.type === 'kakan' && m.tiles[0] === kind);
if (meld) meld.type = 'pon'; if (meld) {
meld.type = 'pon';
// 撤销加杠:第 4 张牌被和了者取走,扣回其赤宝牌计数
meld.redCount = Math.max(0, (meld.redCount ?? 0) - kakan.redRemoved);
}
G.turn = kakan.player; G.turn = kakan.player;
clearClaimWindow(G); clearClaimWindow(G);
settleWin(G, win, 'ron'); settleWin(G, win, 'ron');
@@ -1521,5 +1567,6 @@ export const MAHJONG_INTERNALS = {
resolveClaimWindow, resolveClaimWindow,
checkAbortiveDraws, checkAbortiveDraws,
fourKanAbort, fourKanAbort,
finalizeRanking,
CLAIM_TIMEOUT_MS, CLAIM_TIMEOUT_MS,
}; };