Compare commits
2 Commits
79be58e76a
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d02d718237 | ||
|
|
ffd36cfee1 |
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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
@@ -268,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">
|
||||
宝牌指示:
|
||||
@@ -399,6 +406,12 @@ function TableView({
|
||||
{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>
|
||||
|
||||
@@ -625,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 && (
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
> 依据:`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`。
|
||||
|
||||
## 图例
|
||||
@@ -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. 牌具与牌组
|
||||
|
||||
| 编号 | 级别 | 检查项 | 期望 | 状态 |
|
||||
@@ -82,7 +98,7 @@
|
||||
| D6 | P1 | 立直后暗杠 | 仅在不改变听牌时可暗杠 | ✅ |
|
||||
| D7 | P1 | 四家立直流局 | → 途中流局 | ✅ |
|
||||
| D8 | P1 | 里宝牌 | 立直和了加算里宝 | ✅ |
|
||||
| D9 | P2 | 立直棒可视化 | 桌面立直棒 | ⚠️ 仅数字 + 自己立直提示 |
|
||||
| D9 | P2 | 立直棒可视化 | 桌面立直棒 | ✅ 顶栏棍形显示 + 自己立直提示 |
|
||||
|
||||
## E. 鸣牌
|
||||
|
||||
@@ -101,7 +117,7 @@
|
||||
| E11 | P1 | 四杠散了 | → 流局 | ✅ |
|
||||
| E12 | P1 | 三麻拔北 | 亮北 +1 宝、岭上 | 🟩 |
|
||||
| E13 | P1 | 抢北 | 对拔北荣和 | ✅ |
|
||||
| E14 | P2 | 碰后不可立即拔北 | 拔北近似鸣牌 | ❌ 未限制 |
|
||||
| E14 | P2 | 碰后不可立即拔北 | 拔北近似鸣牌 | ✅ |
|
||||
| E15 | P2 | 明杠/加杠的宝牌与岭上顺序 | 符合规则 | 🟩 |
|
||||
|
||||
## F. 和了判定
|
||||
@@ -204,7 +220,7 @@
|
||||
| L4 | P1 | 和了终局/听牌终局 | All Last 亲一位+达返点+连庄 → 终局 | ✅ |
|
||||
| L5 | P1 | 西入 | 南 4 无人达返点 → 西入 | 🟩 |
|
||||
| L6 | P0 | 击飞终局 | — | 🟩 |
|
||||
| L7 | P2 | 顺位马 / 头名赏 | 段位点结算 | ❌ 仅场内点数 |
|
||||
| L7 | P2 | 顺位马 / 头名赏 | 段位点结算 | ✅ 终局计算 + 结算面板显示 |
|
||||
|
||||
## M. 三麻特有
|
||||
|
||||
@@ -230,8 +246,8 @@
|
||||
| N5 | P1 | 操作按钮 | 自摸/暗杠/加杠/拔北/九种九牌 | 🟩 |
|
||||
| N6 | P1 | 结算面板 | 役种/番符/点数/顺位/下一局 | 🟩 |
|
||||
| N7 | P2 | 里宝牌展示 | 和了时揭开 | ✅ |
|
||||
| N8 | P2 | 立直棒/自己立直提示 | — | ⚠️ 数字 + 自己立直提示;桌面立直棒未画 |
|
||||
| N9 | P2 | 听牌/振听提示 | — | ❌ |
|
||||
| N8 | P2 | 立直棒/自己立直提示 | — | ✅ 棍形供托 + 自己立直提示 |
|
||||
| N9 | P2 | 听牌/振听提示 | — | ✅ |
|
||||
| N10 | P1 | 鸣牌超时对“非本人”推进 | 掉线也能推进 | ✅🧪(任一客户端在线即可驱动) |
|
||||
|
||||
## O. 联机 / 隐藏信息
|
||||
@@ -241,24 +257,25 @@
|
||||
| O1 | P0 | playerView 隐藏 | 他家手牌/牌山/里宝隐藏 | 🟩 |
|
||||
| O2 | P0 | 公开信息 | handCounts/wallCount/doraIndicators | 🧪 |
|
||||
| O3 | P1 | 本地换座位不重置 | updatePlayerID 原地切换 | 🧪 |
|
||||
| O4 | P1 | 联机 4 端对局 | SocketIO 正常同步 | 🟩 未做完整 4 端 E2E |
|
||||
| O4 | P1 | 联机 4 端对局 | SocketIO 正常同步 | ✅ E2E(`mahjong-multiplayer.spec.ts`) |
|
||||
| O5 | P2 | 掉线玩家鸣牌超时 | 服务端推进 | 🟩(客户端驱动;全部离线则仍会停) |
|
||||
|
||||
## P. 已修复与剩余风险
|
||||
|
||||
**已修复(P0/P1)**:P-01 摸切、P-02 鸣牌超时、P-03 途中流局×4、P-04 流局满贯、P-05 和了终局、P-06 立直后暗杠校验、P-07 抢北、P-08 里宝/杠宝展示、P-09 食替筋、P-10 振听边界。
|
||||
**已修复(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-02 | P2 | 加杠被抢后第 4 张牌未归还手牌 | 抢杠后本局立即结束,暂无实际影响 | 若后续支持“抢杠后继续”需修 |
|
||||
| R-03 | P2 | 掷骰/开牌位置、宝牌物理位置简化 | 与真实砌墙无关紧要 | 保持简化 |
|
||||
| R-04 | P2 | 无观战、无 AI 补位 | 需满员开局 | 后续独立阶段 |
|
||||
| R-05 | P2 | 段位马点/头名赏未计算 | 仅显示场内点数 | 终局结算时补 |
|
||||
| R-06 | P2 | 听牌/振听无 UI 提示 | 体验 | 可选 |
|
||||
| R-07 | P1 | 联机 4 端 E2E 未做 | 缺回归 | 建议后续补 Playwright |
|
||||
| 编号 | 级别 | 项目 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| R-03 | P2 | 掷骰/开牌位置、宝牌物理位置 | 采用简化布局,不影响玩法 |
|
||||
| R-04 | — | 观战 + AI 补位 | 独立阶段(已确认 AI 后续再做) |
|
||||
| S-01 | P2 | 自摸牌在“摸到”与手牌行各显示一次 | 纯 UX,可后续去重 |
|
||||
| S-02 | P2 | 杠宝翻开时机为“杠成立即翻” | 与真实时序略有差异,无算分影响 |
|
||||
| S-03 | P2 | 人和(古役)不实现 | 雀魂不采用(有意) |
|
||||
| S-04 | P2 | 鸣牌超时由客户端驱动 | 任一客户端在线即可推进;全部离线则停 |
|
||||
|
||||
---
|
||||
|
||||
@@ -272,6 +289,74 @@
|
||||
|
||||
## 建议的后续优先项
|
||||
|
||||
1. **R-07 联机 4 端 E2E**(回归保障)。
|
||||
2. R-01/R-02 两个三麻/抢杠细节。
|
||||
3. R-05 终局马点/头名赏(若需要段位结算)。
|
||||
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 | 未实现,无需测试 |
|
||||
|
||||
@@ -477,3 +477,97 @@ describe('mahjong — P1-08 里宝牌揭示', () => {
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -123,10 +123,12 @@ export interface MahjongState {
|
||||
claimDiscarder: PlayerId | null;
|
||||
/** '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;
|
||||
/** 鸣牌响应的截止时间(epoch ms),用于掉线自动跳过。 */
|
||||
claimDeadline: number | null;
|
||||
/** 刚鸣牌(碰/明杠)尚未打牌者;此期间不可拔北。 */
|
||||
justCalledBy: PlayerId | null;
|
||||
/** 当前行动者因刚鸣牌而禁止打出的牌(食替)。 */
|
||||
kuikaeForbidden: number[];
|
||||
/** 自弃牌的全部牌种(不清除,含被鸣走者),用于舍张振听。 */
|
||||
@@ -141,6 +143,10 @@ export interface MahjongState {
|
||||
doraIndicators?: string[];
|
||||
/** playerView 注入:和了后揭开的里宝牌指示牌牌种(公开)。 */
|
||||
uraIndicators?: string[];
|
||||
/** playerView 注入:本人听牌牌种(公开给本人)。 */
|
||||
myWaits?: string[];
|
||||
/** playerView 注入:本人是否舍张振听。 */
|
||||
myFuriten?: boolean;
|
||||
|
||||
// 立直
|
||||
pendingRiichi: Record<PlayerId, boolean>;
|
||||
@@ -171,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 {
|
||||
@@ -475,6 +481,7 @@ function freshRoundState(G: MahjongState, random: () => number = Math.random): v
|
||||
G.pendingKakan = null;
|
||||
G.pendingNuki = null;
|
||||
G.claimDeadline = null;
|
||||
G.justCalledBy = null;
|
||||
G.kuikaeForbidden = [];
|
||||
G.furitenKinds = {};
|
||||
G.kanByPlayer = {};
|
||||
@@ -520,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;
|
||||
@@ -756,12 +764,28 @@ 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,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
@@ -832,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);
|
||||
@@ -853,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);
|
||||
@@ -887,6 +913,7 @@ 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;
|
||||
@@ -950,6 +977,7 @@ export const Mahjong: TTSGame<MahjongState> = {
|
||||
pendingKakan: null,
|
||||
pendingNuki: null,
|
||||
claimDeadline: null,
|
||||
justCalledBy: null,
|
||||
kuikaeForbidden: [],
|
||||
furitenKinds: {},
|
||||
kanByPlayer: {},
|
||||
@@ -1061,6 +1089,7 @@ 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;
|
||||
@@ -1198,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);
|
||||
@@ -1213,7 +1243,7 @@ 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;
|
||||
@@ -1229,6 +1259,7 @@ export const Mahjong: TTSGame<MahjongState> = {
|
||||
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);
|
||||
@@ -1322,10 +1353,21 @@ export const Mahjong: TTSGame<MahjongState> = {
|
||||
: [];
|
||||
// 只暴露请求者自己的鸣牌响应选项
|
||||
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,
|
||||
@@ -1419,7 +1461,11 @@ function resolveClaimWindow(G: MahjongState): void {
|
||||
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';
|
||||
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');
|
||||
@@ -1521,5 +1567,6 @@ export const MAHJONG_INTERNALS = {
|
||||
resolveClaimWindow,
|
||||
checkAbortiveDraws,
|
||||
fourKanAbort,
|
||||
finalizeRanking,
|
||||
CLAIM_TIMEOUT_MS,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user