Compare commits

...

6 Commits

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

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

验证
- engine 324 通过(新增 P0/P1 用例 11 条);engine/web tsc 干净;web vite build 通过
- 浏览器冒烟:开局/打牌/换座不重置/鸣牌窗口;故意不响应等待 >12s 自动跳过并推进;无 JS 异常
- 更新 docs/mahjong/02-verification-checklist.md 至 v2(P0+P1 全完成)
2026-10-03 15:03:59 +08:00
e2hang
2a376e142b fix(mahjong): 修复无法开局/立直卡死/本地换座重置/赤宝牌高估 + UI 冒烟
- engine: boardgame.io 注入的 random 是插件对象(非函数),freshRoundState 传入导致
  TypeError「random is not a function」,开局即失败 → 改用 Math.random(与 bridge 一致)
- engine: 赤宝牌改为按实际持有重算(手牌 red + 副露 redCount);旧实现用累计计数器,
  打出的赤 5 不归还导致和了时高估番/点
- engine: respond 校验「吃」的两张手牌必须匹配某个合法选项,防止客户端传错牌破坏状态
- ui: 立直「宣言 + 打出」绕过 250ms 防抖(原第二发被丢弃 → 玩家卡死无法出牌)
- ui: ClaimOverlay 倒计时依赖改为稳定签名 + 单次触发,避免 playerView 新对象导致反复重置/重复 pass
- ui: 本地换座位不再 remount(key 去掉 seat),改由 boardgame.io updatePlayerID 原地切换,
  避免切换视角时 local master 被销毁、整局重置
- ui: 无自摸牌(如碰/吃后)禁用「自摸」按钮
- test: 补赤宝牌回归、applyWin 点数守恒、整手随机打牌至荒牌流局;engine 313 全通过
2026-10-03 14:43:09 +08:00
e2hang
8d5fb3c511 feat(mahjong): 立直麻将(四麻/三麻,半庄战,雀魂规则)
- engine/tiles: 34 种牌、四麻 136/三麻 108(去 2m~8m)、赤 5(3/2 枚)、宝牌指示映射、排序与计数
- engine/yaku: 和牌分解(标准/七对/国士)+ 全役种(含双倍役满、累计役满)+ 符计算 + 番符→点数(满贯封顶、不采用切上满贯、三麻自摸损)+ 听牌/振听/食替
- engine/game: boardgame.io Game(阶段机 + 摸打/立直/鸣牌/和了/流局 moves + 鸣牌窗口“荣>碰/杠>吃/头跳” + playerView 隐藏 + 连庄/进庄/击飞/西入/半庄终局)
- ui: 新增 MahjongBoard(立式麻将桌:手牌/牌河/副露/宝牌/剩余张数/点数/鸣牌倒计时)
- 注册: server 两数组 + App.tsx GAMES/GAME_ORDER/GameKey/本地+联机 View(maxSeats=4, seatChoices=[3,4])
- docs: docs/mahjong/{00-rules,01-implementation-plan}.md + dev-log 15;CODEBUDDY.md 游戏清单更新
- 测试: mahjong-tiles/yaku/game 共 59 条;engine 全量 310 通过;web tsc+vite build、server tsc 通过
2026-10-03 12:43:51 +08:00
e2hang
bec06a1389 fix(pig+bridge+double-up): 拱猪全红/大满贯计分顺序 + 桥牌叫牌合法性/明手控制 + 联机 E2E
- pig: 全红翻转提前到变压器 ×2 之前;大满贯改为独立定值 800(避免被 ×2 重复计入);补 3 条单测(P-1/P-2)
- bridge: 叫品按钮按 isLegalCall 客户端预判(禁用 + data-legal + title 提示);修正庄家控制明手的时机为 currentPlayer===dummy;CardFace 加 data-playable
- double-up: Board 补 playerLevels 兜底;E2E infoNumbers 正则结果空值保护
- e2e: 新增桥牌联机 E2E 用例
2026-10-03 12:43:41 +08:00
22 changed files with 6212 additions and 46 deletions

View File

@@ -12,7 +12,7 @@ The architecture is a strict 4-layer separation: **game definitions** (pure boar
pnpm workspace (`pnpm-workspace.yaml` globs `packages/*` and `apps/*`). Each package/app has its own `package.json` with scripts; run them via `pnpm --filter @tts-like/<name> <script>` or `pnpm -r <script>` (recursive). pnpm workspace (`pnpm-workspace.yaml` globs `packages/*` and `apps/*`). Each package/app has its own `package.json` with scripts; run them via `pnpm --filter @tts-like/<name> <script>` or `pnpm -r <script>` (recursive).
- `packages/engine` — Re-exports `boardgame.io`, the `TTSGame` type (boardgame.io `Game` + custom `ui` field), and the `createLobbyClient` lobby SDK. **Holds all game definitions** in `src/games/*.ts` (`drag-test`, `war`, `mill`/Nine Men's Morris, `holdem`/Texas Hold'em, `pig`/拱猪). Game logic has vitest unit tests (`*.test.ts`). - `packages/engine` — Re-exports `boardgame.io`, the `TTSGame` type (boardgame.io `Game` + custom `ui` field), and the `createLobbyClient` lobby SDK. **Holds all game definitions** in `src/games/*.ts` (`drag-test`, `war`, `mill`/Nine Men's Morris, `holdem`/Texas Hold'em, `pig`/拱猪, `double-up`/双升, `bridge`/桥牌, `mahjong`/立直麻将). Game logic has vitest unit tests (`*.test.ts`).
- `packages/protocol` — **Types only** (no runtime). Defines `UISchema`, `ZoneDef`, `CardTemplate`, `ActionDef`, and the `resolveZoneEntities` helper used by the UI to map `game.ui.zones` to entities in `G`. - `packages/protocol` — **Types only** (no runtime). Defines `UISchema`, `ZoneDef`, `CardTemplate`, `ActionDef`, and the `resolveZoneEntities` helper used by the UI to map `game.ui.zones` to entities in `G`.
- `packages/ui` — React UI primitives (`Board`, `Zone`, `Card`, `Token`, `ActionButton`, `LobbyRoomList`). Thin wrappers driven declaratively by `game.ui`. - `packages/ui` — React UI primitives (`Board`, `Zone`, `Card`, `Token`, `ActionButton`, `LobbyRoomList`). Thin wrappers driven declaratively by `game.ui`.
- `packages/server` — Node/Koa server wrapping boardgame.io's `Server` with: `/api`-prefixed lobby routes, custom `/api/rooms/*` room management (delete/heartbeat/kick), an auth system (SQLite + JWT-style tokens), and static file serving of `apps/web/dist` in production. - `packages/server` — Node/Koa server wrapping boardgame.io's `Server` with: `/api`-prefixed lobby routes, custom `/api/rooms/*` room management (delete/heartbeat/kick), an auth system (SQLite + JWT-style tokens), and static file serving of `apps/web/dist` in production.
@@ -61,7 +61,7 @@ Each game in `packages/engine/src/games/*.ts` exports a `TTSGame` (a boardgame.i
**Adding a game** requires three coordinated edits: (1) create `packages/engine/src/games/<name>.ts`, (2) register it in `packages/server/src/index.ts` (both the `Server({ games: [...] })` array and the `GAMES_ENTRIES` array — these are separate and both needed), and (3) add an entry to the `GAMES` map in `apps/web/src/App.tsx` (with `engineName` matching the boardgame.io `name`, `maxSeats`, optional `seatChoices`/`setupOptions`). **Adding a game** requires three coordinated edits: (1) create `packages/engine/src/games/<name>.ts`, (2) register it in `packages/server/src/index.ts` (both the `Server({ games: [...] })` array and the `GAMES_ENTRIES` array — these are separate and both needed), and (3) add an entry to the `GAMES` map in `apps/web/src/App.tsx` (with `engineName` matching the boardgame.io `name`, `maxSeats`, optional `seatChoices`/`setupOptions`).
### UI is data-driven by `game.ui` ### UI is data-driven by `game.ui`
`packages/ui/src/Board.tsx` iterates `game.ui.zones`, resolves each zone's entities from `G` via `resolveZoneEntities` (protocol package; reads `zone.collection` for the ID array, `zone.entityField` for the ID→object map, defaulting to `'drawPile'`), and renders `<Zone>`/`<Card>`. Drag-drop calls `zone.dropMove` with `dropArgs`. Player-view filtering (hiding opponents' cards) is handled entirely by boardgame.io's `playerView` server-side — the UI must NOT re-implement it. Holdem/Mill/Pig use bespoke `Board` components in `apps/web/src/*Board.tsx` instead of the generic `Board`. `packages/ui/src/Board.tsx` iterates `game.ui.zones`, resolves each zone's entities from `G` via `resolveZoneEntities` (protocol package; reads `zone.collection` for the ID array, `zone.entityField` for the ID→object map, defaulting to `'drawPile'`), and renders `<Zone>`/`<Card>`. Drag-drop calls `zone.dropMove` with `dropArgs`. Player-view filtering (hiding opponents' cards) is handled entirely by boardgame.io's `playerView` server-side — the UI must NOT re-implement it. Holdem/Mill/Pig/DoubleUp/Bridge/Mahjong use bespoke `Board` components in `apps/web/src/*Board.tsx` instead of the generic `Board`.
### Unified-port networking (dev-log #12) ### Unified-port networking (dev-log #12)
The web (`:5173`) is the only port the browser/LAN sees. Vite proxies `/api` (→ `:8000/api`, rewritten to strip `/api` by `mountApiPrefix`) and `/socket.io` (ws) to the server (`:8000`). In production the server serves `apps/web/dist` itself, so a single port works. `createLobbyClient('')` uses same-origin `/api` automatically. `ALLOWED_ORIGINS` defaults to LAN ranges when unset (dev); set it explicitly in production. The web (`:5173`) is the only port the browser/LAN sees. Vite proxies `/api` (→ `:8000/api`, rewritten to strip `/api` by `mountApiPrefix`) and `/socket.io` (ws) to the server (`:8000`). In production the server serves `apps/web/dist` itself, so a single port works. `createLobbyClient('')` uses same-origin `/api` automatically. `ALLOWED_ORIGINS` defaults to LAN ranges when unset (dev); set it explicitly in production.

View File

@@ -0,0 +1,276 @@
/**
* 桥牌 — 联机 E2E(真实 UI + server + engine)。
*
* 覆盖:
* 1. 4 人加入 fixedSeats=4 的房间 → 房主点「发牌开始」→ 4 家各得 13 张。
* 2. 叫牌阶段:当前行动者按钮可用,非行动者禁用;依次 1♠ + 3 家 Pass → 进入 play。
* 3. play 阶段:明手牌对所有人公开;庄家可控制自己 + 明手。
* 4. 满墩 3 秒后 collectTrick:trickCount 推进。
* 5. 13 墩打完 → 进入 score 阶段 → 房主可发下一副。
*
* 参考:apps/web/e2e/pig-names-and-trick-delay.spec.ts(4 座房 + 联机流程模式)。
*/
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 = `br${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('bridge');
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 bidActor(seats: Seat[]): Promise<Seat | null> {
for (const s of seats) {
const text = await s.page.getByTestId(`bridge-seat-${s.pid}`).innerText().catch(() => '');
if (text.includes('叫牌中')) return s;
}
return null;
}
/** 抓取「叫牌中」玩家的所有叫品按钮 + data-legal 标记。
* 返回 { callButtons: { id, label, legal }[] } —— 用于断言「非法叫品已禁用」。 */
async function bidButtonStates(actor: Seat): Promise<Array<{ id: string; label: string; legal: boolean }>> {
return await actor.page.evaluate(() => {
const buttons = Array.from(document.querySelectorAll('[data-testid^="call-"]')) as HTMLButtonElement[];
return buttons
.filter((b) => b.tagName === 'BUTTON')
.map((b) => ({
id: b.getAttribute('data-testid') ?? '',
label: b.textContent?.trim() ?? '',
legal: b.getAttribute('data-legal') === 'true',
}));
});
}
/** 当前可出牌的玩家:找有「可点牌」(data-playable=true)的 seat。
* - 防守方:自己 seat 上有可点牌 → 自己出。
* - 庄家:自己 seat 上有可点牌(轮到自己时) OR 明手 seat 上有可点牌(轮到明手时)。
* - 明手本人:永远没有可点牌(declarer 控)。 */
async function playActor(seats: Seat[]): Promise<Seat | null> {
for (const s of seats) {
const playable = await s.page.locator('[data-playable="true"]').count();
if (playable > 0) return s;
}
return null;
}
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]!),
];
// P0 创建桥牌房间(bridge 在 GAMES 中 fixedSeats=4,自动 4 座)
await expect(async () => {
const btn = seats[0]!.page.getByTestId('lobby-create-room');
await expect(btn).toBeEnabled({ timeout: 2_000 });
await btn.click();
}).toPass({ timeout: 15_000 });
await expect(seats[0]!.page.getByTestId('bridge-waiting')).toBeVisible({ timeout: 15_000 });
// 读 P0 matchID,让其他人加入同一房间
const roomText = await seats[0]!.page.getByTestId('lobby-current-room').innerText();
const mid = roomText.match(/在房间\s+(\S+)\s+作为/)?.[1] ?? '';
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('bridge-waiting')).toBeVisible({ timeout: 15_000 });
}
// 房主开始发牌
await expect(async () => {
const btn = seats[0]!.page.getByTestId('bridge-start');
await expect(btn).toBeEnabled({ timeout: 2_000 });
await btn.click();
}).toPass({ timeout: 15_000 });
// ===== 1. 发牌完成 → 进入 bidding 阶段 =====
await expect(async () => {
for (const seat of seats) {
await expect(seat.page.getByTestId('bridge-bidding')).toBeVisible({ timeout: 5_000 });
}
}).toPass({ timeout: 15_000 });
// 每人手牌 13 张
for (const seat of seats) {
await expect(async () => {
const cardCount = await seat.page.locator('[data-testid^="card-"]').count();
expect(cardCount).toBeGreaterThanOrEqual(13);
}).toPass({ timeout: 5_000 });
}
// ===== 2. 叫牌:P0 叫 1♠,其余 3 家 Pass =====
// 找出当前 bidTurn 玩家,逐家出牌
for (let i = 0; i < 4; i++) {
await expect(async () => {
const actor = await bidActor(seats);
expect(actor).not.toBeNull();
if (!actor) return;
if (i === 0) {
// P0 (NS) 叫 1♠。第一次开叫:X/XX 应禁用(没有敌方叫品可加倍)
const states = await bidButtonStates(actor);
const xBtn = states.find((s) => s.id === 'call-double');
const xxBtn = states.find((s) => s.id === 'call-redouble');
expect(xBtn?.legal).toBe(false);
expect(xxBtn?.legal).toBe(false);
// 1♠ 应合法(第一家开叫无约束)
const bid1S = states.find((s) => s.id === 'call-bid-1♠');
expect(bid1S?.legal).toBe(true);
await actor.page.getByTestId('call-bid-1♠').click();
} else if (i === 1) {
// P1 (EW):上一叫品是 1♠。1♣..1♥ 应禁用;1♠ / Pass 合法;X 合法(敌方叫品)
const states = await bidButtonStates(actor);
expect(states.find((s) => s.id === 'call-bid-1♣')?.legal).toBe(false);
expect(states.find((s) => s.id === 'call-bid-1♦')?.legal).toBe(false);
expect(states.find((s) => s.id === 'call-bid-1♥')?.legal).toBe(false);
expect(states.find((s) => s.id === 'call-bid-1♠')?.legal).toBe(false); // 等于上一叫品 = 非法
expect(states.find((s) => s.id === 'call-bid-1NT')?.legal).toBe(true);
expect(states.find((s) => s.id === 'call-bid-2♣')?.legal).toBe(true);
expect(states.find((s) => s.id === 'call-double')?.legal).toBe(true); // X 合法
expect(states.find((s) => s.id === 'call-redouble')?.legal).toBe(false); // 没被加倍 → 非法
await actor.page.getByTestId('call-pass').click();
} else if (i === 2) {
// P2 (NS):1♠ 之后 Pass。P2 是 dummy;叫牌 +1♠ 不可能(已叫过);1NT / 2♣ 合法
const states = await bidButtonStates(actor);
expect(states.find((s) => s.id === 'call-bid-1♠')?.legal).toBe(false); // 同阶同花色
expect(states.find((s) => s.id === 'call-bid-1NT')?.legal).toBe(true);
await actor.page.getByTestId('call-pass').click();
} else {
// P3 (EW):Pass
await actor.page.getByTestId('call-pass').click();
}
}).toPass({ timeout: 10_000 });
}
// ===== 3. 进入 play 阶段:contract 1♠,declarer=0, dummy=2, openingLeader=1 =====
await expect(async () => {
for (const seat of seats) {
await expect(seat.page.getByTestId('bridge-play')).toBeVisible({ timeout: 5_000 });
// contract display 应为「契约 1♠」
await expect(seat.page.getByTestId('contract-display')).toContainText('1♠', { timeout: 5_000 });
}
}).toPass({ timeout: 15_000 });
// 明手牌对所有人公开(P2 是 dummy)。等待进入 play 阶段且明手亮出。
await expect(async () => {
for (const seat of seats) {
await expect(seat.page.getByTestId('bridge-play')).toBeVisible({ timeout: 5_000 });
const handArea = seat.page.getByTestId('bridge-hand-area');
await expect(handArea).toContainText('明手', { timeout: 5_000 });
}
}).toPass({ timeout: 15_000 });
// ===== 2.5 明手规则验证 =====
// P0 (declarer) 应能控制明手牌 — 明手牌区显示「你控制」 + dummy 牌 13 张可点(轮到 dummy 时)
const declarerPage = seats[0]!.page;
await expect(declarerPage.getByTestId('bridge-hand-area')).toContainText('明手', { timeout: 3_000 });
await expect(declarerPage.getByTestId('bridge-hand-area')).toContainText('你控制', { timeout: 3_000 });
// 明手牌 13 张可见
const dummyCardsCount = await declarerPage.locator('[data-testid^="card-"]').count();
expect(dummyCardsCount).toBeGreaterThanOrEqual(26); // 13 自己 + 13 明手
// P2 (dummy) 自己看自己的牌:明手本人不能自己出牌(不可点)
const dummySeat = seats[2]!;
// 明手本人页面:手牌区应明示「由庄家 XXX 控制(你不可出牌)」
await expect(dummySeat.page.getByTestId('bridge-hand-area')).toContainText('由庄家', { timeout: 3_000 });
await expect(dummySeat.page.getByTestId('bridge-hand-area')).toContainText('你不可出牌', { timeout: 3_000 });
// 自己的牌 13 张可见但都不可点
const dummySelfCardCount = await dummySeat.page.locator('[data-testid^="card-"]').count();
expect(dummySelfCardCount).toBeGreaterThanOrEqual(13);
const dummySelfPlayable = await dummySeat.page.locator('[data-playable="true"]').count();
expect(dummySelfPlayable).toBe(0); // 明手牌无 data-playable(不能自己出)
// 防守方 (P1) 也能看到明手牌(playerView 公开)
const defenderPage = seats[1]!.page;
await expect(defenderPage.getByTestId('bridge-hand-area')).toContainText('明手', { timeout: 3_000 });
// ===== 4. 出第一墩(4 人各出一张合法牌) =====
for (let i = 0; i < 4; i++) {
// 等到至少有 1 个 seat 有可点的牌(确保状态稳定)
await expect(async () => {
const actor = await playActor(seats);
expect(actor).not.toBeNull();
}).toPass({ timeout: 10_000 });
// 然后点击
const actor = await playActor(seats);
if (!actor) throw new Error(`第 ${i + 1} 张牌:没有 actor`);
const card = actor.page.locator('[data-playable="true"]').first();
await expect(card).toBeVisible({ timeout: 3_000 });
await card.click();
// 等一下让状态传播
await seats[0]!.page.waitForTimeout(500);
}
// ===== 5. 满墩:所有客户端看到「上一墩 · XXX 赢(3 秒后收)」提示 =====
for (const seat of seats) {
await expect(async () => {
await expect(seat.page.getByTestId('last-trick')).toBeVisible({ timeout: 5_000 });
}).toPass({ timeout: 10_000 });
}
// ===== 6. 等 3 秒后收牌:trickCount 应推进到 1 =====
await seats[0]!.page.waitForTimeout(4_000);
await expect(async () => {
for (const seat of seats) {
const boardText = await seat.page.getByTestId('bridge-play').innerText();
expect(boardText).toMatch(/第\s*2\s*\/\s*13\s*墩/);
}
}).toPass({ timeout: 10_000 });
console.log('bridge matchID:', mid);
});
});

View File

@@ -100,8 +100,8 @@ async function infoNumbers(page: Page): Promise<{ level: number; tricks: number
const lv = txt.match(/级数\s*(\S+)/); const lv = txt.match(/级数\s*(\S+)/);
const tr = txt.match(/已打\s*(\d+)\s*墩/); const tr = txt.match(/已打\s*(\d+)\s*墩/);
return { return {
level: lv ? Number(lv[1].replace(/[^0-9]/g, '')) || 0 : 0, level: lv && lv[1] ? Number(lv[1].replace(/[^0-9]/g, '')) || 0 : 0,
tricks: tr ? Number(tr[1]) : 0, tricks: tr && tr[1] ? Number(tr[1]) : 0,
}; };
} }

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

@@ -22,6 +22,7 @@ import { Holdem } from '@tts-like/engine/games/holdem';
import { Pig } from '@tts-like/engine/games/pig'; import { Pig } from '@tts-like/engine/games/pig';
import { DoubleUp } from '@tts-like/engine/games/double-up'; import { DoubleUp } from '@tts-like/engine/games/double-up';
import { Bridge } from '@tts-like/engine/games/bridge'; import { Bridge } from '@tts-like/engine/games/bridge';
import { Mahjong } from '@tts-like/engine/games/mahjong';
import { Board, ActionButton, LobbyRoomList } from '@tts-like/ui'; import { Board, ActionButton, LobbyRoomList } from '@tts-like/ui';
import { createLobbyClient, LobbyClientError } from '@tts-like/engine'; import { createLobbyClient, LobbyClientError } from '@tts-like/engine';
import type { SetupOption } from '@tts-like/ui'; import type { SetupOption } from '@tts-like/ui';
@@ -32,20 +33,21 @@ import { HoldemBoard } from './HoldemBoard.js';
import { PigBoard } from './PigBoard.js'; import { PigBoard } from './PigBoard.js';
import { DoubleUpBoard } from './DoubleUpBoard.js'; import { DoubleUpBoard } from './DoubleUpBoard.js';
import { BridgeBoard } from './BridgeBoard.js'; import { BridgeBoard } from './BridgeBoard.js';
import { MahjongBoard } from './MahjongBoard.js';
// ---------- 登录态(问题8) ---------- // ---------- 登录态(问题8) ----------
// token 存 localStorage;创建/加入房间时携带,未登录则被 server 网关拦截。 // token 存 localStorage;创建/加入房间时携带,未登录则被 server 网关拦截。
const TOKEN_KEY = 'tts-like.token'; const TOKEN_KEY = 'tts-like.token';
const USERNAME_KEY = 'tts-like.username'; const USERNAME_KEY = 'tts-like.username';
type GameKey = 'drag-test' | 'war' | 'mill' | 'holdem' | 'pig' | 'double-up' | 'bridge'; type GameKey = 'drag-test' | 'war' | 'mill' | 'holdem' | 'pig' | 'double-up' | 'bridge' | 'mahjong';
type Mode = 'local' | 'online'; type Mode = 'local' | 'online';
/** /**
* 游戏列表顺序(问题9 UI 需求):德州扑克排第一,Mill 第二,其余靠后。 * 游戏列表顺序(问题9 UI 需求):德州扑克排第一,Mill 第二,其余靠后。
* 下拉框、默认选中游戏都按这个顺序走,避免硬编码散落各处。 * 下拉框、默认选中游戏都按这个顺序走,避免硬编码散落各处。
*/ */
const GAME_ORDER: GameKey[] = ['holdem', 'mill', 'pig', 'double-up', 'bridge', 'war', 'drag-test']; const GAME_ORDER: GameKey[] = ['holdem', 'mahjong', 'mill', 'pig', 'double-up', 'bridge', 'war', 'drag-test'];
interface GameMeta { interface GameMeta {
game: any; game: any;
@@ -75,6 +77,7 @@ const GAMES: Record<GameKey, GameMeta> = {
pig: { game: Pig, name: '拱猪 (Gong Zhu)', engineName: 'GongZhu', maxSeats: 4, seatChoices: [3, 4] }, pig: { game: Pig, name: '拱猪 (Gong Zhu)', engineName: 'GongZhu', maxSeats: 4, seatChoices: [3, 4] },
'double-up': { game: DoubleUp, name: '双升 (Double Up)', engineName: 'DoubleUp', maxSeats: 4, fixedSeats: 4 }, 'double-up': { game: DoubleUp, name: '双升 (Double Up)', engineName: 'DoubleUp', maxSeats: 4, fixedSeats: 4 },
bridge: { game: Bridge, name: '桥牌 (Bridge)', engineName: 'Bridge', maxSeats: 4, fixedSeats: 4 }, bridge: { game: Bridge, name: '桥牌 (Bridge)', engineName: 'Bridge', maxSeats: 4, fixedSeats: 4 },
mahjong: { game: Mahjong, name: '立直麻将 (Riichi Mahjong)', engineName: 'Mahjong', maxSeats: 4, seatChoices: [3, 4] },
}; };
interface OnlineConfig { interface OnlineConfig {
@@ -592,6 +595,7 @@ function LocalGameView({ gameKey }: { gameKey: GameKey }) {
if (gameKey === 'pig') return <PigLocalView />; if (gameKey === 'pig') return <PigLocalView />;
if (gameKey === 'double-up') return <DoubleUpLocalView />; if (gameKey === 'double-up') return <DoubleUpLocalView />;
if (gameKey === 'bridge') return <BridgeLocalView />; if (gameKey === 'bridge') return <BridgeLocalView />;
if (gameKey === 'mahjong') return <MahjongLocalView />;
return <CardLocalView gameKey={gameKey} />; return <CardLocalView gameKey={gameKey} />;
} }
@@ -705,6 +709,7 @@ function OnlineGameView({ gameKey, config, playerName, onLeaveRoom }: { gameKey:
if (gameKey === 'pig') return <PigOnlineView config={config} playerName={playerName} onKicked={onLeaveRoom} />; if (gameKey === 'pig') return <PigOnlineView config={config} playerName={playerName} onKicked={onLeaveRoom} />;
if (gameKey === 'double-up') return <DoubleUpOnlineView config={config} playerName={playerName} onKicked={onLeaveRoom} />; if (gameKey === 'double-up') return <DoubleUpOnlineView config={config} playerName={playerName} onKicked={onLeaveRoom} />;
if (gameKey === 'bridge') return <BridgeOnlineView config={config} playerName={playerName} onKicked={onLeaveRoom} />; if (gameKey === 'bridge') return <BridgeOnlineView config={config} playerName={playerName} onKicked={onLeaveRoom} />;
if (gameKey === 'mahjong') return <MahjongOnlineView config={config} playerName={playerName} onKicked={onLeaveRoom} />;
return <CardOnlineView gameKey={gameKey} config={config} />; return <CardOnlineView gameKey={gameKey} config={config} />;
} }
@@ -1312,6 +1317,143 @@ function BridgeOnlineView({ config, playerName, onKicked }: { config: OnlineConf
); );
} }
/**
* Mahjong 本地模式:可选 3/4 人,下拉切座位模拟不同视角(隐藏信息按 playerView 剥离)。
*/
function MahjongLocalView() {
const [seat, setSeat] = useState<string>('0');
const [mode, setMode] = useState<3 | 4>(4);
const order = mode === 3 ? ['0', '1', '2'] : ['0', '1', '2', '3'];
const LocalClient = useMemo(
() =>
Client({
game: Mahjong,
numPlayers: mode,
board: ({ G, ctx, moves, playerID, isConnected }: any) => {
const callMove = (name: string, args: any[]) => (moves as any)[name]?.(...((args as any[]) ?? []));
return (
<div style={{ minHeight: '100%', background: '#07130b' }}>
<div
data-testid="mj-local-seat-bar"
style={{
display: 'flex', gap: 8, alignItems: 'center',
padding: '6px 16px', background: '#111', borderBottom: '1px solid #222',
fontSize: 12, color: '#aaa',
}}
>
<span>人数:</span>
<select
data-testid="mj-local-mode"
value={mode}
onChange={(e) => { setMode(Number(e.target.value) as 3 | 4); setSeat('0'); }}
style={selectStyle}
>
<option value={4}>四人麻将</option>
<option value={3}>三人麻将</option>
</select>
<span>当前座位:</span>
<select
data-testid="mj-local-seat-select"
value={seat}
onChange={(e) => setSeat(e.target.value)}
style={selectStyle}
>
{order.map((s) => (
<option key={s} value={s}>P{s}</option>
))}
</select>
<span style={{ opacity: 0.6 }}>切座位看不同视角</span>
</div>
<MahjongBoard
G={G}
ctx={ctx as Ctx}
playerID={playerID ?? seat}
onMove={callMove}
playerName="本地"
isConnected={isConnected}
/>
</div>
);
},
debug: true,
}),
[mode],
);
// 注意:key 只随人数变化——切换座位时让 boardgame.io 原地 updatePlayerID,
// 若把 seat 放进 key 会导致组件重挂载、local master 被销毁、整局重置。
return <LocalClient matchID="local" playerID={seat} key={`mj-local-${mode}`} />;
}
/**
* Mahjong 联机模式:3/4 人,每个 client 只能控制自己的 playerID 座位。
*/
function MahjongOnlineView({ config, playerName, onKicked }: { config: OnlineConfig; playerName: string; onKicked: () => void }) {
if (!config.matchID || !config.credentials) {
return (
<div data-testid="online-need-room" style={{ padding: 32, textAlign: 'center', color: '#aaa', fontSize: 14 }}>
请在上面的房间列表里创建或加入一个房间。
</div>
);
}
const numPlayers = Math.max(3, config.numPlayers || 4);
const OnlineClient = useMemo(
() =>
Client({
game: Mahjong,
numPlayers,
board: ({ G, ctx, moves, playerID, isConnected }: any) => {
const callMove = (name: string, args: any[]) => (moves as any)[name]?.(...((args as any[]) ?? []));
const players: Record<string, any> = (ctx as any)?.players ?? {};
const playerNames: Record<string, string> = {};
for (const pid of Object.keys(players)) {
const nm = players[pid]?.name;
if (nm) playerNames[pid] = nm;
}
return (
<div style={{ minHeight: '100%', background: '#07130b' }}>
<div
data-testid="connection-status"
data-connected={isConnected ? 'true' : 'false'}
style={{
padding: '4px 16px', fontSize: 11,
background: isConnected ? '#1a4a1a' : '#4a1a1a',
color: isConnected ? '#8f8' : '#f88',
borderBottom: '1px solid #222',
display: 'flex', gap: 12,
}}
>
<span>{isConnected ? '● 已连接' : '○ 未连接'}</span>
{ctx.gameover && <span>游戏结束</span>}
</div>
<MahjongBoard
G={G}
ctx={ctx as Ctx}
playerID={playerID ?? config.playerID}
onMove={callMove}
playerName={playerName}
playerNames={playerNames}
isConnected={isConnected}
onKicked={onKicked}
/>
</div>
);
},
multiplayer: SocketIO({ server: config.serverUrl }),
debug: false,
}),
[config.serverUrl, config.matchID, config.playerID, config.credentials, numPlayers],
);
return (
<OnlineClient
key={`${config.matchID}-${config.playerID}-${config.credentials}-${numPlayers}`}
matchID={config.matchID}
playerID={config.playerID}
credentials={config.credentials}
/>
);
}
const modeBtnStyle: React.CSSProperties = { const modeBtnStyle: React.CSSProperties = {
background: '#444', background: '#444',
color: 'white', color: 'white',

View File

@@ -15,8 +15,9 @@
import { useEffect, useMemo, useRef, useState } from 'react'; import { useEffect, useMemo, useRef, useState } from 'react';
import type { Ctx } from '@tts-like/engine'; import type { Ctx } from '@tts-like/engine';
import type { import {
BridgeState, isLegalCall as isLegalCallEngine,
type BridgeState,
Card as BridgeCard, Card as BridgeCard,
Call, Call,
BidCall, BidCall,
@@ -98,6 +99,7 @@ function CardFace({
onClick={onClick} onClick={onClick}
data-testid={`card-${card.id}`} data-testid={`card-${card.id}`}
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,
@@ -291,6 +293,12 @@ function BiddingView({ G, me, onMove, nameOf, isMyTurn }: { G: BridgeState; me:
const last3 = callHistory.slice(-3); const last3 = callHistory.slice(-3);
const last3AllPass = last3.length === 3 && last3.every((r) => r.call.kind === 'pass'); const last3AllPass = last3.length === 3 && last3.every((r) => r.call.kind === 'pass');
/** 客户端合法性预判:基于当前叫牌历史判断某个叫品是否合法。
* - 不轮到我:所有按钮 disable(外层处理)
* - 轮到我但叫品非法:单独 disable(不静默拒绝,让玩家知道为什么不能叫)
*/
const legalOf = (c: Call): boolean => isLegalCallEngine(callHistory as any, c, me).ok;
const bids: BidCall[] = []; const bids: BidCall[] = [];
for (let level = 1; level <= 7; level++) { for (let level = 1; level <= 7; level++) {
for (const suit of ['♣', '♦', '♥', '♠', 'NT'] as Trump[]) { for (const suit of ['♣', '♦', '♥', '♠', 'NT'] as Trump[]) {
@@ -442,34 +450,47 @@ function BiddingView({ G, me, onMove, nameOf, isMyTurn }: { G: BridgeState; me:
{isMyTurn ? '请选择叫品' : `等待 ${nameOf(G.bidTurn)} 叫牌…`} {isMyTurn ? '请选择叫品' : `等待 ${nameOf(G.bidTurn)} 叫牌…`}
</div> </div>
<div style={{ display: 'flex', gap: 4, marginBottom: 6 }}> <div style={{ display: 'flex', gap: 4, marginBottom: 6 }}>
<button data-testid="call-pass" onClick={() => doCall({ kind: 'pass' })} <button data-testid="call-pass"
onClick={() => doCall({ kind: 'pass' })}
disabled={!isMyTurn} disabled={!isMyTurn}
data-legal="true"
style={btnStyle('#444')}> style={btnStyle('#444')}>
Pass Pass
</button> </button>
<button data-testid="call-double" onClick={() => doCall({ kind: 'double' })} <button data-testid="call-double"
disabled={!isMyTurn} onClick={() => doCall({ kind: 'double' })}
disabled={!isMyTurn || !legalOf({ kind: 'double' })}
data-legal={legalOf({ kind: 'double' }) ? 'true' : 'false'}
title={legalOf({ kind: 'double' }) ? '加倍敌方叫品' : '当前不能加倍(无可加倍的敌方叫品)'}
style={btnStyle('#a60')}> style={btnStyle('#a60')}>
X X
</button> </button>
<button data-testid="call-redouble" onClick={() => doCall({ kind: 'redouble' })} <button data-testid="call-redouble"
disabled={!isMyTurn} onClick={() => doCall({ kind: 'redouble' })}
disabled={!isMyTurn || !legalOf({ kind: 'redouble' })}
data-legal={legalOf({ kind: 'redouble' }) ? 'true' : 'false'}
title={legalOf({ kind: 'redouble' }) ? '再加倍' : '当前不能再加倍'}
style={btnStyle('#a33')}> style={btnStyle('#a33')}>
XX XX
</button> </button>
</div> </div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(5, 1fr)', gap: 3 }}> <div style={{ display: 'grid', gridTemplateColumns: 'repeat(5, 1fr)', gap: 3 }}>
{bids.map((b) => ( {bids.map((b) => {
const legal = legalOf(b);
return (
<button <button
key={`${b.level}${b.suit}`} key={`${b.level}${b.suit}`}
data-testid={`call-bid-${b.level}${b.suit}`} data-testid={`call-bid-${b.level}${b.suit}`}
onClick={() => doCall(b)} onClick={() => doCall(b)}
disabled={!isMyTurn} disabled={!isMyTurn || !legal}
style={btnSmallStyle('#2a4a7a', !isMyTurn)} data-legal={legal ? 'true' : 'false'}
title={legal ? `${b.level}${suitLabel(b.suit)}` : '低于当前最高叫品'}
style={btnSmallStyle('#2a4a7a', !isMyTurn || !legal)}
> >
{b.level}{suitLabel(b.suit)} {b.level}{suitLabel(b.suit)}
</button> </button>
))} );
})}
</div> </div>
</div> </div>
@@ -702,8 +723,8 @@ function PlayView({ G, me, onMove, nameOf }: { G: BridgeState; me: string; onMov
</div> </div>
<div style={{ display: 'flex', gap: 3, flexWrap: 'wrap' }}> <div style={{ display: 'flex', gap: 3, flexWrap: 'wrap' }}>
{sortHand(dummyVisibleHand).map((c) => { {sortHand(dummyVisibleHand).map((c) => {
// 庄家控制明手:轮到庄家时明手牌可点 // 庄家控制明手:轮到明手时(currentPlayer === dummy),庄家可为明手出牌
const canClick = isDeclarer && currentPlayer === me && !isDummy; const canClick = isDeclarer && !isDummy && currentPlayer === dummy;
return ( return (
<CardFace <CardFace
key={`dummy-${c.id}`} key={`dummy-${c.id}`}
@@ -721,15 +742,23 @@ function PlayView({ G, me, onMove, nameOf }: { G: BridgeState; me: string; onMov
<div> <div>
<div style={{ fontSize: 11, color: '#aaa', marginBottom: 4 }}> <div style={{ fontSize: 11, color: '#aaa', marginBottom: 4 }}>
{isDummy ? `你的牌(明手,不可自己出)` : `你的手牌(${myVisibleHand.length} 张)`} {isDummy ? `你的牌(明手,不可自己出)` : `你的手牌(${myVisibleHand.length} 张)`}
{currentPlayer === me && !isDummy && ( {/* 轮到我自己:不论我是 declarer/defender,只要 currentPlayer === me 都能为自己出牌 */}
{/* 庄家控明手(currentPlayer === dummy)时,自己牌的提示就隐藏;只可点 dummy 牌 */}
{!isDummy && currentPlayer === me && (
<span style={{ color: '#fc6', marginLeft: 8 }}> <span style={{ color: '#fc6', marginLeft: 8 }}>
← 轮到你{leadSuit && myVisibleHand.some((c) => c.suit === leadSuit) ? `(须跟 ${leadSuit})` : ''} ← 轮到你{leadSuit && myVisibleHand.some((c) => c.suit === leadSuit) ? `(须跟 ${leadSuit})` : ''}
</span> </span>
)} )}
{!isDummy && isDeclarer && currentPlayer === dummy && (
<span style={{ color: '#fc6', marginLeft: 8 }}>
← 轮到你为明手出牌{leadSuit && dummyVisibleHand.some((c) => c.suit === leadSuit) ? `(须跟 ${leadSuit})` : ''}
</span>
)}
</div> </div>
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}> <div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
{sortHand(myVisibleHand).map((c) => { {sortHand(myVisibleHand).map((c) => {
const canClick = currentPlayer === me && !isDummy; // 自己手牌可点:轮到 me 自己(不管我是 declarer 还是 defender;明手不可点自己的牌)
const canClick = !isDummy && currentPlayer === me;
return ( return (
<CardFace <CardFace
key={c.id} key={c.id}

View File

@@ -162,6 +162,7 @@ export function DoubleUpBoard({ G: rawG, ctx, playerID, onMove, playerName, play
revealed: realG.revealed ?? { '0': [], '1': [], '2': [], '3': [] }, revealed: realG.revealed ?? { '0': [], '1': [], '2': [], '3': [] },
revealedOwner: realG.revealedOwner ?? null, revealedOwner: realG.revealedOwner ?? null,
playerNames: realG.playerNames ?? {}, playerNames: realG.playerNames ?? {},
playerLevels: realG.playerLevels ?? { '0': 2, '1': 2, '2': 2, '3': 2 },
level: realG.level ?? 2, level: realG.level ?? 2,
trumpSuit: realG.trumpSuit ?? null, trumpSuit: realG.trumpSuit ?? null,
trumpDecided: realG.trumpDecided ?? false, trumpDecided: realG.trumpDecided ?? false,

View File

@@ -0,0 +1,717 @@
/**
* MahjongBoard — 立直麻将牌桌 UI(参照 BridgeBoard 的椭圆桌 + 座位卡思路,重设计为立式麻将桌)。
*
* 视角:自己手牌在底部;下家在右、对家在上、上家在左(三麻无对家,另一家置左)。
* 隐藏信息由引擎 playerView 负责,本组件不做二次隐藏。
*
* 阶段:waiting → playing → roundEnd → gameEnd。
* playing 的 turnStep:'discard'(可打牌/立直/自摸/杠/拔北)|'claim'(等鸣牌响应)。
*/
import { useEffect, useRef, useState } from 'react';
import type { Ctx } from '@tts-like/engine';
import type { ClaimType, MahjongState, PendingClaim } from '@tts-like/engine/games/mahjong';
import { kindToIndex, sortTiles, tileLabel } from '@tts-like/engine/games/mahjong-tiles';
interface Props {
G: any;
ctx: Ctx;
playerID: string;
onMove: (name: string, args: any[]) => void;
playerName?: string;
playerNames?: Record<string, string>;
isConnected?: boolean;
onKicked?: () => void;
}
const TABLE_W = 1040;
const TABLE_H = 780;
const CX = TABLE_W / 2;
const CY = TABLE_H / 2;
const SUIT_COLOR: Record<string, string> = { m: '#222', p: '#1565c0', s: '#2e7d32', z: '#b71c1c' };
function tileColor(kind: string): string {
const k = kind === '0m' ? '5m' : kind === '0p' ? '5p' : kind === '0s' ? '5s' : kind;
return SUIT_COLOR[k[1] ?? 'z'] ?? '#222';
}
function isRed(kind: string): boolean {
return kind === '0m' || kind === '0p' || kind === '0s';
}
function TileFace({
kind,
size = 'normal',
onClick,
selected,
dim,
title,
}: {
kind: string;
size?: 'tiny' | 'small' | 'normal' | 'big';
onClick?: () => void;
selected?: boolean;
dim?: boolean;
title?: string;
}) {
const dims = { tiny: [22, 30], small: [28, 38], normal: [34, 46], big: [46, 64] }[size];
const [w, h] = dims;
const fs = size === 'big' ? 18 : size === 'normal' ? 15 : size === 'small' ? 12 : 10;
const red = isRed(kind);
return (
<div
onClick={onClick}
title={title}
data-testid={`mj-tile-${kind}`}
data-selected={selected ? 'true' : 'false'}
data-playable={onClick ? 'true' : 'false'}
style={{
width: w,
height: h,
background: '#fffdf5',
color: red ? '#d00' : tileColor(kind),
border: selected ? '2px solid #fc6' : '1px solid #b7a888',
borderBottom: '3px solid #b7a888',
borderRadius: 4,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontFamily: 'monospace',
fontWeight: 'bold',
fontSize: fs,
boxShadow: '0 1px 2px rgba(0,0,0,0.4)',
opacity: dim ? 0.4 : 1,
cursor: onClick ? 'pointer' : 'default',
flexShrink: 0,
userSelect: 'none',
lineHeight: 1,
}}
>
{tileLabel(kind)}
</div>
);
}
function TileBack({ size = 'small' }: { size?: 'small' | 'normal' }) {
const [w, h] = size === 'normal' ? [34, 46] : [24, 34];
return (
<div
style={{
width: w,
height: h,
background: 'linear-gradient(135deg,#2e7d32,#1b5e20)',
border: '1px solid #0d3311',
borderRadius: 4,
boxShadow: '0 1px 2px rgba(0,0,0,0.5)',
flexShrink: 0,
}}
/>
);
}
function WindBadge({ wind, isDealer }: { wind: number; isDealer: boolean }) {
const label = ['東', '南', '西', '北'][wind - 1] ?? '?';
return (
<span style={{ color: isDealer ? '#fc6' : '#8cf', fontWeight: 'bold' }}>
{label}
{isDealer ? '(亲)' : ''}
</span>
);
}
export function MahjongBoard({ G: rawG, ctx, playerID, onMove, playerName, playerNames }: Props) {
let realG: any = rawG;
if (rawG && typeof rawG === 'object') {
if ('myHand' in rawG || ('hands' in rawG && !('G' in rawG))) realG = rawG;
else if (rawG.G && typeof rawG.G === 'object') realG = rawG.G;
}
if (!realG) return <div data-testid="mj-loading" style={{ padding: 32, color: '#aaa' }}>加载中…</div>;
const G: MahjongState = realG as MahjongState;
const me = playerID ?? '0';
const phase = ctx.phase ?? 'waiting';
const order = (G.mode === 3 ? ['0', '1', '2'] : ['0', '1', '2', '3']).filter((p) => p in (G.hands ?? { '0': 1 } as any));
const seats = order.length > 0 ? order : ['0', '1', '2', '3'];
const nameOf = (pid: string): string => {
if (G.playerNames?.[pid]) return G.playerNames[pid];
if (playerNames?.[pid]) return playerNames[pid];
if (pid === me && playerName) return playerName;
return `玩家${Number(pid) + 1}`;
};
const onMoveRef = useRef(onMove);
onMoveRef.current = onMove;
const nameWritten = useRef(false);
useEffect(() => {
const nm = playerName || G.playerNames?.[me];
if (nm && !nameWritten.current) {
nameWritten.current = true;
onMoveRef.current('setName', [me, nm]);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [me]);
if (phase === 'waiting') return <WaitingView G={G} me={me} onMove={onMove} isHost={me === '0'} />;
if (phase === 'playing' || phase === 'roundEnd' || phase === 'gameEnd') {
return <TableView G={G} ctx={ctx} me={me} seats={seats} onMove={onMove} nameOf={nameOf} />;
}
return <div style={{ padding: 32, color: '#aaa' }}>未知阶段:{phase}</div>;
}
// ============================================================
// waiting
// ============================================================
function WaitingView({ G, me, onMove, isHost }: { G: MahjongState; me: string; onMove: (n: string, a: any[]) => void; isHost: boolean }) {
return (
<div style={{ minHeight: 300, display: 'flex', alignItems: 'center', justifyContent: 'center', background: '#0a0f0a' }}>
<div data-testid="mj-waiting" style={{ textAlign: 'center', color: '#eee', fontFamily: 'monospace', padding: 24, background: 'rgba(0,0,0,0.6)', border: '1px solid #2e7d32', borderRadius: 10 }}>
<div style={{ fontSize: 18, color: '#fc6', marginBottom: 8 }}>立直麻将 · {G.mode === 3 ? '三麻' : '四麻'} · 半庄战</div>
<div style={{ fontSize: 12, color: '#aaa', marginBottom: 4 }}>起始 {G.startScore} · 返点 {G.returnScore}</div>
<div style={{ fontSize: 12, color: '#aaa', marginBottom: 16 }}>需要 {G.mode} 名真人玩家</div>
{isHost && !G.started && (
<button data-testid="mj-start" onClick={() => onMove('startGame', [])} style={btn('#2e7d32')}>
开始对局
</button>
)}
{!isHost && <div style={{ fontSize: 12, color: '#8f8' }}>等待房主开始…</div>}
</div>
</div>
);
}
// ============================================================
// 主牌桌
// ============================================================
function TableView({
G,
ctx,
me,
seats,
onMove,
nameOf,
}: {
G: MahjongState;
ctx: Ctx;
me: string;
seats: string[];
onMove: (n: string, a: any[]) => void;
nameOf: (p: string) => string;
}) {
const n = seats.length;
const myIdx = seats.indexOf(me);
const relOf = (pid: string): number => (seats.indexOf(pid) - myIdx + n) % n;
const myHand = sortTiles(G.myHand ?? []);
const myMeldTiles = (G.melds?.[me] ?? []).flatMap((m) => m.tiles);
const turn = G.turn;
const isMyTurn = turn === me;
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);
const doMove = (name: string, args: any[]) => {
if (lastMoveRef.current && Date.now() - lastMoveRef.current < 250) return;
lastMoveRef.current = Date.now();
onMove(name, args);
};
const onTileClick = (tileId: string) => {
if (!isMyTurn || turnStep !== 'discard') return;
if (riichiMode) {
// 立直宣言 + 打出必须成对派发,绕过 250ms 防抖,否则第二发会被丢弃导致卡死
lastMoveRef.current = Date.now();
onMove('declareRiichi', [tileId]);
onMove('discard', [tileId]);
setRiichiMode(false);
} else {
doMove('discard', [tileId]);
}
};
// 可暗杠的牌种
const ankanKinds = (() => {
const counts: Record<number, number> = {};
for (const t of G.myHand ?? []) {
const k = kindToIndex(t.kind);
counts[k] = (counts[k] ?? 0) + 1;
}
return Object.keys(counts).map(Number).filter((k) => counts[k]! >= 4);
})();
// 可加杠
const kakanKinds = (G.melds?.[me] ?? [])
.filter((m) => m.type === 'pon')
.map((m) => m.tiles[0]!)
.filter((k) => (G.myHand ?? []).some((t) => kindToIndex(t.kind) === k));
const hasNorth = (G.myHand ?? []).some((t) => kindToIndex(t.kind) === 30);
const discardsOf = (pid: string) => G.discards?.[pid] ?? [];
const meldsOf = (pid: string) => G.melds?.[pid] ?? [];
return (
<div style={{ position: 'relative', width: TABLE_W, height: TABLE_H, margin: '0 auto', background: 'radial-gradient(circle at 50% 45%, #14532d 0%, #08240f 100%)' }}>
{/* 顶部信息条 */}
<div style={topBar}>
<span>{['', '東', '南', '西', '北'][G.bakaze]}{G.kyoku}局</span>
<span>本场 <b style={{ color: '#fc6' }}>{G.honba}</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">
宝牌指示:
{(G.doraIndicators ?? []).map((k, i) => (
<span key={i} style={{ marginLeft: 4, display: 'inline-block', verticalAlign: 'middle' }}>
<TileFace kind={k} size="tiny" />
</span>
))}
</span>
</div>
{/* 对家 / 上家 / 下家 */}
{seats.filter((p) => p !== me).map((pid) => {
const rel = relOf(pid);
const pos = seatPos(rel, G.mode);
return (
<SeatPanel
key={pid}
pid={pid}
x={pos.x}
y={pos.y}
name={nameOf(pid)}
wind={windOf(G, pid)}
isDealer={G.dealer === pid}
handCount={G.handCounts?.[pid] ?? 0}
melds={meldsOf(pid)}
riichi={!!G.riichi?.[pid]}
isTurn={turn === pid}
score={G.scores?.[pid] ?? 0}
vertical={rel === 1 || rel === n - 1}
/>
);
})}
{/* 中央牌河 */}
<div style={centerPanel}>
<div style={{ fontSize: 11, color: '#9c9', marginBottom: 4 }}>牌河</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 6 }}>
{seats.map((pid) => (
<div key={pid} data-testid={`mj-river-${pid}`} style={{ background: 'rgba(0,0,0,0.25)', borderRadius: 6, padding: 4 }}>
<div style={{ fontSize: 10, color: G.dealer === pid ? '#fc6' : '#8cf', marginBottom: 2 }}>
{nameOf(pid)}({['', '東', '南', '西'][windOf(G, pid) - 1]})· 河
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 2, maxWidth: 210 }}>
{discardsOf(pid).map((t, i) => (
<TileFace key={t.id ?? i} kind={t.kind} size="tiny" />
))}
{discardsOf(pid).length === 0 && <span style={{ fontSize: 10, color: '#666' }}>—</span>}
</div>
</div>
))}
</div>
</div>
{/* 我的副露 */}
{meldsOf(me).length > 0 && (
<div style={{ position: 'absolute', left: 16, bottom: 150, display: 'flex', gap: 8 }}>
{meldsOf(me).map((m, i) => (
<div key={i} style={{ display: 'flex', gap: 1 }}>
{m.tiles.map((k, j) => (
<TileFace key={j} kind={kindToIndexToKind(k)} size="small" />
))}
</div>
))}
</div>
)}
{/* 底部:我的手牌 + 操作 */}
<div style={{ position: 'absolute', left: '50%', bottom: 12, transform: 'translateX(-50%)', width: TABLE_W - 24, display: 'flex', justifyContent: 'space-between', alignItems: 'flex-end', gap: 12 }}>
<div data-testid="mj-myhand" style={{ display: 'flex', gap: 3, flexWrap: 'wrap', flex: 1, padding: 8, background: 'rgba(0,0,0,0.35)', borderRadius: 8 }}>
{myHand.map((t) => {
const playable = isMyTurn && turnStep === 'discard';
return (
<TileFace
key={t.id}
kind={t.kind}
size="big"
dim={!playable}
selected={riichiMode && playable}
onClick={playable ? () => onTileClick(t.id) : undefined}
title={playable ? '点击出牌' : ''}
/>
);
})}
</div>
{/* 操作区 */}
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, alignItems: 'flex-end', minWidth: 220 }}>
{isMyTurn && turnStep === 'discard' && (
<>
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', justifyContent: 'flex-end' }}>
<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)}
disabled={!G.drawnTile || (G.melds?.[me] ?? []).some((m) => m.type !== 'ankan')}
style={btn(riichiMode ? '#c62828' : '#8d6e63', !G.drawnTile || (G.melds?.[me] ?? []).some((m) => m.type !== 'ankan'))}
>
{riichiMode ? '选择宣言牌…' : '立直'}
</button>
{G.mode === 3 && hasNorth && (
<button data-testid="mj-nuki" onClick={() => doMove('nuki', [])} style={btn('#455a64')}>拔北</button>
)}
{ankanKinds.map((k) => (
<button key={`a${k}`} onClick={() => doMove('ankan', [k])} style={btn('#37474f')}>暗杠{kindToIndexToKind(k)}</button>
))}
{kakanKinds.map((k) => (
<button key={`k${k}`} onClick={() => doMove('kakan', [k])} style={btn('#37474f')}>加杠{kindToIndexToKind(k)}</button>
))}
{G.noCallsYet && (G.melds?.[me] ?? []).length === 0 && (
<button data-testid="mj-kyushu" onClick={() => doMove('kyushu9', [])} style={btn('#546e7a')}>九种九牌</button>
)}
</div>
<div style={{ fontSize: 11, color: '#9c9' }}>
{riichiMode ? '点击一张手牌:宣言立直并打出' : '你的回合:点击手牌出牌'}
</div>
</>
)}
{isMyTurn && turnStep === 'discard' && G.drawnTile && (
<div style={{ fontSize: 11, color: '#fc6' }}>摸到:<TileFace kind={G.drawnTile.kind} size="small" /></div>
)}
{!isMyTurn && turnStep === 'discard' && (
<div style={{ fontSize: 12, color: '#aaa' }}>等待 {nameOf(turn)} 出牌…</div>
)}
{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>
{/* 鸣牌响应 */}
{myClaim && <ClaimOverlay claim={myClaim} onMove={doMove} claimTile={G.claimTile} />}
{/* 结算覆盖层 */}
{(ctx.phase === 'roundEnd' || ctx.phase === 'gameEnd') && (
<RoundEndOverlay G={G} ctx={ctx} me={me} seats={seats} onMove={doMove} nameOf={nameOf} />
)}
</div>
);
}
function windOf(G: MahjongState, pid: string): number {
const order = G.mode === 3 ? ['0', '1', '2'] : ['0', '1', '2', '3'];
const di = order.indexOf(G.dealer);
const pi = order.indexOf(pid);
return ((pi - di + order.length) % order.length) + 1;
}
function seatPos(rel: number, mode: 3 | 4): { x: number; y: number } {
if (mode === 3) {
if (rel === 1) return { x: CX + 400, y: CY };
if (rel === 2) return { x: CX - 400, y: CY };
return { x: CX, y: CY + 300 };
}
if (rel === 1) return { x: CX + 410, y: CY };
if (rel === 2) return { x: CX, y: CY - 280 };
if (rel === 3) return { x: CX - 410, y: CY };
return { x: CX, y: CY + 300 };
}
function kindToIndexToKind(k: number): string {
// 索引 → 牌种;赤牌在副露中不区分
const kinds = [
'1m', '2m', '3m', '4m', '5m', '6m', '7m', '8m', '9m',
'1p', '2p', '3p', '4p', '5p', '6p', '7p', '8p', '9p',
'1s', '2s', '3s', '4s', '5s', '6s', '7s', '8s', '9s',
'1z', '2z', '3z', '4z', '5z', '6z', '7z',
];
return kinds[k] ?? '1z';
}
function SeatPanel({
pid,
x,
y,
name,
wind,
isDealer,
handCount,
melds,
riichi,
isTurn,
score,
vertical,
}: {
pid: string;
x: number;
y: number;
name: string;
wind: number;
isDealer: boolean;
handCount: number;
melds: { tiles: number[] }[];
riichi: boolean;
isTurn: boolean;
score: number;
vertical: boolean;
}) {
return (
<div
data-testid={`mj-seat-${pid}`}
style={{
position: 'absolute',
left: x,
top: y,
transform: 'translate(-50%,-50%)',
background: isTurn ? '#3a2a1a' : '#10241a',
border: `2px solid ${isTurn ? '#fc6' : '#2e7d32'}`,
borderRadius: 8,
padding: '6px 10px',
color: '#eaf6ff',
fontFamily: 'monospace',
fontSize: 12,
textAlign: 'center',
boxShadow: isTurn ? '0 0 12px #fc6' : 'none',
zIndex: 5,
maxWidth: 200,
}}
>
<div><WindBadge wind={wind} isDealer={isDealer} /> · {name} {riichi ? '· 🔴立直' : ''}</div>
<div style={{ marginTop: 4, display: 'flex', gap: 2, justifyContent: 'center', flexWrap: 'wrap' }}>
{Array.from({ length: Math.min(handCount, 14) }).map((_, i) => (
<TileBack key={i} />
))}
</div>
{melds.length > 0 && (
<div style={{ marginTop: 4, display: 'flex', gap: 6, justifyContent: 'center', flexWrap: 'wrap' }}>
{melds.map((m, i) => (
<div key={i} style={{ display: 'flex', gap: 1 }}>
{m.tiles.map((k, j) => (
<TileFace key={j} kind={kindToIndexToKind(k)} size="tiny" />
))}
</div>
))}
</div>
)}
<div style={{ marginTop: 4, fontSize: 11, color: '#fc6' }}>{score} 点 · 手{handCount}</div>
{vertical ? null : null}
</div>
);
}
function ClaimOverlay({ claim, onMove, claimTile }: { claim: PendingClaim; onMove: (n: string, a: any[]) => void; claimTile: any }) {
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(() => {
actedRef.current = false;
setSecs(10);
const iv = setInterval(() => setSecs((s) => (s > 0 ? s - 1 : 0)), 1000);
return () => clearInterval(iv);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [sig]);
useEffect(() => {
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 }}>
<div style={{ background: '#10241a', border: '2px solid #fc6', borderRadius: 10, padding: 20, fontFamily: 'monospace', color: '#eee', textAlign: 'center' }}>
<div style={{ fontSize: 14, color: '#fc6', marginBottom: 6 }}>
可鸣牌 {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={() => act('ron')} style={btn('#b71c1c')}>荣和</button>}
{claim.options.some((o) => o.type === 'pon') && (
<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={() => act('kan')} style={btn('#4527a0')}>杠</button>
)}
{chiOptions.map((o, i) => (
<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={() => act('pass')} style={btn('#555')}>跳过</button>
</div>
</div>
</div>
);
}
function RoundEndOverlay({
G,
ctx,
me,
seats,
onMove,
nameOf,
}: {
G: MahjongState;
ctx: Ctx;
me: string;
seats: string[];
onMove: (n: string, a: any[]) => void;
nameOf: (p: string) => string;
}) {
const isHost = me === '0';
return (
<div style={{ position: 'absolute', inset: 0, background: 'rgba(0,0,0,0.55)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 30 }}>
<div style={{ background: '#10241a', border: '2px solid #2e7d32', borderRadius: 10, padding: 20, fontFamily: 'monospace', color: '#eee', minWidth: 420 }}>
<div style={{ fontSize: 16, color: '#fc6', marginBottom: 10, textAlign: 'center' }}>
{G.gameOver ? '对局结束' : `${['', '東', '南', '西', '北'][G.bakaze]}${G.kyoku}局 结算`}
</div>
{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}符 ·{' '}
{w.yaku.filter((y) => y.han > 0 || y.yakuman).map((y) => y.name).join(' ')}
{w.doraHan > 0 ? ` +宝牌${w.doraHan}` : ''} · {w.points}点
</div>
))}
<div style={{ margin: '10px 0', display: 'grid', gridTemplateColumns: `repeat(${seats.length},1fr)`, gap: 6 }}>
{seats.map((pid) => (
<div key={pid} style={{ padding: 6, background: '#173a27', borderRadius: 6, fontSize: 12, textAlign: 'center' }}>
<div>{nameOf(pid)}</div>
<div style={{ color: '#fc6', fontWeight: 'bold' }}>{G.scores?.[pid] ?? 0}</div>
</div>
))}
</div>
{G.gameOver && G.finalRanking && (
<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 && (
<div style={{ textAlign: 'center' }}>
<button data-testid="mj-next-round" onClick={() => onMove('nextRound', [])} style={btn('#2e7d32')}>
{G.gameOver ? '结束' : '下一局'}
</button>
</div>
)}
{!isHost && <div style={{ textAlign: 'center', fontSize: 12, color: '#8f8' }}>等待房主继续…</div>}
{ctx.phase === 'gameEnd' && <div style={{ textAlign: 'center', fontSize: 12, color: '#fc6', marginTop: 8 }}>已结束</div>}
</div>
</div>
);
}
// ============================================================
// 样式
// ============================================================
const topBar: React.CSSProperties = {
position: 'absolute',
top: 8,
left: '50%',
transform: 'translateX(-50%)',
padding: '5px 16px',
background: 'rgba(0,0,0,0.7)',
border: '1px solid #2e7d32',
borderRadius: 6,
color: '#eee',
fontFamily: 'monospace',
fontSize: 13,
display: 'flex',
gap: 14,
alignItems: 'center',
zIndex: 10,
};
const centerPanel: React.CSSProperties = {
position: 'absolute',
left: CX,
top: CY,
transform: 'translate(-50%,-50%)',
width: 460,
padding: 10,
background: 'rgba(0,0,0,0.4)',
border: '1px dashed #4a7',
borderRadius: 10,
color: '#dfe',
fontFamily: 'monospace',
};
function btn(bg: string, disabled = false): React.CSSProperties {
return {
padding: '6px 12px',
background: disabled ? '#333' : bg,
color: disabled ? '#777' : '#fff',
border: 'none',
borderRadius: 4,
cursor: disabled ? 'not-allowed' : 'pointer',
fontSize: 12,
fontFamily: 'monospace',
};
}
export default MahjongBoard;

View File

@@ -0,0 +1,34 @@
# 15 · 立直麻将(Mahjong)游戏
> **做什么**(用户要求)→ **遇到什么问题** → **怎么解决**
## 做什么
在 tts-like 中加入**日本立直麻将**,规则采用**雀魂标准**,覆盖**四麻 + 三麻**、**半庄战**、**纯真人**(AI 后续)。
规则文档先行:`docs/mahjong/00-rules.md`(玩法依据 + 选型)、`docs/mahjong/01-implementation-plan.md`(工程拆分)。
UI 参照桥牌 `BridgeBoard.tsx` 的思路二次设计为立式麻将桌。
## 实现结构
- `packages/engine/src/games/mahjong-tiles.ts` — 牌具:34 种牌、四麻 136/三麻 108 去 **2m~8m**、赤 5(四麻 3 / 三麻 2)、宝牌指示映射、排序与计数。测试 `mahjong-tiles.test.ts`。
- `packages/engine/src/games/mahjong-yaku.ts` — 和牌分解(标准型/七对/国士)、**全役种**(含双倍役满、累计役满)、符计算、点数(含满贯封顶、**不采用切上满贯**、亲/子/自摸/荣和、本场、三麻自摸损)、听牌/振听/食替辅助。测试 `mahjong-yaku.test.ts`。
- `packages/engine/src/games/mahjong.ts` — boardgame.io `Game`:阶段机 `waiting→playing→roundEnd→gameEnd`,moves(`startGame/declareRiichi/discard/tsumo/respond/ankan/kakan/nuki/kyushu9/nextRound/setName`),鸣牌窗口(荣>碰/杠>吃、**头跳**),`playerView` 剥离手牌/牌山/里宝。测试 `mahjong.test.ts`。
- `apps/web/src/MahjongBoard.tsx` — 麻将桌 UI(手牌/牌河/副露/宝牌指示/剩余牌数/点数/操作按钮 + 鸣牌响应倒计时)。
- 注册:`packages/server/src/index.ts` 两数组 + `apps/web/src/App.tsx`(`GAMES`/`GAME_ORDER`/`GameKey`/本地+联机 View,`maxSeats=4`、`seatChoices=[3,4]`)。
## 遇到的问题
1. **赤宝牌表达**:牌型判定用索引(0..33),索引会丢赤牌信息。→ 赤 5 用 `'0m'/'0p'/'0s'`,判定时 `normalizeKind` 归一到 `5m/5p/5s`;赤牌数单独按 `Tile.red` + `Meld.redCount` 统计。
2. **隐藏信息与剩余牌数**:`playerView` 把 `wall` 置空后 UI 无法显示“剩余张数”。→ 额外注入公开字段 `wallCount`、`doraIndicators`、`handCounts`。
3. **boardgame.io 单线程下的鸣牌并发**:真实麻将是并发申请+优先级比较。→ 用“响应窗口 + 全部响应后按 荣>碰/杠>吃 结算”建模,UI 端 8 秒倒计时自动跳过来避免卡死。
4. **立直支付时机**:先宣言后打牌,若在宣言与打牌之间自摸会漏付供托。→ 把 1000 点支付与 `riichi=true` 提前到 `declareRiichi`,`discard` 只负责进入一发。
## 结果
- `pnpm --filter @tts-like/engine test`:**310 通过**(新增麻将 59 条)。
- `apps/web` 的 `tsc --noEmit && vite build` 通过;server 类型检查通过。
- 已知**待补**(`mahjong.ts` 内有 TODO):四风连打/四家立直/三家和了/四杠散了等途中流局、流局满贯、立直后暗杠的听牌不变校验、和了终局/听牌终局的精确 All Last 判定、精确一发计时。九种九牌、荒牌流局听牌罚符、连庄/进庄、击飞、西入已实现。
## 与 §14 对接要点的落地
规则文档 §14 列的三处注册与 `playerView` 约束均已按约定实现;发牌为 `freshRoundState` 内**原子完成**(避免多客户端抢发同一 move 造成状态错乱)。

View File

@@ -22,6 +22,7 @@
| 12 | [统一端口 + 玩家命名系统](./12-unified-port-and-player-names.md) | /api 前缀、vite proxy、静态 serve、名字显示/改名 | | 12 | [统一端口 + 玩家命名系统](./12-unified-port-and-player-names.md) | /api 前缀、vite proxy、静态 serve、名字显示/改名 |
| 13 | [德州扑克(Holdem)游戏](./13-holdem-game.md) | 引擎 + 评估器 + 牌桌 UI + 可变人数大厅 + E2E | | 13 | [德州扑克(Holdem)游戏](./13-holdem-game.md) | 引擎 + 评估器 + 牌桌 UI + 可变人数大厅 + E2E |
| 14 | [双升游戏 + 单端口启动/部署](./14-double-up-game.md) | DoubleUp 引擎+UI+联机、start.sh(PORT=5173)、玩家名 server 权威、固定人数大厅 | | 14 | [双升游戏 + 单端口启动/部署](./14-double-up-game.md) | DoubleUp 引擎+UI+联机、start.sh(PORT=5173)、玩家名 server 权威、固定人数大厅 |
| 15 | [立直麻将游戏](./15-mahjong-game.md) | Mahjong 引擎(牌具/全役种/符点)+ 鸣牌窗口 + 麻将桌 UI + 三麻/半庄 |
| — | [踩坑总结](./lessons-learned.md) | 所有踩过的坑(按现象分类) | | — | [踩坑总结](./lessons-learned.md) | 所有踩过的坑(按现象分类) |
| — | [决策记录](./decisions.md) | 已锁定决策 + 日期 | | — | [决策记录](./decisions.md) | 已锁定决策 + 日期 |
@@ -45,6 +46,7 @@ docs/
│ ├── 12-unified-port-and-player-names.md │ ├── 12-unified-port-and-player-names.md
│ ├── 13-holdem-game.md │ ├── 13-holdem-game.md
│ ├── 14-double-up-game.md │ ├── 14-double-up-game.md
│ ├── 15-mahjong-game.md
│ ├── lessons-learned.md │ ├── lessons-learned.md
│ └── decisions.md │ └── decisions.md
├── impl.md 整体实现规划(高层) ├── impl.md 整体实现规划(高层)
@@ -56,6 +58,6 @@ docs/
- **架构 + 骨架**:✅ 完成 - **架构 + 骨架**:✅ 完成
- **本地 2D MVP**(拖拽 + 3 个游戏):✅ 完成 - **本地 2D MVP**(拖拽 + 3 个游戏):✅ 完成
- **联机**:✅ Phase 2(创建房间 + 房间列表 + E2E/unit 覆盖) - **联机**:✅ Phase 2(创建房间 + 房间列表 + E2E/unit 覆盖)
- **游戏生态**:✅ Holdem(德州扑克,2-9 人,含隐藏信息 + 边池);✅ DoubleUp(双升,固定 4 人,拖拉机/甩牌/抠底) - **游戏生态**:✅ Holdem(德州扑克,2-9 人,含隐藏信息 + 边池);✅ DoubleUp(双升,固定 4 人,拖拉机/甩牌/抠底);✅ Bridge(桥牌,固定 4 人);✅ Mahjong(立直麻将,四麻/三麻,半庄战,雀魂规则)
- **桌面壳(Tauri)**:⏳ Phase 4(待启动) - **桌面壳(Tauri)**:⏳ Phase 4(待启动)
- **脚本化层(玩家自写游戏)**:⏳ Phase 3(待启动) - **脚本化层(玩家自写游戏)**:⏳ Phase 3(待启动)

494
docs/mahjong/00-rules.md Normal file
View File

@@ -0,0 +1,494 @@
# 日本立直麻将(雀魂规则)玩法规则与实现选型汇总
> 本文档是给 CodeBuddy 实现日麻游戏 `packages/engine/src/games/mahjong.ts` 的玩法依据。
> 目标:**规则完全符合日本立直麻将,采用雀魂(Majsoul)标准规则**,覆盖**四人麻将(四麻)与三人麻将(三麻)**、**半庄战**。
> 来源:雀魂官方规则说明、OurPlay《雀魂麻将规则和打法》、萌娘百科/维基百科立直麻将词条、MemoJong《三麻规则一览》、天鳳/万年東風等日麻规则站。
> 所有 **【已拍板】** 标记的实现选型以本文为准;与真实日麻存在差异的条目在 §15「与标准日麻的差异/实现裁剪」中单独说明。
>
> **用户已拍板(2026-10-03)**:
> 1. **纯真人**开局(AI 补位作为后续独立阶段,本期不做)。
> 2. **半庄战**(东南战,东风+南风;不足则西入)。
> 3. **完整雀魂规则**(全部役种+役满+振听+鸣牌+赤宝+流局+连庄+立直/一发/里宝/杠宝等)。
> 4. **四麻 + 三麻** 两种人数模式。
---
## 0. 一句话定义
四(三)人围坐,用含有赤宝牌的 136(108)张牌,通过「**配牌 → 摸打(可吃碰杠)→ 立直 → 和了/流局 → 结算 → 连庄/进庄**」的循环,先做出「**役**」并满足「**番+符**」的和牌型者得分;以**半庄战**(东风战+南风战)累计点数决出顺位,点数低于 0 立即**击飞**终局。
---
## 1. 牌具与座位
### 1.1 四麻(4 人)
- **牌**:34 种 × 4 张 = **136 张**。
- 数牌:万子 `1m~9m`、筒子 `1p~9p`、索子 `1s~9s`,各 4 张。
- 字牌:风牌 `東 南 西 北`、三元牌 `白 發 中`,各 4 张。
- **赤宝牌(赤ドラ)**:**红 5 万、红 5 筒、红 5 索 各 1 张**(共 3 枚),占用对应 5 的一张位置。
- **座位**:4 人围坐,起家为 **东**,按**逆时针**(麻将的「下家」方向,即东→南→西→北)编号 P0/P1/P2/P3。
- 起家(最初亲家)= 东家;每局结束后亲家按东→南→西→北轮转。
- **自风**:每家的当前门风(东/南/西/北)。
- **场风**:当前是东风战还是南风战(场风 = 东 或 南)。
- **手牌**:每家 **13 张**(摸打时 14 张);和牌型 = 4 面子 + 1 雀头(+ 可能的杠)或七对子/国士无双。
### 1.2 三麻(3 人,雀魂友人场/段位三麻)
- **牌**:**去掉 2m~8m 共 28 张**,保留 `1m 9m` + 全筒 + 全索 + 全字牌,共 **108 张**(27 种 × 4)。
- **赤宝牌**:**红 5 筒、红 5 索 各 1 张**(共 2 枚;万子无 5,故无红 5 万)。
- **座位**:只有 **东、南、西** 三家(**无北家**)。
- **吃牌**:**不可吃**(碰、杠、拔北允许)。
- 其余流程与四麻一致,差异见 §12。
> 一张牌用字符串表示:`{ id, kind }`,如 `5m`(普通五万)、`0m`(红五万)、`1p`、`東`、`白`。红 5 用 `0m/0p/0s` 或 `r5m/r5p/r5s` 表示(实现自定,需全局一致)。
---
## 2. 场次与对局长度
### 2.1 半庄战(本实现采用)
- 打 **东风战(东 1~4 局)+ 南风战(南 1~4 局)**;庄家按东→南→西→北循环(例如东 4 局庄家为北家,之后南 1 局庄家回到东家)。
- **起始点数 25000**;**返点 30000**。
- **连庄**:亲家和了、或荒牌流局时亲家听牌 → 连庄(本场 +1,亲家不变);否则亲家下庄(本场 +1,下家做亲)。
- **顺位马(ウマ)**:1 位 +15 / 2 位 +5 / 3 位 −5 / 4 位 −15(用于段位分/最终顺位,非对局内点数);**头名赏** = 返点 − 起点 = 30000 − 25000 = 5000/人,共 20000 全部给一位(仅结算时计算)。
- **本场(積み棒)**:每本场 **300 点**(四麻),计入和了点数。
- **供托(立直棒)**:每根 **1000 点**,立直时置于桌上;和了者收走全部供托。
### 2.2 终局条件(半庄)
- **击飞(トビ)**:任一玩家点数 **< 0** 时立即终局(负分部分照算;恰好 = 0 继续)。
- **和了终局(アガリやめ)**:南 4 局(All Last)**亲家为一位**、点数 **> 30000**、且满足连庄条件时,**不再连庄**,直接终局。
- **西入(シャンボ)**:南 4 局结束(含流局)时,**若无人点数 ≥ 30000**,进入 **西风战**(西 1~4 局)加时;**加时赛一旦有人 ≥ 30000 立即终局**;最长打到西 4 局结束强制结算。
- (东风战模式——非本期默认——则为「南入」,规则同上,切到南风战加时。)
---
## 3. 配牌与摸打
1. **洗牌砌墙**:136(108)张洗匀,砌成牌墙,开牌确定「**王牌**」(最后 14 张,其中 4 张岭上牌 + 若干宝牌指示牌)。
2. **掷骰定亲开牌**:确定起家与开牌位置(实现可简化为随机亲家 + 随机墙位置)。
3. **配牌**:各家依次摸 13 张(亲家先摸第 14 张作为开局手牌)。
4. **摸打循环**:
- 当前行动者**摸一张**(自摸牌)→ 打出一张(**打牌**)。
- 打出的牌进入该家**牌河(河)**,按顺序排列。
- 下一行动者 = 下家(逆时针)。若有鸣牌,行动者改变(见 §4)。
- **摸牌顺序**:从牌墙顺序摸;杠后从**岭上牌**摸(见 §4.3)。
5. **自摸牌处理**:摸到的牌可**插入手牌**(立直后仅能摸切/暗杠,见 §5)。
6. **本场/供托**:每局开始时把上一局累积的立直棒仍留在桌上,本场数显示在中央。
---
## 4. 鸣牌(副露)
鸣牌后手牌**不再门清**(影响立直、门前清自摸和、平和、一杯口、二杯口等)。
### 4.1 吃(チー)
- **仅能吃上家**(左手,逆时针上一位)打出的牌。
- 用手中两张牌 + 上家弃牌组成顺子;**顺子不得重复吃**导致食替(见 4.5)。
- **三麻不可吃**。
- 吃后由**吃牌者**打出下一张。
### 4.2 碰(ポン)
- 任意玩家可碰**任意出牌者**打出的牌,用手中两张同种牌组成刻子。
- **碰优先于吃**;同一张牌多人可鸣时**碰/杠优先于吃**。
- 碰后由碰牌者打出下一张。
- **三麻**:碰后**不可立即拔北**(拔北近似鸣牌,需先打牌)。
### 4.3 杠(カン)
| 类型 | 说明 | 新宝牌 | 岭上 |
| --- | --- | --- | --- |
| **大明杠** | 手中 3 张 + 他家弃牌 | ✅ 翻开新宝牌指示牌 | 从岭上摸 1 张 |
| **加杠** | 已碰的刻子 + 摸到的第 4 张 | ✅ | 从岭上摸 1 张(可被**抢杠**) |
| **暗杠** | 手中 4 张(门清) | ✅(一般规则) | 从岭上摸 1 张(可被国士**抢杠**) |
- 杠后从**岭上牌**补摸;若和了 → **岭上开花**(1 番)。
- **四杠散了**:若 4 个杠分属不同玩家(或含 1 暗杠共 4 杠)→ 途中流局(见 §10)。
- **加杠**的牌可被**抢杠(チャンカン)**:同一张牌他家和了则加杠不成立,和了者算抢杠(1 番);他人枪杠失败则加杠继续。
### 4.4 拔北(三麻专有)
- 摸到 **北** 时可**拔北**:把北亮出置于自留区,**从岭上补摸一张**。
- 每张拔出的北计为 **1 张宝牌(抜きドラ)**。
- **拔北后自摸和了视为岭上开花**(因补牌来自岭上)。
- 拔北近似鸣牌;**碰后不可立即拔北**。
- **抢北**:他家可对「拔北」宣言**荣和**(相当于抢杠,**非役满牌型也可抢**);北牌被拔时若他家能和 → 拔北不成立。
- 北若**作为面子**使用 → 按**客风**计算(不构成役牌役)。
- 三麻**无北家**,故**无四风连打、无四家立直**流局。
### 4.5 禁止食替(食い替え禁止)
- **现物食替禁止**:吃/碰后,**不能立即打出**与所鸣牌同种的牌(例如吃 `3m4m` 鸣 `2m` 后不能马上打 `2m`)。
- **筋食替禁止**:吃两面/嵌张后有对应的禁止打牌(如吃 `2m3m` 鸣 `1m`/`4m` 后不能打 `4m`/`1m` 等)。
- **雀魂采用食替禁止(无食替)**。实现时在鸣牌 move 中校验。
- 碰、杠无食替限制。
---
## 5. 立直(リーチ)
- **条件**:**门清**(未吃/碰/明杠)且**听牌**,且**点数 ≥ 1000**(付出一根立直棒)。
- **宣言**:在自摸后打牌前宣言立直,并**横置一张立直宣言牌**(若该牌被鸣,则立直仍成立,宣言牌被鸣走;若因此**破坏听牌**——雀魂允许「振听立直」情形见下)。
- 雀魂:宣言牌被吃/碰/杠时,**立直照常成立**(立直棒已支付)。若宣言牌被鸣后手牌仍听牌则正常;若变成不听,一般规则不允许(实现需校验仍听牌,否则拒绝立直)。
- **费用**:支付 **1000 点**作为供托。
- **限制**:立直后**只能摸切或暗杠**(暗杠不得改变听牌型,且所暗杠牌不破坏听牌结构);**不能吃碰明杠**。
- **一発(イッパツ)**:立直后**一巡内**和了,且期间**无任何鸣牌(含拔北)** → +1 番。
- **里宝牌(裏ドラ)**:立直和了时,翻开宝牌指示牌**下方**的里宝牌指示牌,对应牌每张 +1 番。
- **双立直(ダブルリーチ)**:**第一巡(无人鸣牌前)**立直 → 2 番。
- **四家立直**:四家全部立直(宣言牌无人和)→ **途中流局**(三麻无此,因只有三家)。
---
## 6. 和了与特殊和了
### 6.1 和了方式
- **自摸(ツモ)**:自己摸到和了牌,其余各家付点(亲家自摸由三家平摊;子家自摸由亲家付 2 倍、其余闲家各付 1 倍)。
- **荣和(ロン)**:他家打出的牌(或加杠牌、拔北牌)使自己成和牌型,由**放铳者**付全部点数(含本场)。
- **和了条件**:必须**至少有一个役**(无役不能和,宝牌不算役)。门前清自摸和有役。
### 6.2 振听(フリテン)
以下情况**不能荣和**(只能自摸):
1. **舍张振听**:自己的牌河中(含被鸣走前的鸣牌)有**自己听牌所需的牌**(任意一张听牌即振听全部)。
2. **同巡振听**:**本轮内**他家打出的和了牌被自己放过(未荣和),则本轮内不能再荣和(下一巡解除)。
3. **立直后振听**:**立直后**他家打出的和了牌被放过 → 立直期间持续振听(转听也保持),只能自摸。
- 振听仅限制**荣和**;**自摸不受影响**。
- **振听立直**:听牌但处于舍张振听时仍可立直(雀魂允许),立直后依然只能自摸。
### 6.3 一炮多响与头跳
- 一张牌可被多人同时荣和(**一炮多响/双响**)。
- **雀魂采用头跳(アタマハネ/截和)**:只承认**按出牌者逆时针顺序最近的一家**荣和;其和了成立,其余不成立。
- **供托与本场**全部归头跳和了者。
### 6.4 抢杠与抢北
- **抢杠**:他家对**加杠**的牌荣和 → 加杠不成立,和了者 +1 番(抢杠)。
- **抢北(三麻)**:对他家**拔北**的北荣和(非役满牌型也可)。
### 6.5 特殊和了
- **岭上开花(リンシャン)**:杠/拔北后从岭上补摸的牌自摸 → +1 番。
- **海底摸月(ハイテイ)**:牌墙**最后一张**自摸和了 → +1 番。
- **河底捞鱼(ホウテイ)**:牌墙**最后一张**被打出后荣和 → +1 番。
- **天和(テンホウ)**:亲家配牌后**立即**自摸和了 → 役满。
- **地和(チーホウ)**:子家第一巡自摸和了,且期间无人鸣牌 → 役满。
---
## 7. 役种完整表(雀魂)
> 「食下」= 副露时降番。宝牌(含赤、里、拔北)**不计入役**,仅在和了后加算番数。
### 7.1 一番役
| 役种 | 门清 | 食下 | 说明 |
| --- | --- | --- | --- |
| 立直 | ✅必 | — | 门清听牌宣言,付 1000 点 |
| 一发 | ✅必 | — | 立直后一巡内和了且无鸣牌 |
| 门前清自摸和 | ✅必 | — | 门清状态下自摸和了 |
| 平和(ピンフ) | ✅必 | — | 4 顺子+非役牌雀头+两面听;**固定 20 符** |
| 断幺九(タンヤオ) | ✅ | ✅ | 全手无幺九牌与字牌(雀魂**有食断**) |
| 一杯口(イーペーコー) | ✅必 | — | 两组完全相同的顺子 |
| 役牌·场风 | ✅ | ✅ | 场风刻子 |
| 役牌·自风 | ✅ | ✅ | 自风刻子(与场风重合=**连风**,计 2 番) |
| 役牌·三元牌 | ✅ | ✅ | 白/發/中刻子(各 1 番) |
| 岭上开花 | ✅ | ✅ | 杠/拔北后岭上自摸 |
| 抢杠 | ✅ | ✅ | 抢他家加杠 |
| 海底摸月 | ✅ | ✅ | 牌墙最后一张自摸 |
| 河底捞鱼 | ✅ | ✅ | 牌墙最后一张荣和 |
### 7.2 二番役
| 役种 | 门清 | 食下 | 说明 |
| --- | --- | --- | --- |
| 双立直 | ✅必 | — | 第一巡立直 |
| 三色同顺(三色同順) | ✅ | 1 番 | 三种花色同一顺子 |
| 一气通贯(一気通貫) | ✅ | 1 番 | 同花色 `123+456+789` |
| 混全带幺九(チャンタ) | ✅ | 1 番 | 全部面子/雀头含幺九或字牌(且含字牌) |
| 七对子(チートイツ) | ✅必 | — | 7 个对子;**固定 25 符** |
| 对对和(トイトイ) | ✅ | ✅ | 4 刻子+雀头 |
| 三暗刻 | ✅ | ✅ | 3 个暗刻(荣和的三暗刻型仍成立) |
| 三色同刻 | ✅ | ✅ | 三种花色同一数字刻子 |
| 三杠子 | ✅ | ✅ | 3 个杠 |
| 混老头(ホンロウトウ) | ✅ | ✅ | 仅由幺九牌与字牌组成(对对型或七对型) |
| 小三元 | ✅ | ✅ | 两种三元刻子 + 第三种三元对子 |
### 7.3 三番役
| 役种 | 门清 | 食下 | 说明 |
| --- | --- | --- | --- |
| 二杯口(リャンペーコー) | ✅必 | — | 两组一杯口(含七对型时按二杯口) |
| 纯全带幺九(ジュンチャン) | ✅ | 2 番 | 全部面子/雀头含幺九,**不含字牌** |
| 混一色(ホンイツ) | ✅ | 2 番 | 一种花色 + 字牌 |
### 7.4 六番役
| 役种 | 门清 | 食下 | 说明 |
| --- | --- | --- | --- |
| 清一色(チンイツ) | ✅ | 5 番 | 仅一种花色 |
### 7.5 役满(ヤクマン)
| 役种 | 倍数 | 说明 |
| --- | --- | --- |
| 国士无双 | 1 | 13 种幺九/字牌各 1 + 其中一种成对 |
| 四暗刻 | 1 | 4 个暗刻;**自摸**成立(荣和一般算三暗刻) |
| 大三元 | 1 | 白發中三刻子 |
| 小四喜 | 1 | 三风刻子 + 第四风对子(雀魂计役满) |
| 大四喜 | **2(双倍)** | 四风刻子 |
| 字一色 | 1 | 全字牌 |
| 绿一色 | 1 | 仅 `2s3s4s6s8s` + `發` |
| 清老头(チンロウトウ) | 1 | 仅幺九牌,无字牌 |
| 九莲宝灯 | 1 | 同花色 `1112345678999` + 任意同花一张 |
| 纯正九莲宝灯 | **2(双倍)** | 九莲**只听 9 面**(`1112345678999` 和该花任意张) |
| 四杠子 | 1 | 4 个杠 |
| 天和 | 1 | 亲家配牌即和 |
| 地和 | 1 | 子家第一巡自摸且无鸣牌 |
| 国士无双十三面 | **2(双倍)** | 国士只听 13 面 |
| 四暗刻单骑 | **2(双倍)** | 四暗刻+单骑听 |
| 累计役满 | 1 | 非役满牌型番数 ≥ 13(**不叠加双倍**) |
> **双倍役满**:雀魂采用(四暗刻单骑、国士十三面、纯正九莲、大四喜)。**复合役满**可叠加(如字一色+四暗刻)。**累计役满**封顶 1 倍。
### 7.6 不采用的役/古役
- **人和(レンホウ)**:非标准役,雀魂段位**不采用**(实现不判定)。
- **其他古役**(十二落抬、十五本場、三連刻等):不采用。
---
## 8. 宝牌(ドラ)
| 类型 | 触发 | 说明 |
| --- | --- | --- |
| 表宝牌(ドラ) | 开局 | 翻开宝牌指示牌的「下一张」为宝牌(`9→1` 循环,风 `北→東`,三元 `中→白`) |
| 里宝牌(裏ドラ) | 立直和了 | 看指示牌下方一层 |
| 赤宝牌(赤ドラ) | 固定 | 红 5:四麻 3 枚、三麻 2 枚,各计 1 番 |
| 杠宝牌(カンドラ) | 每次杠 | 翻开新的宝牌指示牌 |
| 杠里宝牌(カン裏) | 杠 + 立直和了 | 对应里指示牌 |
| 拔北宝牌(三麻) | 每次拔北 | 每张拔出的北 +1 番 |
- **指示牌 → 宝牌**:数牌按 `1→2→…→9→1` 循环;风牌 `東→南→西→北→東`;三元牌 `白→發→中→白`。
- 宝牌**不是役**:没有役时即便有宝牌也不能和。
- **赤宝牌可作普通 5 使用**(兼具宝牌与顺子/刻子功能)。
---
## 9. 符计算与点数
### 9.1 符(符計算)
| 项目 | 符 |
| --- | --- |
| 副底(基础) | 20 |
| 门前清荣和 | +10 |
| 自摸 | +2 |
| 雀头·三元牌 | +2 |
| 雀头·自风/场风 | +2(连风=+4) |
| 中张明刻 | +2 |
| 幺九明刻 | +4 |
| 中张暗刻 | +4 |
| 幺九暗刻 | +8 |
| 中张明杠 | +8 |
| 幺九明杠 | +16 |
| 中张暗杠 | +16 |
| 幺九暗杠 | +32 |
| 听牌·单骑/嵌张/边张 | +2 |
| 听牌·两面/双碰 | +0 |
- **特殊**:平和自摸 = **20 符**;七对子 = **25 符**(不加算其它符)。
- **最低符**:食和最低 **30 符**(20 副底不满 30 进位);自摸(非平和)最低 **30 符**(20+2→进位)。
- 符数**向上取整到 10 位**(如 32 → 40)。
### 9.2 番符 → 基本点
```
基本点 = 符 × 2^(2 + 番)
```
**满贯封顶规则**:
| 番数/条件 | 级别 | 基本点 |
| --- | --- | --- |
| 5 番;或 4 番 40 符+;或 3 番 70 符+ | 满贯 | 2000 |
| 6–7 番 | 跳满(跳满) | 3000 |
| 8–10 番 | 倍满 | 4000 |
| 11–12 番 | 三倍满 | 6000 |
| 13 番+ | 累计役满 | 8000 |
| 役满 | 役满 | 8000 × 倍数 |
> **切上满贯(切り上げ満貫)不采用**(雀魂默认):4 番 30 符 / 3 番 60 符等按原值计算,不提前进位满贯。
### 9.3 支付
- **亲家荣和** = 基本点 × 6;**子家荣和** = 基本点 × 4。
- **亲家自摸** = 每家付 基本点 × 2(三家各付);**子家自摸** = 亲家付 基本点 × 2、其余闲家各付 基本点 × 1。
- 所有点数**向上取整到 100 位**。
- **本场**:每本场 **300 点**(四麻)/**200 点**(三麻),荣和由放铳者全额支付 `300×本场`,自摸由各家分摊 `100×本场`(三麻 200 本场对应分摊)。
- **供托**:和了者收走桌上全部立直棒(每根 1000)。
**点数速查(节选,子家/亲家荣和)**:
| 番符 | 子家荣和 | 亲家荣和 |
| --- | --- | --- |
| 1 番 30 符 | 1000 | 1500 |
| 1 番 40 符 | 1300 | 2000 |
| 2 番 25 符(七对) | 1600 | 2400 |
| 2 番 30 符 | 2000 | 2900 |
| 3 番 30 符 | 3900 | 5800 |
| 4 番 30 符 | 7700 | 11600 |
| 满贯 | 8000 | 12000 |
| 跳满 | 12000 | 18000 |
| 倍满 | 16000 | 24000 |
| 三倍满 | 24000 | 36000 |
| 役满 | 32000 | 48000 |
---
## 10. 流局(リュウキョク)
### 10.1 荒牌流局(牌墙摸完无人和了)
- **听牌罚符(ノーテン罰符)**,四麻合计 **3000 点**,三麻合计 **2000 点**:
- 1 家听牌:3 家不听各付 1000(三麻:2 家各付 1000)。
- 2 家听牌:2 家不听各付 1500(三麻:1 家不听付 2000?——三麻 1 听 2 不听按 1000/家、2 听 1 不听按 2000 全额)。
- 3 家听牌:1 家不听付 3000(三麻无第 3 家情形)。
- 全听或全不听:无罚符。
- **连庄**:亲家听牌 → 连庄(本场 +1);亲家不听 → 下庄(本场 +1)。
- **立直棒**:荒牌流局时不收回,留到下一局;和了者继续收。
### 10.2 途中流局(途中流局)
| 类型 | 条件 | 处理 |
| --- | --- | --- |
| **九种九牌** | 门清且第一巡打牌前,手牌含 ≥9 种幺九/字牌 | 可宣言流局;亲下庄,本场 +1(三麻适用) |
| **四风连打** | 第一巡 4 家打出**同一风牌**(无鸣牌) | 流局;亲下庄,本场 +1(**三麻无**) |
| **四家立直** | 4 家全部立直(宣言牌无人和) | 流局;亲下庄,本场 +1(**三麻无**) |
| **三家和了** | 同一张牌 3 家同时荣和 | 流局;亲下庄,本场 +1(**三麻无**) |
| **四杠散了** | 4 个杠分散于不同玩家(或含 1 暗杠) | 流局;亲下庄,本场 +1(三麻:北抜き是否计杠按实现定,默认计) |
> 途中流局**不进行听牌罚符**;立直棒保留。
### 10.3 流局满贯(流し満貫)
- **条件**:荒牌流局时,某玩家**打出的牌全部为幺九牌或字牌**,且**未被任何人鸣走**。
- **处理**:该玩家按**满贯自摸**计算点数(不进和了结算,属流局结算);**不收立直棒与本场**。
- **连庄**:按荒牌流局规则判定(亲听牌则连庄)。
- 多家达成时并列按满贯结算(实现需定义顺序,按头跳思路或全部结算,默认**全部结算**)。
---
## 11. 连庄 / 进庄 / 终局 / 击飞
- **和了连庄**:亲家和了 → 连庄。
- **听牌连庄**:荒牌流局亲家听牌 → 连庄。
- **下庄**:其余情况 → 亲家移至下家(东→南→西→北循环),本场 +1。
- **本场数**:每次连庄或下庄本场 +1;和了时按本场加算点数;有人和了则本场清零(下一局本场从 0 开始——注:和了连庄时本场 +1 继续累计,标准为和了后本场重置?-> 雀魂:和了即本场清零并进入下一局;连庄时本场数保留并 +1。**实现按「和了后本场重置为 0;连庄时保留」的雀魂行为处理**)。
> 精确语义:本场棒是「连续流局或连庄累积的加算棒」;**有人和了后本场归零**(和了者已收下所有本场点)。
- **终局**:见 §2.2(击飞、和了终局、西入)。
- **顺位**:终局按最终点数排序;同点按「起家顺序」或「上家优先」判定,雀魂按座次(东起逆时针)排名。
---
## 12. 三麻特有规则(雀魂)
| 项目 | 三麻 | 四麻 |
| --- | --- | --- |
| 用牌 | 108 张(去 2m~8m) | 136 张 |
| 座位 | 东、南、西(无北家) | 东南西北 |
| 吃 | ❌ 不可 | ✅ |
| 赤宝牌 | 2 枚(红 5p、红 5s) | 3 枚(红 5m/5p/5s) |
| 北 | **拔北宝牌**;作面子按**客风**;可被**抢北** | 普通风牌 |
| 起始/返点 | **35000 / 40000** | 25000 / 30000 |
| 本场 | **200 点** | 300 点 |
| 不听罚符 | 合计 **2000 点** | 3000 点 |
| 自摸损 | **有**:子家自摸少收一名闲家应付款(无北家补位) | 无 |
| 四风连打 / 四家立直 / 三家和了 | ❌ 不适用 | ✅ |
| 九种九牌 / 四杠散了 | ✅ | ✅ |
| 双响 | 头跳 | 头跳/三家和了流局 |
| 顺位马 | 1 位 +15 / 2 位 0 / 3 位 −15(以雀魂实际为准) | +15/+5/−5/−15 |
> **自摸损细节**:子家自摸时,理论上应收「亲家 2 倍 + 闲家 1 倍 + 闲家 1 倍」;三麻少一名闲家,缺失的 1 倍不收(有自摸损)。例如子家满贯自摸:亲付 4000 + 闲付 2000 = 6000(四麻为 8000)。
> **拔北是否计杠**:四杠散了判定中,拔北**计入**杠数(雀魂默认,实现按此);即 3 拔北 + 1 杠也可能触发四杠散了(罕见)。
---
## 13. 已拍板规则选型汇总
| # | 项目 | 选型 |
| --- | --- | --- |
| V1 | 人数 | **四麻 + 三麻**(房间创建时选择) |
| V2 | 场次 | **半庄战**(东南战,含西入);终局条件含击飞/和了终局 |
| V3 | 起始/返点 | 四麻 25000/30000;三麻 35000/40000 |
| V4 | 赤宝牌 | 四麻 3 枚、三麻 2 枚 |
| V5 | 吃碰杠 | 三麻不可吃;四麻可吃;杠含明杠/暗杠/加杠;三麻含拔北 |
| V6 | 食替 | **禁止**(现物食替 + 筋食替) |
| V7 | 一炮多响 | **头跳**(头家荣和);三家和了 → 途中流局 |
| V8 | 振听 | 舍张振听/同巡振听/立直后振听,**均实现** |
| V9 | 流局 | 荒牌流局(听牌罚符)+ 途中流局(九种九牌/四风连打/四家立直/三家和了/四杠散了)+ 流局满贯 |
| V10 | 连庄 | 和了连庄 + 亲听牌流局连庄;本场 300/200 |
| V11 | 役满 | 含双倍役满;累计役满封顶 1 倍;人和不采用 |
| V12 | 切上满贯 | 不采用 |
| V13 | 击飞 | 点数 < 0 立即终局(=0 继续) |
| V14 | AI | 本期不做(纯真人);后续独立阶段 |
| V15 | 终局结算 | 顺位马 + 头名赏按雀魂段位场计算(对局内点数照常显示) |
未拍板项(实现采用雀魂主流默认):
- 途中流局后亲家处理:统一「亲下庄 + 本场 +1」(雀魂主流)。
- 流局满贯多家达成:全部结算。
- 三麻顺位马:+15/0/−15,以雀魂实际为准,可配置。
---
## 14. 与现有代码库的对接要点(实现提示,非规则)
- **引擎文件**:`packages/engine/src/games/mahjong.ts`,导出 `TTSGame<MahjongState>`,`name: 'Mahjong'`,`minPlayers: 3`,`maxPlayers: 4`。
- 建议拆分子模块(纯函数,便于单测):
- `mahjong-tiles.ts`:牌定义、牌组生成(四麻/三麻)、赤宝牌、排序、宝牌指示映射。
- `mahjong-yaku.ts`:和牌型分解(面子分解 / 七对 / 国士)、役种判定、符计算、点数计算。
- `mahjong.ts`:Game 定义、阶段机、moves、playerView、结算。
- **阶段机(phases)建议**:`waiting`(进房)→ `playing`(摸打主循环,含鸣牌子状态)→ `roundEnd`(本局结算/流局展示)→ `gameEnd`(半庄终局)。
- 主循环内部用状态标记区分「等待摸牌 / 等待打牌 / 等待鸣牌响应」;因为 boardgame.io 无法真正并发,鸣牌响应建议用「思考窗口」+ 超时兜底(见踩坑记忆:**发牌必须原子完成,避免多客户端 flood move 导致同步错乱**)。
- **moves 规划**:`startGame`、`draw`(摸牌,可在 move 内自动推进)、`discard`、`riichi`、`chi/pon/kan/ankkan/kakan`、`north`(三麻拔北)、`ron/tsumo`、`pass`(鸣牌放弃)、`kyuushu9`(九种九牌)、`nextRound`、`setName`。
- **playerView**:隐藏他家手牌(含摸到的牌);公开牌河、副露、宝牌指示牌、点数、立直状态;本人手牌通过 `myHand` 注入(与 Bridge 一致)。**不可在 UI 重新实现隐藏逻辑**(沿用 boardgame.io `playerView`)。
- **注册三处**:
1. `packages/server/src/index.ts` 的 `Server({ games: [...] })` 数组;
2. 同文件的 `GAMES_ENTRIES` 数组(`{ name: 'Mahjong', game: Mahjong }`);
3. `apps/web/src/App.tsx` 的 `GAMES` 表(`engineName: 'Mahjong'`,`maxSeats: 4`,`seatChoices: [3, 4]`),并加入 `GAME_ORDER`、`GameKey` 联合类型、`LocalGameView`/`OnlineGameView` 分派。
- **UI**:新增 `apps/web/src/MahjongBoard.tsx`(参照 `BridgeBoard.tsx` 的椭圆桌 + 座位卡思路,重设计为**立式牌山视角**:自己手牌在底部、他家显示背面、四家牌河环形排布、中央显示场风/本场/供托/宝牌指示/剩余牌数)。因隐藏信息多,联机模式每 client 只见自己手牌。
- **单元测试**:`packages/engine/src/games/mahjong.test.ts`(+ `mahjong-yaku.test.ts`):牌组构成、和牌分解、全部役种判定、符与点数表、振听、食替、头跳、流局罚符、连庄/进庄、三麻自摸损。
- **玩家名**:沿用 `setName` move + `G.playerNames`(与 Bridge/DoubleUp 一致)。
- **注意**:Game 的 `moves` 直接 mutate `G`(无 Immer),移动牌时必须**新建数组**,避免别名 bug(见 `drag-test.ts` 注释与踩坑记录)。
---
## 15. 与标准日麻的差异 / 实现裁剪
用于 4 人(及 3 人)浏览器联机场景的合理裁剪,不影响核心玩法:
1. **无「中途流局宣言」之外的罚则**(如错荣、错鸣的纠正),仅**静默拒绝非法 move**。
2. **鸣牌响应窗口**:真实日麻允许并发申请鸣牌并比较优先级;实现采用「按逆时针顺序依次询问 + 超时」,与雀魂的即时响应体验有差异。
3. **无掷骰动画/点棒物理**,开牌位置随机或由引擎决定。
4. **无「观战」**(本期不做)、**无 AI 补位**(本期纯真人)。
5. **人和不实现**(非雀魂标准)。
6. **本场语义**按雀魂「和了后重置」实现,细节见 §11。
7. **顺位马/头名赏**仅用于终局顺位展示,对局内不改变点数。
---
## 16. 参考来源
- 雀魂(Majsoul)官方规则说明与段位场规则页
- OurPlay《雀魂麻将规则和打法》(四麻段位场/友人场/三麻)https://m.ourplay.net/find/detail/150493
- MemoJong《三麻规则一览|四麻差异、拔北、自摸损、点数与马点详解》https://memojong.com/zh-cn/guides/sanma-rule-list/
- 维基百科/萌娘百科「日本麻将」「立直麻将」「役种列表」「符計算」「振听」「流局满贯」
- 百度百科「役满」「双倍役满」「流局满贯」「人和」
- 天鳳/万年東風等日麻规则站(役种、符、点数表校验)

View File

@@ -0,0 +1,51 @@
# 立直麻将(Mahjong)实现计划
> 规则依据:[`00-rules.md`](./00-rules.md)(雀魂标准,四麻 + 三麻,半庄战)。
> 目标文件与集成点见 00-rules §14。本文只描述**工程拆分与实施顺序**。
---
## 一、模块拆分(均放 `packages/engine/src/games/`)
| 文件 | 职责 | 是否纯函数 |
| --- | --- | --- |
| `mahjong-tiles.ts` | 牌定义、牌组生成(四麻/三麻)、赤宝牌、排序、宝牌指示映射、索引/牌种互转 | ✅ |
| `mahjong-yaku.ts` | 和牌型分解(标准型/七对/国士)、役种判定、番数、符计算、点数计算、振听/听牌判定辅助 | ✅ |
| `mahjong.ts` | boardgame.io `Game` 定义:阶段机、moves、playerView、本局/终局结算、连庄/进庄 | ❌(含状态机) |
对应测试:`mahjong-tiles.test.ts`、`mahjong-yaku.test.ts`、`mahjong.test.ts`。
## 二、牌与状态数据模型
- **牌种索引**(34 种):`0-8 = 1m..9m`,`9-17 = 1p..9p`,`18-26 = 1s..9s`,`27-33 = 東南西北白發中`。
- **物理牌**:`{ id: string; kind: string; red?: boolean }`,其中 `kind` 用 `'1m'..'9m'/'1p'../'1s'../'1z'..'7z'`;赤 5 用 `'0m'/'0p'/'0s'` 表示,`normalizeKind` 归一到 `'5m'/'5p'/'5s'` 参与牌型判定。
- **G 关键字段**:`players/hands/deck/wall/deadWall`、`melds[pid]`、`discards[pid]`、`scores[pid]`、`riichi[pid]`、`doraIndicators`、`honba`、`kyoutaku`(供托)、`bakaze`(场风)、`dealer`、`round`、`phase` 相关标记、`pendingClaims`(鸣牌/荣和响应)、`turn`、`lastDiscard`、`furiten` 标记等。
## 三、阶段机与主循环
```
waiting ──startGame──▶ playing ──(和了/流局)──▶ roundEnd ──nextRound──▶ playing ...
└──(半庄终局)──▶ gameEnd
```
- `playing` 内部用 `turnStep` 区分:`draw`(摸牌)→ `discard`(打牌)→ `claim`(鸣牌响应窗口)。
- 鸣牌响应:逆时针依次询问有资格者(吃/碰/杠/荣/拔北),`pass` 或超时继续;优先级 荣和 > 碰/杠 > 吃。
- **发牌必须一次性原子完成**(踩坑记忆:单客户端 flood move 会破坏其他客户端同步)。
## 四、实施顺序(每步附验证)
1. **P1 牌与宝牌**:`mahjong-tiles.ts` + 测试(牌组张数、赤 5、宝牌指示循环、三麻去牌)。
2. **P2 和牌与役**:`mahjong-yaku.ts` + 测试(和牌分解、全部役种表、双倍役满、宝牌加算)。
3. **P3 符与点数**:符计算 + 点数表 + 测试(对拍 §9.3 表、满贯封顶、切上满贯不采用、亲/子/自摸/荣和、本场)。
4. **P4 听牌/振听/食替**:听牌判定、舍张/同巡/立直后振听、现物+筋食替校验 + 测试。
5. **P5 Game 骨架**:阶段机 + 配牌/摸打/立直/鸣牌/和了/流局 moves + playerView + 本局/终局结算 + 测试(用 boardgame.io 的 `Client`/`Game.T` 驱动)。
6. **P6 UI**:`apps/web/src/MahjongBoard.tsx`(参照 `BridgeBoard.tsx` 重设计为立式麻将桌)。
7. **P7 接入**:注册 server 两数组 + `App.tsx`(GAMES/GAME_ORDER/GameKey/Local/Online View)。
8. **P8 验证**:`pnpm -r ts && pnpm --filter @tts-like/engine test`,必要时补 E2E。
## 五、风险与注意
- 役种与符计算是最大复杂度来源:**先写穷尽单测对拍标准表**,再写 Game 逻辑。
- 三麻自摸损、拔北、北客风等需在点数与役判定中分支处理。
- 鸣牌并发在 boardgame.io 下单线程,需用状态窗口 + 超时模拟;避免多客户端抢发同一 move。
- 隐藏信息严格走 `playerView`,UI 不得二次实现。

View File

@@ -0,0 +1,362 @@
# 立直麻将(Mahjong)实现验证清单
> 依据:`docs/mahjong/00-rules.md`(雀魂标准,四麻+三麻,半庄战)。
> 目的:供人工审核实现与规则的一致性。逐条列出**期望行为 / 实现状态 / 证据 / 风险**。
> **版本**:v4(2026-10-03,P0+P1+P2 主要项修复完成;测试清单据实重核)。v1~v3 快照见 git 历史。
> 代码:`packages/engine/src/games/mahjong*.ts`、`apps/web/src/MahjongBoard.tsx`。
## 图例
**级别(重要度)**:**P0** 核心(缺失/错误会导致对局不成立或算错分)|**P1** 重要|**P2** 次要/边角
**实现状态**:✅ 已实现且有单测 | 🟩 已实现(无专门单测/间接覆盖)| 🧪 已实现且浏览器冒烟实测 | ⚠️ 部分/简化/有边界问题 | ❌ 未实现
**测试**:麻将单测 **78 条**(`mahjong-tiles.test.ts` 16 · `mahjong-yaku.test.ts` 33 · `mahjong.test.ts` 29),引擎全量 **329 通过**(13 文件);联机 E2E `e2e/mahjong-multiplayer.spec.ts`(4 端)✅。逐条清单见文末 [Q. 测试清单](#q-测试清单test-inventory)。
---
## 本轮(v2)修复总览:P0 + P1 全部完成
| 编号 | 级别 | 修复内容 | 状态 |
| --- | --- | --- | --- |
| P-01 | P0 | 立直后强制**摸切**(宣言回合可打任意牌,之后只能打摸到的牌) | ✅ 单测 |
| P-02 | P0 | **鸣牌响应超时**:新增 `claimDeadline` + `claimTimeout` move + UI 全局倒计时,掉线也不卡死 | ✅ 单测 + 🧪 冒烟 |
| P-03 | P1 | 途中流局:四风连打 / 四家立直 / 三家和了 / 四杠散了 | ✅ 单测 |
| P-04 | P1 | 流局满贯(弃牌全幺九且未被鸣 → 按满贯自摸,不收供托/本场) | ✅ 单测 |
| P-05 | P1 | 和了终局/听牌终局(All Last 亲家一位且达返点 + 连庄 → 终局) | ✅ 单测 |
| P-06 | P1 | 立直后暗杠**听牌不变**校验 | ✅ 单测 |
| P-07 | P1 | 三麻抢北(对拔北开荣和窗口,按抢杠计) | ✅ 单测 |
| P-08 | P1 | 里宝牌/杠宝 UI 揭示(和了后 `uraIndicators` 公开并在结算面板展示) | ✅ 单测 + UI |
| P-09 | P1 | 食替「筋」规则核对(现物 + 筋,含 123/789 边界) | ✅ 已覆盖 |
| P-10 | P1 | 振听边界:被鸣走的自弃牌仍计入舍张振听(`furitenKinds` 只增不减) | ✅ 单测 |
> 另修正 `waitingKinds`:扣除已锁定在副露中的牌(某牌种自占 4 张时不再作为听牌)。
---
## v3(P2)修复总览
| 编号 | 级别 | 修复内容 | 状态 |
| --- | --- | --- | --- |
| R-01 | P2 | 三麻:碰/明杠后**未打牌前不可拔北**(`justCalledBy`) | ✅ 单测 |
| R-02 | P2 | 加杠被抢后撤销为碰时,**扣回第 4 张牌的赤宝牌计数** | ✅ 单测 |
| R-05 | P2 | 终局**顺位马 + 头名赏**(四麻 +15/+5/-5/-15 且赏+20;三麻 +15/0/-15 且赏+15) | ✅ 单测 |
| R-06 | P2 | **听牌 / 振听提示**(`playerView` 暴露 `myWaits`/`myFuriten`,UI 显示) | ✅ 单测 + UI |
| D9/N8 | P2 | **立直棒可视化**(顶栏按供托数画棍)+ 自己立直提示 | ✅ UI |
| R-07 | P1 | **联机 4 端 E2E**(建房→开局→隐藏信息→轮转打牌) | ✅ Playwright |
**本轮有意保持不变(合理简化,非遗漏)**:R-03 开牌/宝牌物理位置、C6 自摸牌显示、E15 杠宝时机、F14 人和(雀魂不采用)、O5 掉线超时(客户端驱动已够用)。
**独立阶段(未做)**:R-04 观战 + AI 补位。
---
## A. 牌具与牌组
| 编号 | 级别 | 检查项 | 期望 | 状态 |
| --- | --- | --- | --- | --- |
| A1 | P0 | 四麻牌组 | 34 种 × 4 = 136 张 | ✅ |
| A2 | P0 | 三麻牌组 | 去 2m~8m,108 张 | ✅ |
| A3 | P0 | 四麻赤宝牌 | 红 5m/5p/5s 各 1(3 枚) | ✅ |
| A4 | P0 | 三麻赤宝牌 | 红 5p/5s 各 1(2 枚) | ✅ |
| A5 | P0 | 牌种索引与赤牌归一 | `0m→5m`;`red` 单独保留 | ✅ |
| A6 | P1 | 宝牌指示映射 | 数牌 9→1;风循环;三元循环 | ✅ |
| A7 | P2 | 手牌排序 | m→p→s→z,各 1..9 | ✅ |
## B. 牌山 / 王牌 / 指示牌 / 岭上 / 里宝
| 编号 | 级别 | 检查项 | 期望 | 状态 |
| --- | --- | --- | --- | --- |
| B1 | P0 | 王牌 | 14 张不参与摸牌 | ✅ |
| B2 | P0 | 开局表宝牌指示牌 | 开局翻 1 张 | 🧪 |
| B3 | P0 | 杠宝牌 | 每次杠翻新指示牌(≤5) | 🟩 |
| B4 | P0 | 岭上牌 | 4 张,杠/拔北后补摸 | 🟩 |
| B5 | P1 | 里宝牌 | 立直和了按里指示加番;和了后 UI 揭示 | ✅ 计分 + UI |
| B6 | P0 | 牌山摸尽 | 荒牌流局 | ✅ |
| B7 | P2 | 指示牌/岭上物理位置 | 与真实砌墙一致 | ⚠️ 简化布局 |
| B8 | P1 | 杠宝翻开时机 | 抢杠则不翻 | 🟩 |
## C. 配牌与摸打
| 编号 | 级别 | 检查项 | 期望 | 状态 |
| --- | --- | --- | --- | --- |
| C1 | P0 | 发牌 | 每人 13 张,原子完成 | ✅ |
| C2 | P0 | 庄家先摸 | 起手 14 张 | ✅ |
| C3 | P0 | 摸打循环 | 打牌 → 下家摸牌 | ✅ |
| C4 | P0 | 出牌合法性 | 只能打自己手牌 | 🟩 |
| C5 | P1 | 摸牌并入 | 摸到的牌进手牌 | ✅ |
| C6 | P2 | 自摸牌提示 | 显示「摸到」 | 🧪(手牌行内亦有,UX 小事) |
## D. 立直
| 编号 | 级别 | 检查项 | 期望 | 状态 |
| --- | --- | --- | --- | --- |
| D1 | P0 | 立直条件 | 门清 + 听牌 + ≥1000 | 🟩 |
| D2 | P0 | 立直供托 | 支付 1000 计供托 | 🟩 |
| D3 | P0 | **立直后摸切** | 立直后只能打刚摸的牌 | ✅ |
| D4 | P0 | 一发 | 立直后一巡内和了且无鸣牌 | 🟩 |
| D5 | P1 | 双立直 | 第一巡立直(2 番) | 🟩 |
| D6 | P1 | 立直后暗杠 | 仅在不改变听牌时可暗杠 | ✅ |
| D7 | P1 | 四家立直流局 | → 途中流局 | ✅ |
| D8 | P1 | 里宝牌 | 立直和了加算里宝 | ✅ |
| D9 | P2 | 立直棒可视化 | 桌面立直棒 | ✅ 顶栏棍形显示 + 自己立直提示 |
## E. 鸣牌
| 编号 | 级别 | 检查项 | 期望 | 状态 |
| --- | --- | --- | --- | --- |
| E1 | P0 | 吃 | 仅下家(四麻) | ✅🧪 |
| E2 | P0 | 碰 | 任意家 | 🟩 |
| E3 | P0 | 大明杠 | 3 手牌 + 弃牌 | 🟩 |
| E4 | P0 | 暗杠 | 手持 4 张 | 🟩 |
| E5 | P0 | 加杠 | 已碰 + 第 4 张,含抢杠窗口 | 🟩 |
| E6 | P0 | 三麻不可吃 | 无吃选项 | 🟩 |
| E7 | P0 | 食替禁止 | 现物 + 筋 | ✅ |
| E8 | P0 | 鸣牌优先级 | 荣 > 碰/杠 > 吃 | ✅ |
| E9 | P0 | 一炮多响(头跳) | 最近者 | ✅ |
| E10 | P1 | 三家和了 | → 流局 | ✅ |
| E11 | P1 | 四杠散了 | → 流局 | ✅ |
| E12 | P1 | 三麻拔北 | 亮北 +1 宝、岭上 | 🟩 |
| E13 | P1 | 抢北 | 对拔北荣和 | ✅ |
| E14 | P2 | 碰后不可立即拔北 | 拔北近似鸣牌 | ✅ |
| E15 | P2 | 明杠/加杠的宝牌与岭上顺序 | 符合规则 | 🟩 |
## F. 和了判定
| 编号 | 级别 | 检查项 | 期望 | 状态 |
| --- | --- | --- | --- | --- |
| F1 | P0 | 自摸 | 三家付点 | ✅ |
| F2 | P0 | 荣和 | 放铳者付 | 🟩 |
| F3 | P0 | **无役不能和** | 番=0 不能和(宝牌不算役) | ✅ |
| F4 | P0 | 宝牌不计入役 | 无役即使有宝牌也不能和 | ✅ |
| F5 | P0 | 振听·舍张 | 含被鸣走的自弃牌 | ✅ |
| F6 | P0 | 振听·同巡 | 本轮放过 → 本轮不能再荣和 | 🟩 |
| F7 | P0 | 振听·立直后 | 永久振听 | 🟩 |
| F8 | P1 | 一发和了 | +1 番 | 🟩 |
| F9 | P0 | 岭上开花 | 杠/拔北后岭上自摸 | 🟩 |
| F10 | P0 | 抢杠 | 抢加杠荣和 | 🟩 |
| F11 | P0 | 海底摸月 | 牌山最后自摸 | 🟩 |
| F12 | P0 | 河底捞鱼 | 牌山最后弃牌荣和 | 🟩 |
| F13 | P0 | 天和 / 地和 | 役满 | 🟩 |
| F14 | P2 | 人和 | 古役 | ❌ 有意不做 |
| F15 | P1 | 振听边界(被鸣走牌) | 仍计入 | ✅ |
## G. 宝牌
| 编号 | 级别 | 检查项 | 期望 | 状态 |
| --- | --- | --- | --- | --- |
| G1 | P0 | 表宝牌 | 每张 +1 番 | ✅ |
| G2 | P0 | 赤宝牌 | 按实际持有(含副露) | ✅ |
| G3 | P1 | 里宝牌 | 立直和了 + 里指示 | ✅ 计分 + UI |
| G4 | P0 | 杠宝牌 | 每杠翻新 | 🟩 |
| G5 | P1 | 拔北宝牌 | 每张拔出北 +1 | 🟩 |
| G6 | P0 | 宝牌不是役 | — | ✅ |
## H. 役种(按番数)
> ✅ 有单测;🟩 已实现未单测;❌ 未实现。
| 番 | 役种 | 状态 |
| --- | --- | --- |
| 1 番 | 立直 / 一发 / 门前清自摸和 / 平和 / 断幺九(有食断)/ 一杯口 / 役牌(含连风)/ 岭上 / 抢杠 / 海底 / 河底 | ✅ 平和·断幺·一杯口·役牌·门前清自摸;🟩 立直·一发·岭上·抢杠·海底·河底 |
| 2 番 | 双立直 / 三色同顺 / 一气通贯 / 混全带 / 七对子 / 对对和 / 三暗刻 / 三色同刻 / 三杠子 / 混老头 / 小三元 | ✅ 除双立直·三杠子为 🟩 |
| 3 番 | 二杯口 / 纯全带 / 混一色 | ✅ |
| 6 番 | 清一色 | ✅ |
| 役满 | 国士 / 四暗刻 / 大三元 / 小四喜 / 大四喜(双) / 字一色 / 绿一色 / 清老头 / 九莲 / 纯正九莲(双) / 四杠子 / 天和 / 地和 / 国士十三面(双) / 四暗刻单骑(双) / 累计役满 | ✅ 国士·四暗刻·大三元·字一色·九莲·纯正·国士十三面·四暗刻单骑;🟩 其余 |
| 未实现 | 人和 / 古役(有意不做)/ 抢北已补 | ❌ 仅人和·古役 |
## I. 符计算
| 编号 | 级别 | 检查项 | 期望 | 状态 |
| --- | --- | --- | --- | --- |
| I1 | P0 | 副底 | 20 符 | ✅ |
| I2 | P0 | 门前清荣和 | +10 | ✅ |
| I3 | P0 | 自摸 | +2 | 🟩 |
| I4 | P0 | 雀头 | 三元/自风/场风 +2,连风 +4 | 🟩 |
| I5 | P0 | 刻/杠符 | 分档(暗杠幺九 +32) | ✅ |
| I6 | P0 | 听牌形 | 嵌张/单骑/边张 +2 | 🟩 |
| I7 | P0 | 平和自摸 | 20 符 | ✅ |
| I8 | P0 | 七对子 | 25 符 | 🟩 |
| I9 | P0 | 最低符 | 不足 30 进位 | 🟩 |
| I10 | P0 | 进位 | 到 10 位 | 🟩 |
## J. 点数计算
| 编号 | 级别 | 检查项 | 期望 | 状态 |
| --- | --- | --- | --- | --- |
| J1 | P0 | 基本点 | 符 × 2^(2+番) | ✅ |
| J2 | P0 | 满贯判定 | 5 番 / 4番40符 / 3番70符 | ✅ |
| J3 | P0 | 跳满/倍满/三倍满/役满 | 6/8/11/13 档 | ✅ |
| J4 | P0 | 切上满贯 | 不采用 | ✅ |
| J5 | P0 | 亲/子荣和 | ×6 / ×4 | ✅ |
| J6 | P0 | 自摸分配 | 亲平摊;子庄2倍闲1倍 | ✅ |
| J7 | P0 | 本场 | 300 / 200 | ✅ |
| J8 | P0 | 供托 | 1000 归和了者 | 🟩 |
| J9 | P0 | 三麻自摸损 | 少收一名闲家 | ✅ |
| J10 | P0 | 取整 | 向上到 100 | ✅ |
| J11 | P0 | 点数守恒 | 无供托零和 | ✅ |
| J12 | P1 | 击飞 | < 0 终局(=0 继续) | 🟩 |
## K. 流局
| 编号 | 级别 | 检查项 | 期望 | 状态 |
| --- | --- | --- | --- | --- |
| K1 | P0 | 荒牌流局 | 牌山摸尽 | ✅ |
| K2 | P0 | 听牌罚符 | 3000 / 2000 按听牌家数 | ✅ |
| K3 | P0 | 连庄 | 亲听牌连庄 | ✅ |
| K4 | P1 | 九种九牌 | 首巡 ≥9 种幺九字牌可宣言 | 🟩 |
| K5 | P1 | 四风连打 | → 流局 | ✅ |
| K6 | P1 | 四家立直 | → 流局 | ✅ |
| K7 | P1 | 三家和了 | → 流局 | ✅ |
| K8 | P2 | 四杠散了 | → 流局 | ✅ |
| K9 | P1 | 流局满贯 | 弃牌全幺九未被鸣 → 满贯自摸 | ✅ |
## L. 连庄 / 进庄 / 终局 / 西入
| 编号 | 级别 | 检查项 | 期望 | 状态 |
| --- | --- | --- | --- | --- |
| L1 | P0 | 和了连庄/下庄 | 亲和了连庄 | 🟩 |
| L2 | P0 | 听牌连庄 | — | ✅ |
| L3 | P0 | 场次推进 | 东南半庄 4 局一场风 | 🟩 |
| L4 | P1 | 和了终局/听牌终局 | All Last 亲一位+达返点+连庄 → 终局 | ✅ |
| L5 | P1 | 西入 | 南 4 无人达返点 → 西入 | 🟩 |
| L6 | P0 | 击飞终局 | — | 🟩 |
| L7 | P2 | 顺位马 / 头名赏 | 段位点结算 | ✅ 终局计算 + 结算面板显示 |
## M. 三麻特有
| 编号 | 级别 | 检查项 | 期望 | 状态 |
| --- | --- | --- | --- | --- |
| M1 | P0 | 去牌 / 无吃 | — | ✅ |
| M2 | P0 | 起点/返点 | 35000 / 40000 | 🟩 |
| M3 | P0 | 自摸损 | — | ✅ |
| M4 | P1 | 拔北 | — | 🟩 |
| M5 | P1 | 拔北后岭上 | 算岭上开花 | 🟩 |
| M6 | P1 | 北作面子=客风 | 非役 | 🟩 |
| M7 | P1 | 无四风连打/四家立直 | 自动不适用 | 🟩 |
| M8 | P2 | 抢北 | 对拔北荣和 | ✅ |
## N. UI / 交互
| 编号 | 级别 | 检查项 | 期望 | 状态 |
| --- | --- | --- | --- | --- |
| N1 | P0 | 牌桌渲染 | 手牌/牌河/副露/宝牌/剩余/点数/场况 | 🧪 |
| N2 | P0 | 出牌 | 点击手牌、轮转 | 🧪 |
| N3 | P0 | 鸣牌窗口 | 荣/碰/杠/吃/跳过 + 倒计时 | 🧪 |
| N4 | P1 | 立直操作 | 宣言 + 选牌打出 | 🟩 |
| N5 | P1 | 操作按钮 | 自摸/暗杠/加杠/拔北/九种九牌 | 🟩 |
| N6 | P1 | 结算面板 | 役种/番符/点数/顺位/下一局 | 🟩 |
| N7 | P2 | 里宝牌展示 | 和了时揭开 | ✅ |
| N8 | P2 | 立直棒/自己立直提示 | — | ✅ 棍形供托 + 自己立直提示 |
| N9 | P2 | 听牌/振听提示 | — | ✅ |
| N10 | P1 | 鸣牌超时对“非本人”推进 | 掉线也能推进 | ✅🧪(任一客户端在线即可驱动) |
## O. 联机 / 隐藏信息
| 编号 | 级别 | 检查项 | 期望 | 状态 |
| --- | --- | --- | --- | --- |
| O1 | P0 | playerView 隐藏 | 他家手牌/牌山/里宝隐藏 | 🟩 |
| O2 | P0 | 公开信息 | handCounts/wallCount/doraIndicators | 🧪 |
| O3 | P1 | 本地换座位不重置 | updatePlayerID 原地切换 | 🧪 |
| O4 | P1 | 联机 4 端对局 | SocketIO 正常同步 | ✅ E2E(`mahjong-multiplayer.spec.ts`) |
| O5 | P2 | 掉线玩家鸣牌超时 | 服务端推进 | 🟩(客户端驱动;全部离线则仍会停) |
## P. 已修复与剩余风险
**已修复(P0/P1/P2 主要项)**:
- P0/P1:P-01 摸切、P-02 鸣牌超时、P-03 途中流局×4、P-04 流局满贯、P-05 和了终局、P-06 立直后暗杠校验、P-07 抢北、P-08 里宝/杠宝展示、P-09 食替筋、P-10 振听边界。
- P2:R-01 碰后不可立即拔北、R-02 抢杠撤销红牌计数、R-05 顺位马/头名赏、R-06 听牌/振听提示、D9/N8 立直棒可视化、R-07 联机 4 端 E2E。
**仍存在(均为“有意简化”或“独立阶段”,非缺陷)**:
| 编号 | 级别 | 项目 | 说明 |
| --- | --- | --- | --- |
| R-03 | P2 | 掷骰/开牌位置、宝牌物理位置 | 采用简化布局,不影响玩法 |
| R-04 | — | 观战 + AI 补位 | 独立阶段(已确认 AI 后续再做) |
| S-01 | P2 | 自摸牌在“摸到”与手牌行各显示一次 | 纯 UX,可后续去重 |
| S-02 | P2 | 杠宝翻开时机为“杠成立即翻” | 与真实时序略有差异,无算分影响 |
| S-03 | P2 | 人和(古役)不实现 | 雀魂不采用(有意) |
| S-04 | P2 | 鸣牌超时由客户端驱动 | 任一客户端在线即可推进;全部离线则停 |
---
## 本轮浏览器冒烟实测(v2,均已通过、无 JS 异常)
- 开局(修复后正常)→ 打牌 → 轮转
- 切换座位不重置
- 鸣牌窗口出现;**故意不响应,等待 >12s 后自动跳过并推进**(验证 P0-02 / N10)
- 连续多回合打牌正常(牌河累加、宝牌指示显示、点数显示)
- 注入 `window.onerror` 捕获:全程 `[]`
## 建议的后续优先项
1. **R-04 观战 + AI 补位**(唯一剩余的大阶段,已确认 AI 后续再做)。
2. 观战/补位完成后再补「观战者不可见手牌」的 E2E。
---
## Q. 测试清单(Test Inventory)
> 数据来源:`packages/engine` 实跑(2026-10-03)。命令:`pnpm --filter @tts-like/engine test`。
> 引擎全量 **329 / 13 文件全通过**;下表为麻将相关的 **78 条**逐组明细。
### Q1. `mahjong-tiles.test.ts` — 16 条(牌具/牌组)
| # | 分组 | 用例 |
| --- | --- | --- |
| 1-3 | 四麻牌组 | 136 张 id 唯一 · 每种牌种恰好 4 张 · 红 5m/5p/5s 各 1 |
| 4-5 | 三麻牌组 | 108 张且不含 2m~8m · 红 5p/5s 各 1(无红 5m) |
| 6-8 | 索引↔牌种 | 34 种索引 0..33 一一对应 · 赤牌归一到普通 5 · 幺九/字牌判定 |
| 9-12 | 宝牌指示映射 | 数牌 9→1 循环 · 赤牌按普通 5 计算 · 风牌 東→南→西→北→東 · 三元牌 白→發→中→白 |
| 13-15 | 排序与展示 | `sortTiles` 按 m→p→s→z 且各花色 1..9 · `tileLabel` 中文/数字 · 计数↔牌种互转 |
| 16 | 洗牌 | 注入固定随机源可复现,且不改原数组 |
### Q2. `mahjong-yaku.test.ts` — 33 条(役种/符/点/听牌)
| # | 分组 | 用例 |
| --- | --- | --- |
| 1-2 | 和牌型分解 | 标准型分解数量正确 · 七对子/国士判定 |
| 3-15 | 普通役种(13) | 平和 · 平和+断幺九 · 一杯口 · 二杯口 · 役牌(三元)与副露 · 混一色/清一色 · 混全带/纯全带 · 无役→null · 三色同顺/一气通贯/三色同刻 · 对对和+三暗刻 · 混老头/小三元 · 立直/一发/门清自摸/平和 · 海底/河底/岭上/抢杠 旗标 |
| 16 | 符细节 | 幺九暗杠 +32 符、门清荣和 70 符 |
| 17-22 | 役满(6) | 国士无双 · 国士十三面(双倍) · 大三元 · 四暗刻/四暗刻单骑(双倍) · 字一色 · 九莲宝灯/纯正九莲(双倍) |
| 23 | 宝牌计数 | 指示 3m→宝牌 4m;赤牌归一 |
| 24-28 | 符与点数(5) | 点数基本表(子/亲×荣/自摸) · 满贯封顶且不采用切上满贯 · 役满/双倍役满 · 本场加算(+300/本场) · 三麻自摸损 |
| 29-33 | 听牌/振听/食替 | 单骑听 · 两面听 · 不成和牌型→false · 食替(碰现物 / 吃现物+筋 / 嵌张) · 舍张振听 |
### Q3. `mahjong.test.ts` — 29 条(对局流程 / P0-P1-P2)
| # | 分组 | 用例 |
| --- | --- | --- |
| 1-2 | 配牌与开局 | 四麻:庄 14/闲 13/牌山 69/赤初始化 · 三麻:108 张/每家 35000/无 2m~8m |
| 3 | 打牌推进 | 无人鸣牌时推进下家并摸牌 |
| 4-6 | 鸣牌窗口 | `buildClaims` 检出碰/杠/吃/荣 · 双响**头跳**取最近 · 荣和优先于碰 |
| 7-10 | 和了计算 | 平和+门清自摸点数 · **无役不能和** · 赤宝牌按实际持有重算 · `applyWin` 点数守恒 |
| 11-12 | 荒牌流局 | 听牌罚符 3000 与庄家轮转 · 庄家听牌则连庄 |
| 13 | 整手模拟(冒烟) | 随机打至荒牌流局:不抛异常、点数守恒、可开下一局 |
| 14 | P0-01 | 立直后不能打非摸到的牌,只能摸切 |
| 15 | P0-02 | 鸣牌超时:未到点不生效;到点自动跳过并推进 |
| 16-18 | P1-03 | 四家立直/四风连打 · 三家和了→流局 · 四杠散了判定 |
| 19 | P1-04 | 流局满贯:弃牌全幺九未被鸣→满贯自摸,不收供托 |
| 20 | P1-05 | All Last 亲家一位、达返点且连庄→终局 |
| 21 | P1-06 | 立直后暗杠:改变听牌被拒 / 不变则允许 |
| 22 | P1-07 | 三麻抢北:对拔北开启荣和窗口 |
| 23 | P1-08 | 里宝牌:和了前隐藏,和了后公开 `uraIndicators` |
| 24 | P1-10 | 被鸣走的自弃牌仍计入舍张振听 |
| 25 | R-01 | 碰后 `justCalledBy` 期间拔北被拒,打牌后恢复 |
| 26 | R-02 | 加杠被抢撤销为碰并扣回赤宝牌计数 |
| 27-28 | R-05 | 四麻ウマ +15/+5/−5/−15 且头名赏 +20 · 三麻 +15/0/−15 且 +15 |
| 29 | R-06 | `playerView` 暴露 `myWaits` / `myFuriten` |
### Q4. 联机 E2E(Playwright)
| 用例 | 覆盖 |
| --- | --- |
| `e2e/mahjong-multiplayer.spec.ts` | 4 客户端注册/建房/加入/开局 · 各自仅见己方手牌 · 对手座位无真实牌泄漏 · 连续轮转打牌 · 牌河累积 |
### Q5. 未纳入自动化(人工/简化,非缺陷)
| 项 | 说明 |
| --- | --- |
| 三麻自摸损全表 | 仅抽样(满贯)验证,未穷举 |
| 浏览器冒烟(人工) | 开局/打牌/换座/鸣牌/超时自动跳过,见上节记录 |
| R-03 / S-01~S-04 | 有意简化项,见 [P 节](#p-已修复与剩余风险) |
| R-04 观战/AI | 未实现,无需测试 |

View File

@@ -0,0 +1,144 @@
/**
* mahjong-tiles — 牌具与牌组单元测试。
*
* 覆盖:牌组张数与构成、赤宝牌、三麻去牌、索引互转、宝牌指示映射、排序与计数。
*/
import { describe, expect, test } from 'vitest';
import {
ALL_KINDS,
HONOR_KINDS,
makeDeck,
kindsForMode,
kindToIndex,
indexToKind,
normalizeKind,
isRedKind,
doraFromIndicator,
sortTiles,
tileLabel,
countsFromKinds,
kindsFromCounts,
tilesFromKinds,
isTerminalOrHonorIndex,
isHonorIndex,
shuffle,
} from './mahjong-tiles.js';
describe('mahjong-tiles — 四麻牌组', () => {
test('136 张,id 唯一', () => {
const deck = makeDeck(4);
expect(deck).toHaveLength(136);
expect(new Set(deck.map((t) => t.id)).size).toBe(136);
});
test('每种牌种恰好 4 张', () => {
const counts = countsFromKinds(makeDeck(4).map((t) => t.kind));
for (const i of ALL_KINDS.keys()) expect(counts[i]).toBe(4);
});
test('赤宝牌:红 5m/5p/5s 各 1', () => {
const reds = makeDeck(4).filter((t) => t.red);
expect(reds).toHaveLength(3);
expect(reds.map((t) => t.kind).sort()).toEqual(['0m', '0p', '0s']);
});
});
describe('mahjong-tiles — 三麻牌组', () => {
test('108 张,且不含 2m~8m', () => {
const deck = makeDeck(3);
expect(deck).toHaveLength(108);
const kinds = kindsForMode(3);
expect(kinds).toHaveLength(27);
for (const t of deck) {
const k = normalizeKind(t.kind);
if (k[1] === 'm') expect(['1m', '9m']).toContain(k);
}
});
test('赤宝牌:红 5p/5s 各 1(无红 5m)', () => {
const reds = makeDeck(3).filter((t) => t.red);
expect(reds).toHaveLength(2);
expect(reds.map((t) => t.kind).sort()).toEqual(['0p', '0s']);
});
});
describe('mahjong-tiles — 索引与牌种互转', () => {
test('34 种牌种索引 0..33 一一对应', () => {
ALL_KINDS.forEach((k, i) => {
expect(kindToIndex(k)).toBe(i);
expect(indexToKind(i)).toBe(k);
});
});
test('赤牌归一到普通 5', () => {
expect(normalizeKind('0m')).toBe('5m');
expect(kindToIndex('0m')).toBe(kindToIndex('5m'));
expect(isRedKind('0m')).toBe(true);
expect(isRedKind('5m')).toBe(false);
});
test('幺九/字牌判定', () => {
expect(isTerminalOrHonorIndex(kindToIndex('1m'))).toBe(true);
expect(isTerminalOrHonorIndex(kindToIndex('9s'))).toBe(true);
expect(isTerminalOrHonorIndex(kindToIndex('5p'))).toBe(false);
expect(isHonorIndex(kindToIndex('1z'))).toBe(true);
expect(isTerminalOrHonorIndex(kindToIndex('7z'))).toBe(true);
});
});
describe('mahjong-tiles — 宝牌指示映射', () => {
test('数牌 9→1 循环', () => {
expect(doraFromIndicator('9m')).toBe('1m');
expect(doraFromIndicator('1m')).toBe('2m');
expect(doraFromIndicator('8s')).toBe('9s');
});
test('赤牌指示按普通 5 计算', () => {
expect(doraFromIndicator('0p')).toBe('6p');
});
test('风牌 東→南→西→北→東', () => {
expect(doraFromIndicator('1z')).toBe('2z');
expect(doraFromIndicator('4z')).toBe('1z');
});
test('三元牌 白→發→中→白', () => {
expect(doraFromIndicator('5z')).toBe('6z');
expect(doraFromIndicator('7z')).toBe('5z');
});
});
describe('mahjong-tiles — 排序与展示', () => {
test('sortTiles 按 m→p→s→z、各花色 1..9', () => {
const tiles = tilesFromKinds(['7z', '3m', '1p', '9s', '2m', '5z']);
expect(sortTiles(tiles).map((t) => t.kind)).toEqual(['2m', '3m', '1p', '9s', '5z', '7z']);
});
test('tileLabel:字牌中文、数牌数字+花色', () => {
expect(tileLabel('1z')).toBe('東');
expect(tileLabel('7z')).toBe('中');
expect(tileLabel('3p')).toBe('3P');
expect(tileLabel('0s')).toBe('5S');
});
test('计数与牌种列表互转(赤牌归一)', () => {
const kinds = ['1m', '1m', '0p', '9s', '1z', '1z', '1z'];
const counts = countsFromKinds(kinds);
expect(counts[kindToIndex('1m')]).toBe(2);
expect(counts[kindToIndex('5p')]).toBe(1);
expect(kindsFromCounts(counts)).toHaveLength(7);
});
});
describe('mahjong-tiles — 洗牌', () => {
test('注入固定随机源可复现,且不改原数组', () => {
const src = [1, 2, 3, 4, 5];
const rnd = () => 0.5;
const a = shuffle(src, rnd);
const b = shuffle(src, rnd);
expect(a).toEqual(b);
expect(src).toEqual([1, 2, 3, 4, 5]);
expect([...a].sort()).toEqual([1, 2, 3, 4, 5]);
});
});

View File

@@ -0,0 +1,282 @@
/**
* 日本立直麻将 — 牌具与牌组(纯函数,无状态机)。
*
* 规则依据:docs/mahjong/00-rules.md §1、§8。
*
* 牌种(34 种)索引约定:
* 0..8 = 1m..9m
* 9..17 = 1p..9p
* 18..26 = 1s..9s
* 27..33 = 東 南 西 北 白 發 中(记作 1z..7z)
*
* 赤宝牌用 kind = '0m'/'0p'/'0s' 表示;参与牌型判定时用 normalizeKind 归一到 '5m'/'5p'/'5s'。
* 四麻:红 5m/5p/5s 各 1 张,共 136 张。
* 三麻:去掉 2m..8m,保留 1m/9m,共 108 张;红 5p/5s 各 1 张(无红 5m)。
*/
/** 数牌花色 m/p/s;字牌 z。 */
export type TileSuit = 'm' | 'p' | 's' | 'z';
/** 牌种字符串:'1m'..'9m' / '1p'..'9p' / '1s'..'9s' / '1z'..'7z',赤牌 '0m'/'0p'/'0s'。 */
export type TileKind = string;
/** 一张物理牌。 */
export interface Tile {
/** 全局唯一 id(如 '5m-2'、'0m-0')。 */
id: string;
/** 牌种(赤牌为 '0m'/'0p'/'0s')。 */
kind: TileKind;
/** 是否为赤宝牌。 */
red?: boolean;
}
/** 四麻/三麻人数模式。 */
export type MahjongMode = 4 | 3;
// ============================================================
// 牌种常量与互转
// ============================================================
/** 34 种标准牌种(按索引顺序)。 */
export const ALL_KINDS: readonly TileKind[] = [
'1m', '2m', '3m', '4m', '5m', '6m', '7m', '8m', '9m',
'1p', '2p', '3p', '4p', '5p', '6p', '7p', '8p', '9p',
'1s', '2s', '3s', '4s', '5s', '6s', '7s', '8s', '9s',
'1z', '2z', '3z', '4z', '5z', '6z', '7z',
] as const;
/** 字牌牌种(東南西北白發中)。 */
export const HONOR_KINDS: readonly TileKind[] = ['1z', '2z', '3z', '4z', '5z', '6z', '7z'] as const;
/** 风牌牌种(東南西北)。 */
export const WIND_KINDS: readonly TileKind[] = ['1z', '2z', '3z', '4z'] as const;
/** 三元牌牌种(白發中)。 */
export const DRAGON_KINDS: readonly TileKind[] = ['5z', '6z', '7z'] as const;
/** 三麻中被移除的牌种(2m..8m)。 */
export const SANMA_REMOVED_KINDS: readonly TileKind[] = ['2m', '3m', '4m', '5m', '6m', '7m', '8m'] as const;
/** 某牌种是否为赤宝牌。 */
export function isRedKind(kind: TileKind): boolean {
return kind === '0m' || kind === '0p' || kind === '0s';
}
/** 赤牌归一到普通牌种:'0m'→'5m'。 */
export function normalizeKind(kind: TileKind): TileKind {
if (kind === '0m') return '5m';
if (kind === '0p') return '5p';
if (kind === '0s') return '5s';
return kind;
}
/** 牌种 → 0..33 索引。 */
export function kindToIndex(kind: TileKind): number {
const k = normalizeKind(kind);
const suit = k[1] as TileSuit;
const num = Number(k[0]);
switch (suit) {
case 'm':
return num - 1;
case 'p':
return 9 + num - 1;
case 's':
return 18 + num - 1;
case 'z':
return 27 + num - 1;
default:
throw new Error(`未知牌种: ${kind}`);
}
}
/** 0..33 索引 → 牌种。 */
export function indexToKind(index: number): TileKind {
if (index < 0 || index > 33) throw new Error(`索引越界: ${index}`);
return ALL_KINDS[index]!;
}
/** 索引是否为字牌。 */
export function isHonorIndex(index: number): boolean {
return index >= 27;
}
/** 索引是否为幺九牌(数牌 1/9 或字牌)。 */
export function isTerminalOrHonorIndex(index: number): boolean {
if (index >= 27) return true;
const n = (index % 9) + 1;
return n === 1 || n === 9;
}
/** 索引是否为数牌 2..8(中张)。 */
export function isSimpleIndex(index: number): boolean {
return !isTerminalOrHonorIndex(index);
}
// ============================================================
// 牌组生成
// ============================================================
/** 该模式下参与牌局的 34→N 种牌种列表(含三麻去牌)。 */
export function kindsForMode(mode: MahjongMode): TileKind[] {
if (mode === 3) {
return ALL_KINDS.filter((k) => !SANMA_REMOVED_KINDS.includes(k));
}
return [...ALL_KINDS];
}
/**
* 生成一副牌(未洗)。
* - 四麻:136 张,红 5m/5p/5s 各 1。
* - 三麻:108 张,红 5p/5s 各 1。
*/
export function makeDeck(mode: MahjongMode = 4): Tile[] {
const kinds = kindsForMode(mode);
const deck: Tile[] = [];
for (const kind of kinds) {
for (let i = 0; i < 4; i++) {
deck.push({ id: `${kind}-${i}`, kind });
}
}
// 标记赤宝牌:把每个有红牌的牌种的第一张改成 0? 形式。
const redSources: TileKind[] = mode === 3 ? ['5p', '5s'] : ['5m', '5p', '5s'];
for (const src of redSources) {
const tile = deck.find((t) => t.kind === src);
if (tile) {
const redKind = `0${src[1]}`;
tile.kind = redKind;
tile.red = true;
tile.id = `${redKind}-0`;
}
}
return deck;
}
/** Fisher–Yates 洗牌(可注入随机源便于测试)。 */
export function shuffle<T>(arr: readonly T[], random: () => number = Math.random): T[] {
const a = arr.slice();
for (let i = a.length - 1; i > 0; i--) {
const j = Math.floor(random() * (i + 1));
const ai = a[i] as T;
const aj = a[j] as T;
a[i] = aj;
a[j] = ai;
}
return a;
}
// ============================================================
// 排序 / 显示
// ============================================================
/** 手牌排序:按索引升序(m → p → s → z,各花色 1..9)。 */
export function sortTiles(tiles: readonly Tile[]): Tile[] {
return [...tiles].sort((a, b) => kindToIndex(a.kind) - kindToIndex(b.kind));
}
/** 牌种显示标签(赤五用红色数字 0 的语义,这里返回 '5m' 并另标 red)。 */
export function tileLabel(kind: TileKind): string {
const k = normalizeKind(kind);
const suit = k[1] ?? '';
if (suit === 'z') {
return ['東', '南', '西', '北', '白', '發', '中'][Number(k[0]) - 1] ?? k;
}
return `${k[0]}${suit.toUpperCase()}`;
}
// ============================================================
// 宝牌指示映射
// ============================================================
/**
* 由宝牌指示牌推出宝牌牌种。
* 数牌 1→2→…→9→1;风 東→南→西→北→東;三元 白→發→中→白。
*/
export function doraFromIndicator(indicator: TileKind): TileKind {
const k = normalizeKind(indicator);
const suit = k[1] as TileSuit;
const num = Number(k[0]);
if (suit === 'z') {
if (num <= 4) return `${num === 4 ? 1 : num + 1}z`;
return `${num === 7 ? 5 : num + 1}z`;
}
return `${num === 9 ? 1 : num + 1}${suit}`;
}
// ============================================================
// 计数辅助(34 长度计数数组)
// ============================================================
/** 牌种计数数组(长度 34)。 */
export type Counts34 = number[];
export function emptyCounts(): Counts34 {
return new Array(34).fill(0);
}
/** 由牌种列表得到计数数组(赤牌先归一)。 */
export function countsFromKinds(kinds: readonly TileKind[]): Counts34 {
const counts = emptyCounts();
for (const k of kinds) counts[kindToIndex(k)]! += 1;
return counts;
}
/** 由物理牌得到计数数组。 */
export function countsFromTiles(tiles: readonly Tile[]): Counts34 {
return countsFromKinds(tiles.map((t) => t.kind));
}
/** 计数数组 → 牌种列表(每个索引重复 count 次)。 */
export function kindsFromCounts(counts: Counts34): TileKind[] {
const out: TileKind[] = [];
for (let i = 0; i < 34; i++) {
for (let c = 0; c < (counts[i] ?? 0); c++) out.push(indexToKind(i));
}
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, ...(isRedKind(kind) ? { red: true } : {}) };
});
}
// ============================================================
// 死墙常量
// ============================================================
/** 王牌(死墙)张数:4 张岭上牌 + 最多 5 张宝牌指示牌位(预留);标准 14 张。 */
export const DEAD_WALL_SIZE = 14;
/** 岭上牌张数。 */
export const RINSHAN_SIZE = 4;
export const MAHJONG_TILE_HELPERS = {
ALL_KINDS,
HONOR_KINDS,
WIND_KINDS,
DRAGON_KINDS,
SANMA_REMOVED_KINDS,
isRedKind,
normalizeKind,
kindToIndex,
indexToKind,
isHonorIndex,
isTerminalOrHonorIndex,
isSimpleIndex,
kindsForMode,
makeDeck,
shuffle,
sortTiles,
tileLabel,
doraFromIndicator,
emptyCounts,
countsFromKinds,
countsFromTiles,
kindsFromCounts,
tilesFromKinds,
DEAD_WALL_SIZE,
RINSHAN_SIZE,
};

View File

@@ -0,0 +1,320 @@
/**
* mahjong-yaku — 和牌分解 / 役种 / 符 / 点数单元测试。
*
* 重点对拍标准表:役种识别、符计算、点数(含满贯封顶、切上满贯不采用、三麻自摸损)。
*/
import { describe, expect, test } from 'vitest';
import { kindToIndex } from './mahjong-tiles.js';
import {
evaluateHand,
countDora,
computeScore,
basePoints,
decomposeStandard,
isChiitoi,
isKokushi,
countsFromIndices,
waitingKinds,
isTenpai,
formsWinningShape,
kuikaeForbiddenDiscards,
isKuikaeForbidden,
isDiscardFuriten,
type HandContext,
type Meld,
} from './mahjong-yaku.js';
const I = (k: string): number => kindToIndex(k);
/** 便捷构造 HandContext;winTile 缺省取最后一张。 */
function makeCtx(
concealedKinds: string[],
opts: Partial<HandContext> & { winKind?: string; melds?: Meld[] } = {},
): HandContext {
const concealed = concealedKinds.map(I);
const winTile = opts.winKind ? I(opts.winKind) : concealed[concealed.length - 1]!;
const { winKind: _w, ...rest } = opts;
return {
concealed,
melds: [],
winTile,
tsumo: false,
seatWind: 2,
roundWind: 1,
isDealer: false,
...rest,
};
}
const names = (ctx: HandContext): string[] => (evaluateHand(ctx)?.yaku ?? []).map((y) => y.name);
describe('mahjong-yaku — 和牌型分解', () => {
test('标准型分解数量正确(223344m + 223344p + 55s)', () => {
const counts = countsFromIndices(['2m', '2m', '3m', '3m', '4m', '4m', '2p', '2p', '3p', '3p', '4p', '4p', '5s', '5s'].map(I));
const decomps = decomposeStandard(counts, 4);
expect(decomps.length).toBeGreaterThan(0);
expect(decomps[0]!.pair).toBe(I('5s'));
});
test('七对子 / 国士判定', () => {
expect(isChiitoi(countsFromIndices(['1m', '1m', '2m', '2m', '3p', '3p', '4p', '4p', '5s', '5s', '6s', '6s', '7z', '7z'].map(I)))).toBe(true);
expect(isKokushi(countsFromIndices(['1m', '9m', '1p', '9p', '1s', '9s', '1z', '2z', '3z', '4z', '5z', '6z', '7z', '1m'].map(I)))).toBe(true);
});
});
describe('mahjong-yaku — 普通役种', () => {
test('平和(门清、全顺子、非役牌雀头、两面)', () => {
const c = makeCtx(['1m', '2m', '3m', '3m', '4m', '5m', '5p', '6p', '7p', '6s', '7s', '8s', '5s', '5s'], { winKind: '3m' });
const e = evaluateHand(c)!;
expect(e.yaku.map((y) => y.name)).toContain('平和');
expect(e.fu).toBe(30); // 平和荣和
expect(e.han).toBe(1);
});
test('平和 + 断幺九', () => {
const c = makeCtx(['2m', '3m', '4m', '3m', '4m', '5m', '5p', '6p', '7p', '6s', '7s', '8s', '5s', '5s'], { winKind: '2m' });
const e = evaluateHand(c)!;
expect(e.yaku.map((y) => y.name)).toEqual(expect.arrayContaining(['平和', '断幺九']));
expect(e.han).toBe(2);
});
test('一杯口', () => {
const c = makeCtx(['2m', '3m', '4m', '2m', '3m', '4m', '5p', '6p', '7p', '6s', '7s', '8s', '9s', '9s'], { winKind: '2m' });
expect(names(c)).toContain('一杯口');
});
test('二杯口', () => {
const c = makeCtx(['2m', '3m', '4m', '2m', '3m', '4m', '2p', '3p', '4p', '2p', '3p', '4p', '5s', '5s'], { winKind: '2m' });
const e = evaluateHand(c)!;
expect(e.yaku.map((y) => y.name)).toContain('二杯口');
expect(e.yakumanMultiplier).toBe(0);
});
test('役牌(三元牌)与副露', () => {
const melds: Meld[] = [{ type: 'pon', tiles: [I('5z'), I('5z'), I('5z')], from: '1' }];
const c = makeCtx(['2m', '3m', '4m', '5m', '6m', '7m', '7s', '8s', '9s', '9p', '9p'], { winKind: '2m', melds });
const e = evaluateHand(c)!;
expect(e.yaku.map((y) => y.name).some((n) => n.startsWith('役牌'))).toBe(true);
expect(e.yaku.map((y) => y.name)).not.toContain('门前清自摸和');
});
test('混一色 / 清一色', () => {
const honitsu = makeCtx(['1m', '2m', '3m', '4m', '5m', '6m', '7m', '8m', '9m', '1z', '1z', '1z', '2z', '2z'], { winKind: '1m' });
expect(names(honitsu)).toContain('混一色');
const chinitsu = makeCtx(['1m', '2m', '3m', '4m', '5m', '6m', '7m', '8m', '9m', '1m', '2m', '3m', '9m', '9m'], { winKind: '1m' });
expect(names(chinitsu)).toContain('清一色');
});
test('混全带幺九 / 纯全带幺九', () => {
// 123m 789m 123p 789s + 東東:全带且有字牌 → 混全带
const chanta = makeCtx(['1m', '2m', '3m', '7m', '8m', '9m', '1p', '2p', '3p', '7s', '8s', '9s', '1z', '1z'], { winKind: '1m' });
expect(names(chanta)).toContain('混全带幺九');
// 123m 789m 123p 789s + 9p9p:全带无字牌 → 纯全带
const junchan = makeCtx(['1m', '2m', '3m', '7m', '8m', '9m', '1p', '2p', '3p', '7s', '8s', '9s', '9p', '9p'], { winKind: '1m' });
expect(names(junchan)).toContain('纯全带幺九');
});
test('无役返回 null(役牌雀头 + 全顺子)', () => {
const c = makeCtx(['1m', '2m', '3m', '4m', '5m', '6m', '7p', '8p', '9p', '2s', '3s', '4s', '1z', '1z'], { winKind: '4s' });
expect(evaluateHand(c)).toBeNull();
});
test('三色同顺 / 一气通贯 / 三色同刻', () => {
const sanshoku = makeCtx(['2m', '3m', '4m', '2p', '3p', '4p', '2s', '3s', '4s', '5m', '6m', '7m', '9s', '9s'], { winKind: '2m' });
expect(names(sanshoku)).toContain('三色同顺');
const ittsu = makeCtx(['1m', '2m', '3m', '4m', '5m', '6m', '7m', '8m', '9m', '1p', '2p', '3p', '5s', '5s'], { winKind: '1p' });
expect(names(ittsu)).toContain('一气通贯');
const doukou = makeCtx(['1m', '1m', '1m', '1p', '1p', '1p', '1s', '1s', '1s', '2m', '3m', '4m', '1z', '1z'], { winKind: '1m' });
expect(names(doukou)).toContain('三色同刻');
});
test('对对和 + 三暗刻(荣和完成一刻,不计四暗刻)', () => {
const c = makeCtx(['1m', '1m', '1m', '2p', '2p', '2p', '3s', '3s', '3s', '4s', '4s', '9m', '9m', '4s'], { winKind: '4s' });
const e = evaluateHand(c)!;
expect(e.yaku.map((y) => y.name)).toEqual(expect.arrayContaining(['对对和', '三暗刻']));
expect(e.yakumanMultiplier).toBe(0);
});
test('混老头 / 小三元', () => {
const honroutou = makeCtx(['1m', '1m', '1m', '9p', '9p', '9p', '1z', '1z', '1z', '5z', '5z', '9s', '9s', '5z'], { winKind: '5z' });
expect(names(honroutou)).toContain('混老头');
const shousangen = makeCtx(['2m', '3m', '4m', '5s', '6s', '7s', '5z', '5z', '5z', '6z', '6z', '6z', '7z', '7z'], { winKind: '2m' });
expect(names(shousangen)).toContain('小三元');
});
test('立直 / 一发 / 门前清自摸和 / 平和(自摸 20 符)', () => {
const c = makeCtx(['1m', '2m', '3m', '3m', '4m', '5m', '5p', '6p', '7p', '6s', '7s', '8s', '5s', '5s'], {
winKind: '3m', tsumo: true, riichi: true, ippatsu: true,
});
const e = evaluateHand(c)!;
expect(e.yaku.map((y) => y.name)).toEqual(expect.arrayContaining(['立直', '一发', '门前清自摸和', '平和']));
expect(e.fu).toBe(20);
});
test('海底摸月 / 河底捞鱼 / 岭上开花 / 抢杠 旗标', () => {
const base = ['2m', '3m', '4m', '2m', '3m', '4m', '5p', '6p', '7p', '6s', '7s', '8s', '9s', '9s'];
for (const [flag, yakuName] of [['haitei', '海底摸月'], ['houtei', '河底捞鱼'], ['rinshan', '岭上开花'], ['chankan', '抢杠']] as const) {
const c = makeCtx(base, { winKind: '2m', [flag]: true } as any);
expect(names(c)).toContain(yakuName);
}
});
});
describe('mahjong-yaku — 符细节', () => {
test('暗杠字牌(幺九暗杠 +32),门清荣和 70 符', () => {
const melds: Meld[] = [{ type: 'ankan', tiles: [I('1z'), I('1z'), I('1z'), I('1z')], from: 'self' }];
const c = makeCtx(['2m', '3m', '4m', '5m', '6m', '7m', '6s', '7s', '8s', '9m', '9m'], { winKind: '2m', melds });
const e = evaluateHand(c)!;
// 20 底 + 10 门清荣和 + 32 幺九暗杠 = 62 → 进位 70
expect(e.fu).toBe(70);
});
});
describe('mahjong-yaku — 役满', () => {
test('国士无双(单倍)', () => {
const c = makeCtx(['1m', '9m', '1p', '9p', '1s', '9s', '1z', '2z', '3z', '4z', '5z', '6z', '7z', '1m'], { winKind: '9m' });
const e = evaluateHand(c)!;
expect(e.isYakuman).toBe(true);
expect(e.yakumanMultiplier).toBe(1);
});
test('国士无双十三面(双倍)', () => {
const c = makeCtx(['1m', '9m', '1p', '9p', '1s', '9s', '1z', '2z', '3z', '4z', '5z', '6z', '7z', '1m'], { winKind: '1m' });
const e = evaluateHand(c)!;
expect(e.yakumanMultiplier).toBe(2);
expect(e.yaku[0]!.name).toBe('国士无双十三面');
});
test('大三元', () => {
const c = makeCtx(['1m', '2m', '3m', '5z', '5z', '5z', '6z', '6z', '6z', '7z', '7z', '7z', '9s', '9s'], { winKind: '1m' });
const e = evaluateHand(c)!;
expect(e.yaku.map((y) => y.name)).toContain('大三元');
expect(e.yakumanMultiplier).toBe(1);
});
test('四暗刻(自摸)与四暗刻单骑(双倍)', () => {
const tsumo = makeCtx(['1m', '1m', '1m', '2p', '2p', '2p', '3s', '3s', '3s', '1z', '1z', '1z', '5m', '5m'], { winKind: '1z', tsumo: true });
const e1 = evaluateHand(tsumo)!;
expect(e1.yaku.map((y) => y.name)).toContain('四暗刻');
expect(e1.yakumanMultiplier).toBe(1);
const tanki = makeCtx(['1m', '1m', '1m', '2p', '2p', '2p', '3s', '3s', '3s', '1z', '1z', '1z', '5m', '5m'], { winKind: '5m', tsumo: false });
const e2 = evaluateHand(tanki)!;
expect(e2.yaku.map((y) => y.name)).toContain('四暗刻单骑');
expect(e2.yakumanMultiplier).toBe(2);
});
test('字一色', () => {
const c = makeCtx(['1z', '1z', '1z', '2z', '2z', '2z', '3z', '3z', '3z', '5z', '5z', '5z', '7z', '7z'], { winKind: '7z' });
const e = evaluateHand(c)!;
expect(e.yaku.map((y) => y.name)).toContain('字一色');
});
test('九莲宝灯(普通 = 1 倍,纯正 = 2 倍)', () => {
// 普通九莲:多出的 1m 已在手,和了 2m(去掉后非纯正形)
const chuuren = makeCtx(['1m', '1m', '1m', '1m', '2m', '3m', '4m', '5m', '6m', '7m', '8m', '9m', '9m', '9m'], { winKind: '2m' });
const e1 = evaluateHand(chuuren)!;
expect(e1.yaku.map((y) => y.name)).toContain('九莲宝灯');
expect(e1.yakumanMultiplier).toBe(1);
const junsei = makeCtx(['1m', '1m', '1m', '2m', '3m', '4m', '5m', '6m', '7m', '8m', '9m', '9m', '9m', '1m'], { winKind: '1m' });
const e2 = evaluateHand(junsei)!;
expect(e2.yaku.map((y) => y.name)).toContain('纯正九莲宝灯');
expect(e2.yakumanMultiplier).toBe(2);
});
});
describe('mahjong-yaku — 宝牌计数', () => {
test('指示牌 3m → 宝牌 4m;赤牌归一到 5', () => {
expect(countDora(['3m'], ['4m', '4m', '5m', '1p'])).toBe(2);
expect(countDora(['9s'], ['1s', '9s'])).toBe(1);
expect(countDora(['4z'], ['1z'])).toBe(1); // 北→東
});
});
describe('mahjong-yaku — 符与点数', () => {
test('点数基本表(子/亲、荣和/自摸)', () => {
const r = (o: Parameters<typeof computeScore>[0]) => computeScore(o);
expect(r({ han: 1, fu: 30, yakumanMultiplier: 0, isDealer: false, tsumo: false, honba: 0, mode: 4 }).fromRon).toBe(1000);
expect(r({ han: 1, fu: 30, yakumanMultiplier: 0, isDealer: true, tsumo: false, honba: 0, mode: 4 }).fromRon).toBe(1500);
expect(r({ han: 1, fu: 30, yakumanMultiplier: 0, isDealer: false, tsumo: true, honba: 0, mode: 4 })).toMatchObject({ fromDealer: 500, fromEach: 300, total: 1100 });
expect(r({ han: 2, fu: 25, yakumanMultiplier: 0, isDealer: false, tsumo: false, honba: 0, mode: 4 }).fromRon).toBe(1600);
expect(r({ han: 3, fu: 30, yakumanMultiplier: 0, isDealer: false, tsumo: false, honba: 0, mode: 4 }).fromRon).toBe(3900);
expect(r({ han: 3, fu: 30, yakumanMultiplier: 0, isDealer: true, tsumo: false, honba: 0, mode: 4 }).fromRon).toBe(5800);
});
test('满贯封顶与切上满贯不采用', () => {
expect(basePoints(4, 30, 0).base).toBe(1920); // 4番30符 = 1920,非满贯
expect(computeScore({ han: 4, fu: 30, yakumanMultiplier: 0, isDealer: false, tsumo: false, honba: 0, mode: 4 }).fromRon).toBe(7700);
expect(computeScore({ han: 3, fu: 60, yakumanMultiplier: 0, isDealer: false, tsumo: false, honba: 0, mode: 4 }).fromRon).toBe(7700);
expect(computeScore({ han: 5, fu: 30, yakumanMultiplier: 0, isDealer: false, tsumo: false, honba: 0, mode: 4 }).fromRon).toBe(8000);
expect(computeScore({ han: 6, fu: 30, yakumanMultiplier: 0, isDealer: false, tsumo: false, honba: 0, mode: 4 }).fromRon).toBe(12000);
expect(computeScore({ han: 8, fu: 30, yakumanMultiplier: 0, isDealer: false, tsumo: false, honba: 0, mode: 4 }).fromRon).toBe(16000);
expect(computeScore({ han: 11, fu: 30, yakumanMultiplier: 0, isDealer: false, tsumo: false, honba: 0, mode: 4 }).fromRon).toBe(24000);
});
test('役满与双倍役满', () => {
expect(computeScore({ han: 0, fu: 0, yakumanMultiplier: 1, isDealer: false, tsumo: false, honba: 0, mode: 4 }).fromRon).toBe(32000);
expect(computeScore({ han: 0, fu: 0, yakumanMultiplier: 1, isDealer: true, tsumo: false, honba: 0, mode: 4 }).fromRon).toBe(48000);
expect(computeScore({ han: 0, fu: 0, yakumanMultiplier: 2, isDealer: false, tsumo: false, honba: 0, mode: 4 }).fromRon).toBe(64000);
});
test('本场加算(四麻荣和 +300/本场)', () => {
const s = computeScore({ han: 1, fu: 30, yakumanMultiplier: 0, isDealer: false, tsumo: false, honba: 2, mode: 4 });
expect(s.fromRon).toBe(1000 + 600);
});
test('三麻自摸损:子家满贯自摸 6000(庄 4000 + 闲 2000)', () => {
const s = computeScore({ han: 5, fu: 30, yakumanMultiplier: 0, isDealer: false, tsumo: true, honba: 0, mode: 3 });
expect(s).toMatchObject({ fromDealer: 4000, fromEach: 2000, total: 6000 });
});
});
describe('mahjong-yaku — 听牌 / 振听 / 食替(P4)', () => {
test('单骑听牌:13 张听 5s', () => {
const concealed = ['1m', '2m', '3m', '4m', '5m', '6m', '7m', '8m', '9m', '1p', '2p', '3p', '5s'].map(I);
expect(isTenpai(concealed)).toBe(true);
expect(waitingKinds(concealed)).toEqual([I('5s')]);
});
test('两面听牌:23m + ... 听 1m/4m', () => {
const concealed = ['2m', '3m', '4m', '5m', '6m', '7p', '8p', '9p', '1s', '2s', '3s', '5s', '5s'].map(I);
const w = waitingKinds(concealed);
expect(w).toContain(I('4m'));
});
test('不成和牌型 returns false', () => {
const concealed = ['1m', '1m', '1m', '2m', '2m', '2m', '3m', '3m', '3m', '4m', '4m', '4m', '5m'].map(I);
expect(isTenpai(concealed)).toBe(true); // 单骑 5m(若为 5m 只有 1 张也可成 4 刻 + 对子)
const dead = ['1m', '3m', '5m', '7m', '9m', '1p', '3p', '5p', '7p', '9p', '1s', '3s', '5s'].map(I);
expect(formsWinningShape(countsFromIndices([...dead, I('7s')]), [])).toBe(false);
});
test('食替:碰的现物、吃的现物 + 筋', () => {
const pon: Meld = { type: 'pon', tiles: [I('5m'), I('5m'), I('5m')], calledTile: I('5m') };
expect(kuikaeForbiddenDiscards(pon)).toEqual([I('5m')]);
// 吃 1m 组成 123m(called 在低端)→ 不可打 1m 或 4m
const chiLow: Meld = { type: 'chi', tiles: [I('1m'), I('2m'), I('3m')], calledTile: I('1m') };
expect(kuikaeForbiddenDiscards(chiLow).sort()).toEqual([I('1m'), I('4m')].sort());
expect(isKuikaeForbidden(chiLow, I('4m'))).toBe(true);
expect(isKuikaeForbidden(chiLow, I('5m'))).toBe(false);
// 吃 4m 组成 234m(called 在高端)→ 不可打 4m 或 1m
const chiHigh: Meld = { type: 'chi', tiles: [I('2m'), I('3m'), I('4m')], calledTile: I('4m') };
expect(kuikaeForbiddenDiscards(chiHigh).sort()).toEqual([I('4m'), I('1m')].sort());
// 吃中间(嵌张)→ 只有现物
const chiMid: Meld = { type: 'chi', tiles: [I('2m'), I('3m'), I('4m')], calledTile: I('3m') };
expect(kuikaeForbiddenDiscards(chiMid)).toEqual([I('3m')]);
});
test('舍张振听判定', () => {
expect(isDiscardFuriten([I('5m')], [I('5m'), I('1p')])).toBe(true);
expect(isDiscardFuriten([I('5m')], [I('1p'), I('9s')])).toBe(false);
});
});

View File

@@ -0,0 +1,954 @@
/**
* 日本立直麻将 — 和牌分解、役种判定、符计算、点数计算(纯函数)。
*
* 规则依据:docs/mahjong/00-rules.md §5–§9。
*
* 设计说明:
* - 牌统一用 0..33 索引(见 mahjong-tiles.ts)。
* - 和牌型分三类:标准型(4 面子 + 雀头)、七对子、国士无双。
* - 一手的「役」可能因面子分解方式不同而变化(如平和 / 三暗刻),
* 因此枚举全部分解与「和了牌归属」,取**点数最高**者(日麻标准)。
* - 宝牌(表/里/赤/拔北)不在本文件内判定,由调用方计入 doraHan。
*/
import type { MahjongMode, TileKind } from './mahjong-tiles.js';
import {
kindToIndex,
indexToKind,
isHonorIndex,
isTerminalOrHonorIndex,
doraFromIndicator,
emptyCounts,
} from './mahjong-tiles.js';
// ============================================================
// 常量与类型
// ============================================================
const WINDS = [27, 28, 29, 30] as const; // 東南西北
const DRAGONS = [31, 32, 33] as const; // 白發中
const GREEN_INDICES = new Set([19, 20, 21, 23, 25, 32]); // 2s3s4s6s8s發
/** 面子(含杠)。 */
export interface Meld {
/** chi 顺子 / pon 碰 / minkan 大明杠 / kakan 加杠 / ankan 暗杠。 */
type: 'chi' | 'pon' | 'minkan' | 'kakan' | 'ankan';
/** 组成该面子的牌索引(chi 3 个连续;pon 3 个相同;kan 4 个相同)。 */
tiles: number[];
/** 被鸣者 playerID;暗杠为 'self'。 */
from?: string;
/** 被鸣的那张牌索引(吃/碰用;用于食替校验)。 */
calledTile?: number;
/** 该面子中含有的赤宝牌张数(用于赤宝牌计数)。 */
redCount?: number;
}
/** 分解出的面子(仅隐藏手牌部分)。 */
export interface DecompSet {
kind: 'run' | 'triplet' | 'kan';
/** run: 起始索引;triplet/kan: 牌索引。 */
start: number;
/** 该面子是否为暗刻(仅对 concealed 分解有效,meld 由 Meld 决定)。 */
concealed: boolean;
}
export interface Decomposition {
sets: DecompSet[];
pair: number;
}
/** 和了上下文(含门风/场风/各种状态旗标)。 */
export interface HandContext {
/** 隐藏手牌索引(已含和了牌),共 14 - 3*melds.length 张。 */
concealed: number[];
/** 副露面子。 */
melds: Meld[];
/** 和了牌索引。 */
winTile: number;
tsumo: boolean;
/** 自风 1..4(1=東)。 */
seatWind: number;
/** 场风 1..4(1=東)。 */
roundWind: number;
isDealer: boolean;
riichi?: boolean;
doubleRiichi?: boolean;
ippatsu?: boolean;
rinshan?: boolean;
chankan?: boolean;
haitei?: boolean;
houtei?: boolean;
tenhou?: boolean;
chiihou?: boolean;
}
export interface YakuEntry {
name: string;
/** 普通役番数(役满时为 0)。 */
han: number;
/** 役满倍数(1 或 2)。 */
yakuman?: number;
}
export interface HandEval {
yaku: YakuEntry[];
/** 不含宝牌的番数(役满时为 0)。 */
han: number;
fu: number;
/** 役满总倍数(0 = 非役满)。 */
yakumanMultiplier: number;
isYakuman: boolean;
decomposition?: Decomposition;
/** 和了牌归属描述(调试用)。 */
wait: string;
}
// ============================================================
// 计数
// ============================================================
/** 由索引数组得到计数数组。 */
export function countsFromIndices(indices: readonly number[]): number[] {
const counts = emptyCounts();
for (const i of indices) counts[i]! += 1;
return counts;
}
function windIndex(wind: number): number {
return WINDS[wind - 1] ?? 27;
}
function isDragonIndex(i: number): boolean {
return i >= 31 && i <= 33;
}
// ============================================================
// 和牌型分解
// ============================================================
function decompSignature(d: Decomposition): string {
const sets = [...d.sets]
.map((s) => `${s.kind}${s.start}`)
.sort()
.join(',');
return `${sets}|${d.pair}`;
}
/**
* 将计数数组分解为 `setsNeeded` 个面子和 1 个雀头(仅标准型)。
* 枚举所有分解。counts 会被修改后还原。
*/
export function decomposeStandard(counts: number[], setsNeeded: number): Decomposition[] {
const results: Decomposition[] = [];
const seen = new Set<string>();
const search = (idx: number, sets: DecompSet[], pair: number | null): void => {
while (idx < 34 && counts[idx] === 0) idx++;
if (idx === 34) {
if (sets.length === setsNeeded && pair !== null) {
const d: Decomposition = { sets: [...sets], pair };
const sig = decompSignature(d);
if (!seen.has(sig)) {
seen.add(sig);
results.push(d);
}
}
return;
}
const need = setsNeeded - sets.length;
// 雀头
if (pair === null && counts[idx]! >= 2) {
counts[idx]! -= 2;
search(idx, sets, idx);
counts[idx]! += 2;
}
// 刻子
if (need > 0 && counts[idx]! >= 3) {
counts[idx]! -= 3;
sets.push({ kind: 'triplet', start: idx, concealed: true });
search(idx, sets, pair);
sets.pop();
counts[idx]! += 3;
}
// 顺子(仅数牌,且不跨花色)
const pos = idx < 27 ? idx % 9 : -1;
if (need > 0 && pos >= 0 && pos <= 6 && counts[idx + 1]! > 0 && counts[idx + 2]! > 0) {
counts[idx]! -= 1;
counts[idx + 1]! -= 1;
counts[idx + 2]! -= 1;
sets.push({ kind: 'run', start: idx, concealed: true });
search(idx, sets, pair);
sets.pop();
counts[idx]! += 1;
counts[idx + 1]! += 1;
counts[idx + 2]! += 1;
}
};
search(0, [], null);
return results;
}
/** 七对子判定。 */
export function isChiitoi(counts: readonly number[]): boolean {
let pairs = 0;
for (let i = 0; i < 34; i++) {
const c = counts[i] ?? 0;
if (c === 0) continue;
if (c !== 2) return false;
pairs += 1;
}
return pairs === 7;
}
/** 国士无双判定。 */
export function isKokushi(counts: readonly number[]): boolean {
let kinds = 0;
let pair = 0;
for (let i = 0; i < 34; i++) {
const c = counts[i] ?? 0;
if (c === 0) continue;
if (!isTerminalOrHonorIndex(i)) return false;
if (c === 2) pair += 1;
else if (c !== 1) return false;
kinds += 1;
}
return kinds === 13 && pair === 1;
}
/** 由 Meld[] 得到分解面子列表(用于役/符统一处理)。 */
function meldToSets(melds: readonly Meld[]): DecompSet[] {
return melds.map((m) => {
if (m.type === 'chi') {
return { kind: 'run' as const, start: m.tiles[0]!, concealed: false };
}
return {
kind: (m.type === 'minkan' || m.type === 'kakan' || m.type === 'ankan' ? 'kan' : 'triplet') as
| 'triplet'
| 'kan',
start: m.tiles[0]!,
concealed: m.type === 'ankan',
};
});
}
// ============================================================
// 和了牌归属
// ============================================================
interface Attribution {
type: 'pair' | 'run' | 'triplet';
setIndex: number; // -1 表示雀头
runPos: number; // run: 0/1/2
}
/** 枚举和了牌在该分解中的归属。 */
function attributionsFor(decomp: Decomposition, winTile: number): Attribution[] {
const out: Attribution[] = [];
if (decomp.pair === winTile) out.push({ type: 'pair', setIndex: -1, runPos: -1 });
decomp.sets.forEach((s, i) => {
if (s.kind === 'run') {
for (let p = 0; p < 3; p++) {
if (s.start + p === winTile) out.push({ type: 'run', setIndex: i, runPos: p });
}
} else if (s.start === winTile) {
out.push({ type: 'triplet', setIndex: i, runPos: -1 });
}
});
return out;
}
function waitName(a: Attribution): string {
if (a.type === 'pair') return '单骑';
if (a.type === 'triplet') return '双碰';
if (a.runPos === 1) return '嵌张';
return '两面';
}
// ============================================================
// 役种判定
// ============================================================
interface EvalFlags {
closed: boolean;
allIndices: number[];
pair: number;
sets: { set: DecompSet; concealed: boolean }[];
attribution: Attribution;
}
/** 某面子是否含幺九/字牌(顺子看首尾)。 */
function setHasTerminalHonor(s: DecompSet): boolean {
if (s.kind === 'run') {
const pos = s.start % 9;
return pos === 0 || pos === 6;
}
return isTerminalOrHonorIndex(s.start);
}
/** 计算役牌刻子番数(三元/自风/场风,连风 = 2)。 */
function yakuhaiHanForTriplet(start: number, ctx: HandContext): number {
let han = 0;
if (isDragonIndex(start)) han += 1;
if (start === windIndex(ctx.roundWind)) han += 1;
if (start === windIndex(ctx.seatWind)) han += 1;
return han;
}
/**
* 对某个「标准型分解 + 和了牌归属」判定役种与符。返回 HandEval。
*/
function evalStandard(
decomp: Decomposition,
attribution: Attribution,
ctx: HandContext,
closed: boolean,
meldSets: DecompSet[],
): HandEval {
const yaku: YakuEntry[] = [];
let yakuman = 0;
// 合并面子:concealed 分解面子 + 副露面子
const concealedSets: { set: DecompSet; concealed: boolean }[] = decomp.sets.map((s, i) => ({
set: s,
concealed: s.kind === 'run' ? false : !(!ctx.tsumo && attribution.setIndex === i),
}));
const openMeldSets: { set: DecompSet; concealed: boolean }[] = meldSets.map((s) => ({
set: s,
concealed: s.kind !== 'run' && s.concealed,
}));
const allSets = [...concealedSets, ...openMeldSets];
const allIndices = [
...ctx.concealed,
...ctx.melds.flatMap((m) => m.tiles),
];
const hasHonor = allIndices.some((i) => isHonorIndex(i));
const allTerminalHonor = allIndices.every((i) => isTerminalOrHonorIndex(i));
// ---- 役满 ----
if (ctx.tenhou) yakuman += 1;
if (ctx.chiihou) yakuman += 1;
const dragonTriplets = allSets.filter((s) => s.set.kind !== 'run' && isDragonIndex(s.set.start)).length;
const windTriplets = allSets.filter((s) => s.set.kind !== 'run' && WINDS.includes(s.set.start as any)).length;
const concealedTriplets = allSets.filter((s) => s.set.kind !== 'run' && s.concealed).length;
const kanCount = allSets.filter((s) => s.set.kind === 'kan').length;
// 四暗刻 / 四暗刻单骑
if (concealedTriplets >= 4) {
if (attribution.type === 'pair') {
yaku.push({ name: '四暗刻单骑', han: 0, yakuman: 2 });
yakuman += 2;
} else if (ctx.tsumo) {
yaku.push({ name: '四暗刻', han: 0, yakuman: 1 });
yakuman += 1;
}
}
// 大三元
if (dragonTriplets === 3) {
yaku.push({ name: '大三元', han: 0, yakuman: 1 });
yakuman += 1;
}
// 大四喜 / 小四喜
if (windTriplets === 4) {
yaku.push({ name: '大四喜', han: 0, yakuman: 2 });
yakuman += 2;
} else if (windTriplets === 3 && WINDS.includes(decomp.pair as any)) {
yaku.push({ name: '小四喜', han: 0, yakuman: 1 });
yakuman += 1;
}
// 字一色
if (allIndices.every((i) => isHonorIndex(i))) {
yaku.push({ name: '字一色', han: 0, yakuman: 1 });
yakuman += 1;
}
// 清老头
if (allIndices.every((i) => isTerminalOrHonorIndex(i) && !isHonorIndex(i))) {
yaku.push({ name: '清老头', han: 0, yakuman: 1 });
yakuman += 1;
}
// 绿一色
if (allIndices.every((i) => GREEN_INDICES.has(i))) {
yaku.push({ name: '绿一色', han: 0, yakuman: 1 });
yakuman += 1;
}
// 四杠子
if (kanCount === 4) {
yaku.push({ name: '四杠子', han: 0, yakuman: 1 });
yakuman += 1;
}
// 九莲宝灯
const chuuren = evalChuuren(ctx, closed);
if (chuuren) {
yaku.push(chuuren);
yakuman += chuuren.yakuman ?? 1;
}
if (yakuman > 0) {
return {
yaku,
han: 0,
fu: 0,
yakumanMultiplier: yakuman,
isYakuman: true,
decomposition: decomp,
wait: waitName(attribution),
};
}
// ---- 普通役 ----
if (ctx.doubleRiichi) yaku.push({ name: '双立直', han: 2 });
else if (ctx.riichi) yaku.push({ name: '立直', han: 1 });
if (ctx.ippatsu) yaku.push({ name: '一发', han: 1 });
if (closed && ctx.tsumo) yaku.push({ name: '门前清自摸和', han: 1 });
if (ctx.rinshan) yaku.push({ name: '岭上开花', han: 1 });
if (ctx.chankan) yaku.push({ name: '抢杠', han: 1 });
if (ctx.haitei) yaku.push({ name: '海底摸月', han: 1 });
if (ctx.houtei) yaku.push({ name: '河底捞鱼', han: 1 });
// 平和
const allRuns = allSets.every((s) => s.set.kind === 'run');
const pairIdx = decomp.pair;
const isYakuhaiPair =
isDragonIndex(pairIdx) || pairIdx === windIndex(ctx.seatWind) || pairIdx === windIndex(ctx.roundWind);
const isRyanmen = attribution.type === 'run' && (attribution.runPos === 0 || attribution.runPos === 2);
const pinfu = closed && allRuns && !isYakuhaiPair && isRyanmen;
if (pinfu) yaku.push({ name: '平和', han: 1 });
// 断幺九
const tanyao = allIndices.every((i) => !isTerminalOrHonorIndex(i));
if (tanyao) yaku.push({ name: '断幺九', han: 1 });
// 役牌
for (const s of allSets) {
if (s.set.kind === 'run') continue;
const h = yakuhaiHanForTriplet(s.set.start, ctx);
if (h > 0) yaku.push({ name: `役牌(${indexToKind(s.set.start)})`, han: h });
}
// 三色同顺
const runStarts = allSets.filter((s) => s.set.kind === 'run').map((s) => s.set.start);
if (hasSanshokuDoujun(runStarts)) yaku.push({ name: '三色同顺', han: closed ? 2 : 1 });
// 一气通贯
if (hasIttsu(runStarts)) yaku.push({ name: '一气通贯', han: closed ? 2 : 1 });
// 三色同刻
const tripletStarts = allSets.filter((s) => s.set.kind !== 'run').map((s) => s.set.start);
if (hasSanshokuDoukou(tripletStarts)) yaku.push({ name: '三色同刻', han: 2 });
// 一杯口 / 二杯口
if (closed) {
const pairs = runPairs(runStarts);
if (pairs >= 2) yaku.push({ name: '二杯口', han: 3 });
else if (pairs === 1) yaku.push({ name: '一杯口', han: 1 });
}
// 对对和
const allTriplets = allSets.every((s) => s.set.kind !== 'run');
if (allTriplets) yaku.push({ name: '对对和', han: 2 });
// 三暗刻
if (concealedTriplets === 3) yaku.push({ name: '三暗刻', han: 2 });
// 三杠子
if (kanCount === 3) yaku.push({ name: '三杠子', han: 2 });
// 三色同顺已计;全带 / 混老头
if (allTerminalHonor) yaku.push({ name: '混老头', han: 2 });
// 小三元
if (dragonTriplets === 2 && isDragonIndex(pairIdx)) yaku.push({ name: '小三元', han: 2 });
// 全带(混全带幺九 / 纯全带幺九)
const allSetsHaveTH = allSets.every((s) => setHasTerminalHonor(s.set));
const pairTH = isTerminalOrHonorIndex(pairIdx);
if (allSetsHaveTH && pairTH && !allTriplets) {
if (hasHonor) yaku.push({ name: '混全带幺九', han: closed ? 2 : 1 });
else yaku.push({ name: '纯全带幺九', han: closed ? 3 : 2 });
}
// 一色(混一色 / 清一色)
const suitSet = new Set<number>();
let suitsHasHonor = false;
for (const i of allIndices) {
if (isHonorIndex(i)) suitsHasHonor = true;
else suitSet.add(Math.floor(i / 9));
}
if (suitSet.size === 1) {
if (suitsHasHonor) yaku.push({ name: '混一色', han: closed ? 3 : 2 });
else yaku.push({ name: '清一色', han: closed ? 6 : 5 });
}
const han = yaku.reduce((a, y) => a + y.han, 0);
const fu = computeFu(decomp, attribution, ctx, closed, allSets, pinfu);
return {
yaku,
han,
fu,
yakumanMultiplier: 0,
isYakuman: false,
decomposition: decomp,
wait: waitName(attribution),
};
}
/** 三色同顺:三种花色存在同一序数顺子。 */
function hasSanshokuDoujun(runStarts: number[]): boolean {
for (let pos = 0; pos <= 6; pos++) {
const has: boolean[] = [false, false, false];
for (const s of runStarts) {
if (s < 27 && s % 9 === pos) has[Math.floor(s / 9)] = true;
}
if (has[0] && has[1] && has[2]) return true;
}
return false;
}
/** 一气通贯:同一花色 123 + 456 + 789。 */
function hasIttsu(runStarts: number[]): boolean {
for (let suit = 0; suit < 3; suit++) {
const base = suit * 9;
if (
runStarts.includes(base + 0) &&
runStarts.includes(base + 3) &&
runStarts.includes(base + 6)
) {
return true;
}
}
return false;
}
/** 三色同刻:三种花色同一数字刻子。 */
function hasSanshokuDoukou(tripletStarts: number[]): boolean {
for (let n = 0; n <= 8; n++) {
let cnt = 0;
for (let suit = 0; suit < 3; suit++) {
if (tripletStarts.includes(suit * 9 + n)) cnt += 1;
}
if (cnt === 3) return true;
}
return false;
}
/** 相同顺子的配对数(floor(count/2) 之和)。 */
function runPairs(runStarts: number[]): number {
const map = new Map<number, number>();
for (const s of runStarts) map.set(s, (map.get(s) ?? 0) + 1);
let pairs = 0;
for (const c of map.values()) pairs += Math.floor(c / 2);
return pairs;
}
/** 九莲宝灯判定。 */
function evalChuuren(ctx: HandContext, closed: boolean): YakuEntry | null {
if (!closed) return null;
const counts = countsFromIndices(ctx.concealed);
for (let suit = 0; suit < 3; suit++) {
const base = suit * 9;
const c = Array.from({ length: 9 }, (_, i) => counts[base + i]!);
if (c.reduce((a, b) => a + b, 0) !== 14) continue;
let ok = c[0]! >= 3 && c[8]! >= 3;
for (let i = 1; i <= 7; i++) if (c[i]! < 1) ok = false;
if (!ok) continue;
// 纯正:去掉和了牌后恰为 1112345678999
const winPos = ctx.winTile - base;
const canonical = [3, 1, 1, 1, 1, 1, 1, 1, 3];
let junsei = winPos >= 0 && winPos < 9;
if (junsei) {
for (let i = 0; i < 9; i++) {
const v = c[i]! - (i === winPos ? 1 : 0);
if (v !== canonical[i]) {
junsei = false;
break;
}
}
}
return junsei
? { name: '纯正九莲宝灯', han: 0, yakuman: 2 }
: { name: '九莲宝灯', han: 0, yakuman: 1 };
}
return null;
}
// ============================================================
// 符计算
// ============================================================
function computeFu(
decomp: Decomposition,
attribution: Attribution,
ctx: HandContext,
closed: boolean,
allSets: { set: DecompSet; concealed: boolean }[],
pinfu: boolean,
): number {
if (pinfu) return ctx.tsumo ? 20 : 30;
let fu = 20;
if (closed && !ctx.tsumo) fu += 10;
if (ctx.tsumo) fu += 2;
// 雀头
const pair = decomp.pair;
if (isDragonIndex(pair)) fu += 2;
if (pair === windIndex(ctx.roundWind)) fu += 2;
if (pair === windIndex(ctx.seatWind)) fu += 2;
// 面子
for (const { set, concealed } of allSets) {
if (set.kind === 'run') continue;
const terminal = isTerminalOrHonorIndex(set.start);
if (set.kind === 'kan') {
fu += terminal ? (concealed ? 32 : 16) : concealed ? 16 : 8;
} else {
fu += terminal ? (concealed ? 8 : 4) : concealed ? 4 : 2;
}
}
// 听牌形
if (attribution.type === 'pair') fu += 2;
else if (attribution.type === 'run' && attribution.runPos === 1) fu += 2;
return Math.ceil(fu / 10) * 10;
}
// ============================================================
// 七对子 / 国士
// ============================================================
function evalChiitoi(ctx: HandContext, closed: boolean): HandEval {
const yaku: YakuEntry[] = [];
if (ctx.doubleRiichi) yaku.push({ name: '双立直', han: 2 });
else if (ctx.riichi) yaku.push({ name: '立直', han: 1 });
if (ctx.ippatsu) yaku.push({ name: '一发', han: 1 });
if (closed && ctx.tsumo) yaku.push({ name: '门前清自摸和', han: 1 });
if (ctx.haitei) yaku.push({ name: '海底摸月', han: 1 });
if (ctx.houtei) yaku.push({ name: '河底捞鱼', han: 1 });
const allIndices = [...ctx.concealed, ...ctx.melds.flatMap((m) => m.tiles)];
if (allIndices.every((i) => !isTerminalOrHonorIndex(i))) yaku.push({ name: '断幺九', han: 1 });
if (allIndices.every((i) => isTerminalOrHonorIndex(i))) yaku.push({ name: '混老头', han: 2 });
const suitSet = new Set<number>();
let hasHonor = false;
for (const i of allIndices) {
if (isHonorIndex(i)) hasHonor = true;
else suitSet.add(Math.floor(i / 9));
}
if (suitSet.size === 1) {
if (hasHonor) yaku.push({ name: '混一色', han: closed ? 3 : 2 });
else yaku.push({ name: '清一色', han: closed ? 6 : 5 });
}
yaku.push({ name: '七对子', han: 2 });
const han = yaku.reduce((a, y) => a + y.han, 0);
return { yaku, han, fu: 25, yakumanMultiplier: 0, isYakuman: false, wait: '单骑' };
}
function evalKokushi(ctx: HandContext): HandEval {
const yaku: YakuEntry[] = [{ name: '国士无双', han: 0, yakuman: 1 }];
let mult = 1;
// 十三面:去掉和了牌后 13 种各 1
const counts = countsFromIndices(ctx.concealed);
counts[ctx.winTile]! -= 1;
const allOne = Array.from({ length: 34 }, (_, i) => counts[i] ?? 0).every((c) => c === 0 || c === 1);
const kinds = Array.from({ length: 34 }, (_, i) => counts[i] ?? 0).filter((c) => c === 1).length;
if (allOne && kinds === 13) {
yaku[0] = { name: '国士无双十三面', han: 0, yakuman: 2 };
mult = 2;
}
return { yaku, han: 0, fu: 0, yakumanMultiplier: mult, isYakuman: true, wait: '单骑' };
}
// ============================================================
// 主评估:取最优
// ============================================================
function betterEval(a: HandEval, b: HandEval): HandEval {
const order = (e: HandEval): number[] => [
e.yakumanMultiplier,
e.isYakuman ? 0 : e.han,
e.fu,
];
const oa = order(a);
const ob = order(b);
for (let i = 0; i < oa.length; i++) {
if (oa[i]! !== ob[i]!) return oa[i]! > ob[i]! ? a : b;
}
return a;
}
/**
* 评估和牌(不含宝牌)。返回最优役种/番/符;若不成和牌型返回 null。
*/
export function evaluateHand(ctx: HandContext): HandEval | null {
const counts = countsFromIndices(ctx.concealed);
const closed = ctx.melds.every((m) => m.type === 'ankan');
const meldSets = meldToSets(ctx.melds);
const candidates: HandEval[] = [];
// 国士(仅门清)
if (closed && ctx.melds.length === 0 && isKokushi(counts)) {
candidates.push(evalKokushi(ctx));
}
// 七对(仅门清)
if (closed && ctx.melds.length === 0 && isChiitoi(counts)) {
candidates.push(evalChiitoi(ctx, closed));
}
// 标准型
const setsNeeded = 4 - ctx.melds.length;
if (setsNeeded >= 0 && ctx.concealed.length === setsNeeded * 3 + 2) {
for (const decomp of decomposeStandard(counts.slice(), setsNeeded)) {
for (const attr of attributionsFor(decomp, ctx.winTile)) {
candidates.push(evalStandard(decomp, attr, ctx, closed, meldSets));
}
}
}
if (candidates.length === 0) return null;
let best = candidates[0]!;
for (const c of candidates) best = betterEval(best, c);
// 若最优仍无役(且非役满),本手不能和(返回 null 由调用方处理)——但保留手牌型供判断听牌;
// 这里按标准:无役返回 null。
if (!best.isYakuman && best.han === 0) return null;
return best;
}
// ============================================================
// 宝牌计数
// ============================================================
/** 统计 allKinds(已归一赤牌之外)中匹配各指示牌的宝牌张数。 */
export function countDora(indicatorKinds: readonly TileKind[], allKinds: readonly TileKind[]): number {
let n = 0;
const targets = indicatorKinds.map((k) => kindToIndex(doraFromIndicator(k)));
for (const k of allKinds) {
const idx = kindToIndex(k);
for (const t of targets) if (idx === t) n += 1;
}
return n;
}
// ============================================================
// 点数计算
// ============================================================
export type ScoreRank =
| 'normal'
| 'mangan'
| 'haneman'
| 'baiman'
| 'sanbaiman'
| 'yakuman';
export interface ScoreInput {
/** 含宝牌的总番数。 */
han: number;
fu: number;
/** 役满倍数;0 = 非役满。 */
yakumanMultiplier: number;
isDealer: boolean;
tsumo: boolean;
honba: number;
mode: MahjongMode;
}
export interface ScoreResult {
base: number;
rank: ScoreRank;
yakumanMultiplier: number;
/** 总和(含本场)。 */
total: number;
/** 自摸时庄家支付(子家和了时为庄家支付额)。 */
fromDealer?: number;
/** 自摸时每名闲家支付。 */
fromEach?: number;
/** 荣和时放铳者支付。 */
fromRon?: number;
}
function ceil100(x: number): number {
return Math.ceil(x / 100) * 100;
}
/** 计算基本点与级别(含满贯封顶;不采用切上满贯)。 */
export function basePoints(han: number, fu: number, yakumanMultiplier: number): { base: number; rank: ScoreRank; mult: number } {
if (yakumanMultiplier > 0) return { base: 8000 * yakumanMultiplier, rank: 'yakuman', mult: yakumanMultiplier };
if (han >= 13) return { base: 8000, rank: 'yakuman', mult: 1 };
if (han >= 11) return { base: 6000, rank: 'sanbaiman', mult: 0 };
if (han >= 8) return { base: 4000, rank: 'baiman', mult: 0 };
if (han >= 6) return { base: 3000, rank: 'haneman', mult: 0 };
if (han >= 5 || (han === 4 && fu >= 40) || (han === 3 && fu >= 70)) {
return { base: 2000, rank: 'mangan', mult: 0 };
}
return { base: fu * Math.pow(2, 2 + han), rank: 'normal', mult: 0 };
}
/** 计算和了点数(含本场;不含供托)。 */
export function computeScore(input: ScoreInput): ScoreResult {
const { han, fu, yakumanMultiplier, isDealer, tsumo, honba, mode } = input;
const { base, rank, mult } = basePoints(han, fu, yakumanMultiplier);
const ronHonba = mode === 4 ? 300 : 200;
const honbaPerPayer = 100;
if (tsumo) {
if (isDealer) {
const each = ceil100(base * 2) + honbaPerPayer * honba;
const payers = mode - 1;
return { base, rank, yakumanMultiplier: mult, fromEach: each, total: each * payers };
}
const fromDealer = ceil100(base * 2) + honbaPerPayer * honba;
const fromEach = ceil100(base) + honbaPerPayer * honba;
const others = mode - 2; // 除庄家与和了者外的闲家数
return {
base,
rank,
yakumanMultiplier: mult,
fromDealer,
fromEach,
total: fromDealer + fromEach * others,
};
}
const perRon = ceil100(base * (isDealer ? 6 : 4)) + ronHonba * honba;
return { base, rank, yakumanMultiplier: mult, fromRon: perRon, total: perRon };
}
/** 便捷:评估 + 点数一步到位。 */
export function scoreHand(
ctx: HandContext,
doraHan: number,
mode: MahjongMode,
): { eval: HandEval; score: ScoreResult } | null {
const evalRes = evaluateHand(ctx);
if (!evalRes) return null;
const score = evalRes.isYakuman
? computeScore({
han: 0,
fu: 0,
yakumanMultiplier: evalRes.yakumanMultiplier,
isDealer: ctx.isDealer,
tsumo: ctx.tsumo,
honba: 0,
mode,
})
: computeScore({
han: evalRes.han + doraHan,
fu: evalRes.fu,
yakumanMultiplier: 0,
isDealer: ctx.isDealer,
tsumo: ctx.tsumo,
honba: 0,
mode,
});
return { eval: evalRes, score };
}
// ============================================================
// 听牌 / 振听 / 食替(P4)
// ============================================================
/** 计数数组 + 副露是否构成和牌型(不判役)。 */
export function formsWinningShape(counts: number[], melds: readonly Meld[]): boolean {
const closed = melds.every((m) => m.type === 'ankan');
if (closed && melds.length === 0 && (isKokushi(counts) || isChiitoi(counts))) return true;
const setsNeeded = 4 - melds.length;
if (setsNeeded < 0) return false;
const total = counts.reduce((a, b) => a + b, 0);
if (total !== setsNeeded * 3 + 2) return false;
if (counts.some((c) => c > 4)) return false;
return decomposeStandard(counts.slice(), setsNeeded).length > 0;
}
/**
* 听牌种类: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 ((used[i] ?? 0) >= 4) continue;
const c = base.slice();
c[i] = (c[i] ?? 0) + 1;
if (formsWinningShape(c, melds)) out.push(i);
}
return out;
}
/** 是否听牌。 */
export function isTenpai(concealed: readonly number[], melds: readonly Meld[] = []): boolean {
return waitingKinds(concealed, melds).length > 0;
}
/** 同花色判定(数牌)。 */
function sameSuit(a: number, b: number): boolean {
return a >= 0 && a < 27 && b >= 0 && b < 27 && Math.floor(a / 9) === Math.floor(b / 9);
}
/**
* 该副露的「食替禁止」打牌集合(现物 + 筋)。
* - 吃:不可打被鸣的牌(现物);若被鸣牌位于顺子两端,不可打另一端的筋牌。
* - 碰:不可打被鸣的牌(现物)。
* - 杠:无限制。
*/
export function kuikaeForbiddenDiscards(meld: Meld): number[] {
if (meld.type === 'minkan' || meld.type === 'kakan' || meld.type === 'ankan') {
return [];
}
if (meld.type === 'pon') {
return [meld.tiles[0]!];
}
// chi
const called = meld.calledTile ?? meld.tiles[1]!;
const forbid = new Set<number>([called]);
const runStart = meld.tiles[0]!;
const pos = called - runStart;
if (pos === 0) {
const other = runStart + 3;
if (sameSuit(runStart, other)) forbid.add(other);
} else if (pos === 2) {
const other = runStart - 1;
if (sameSuit(runStart, other)) forbid.add(other);
}
return [...forbid];
}
/** 该副露后能否打出某张牌(食替校验)。 */
export function isKuikaeForbidden(meld: Meld, discard: number): boolean {
return kuikaeForbiddenDiscards(meld).includes(discard);
}
/**
* 舍张振听:自己的牌河(未被鸣走前)中存在任意一张自己的听牌。
*/
export function isDiscardFuriten(waitKindsList: readonly number[], myDiscards: readonly number[]): boolean {
return myDiscards.some((d) => waitKindsList.includes(d));
}
export const MAHJONG_YAKU_HELPERS = {
WINDS,
DRAGONS,
GREEN_INDICES,
decomposeStandard,
isChiitoi,
isKokushi,
countsFromIndices,
evaluateHand,
countDora,
computeScore,
basePoints,
scoreHand,
formsWinningShape,
waitingKinds,
isTenpai,
kuikaeForbiddenDiscards,
isKuikaeForbidden,
isDiscardFuriten,
};

View File

@@ -0,0 +1,573 @@
// @ts-nocheck
/**
* mahjong.ts — Game 状态机单元测试。
*
* 通过直接调用 move 内部函数与导出的 internals 驱动,覆盖:
* 配牌、打牌推进、鸣牌窗口(碰/荣头跳)、和了点计算、荒牌流局罚符、连庄/进庄。
*/
import { describe, expect, test } from 'vitest';
import { Mahjong, MAHJONG_INTERNALS } from './mahjong.js';
import { buildClaims, resolveClaims, computeWin, seatWindOf, canRon } from './mahjong.js';
import { tilesFromKinds, kindToIndex } from './mahjong-tiles.js';
function rngFactory(seed = 12345) {
let s = seed;
return () => {
s = (s * 9301 + 49297) % 233280;
return s / 233280;
};
}
const setup = (mode: 3 | 4 = 4) =>
Mahjong.setup({ ctx: { numPlayers: mode === 3 ? 3 : 4 }, random: rngFactory() }, { mode });
const moveFn = (name: string) => (Mahjong.moves as any)[name].move;
function startGame(G: any) {
moveFn('startGame')({ G, events: { endPhase() {} }, playerID: '0', random: rngFactory(7) });
}
describe('mahjong — 配牌与开局', () => {
test('四麻:庄家 14 张、闲家 13 张、牌山 69、赤宝牌等初始化', () => {
const G = setup(4);
startGame(G);
expect(G.hands['0'].length).toBe(14);
expect(G.hands['1'].length).toBe(13);
expect(G.wall.length).toBe(136 - 14 - 52 - 1);
expect(G.deadWall.length).toBe(14);
expect(G.doraCount).toBe(1);
expect(G.scores).toEqual({ '0': 25000, '1': 25000, '2': 25000, '3': 25000 });
expect(G.turn).toBe('0');
expect(G.turnStep).toBe('discard');
expect(G.drawnTile).not.toBeNull();
});
test('三麻:108 张、每家 35000 起点、无 2m~8m', () => {
const G = setup(3);
startGame(G);
expect(G.numPlayers).toBe(3);
expect(G.scores).toEqual({ '0': 35000, '1': 35000, '2': 35000 });
for (const pid of ['0', '1', '2']) {
for (const t of G.hands[pid]) {
if (t.kind[1] === 'm') expect(['1m', '9m']).toContain(t.kind);
}
}
});
});
describe('mahjong — 打牌推进', () => {
test('无人鸣牌时推进到下家并摸牌', () => {
const G = setup(4);
startGame(G);
// 把其他家手牌设成不会对 1z 产生鸣牌
for (const p of ['1', '2', '3']) {
G.hands[p] = tilesFromKinds(Array(13).fill('9s'));
G.melds[p] = [];
G.riichi[p] = false;
G.pendingRiichi[p] = false;
G.discards[p] = [];
}
// 庄家放入一张 1z 并打出
G.hands['0'] = [{ id: '1z-99', kind: '1z' }, ...G.hands['0'].slice(0, 13)];
moveFn('discard')({ G, playerID: '0' }, '1z-99');
expect(G.discards['0'].some((t: any) => t.id === '1z-99')).toBe(true);
expect(G.turn).toBe('1');
expect(G.hands['1'].length).toBe(14);
});
});
describe('mahjong — 鸣牌窗口', () => {
test('buildClaims 检出碰/杠/吃/荣', () => {
const G = setup(4);
G.dealer = '0';
G.melds = { '0': [], '1': [], '2': [], '3': [] };
G.hands = {
'0': tilesFromKinds(Array(13).fill('1z')),
'1': tilesFromKinds([...Array(11).fill('9s'), '5p', '5p']),
'2': tilesFromKinds(Array(13).fill('1z')),
'3': tilesFromKinds([...Array(11).fill('9s'), '3m', '4m']),
};
G.riichi = { '0': false, '1': false, '2': false, '3': false };
G.pendingRiichi = { '0': false, '1': false, '2': false, '3': false };
G.discards = { '0': [], '1': [], '2': [], '3': [] };
G.tempFuriten = { '0': false, '1': false, '2': false, '3': false };
G.riichiFuriten = { '0': false, '1': false, '2': false, '3': false };
G.wall = tilesFromKinds(Array(10).fill('1z'));
const tile = { id: '5p-x', kind: '5p' };
const claims = buildClaims(G, '0', tile);
const c1 = claims.find((c) => c.player === '1')!;
expect(c1.options.some((o) => o.type === 'pon')).toBe(true);
// 下家(西家方向 P1)对 3m4m 可吃
const tile2 = { id: '2m-x', kind: '2m' };
const claims2 = buildClaims(G, '0', tile2);
const c3 = claims2.find((c) => c.player === '1');
expect(c3?.options.some((o) => o.type === 'chi') ?? false).toBe(false); // 只有下家能吃,P1 是下家但手里没 3m4m
});
test('resolveClaims:双响头跳取最近', () => {
const claims = [
{ player: '2', options: [{ type: 'ron' }], response: { type: 'ron' } },
{ player: '1', options: [{ type: 'ron' }], response: { type: 'ron' } },
];
expect(resolveClaims(claims, '0', 4)).toEqual({ kind: 'ron', player: '1' });
});
test('resolveClaims:荣和优先于碰', () => {
const claims = [
{ player: '1', options: [{ type: 'pon' }], response: { type: 'pon' } },
{ player: '3', options: [{ type: 'ron' }], response: { type: 'ron' } },
];
expect(resolveClaims(claims, '0', 4)).toEqual({ kind: 'ron', player: '3' });
});
});
describe('mahjong — 和了计算', () => {
function winState() {
const G = setup(4);
G.dealer = '0'; // P1 为子家(自风 南)
G.hands['1'] = tilesFromKinds([
'1m', '2m', '3m', '4m', '5m', '6m', '5p', '6p', '7p', '6s', '7s', '8s', '5s', '5s',
]);
G.melds['1'] = [];
G.deadWall = tilesFromKinds(Array(14).fill('1z')); // 指示牌 1z → 宝牌 2z(手牌无)
G.doraCount = 1;
G.aka['1'] = 0;
G.nukiDora['1'] = 0;
G.riichi['1'] = false;
G.pendingRiichi['1'] = false;
G.ippatsu['1'] = false;
G.honba = 0;
return G;
}
test('平和 + 门前清自摸和(自摸 20 符)点数正确', () => {
const G = winState();
const win = computeWin(G, '1', 2 /* 3m */, true); // 打开 1z 的指示,tsumo
expect(win).not.toBeNull();
const names = win!.yaku.map((y) => y.name);
expect(names).toContain('平和');
expect(names).toContain('门前清自摸和');
expect(win!.fu).toBe(20);
// 2 番 20 符子家自摸:庄 700 + 闲 400×2 = 1500
expect(win!.points).toBe(1500);
expect(seatWindOf(G, '1')).toBe(2);
});
test('无役不能和(役牌雀头 + 门清荣和)', () => {
const G = setup(4);
// 13 张:123m 45m 789p 234s 東東;荣和 6m 成 456m。无役(非自摸、非平和)
G.hands['1'] = tilesFromKinds(['1m', '2m', '3m', '4m', '5m', '7p', '8p', '9p', '2s', '3s', '4s', '1z', '1z']);
G.melds['1'] = [];
G.deadWall = tilesFromKinds(Array(14).fill('1z'));
G.doraCount = 1;
G.aka = { '0': 0, '1': 0, '2': 0, '3': 0 };
G.nukiDora = { '0': 0, '1': 0, '2': 0, '3': 0 };
G.riichi = { '0': false, '1': false, '2': false, '3': false };
G.pendingRiichi = { '0': false, '1': false, '2': false, '3': false };
G.ippatsu = { '0': false, '1': false, '2': false, '3': false };
expect(computeWin(G, '1', 5 /* 6m */, false)).toBeNull();
});
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 — 荒牌流局', () => {
test('听牌罚符 3000 与庄家轮转', () => {
const G = setup(4);
G.dealer = '0';
G.melds = { '0': [], '1': [], '2': [], '3': [] };
G.wall = [];
G.hands = {
'0': tilesFromKinds(['1m', '1m', '3m', '5m', '7m', '9m', '1p', '3p', '5p', '7p', '9p', '1s', '3s']), // 不听
'1': tilesFromKinds(['1m', '2m', '3m', '4m', '5m', '6m', '7m', '8m', '9m', '1p', '2p', '3p', '5s']), // 听 5s
'2': tilesFromKinds(['1m', '1m', '3m', '5m', '7m', '9m', '1p', '3p', '5p', '7p', '9p', '1s', '3s']), // 不听
'3': tilesFromKinds(['2m', '2m', '4m', '6m', '8m', '2p', '4p', '6p', '8p', '2s', '4s', '6s', '8s']), // 不听
};
const before = { ...G.scores };
MAHJONG_INTERNALS.settleExhaustiveDraw(G);
expect(G.result.type).toBe('draw');
expect(G.result.tenpai).toEqual(['1']);
expect(G.scores['1']).toBe(before['1'] + 3000);
expect(G.scores['0']).toBe(before['0'] - 1000);
expect(G.dealer).toBe('1'); // 庄家不听 → 下庄
expect(G.honba).toBe(1);
});
test('庄家听牌则连庄', () => {
const G = setup(4);
G.dealer = '0';
G.melds = { '0': [], '1': [], '2': [], '3': [] };
G.wall = [];
const tenpaiHand = () => tilesFromKinds(['1m', '2m', '3m', '4m', '5m', '6m', '7m', '8m', '9m', '1p', '2p', '3p', '5s']);
const notenHand = () => tilesFromKinds(['1m', '1m', '3m', '5m', '7m', '9m', '1p', '3p', '5p', '7p', '9p', '1s', '3s']);
G.hands = { '0': tenpaiHand(), '1': notenHand(), '2': notenHand(), '3': notenHand() };
MAHJONG_INTERNALS.settleExhaustiveDraw(G);
expect(G.dealer).toBe('0');
expect(G.honba).toBe(1);
expect(G.kyoku).toBe(1);
});
});
describe('mahjong — 整手模拟(冒烟)', () => {
test('随机打牌直至荒牌流局:不抛异常、点数守恒、可再开下一局', () => {
const G = setup(4);
startGame(G);
const discard = moveFn('discard');
const respond = moveFn('respond');
let guard = 0;
while (!G.result && guard++ < 1000) {
if (G.turnStep === 'discard' && G.drawnTile) {
const hand = G.hands[G.turn];
const tile = hand.find((t: any) => !G.kuikaeForbidden.includes(kindToIndex(t.kind)));
if (!tile) break;
discard({ G, playerID: G.turn }, tile.id);
} else if (G.turnStep === 'claim') {
for (const c of G.pendingClaims) {
if (!c.response) respond({ G, playerID: c.player }, 'pass');
}
} else {
break;
}
}
expect(G.result).not.toBeNull();
// 只打不和:正常为荒牌流局(极小概率触发四风连打等途中流局)
expect(['draw', 'abort']).toContain(G.result!.type);
if (G.result!.type === 'draw') expect(G.wall.length).toBe(0);
const sum = Object.values(G.scores).reduce((a: number, b: any) => a + b, 0);
expect(sum).toBe(25000 * 4); // 听牌罚符零和,总分不变
// 记录应包含十几次打牌
expect(G.discardCount).toBeGreaterThan(30);
});
});
// ============================================================
// P0 / P1 规则补全的验证
// ============================================================
const T = (k: string, i = 0) => ({ id: `${k}-${i}`, kind: k });
describe('mahjong — P0-01 立直后摸切', () => {
test('立直成立后不能打非摸到的牌,只能摸切', () => {
const G = setup(4);
startGame(G);
G.riichi['0'] = true;
G.pendingRiichi['0'] = false;
G.turn = '0';
G.turnStep = 'discard';
const drawn = G.drawnTile!;
const other = G.hands['0'].find((t: any) => t.id !== drawn.id)!;
const before = G.discards['0'].length;
moveFn('discard')({ G, playerID: '0' }, other.id);
expect(G.discards['0'].length).toBe(before); // 被拒绝
moveFn('discard')({ G, playerID: '0' }, drawn.id);
expect(G.discards['0'].some((t: any) => t.id === drawn.id)).toBe(true); // 摸切成功
});
});
describe('mahjong — P0-02 鸣牌超时', () => {
test('未到点不生效;到点后自动跳过并推进', () => {
const G = setup(4);
startGame(G);
G.turn = '0';
G.turnStep = 'claim';
G.claimKind = 'normal';
G.claimTile = T('1m');
G.claimDiscarder = '0';
G.pendingClaims = [{ player: '1', options: [{ type: 'pon' }], response: null }];
G.claimDeadline = Date.now() + 10000;
const fn = moveFn('claimTimeout');
fn({ G });
expect(G.turnStep).toBe('claim'); // 未到点
G.claimDeadline = Date.now() - 1;
fn({ G });
expect(G.turnStep).toBe('discard'); // 已跳过 → 推进
expect(G.turn).toBe('1');
});
});
describe('mahjong — P1-03 途中流局', () => {
test('四家立直 / 四风连打', () => {
const G = setup(4);
startGame(G);
G.riichi = { '0': true, '1': true, '2': true, '3': true };
expect(MAHJONG_INTERNALS.checkAbortiveDraws(G)).toBe('四家立直');
const G2 = setup(4);
startGame(G2);
G2.noCallsYet = true;
G2.discardCount = 4;
G2.riichi = { '0': false, '1': false, '2': false, '3': false };
G2.discards = { '0': [T('1z')], '1': [T('1z')], '2': [T('1z')], '3': [T('1z')] };
expect(MAHJONG_INTERNALS.checkAbortiveDraws(G2)).toBe('四风连打');
});
test('三家和了 → 流局', () => {
const G = setup(4);
startGame(G);
G.turn = '0';
G.turnStep = 'claim';
G.claimKind = 'normal';
G.claimDiscarder = '0';
G.claimTile = T('1m');
G.pendingClaims = ['1', '2', '3'].map((p) => ({
player: p,
options: [{ type: 'ron' }],
response: { type: 'ron' },
}));
MAHJONG_INTERNALS.resolveClaimWindow(G);
expect(G.result?.type).toBe('abort');
expect(G.result?.reason).toBe('三家和了');
});
test('四杠散了判定', () => {
const G = setup(4);
G.kanByPlayer = { '0': 1, '1': 1, '2': 1, '3': 1 };
expect(MAHJONG_INTERNALS.fourKanAbort(G)).toBe(true);
G.kanByPlayer = { '0': 4, '1': 0, '2': 0, '3': 0 };
expect(MAHJONG_INTERNALS.fourKanAbort(G)).toBe(false);
});
});
describe('mahjong — P1-04 流局满贯', () => {
test('弃牌全幺九未被鸣 → 按满贯自摸结算,不收供托', () => {
const G = setup(4);
startGame(G);
G.wall = [];
G.dealer = '0';
G.kyoutaku = 2; // 有供托也不该被收走
G.nagashiEligible = { '0': true, '1': false, '2': false, '3': false };
const before = { ...G.scores };
MAHJONG_INTERNALS.settleExhaustiveDraw(G);
expect(G.result?.reason).toBe('流局满贯');
// 亲家满贯自摸:每家付 4000,共 +12000
expect(G.scores['0']).toBe(before['0'] + 12000);
expect(G.scores['1']).toBe(before['1'] - 4000);
expect(G.kyoutaku).toBe(2); // 供托未被收走
});
});
describe('mahjong — P1-05 和了终局', () => {
test('All Last 亲家一位且达返点 + 连庄 → 终局', () => {
const G = setup(4);
startGame(G);
G.bakaze = 2;
G.kyoku = 4;
G.dealer = '0';
G.honba = 1;
G.scores = { '0': 35000, '1': 25000, '2': 20000, '3': 20000 };
G.result = { type: 'tsumo', reason: '自摸', wins: [], deltas: {}, tenpai: [], noten: [], dealerRepeats: true };
MAHJONG_INTERNALS.maybeFinishGame(G);
expect(G.gameOver).toBe(true);
});
});
describe('mahjong — P1-06 立直后暗杠校验', () => {
test('改变听牌的暗杠被拒绝,保持不变则允许', () => {
const G = setup(4);
startGame(G);
G.turn = '0';
G.turnStep = 'discard';
G.riichi['0'] = true;
G.dealer = '0';
G.melds['0'] = [];
G.deadWall = tilesFromKinds(Array(14).fill('1z'));
// 1111m 23m 456m 789m 99p(听 1m/4m);杠 1m 后 1m 不可再和 → 听牌改变
G.hands['0'] = tilesFromKinds(['1m', '1m', '1m', '1m', '2m', '3m', '4m', '5m', '6m', '7m', '8m', '9m', '9p', '9p']);
G.drawnTile = G.hands['0'][0];
moveFn('ankan')({ G, playerID: '0' }, kindToIndex('1m'));
expect(G.melds['0'].length).toBe(0); // 被拒绝
// 杠 1z 不改变 9p/5s 双碰听 → 允许
const G2 = setup(4);
startGame(G2);
G2.turn = '0';
G2.turnStep = 'discard';
G2.riichi['0'] = true;
G2.melds['0'] = [];
G2.deadWall = tilesFromKinds(Array(14).fill('9s'));
G2.hands['0'] = tilesFromKinds(['1z', '1z', '1z', '1z', '2m', '3m', '4m', '5m', '6m', '7m', '9p', '9p', '5s', '5s']);
G2.drawnTile = G2.hands['0'][0];
moveFn('ankan')({ G: G2, playerID: '0' }, kindToIndex('1z'));
expect(G2.melds['0'].length).toBe(1);
});
});
describe('mahjong — P1-07 三麻抢北', () => {
test('对拔北开启抢北荣和窗口', () => {
const G = setup(3);
startGame(G);
G.turn = '0';
G.turnStep = 'discard';
G.melds = { '0': [], '1': [], '2': [] };
G.riichi = { '0': false, '1': false, '2': false };
G.pendingRiichi = { '0': false, '1': false, '2': false };
G.tempFuriten = { '0': false, '1': false, '2': false };
G.riichiFuriten = { '0': false, '1': false, '2': false };
G.furitenKinds = { '0': [], '1': [], '2': [] };
G.discards = { '0': [], '1': [], '2': [] };
G.aka = { '0': 0, '1': 0, '2': 0 };
G.nukiDora = { '0': 0, '1': 0, '2': 0 };
G.deadWall = tilesFromKinds(Array(14).fill('1z'));
G.dealer = '0';
G.bakaze = 1;
// P0 有北;P1 国士听北(4z)
G.hands['0'] = tilesFromKinds(['1m', '9m', '1p', '2p', '3p', '4p', '5p', '6p', '7p', '8p', '9p', '1s', '2s', '4z']);
G.hands['1'] = tilesFromKinds(['1m', '9m', '1p', '9p', '1s', '9s', '1z', '2z', '3z', '5z', '6z', '7z', '1m']);
moveFn('nuki')({ G, playerID: '0' });
expect(G.claimKind).toBe('nuki');
expect(G.pendingClaims.some((c: any) => c.player === '1' && c.options.some((o: any) => o.type === 'ron'))).toBe(true);
});
});
describe('mahjong — P1-10 振听边界(被鸣走的自弃牌)', () => {
test('自弃牌被鸣走后仍计入舍张振听', () => {
const G = setup(4);
startGame(G);
G.dealer = '0';
G.melds = { '0': [], '1': [], '2': [], '3': [] };
G.hands['1'] = tilesFromKinds(['1m', '2m', '3m', '4m', '5m', '6m', '7m', '8m', '9m', '1p', '2p', '3p', '5s']);
G.riichi = { '0': false, '1': false, '2': false, '3': false };
G.pendingRiichi = { '0': false, '1': false, '2': false, '3': false };
G.tempFuriten = { '0': false, '1': false, '2': false, '3': false };
G.riichiFuriten = { '0': false, '1': false, '2': false, '3': false };
G.discards = { '0': [], '1': [], '2': [], '3': [] };
G.deadWall = tilesFromKinds(Array(14).fill('1z'));
G.bakaze = 1;
G.furitenKinds = { '0': [], '1': [kindToIndex('5s')], '2': [], '3': [] };
expect(canRon(G, '1', kindToIndex('5s'))).toBe(false);
G.furitenKinds['1'] = [];
expect(canRon(G, '1', kindToIndex('5s'))).toBe(true);
});
});
describe('mahjong — P1-08 里宝牌揭示', () => {
test('和了前隐藏,和了后公开 uraIndicators', () => {
const G = setup(4);
startGame(G);
const before = Mahjong.playerView(G, '0');
expect(before.uraIndicators ?? []).toEqual([]);
G.result = { type: 'tsumo', reason: '自摸', wins: [], deltas: {}, tenpai: [], noten: [], dealerRepeats: false };
const after = Mahjong.playerView(G, '0');
expect(after.uraIndicators?.length).toBe(1);
});
});
// ============================================================
// P2 补全的验证
// ============================================================
describe('mahjong — R-01 碰后不可立即拔北', () => {
test('justCalledBy 期间拔北被拒,打牌后恢复', () => {
const G = setup(3);
startGame(G);
G.turn = '0';
G.turnStep = 'discard';
G.melds = { '0': [], '1': [], '2': [] };
G.hands['0'] = tilesFromKinds(['1m', '9m', '1p', '2p', '3p', '4p', '5p', '6p', '7p', '8p', '9p', '1s', '2s', '4z']);
G.hands['1'] = tilesFromKinds(Array(13).fill('9s'));
G.hands['2'] = tilesFromKinds(Array(13).fill('9s'));
G.deadWall = tilesFromKinds(Array(14).fill('1z'));
G.dealer = '0';
G.justCalledBy = '0';
moveFn('nuki')({ G, playerID: '0' });
expect(G.nukiDora['0']).toBe(0); // 被拒
G.justCalledBy = null;
moveFn('nuki')({ G, playerID: '0' });
expect(G.nukiDora['0']).toBe(1); // 成立
});
});
describe('mahjong — R-02 抢杠撤销红牌计数', () => {
test('加杠被抢后撤销为碰并扣回赤宝牌计数', () => {
const G = setup(4);
startGame(G);
G.turn = '0';
G.turnStep = 'discard';
G.dealer = '0';
G.bakaze = 1;
G.deadWall = tilesFromKinds(Array(14).fill('1z'));
const P = kindToIndex('5p');
G.melds = {
'0': [{ type: 'pon', tiles: [P, P, P], from: '1', calledTile: P }],
'1': [], '2': [], '3': [],
};
// P0 手牌含红 5p(第 4 张)
G.hands['0'] = tilesFromKinds(['0p', '1m', '2m', '3m', '4m', '6m', '7m', '8m', '9m', '1s', '2s', '3s', '4s']);
// P1 听 5p(34p 两面),可抢杠
G.hands['1'] = tilesFromKinds(['2m', '3m', '4m', '5m', '6m', '7m', '2s', '3s', '4s', '9s', '9s', '3p', '4p']);
G.hands['2'] = tilesFromKinds(Array(13).fill('1z'));
G.hands['3'] = tilesFromKinds(Array(13).fill('1z'));
moveFn('kakan')({ G, playerID: '0' }, P);
expect(G.claimKind).toBe('kakan');
expect(G.pendingClaims.some((c: any) => c.player === '1')).toBe(true);
moveFn('respond')({ G, playerID: '1' }, 'ron');
expect(G.result?.type).toBe('ron');
const meld = G.melds['0'].find((m: any) => m.tiles[0] === P)!;
expect(meld.type).toBe('pon');
expect(meld.redCount ?? 0).toBe(0); // 赤 1 被扣回
});
});
describe('mahjong — R-05 顺位马/头名赏', () => {
test('四麻:ウマ +15/+5/-5/-15 且头名赏 +20 归一位', () => {
const G = setup(4);
G.scores = { '0': 40000, '1': 30000, '2': 20000, '3': 10000 };
MAHJONG_INTERNALS.finalizeRanking(G);
expect(G.finalRanking![0]).toMatchObject({ player: '0', rank: 1, uma: 15, oka: 20, points: 35 });
expect(G.finalRanking![1]).toMatchObject({ player: '1', rank: 2, uma: 5, oka: 0, points: 5 });
expect(G.finalRanking![3]).toMatchObject({ player: '3', rank: 4, uma: -15, points: -15 });
});
test('三麻:ウマ +15/0/-15 且头名赏 +15', () => {
const G = setup(3);
G.scores = { '0': 45000, '1': 35000, '2': 20000 };
MAHJONG_INTERNALS.finalizeRanking(G);
expect(G.finalRanking![0]).toMatchObject({ player: '0', rank: 1, uma: 15, oka: 15, points: 30 });
expect(G.finalRanking![2]).toMatchObject({ player: '2', rank: 3, uma: -15, points: -15 });
});
});
describe('mahjong — R-06 听牌/振听提示', () => {
test('playerView 暴露 myWaits 与 myFuriten', () => {
const G = setup(4);
startGame(G);
G.turn = '0';
G.dealer = '0';
G.deadWall = tilesFromKinds(Array(14).fill('1z'));
G.melds = { '0': [], '1': [], '2': [], '3': [] };
G.hands['1'] = tilesFromKinds(['1m', '2m', '3m', '4m', '5m', '6m', '7m', '8m', '9m', '1p', '2p', '3p', '5s']);
G.furitenKinds['1'] = [];
const v = Mahjong.playerView(G, '1');
expect(v.myWaits).toEqual(['5s']);
expect(v.myFuriten).toBe(false);
G.furitenKinds['1'] = [kindToIndex('5s')];
expect(Mahjong.playerView(G, '1').myFuriten).toBe(true);
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -70,6 +70,47 @@ describe('scoreRound', () => {
expect(roundScores['0']).toBe(800); expect(roundScores['0']).toBe(800);
}); });
it('P-1 修复:大满贯 = 集齐 16 张分牌时,不与变压器 ×2 重复算(大满贯后置覆盖)', () => {
// 累加 + 全红翻转 + 变压器 ×2 流程下:-200 + 400 + ×2 = 1200
// 大满贯独立公式 = 800(必须覆盖之前的 1200)
const cards = [
...['2', '3', '4', '5', '6', '7', '8', '9', '10', 'J', 'Q', 'K', 'A'].map((r) => c(r + '♥')),
c('Q♠'),
c('J♦'),
c('10♣'),
];
const { roundScores } = scoreRound({ '0': cards }, 4);
expect(roundScores['0']).toBe(800); // 不能是 1200(1200 = 大满贯未覆盖的累加 + ×2)
});
it('P-2 修复:全红 + 变压器 + 其他分牌时,红心翻转先于 ×2', () => {
// 13 张红心 + 猪 + 变压器(不含羊,无大满贯)
// 累加:-200(红心)+ -100(猪)= -300
// 全红翻转:-300 - (-200) + 200 = 100
// 变压器 ×2:100 * 2 = 200
const cards = [
...['2', '3', '4', '5', '6', '7', '8', '9', '10', 'J', 'Q', 'K', 'A'].map((r) => c(r + '♥')),
c('Q♠'),
c('10♣'),
];
const { roundScores } = scoreRound({ '0': cards }, 4);
expect(roundScores['0']).toBe(200); // 不能是 0(旧顺序:累加 ×2 = -600,全红替换 = -600 + 400 = -200)也不能是 400(错误翻转顺序)
});
it('P-2 修复:全红 + 羊 + 变压器(非大满贯)= 200 * 2 = 400', () => {
// 13 张红心 + 羊 + 变压器(不含猪)
// 累加:-200 + 100 = -100
// 全红翻转:-100 - (-200) + 200 = 300
// 变压器 ×2:300 * 2 = 600
const cards = [
...['2', '3', '4', '5', '6', '7', '8', '9', '10', 'J', 'Q', 'K', 'A'].map((r) => c(r + '♥')),
c('J♦'),
c('10♣'),
];
const { roundScores } = scoreRound({ '0': cards }, 4);
expect(roundScores['0']).toBe(600);
});
it('全红 + 同时吃猪:红心翻正 +200,猪 -100 仍计入(共 +100)', () => { it('全红 + 同时吃猪:红心翻正 +200,猪 -100 仍计入(共 +100)', () => {
// 文档:全红「只翻转红心,不影响猪/羊/变压器本来的正负」。 // 文档:全红「只翻转红心,不影响猪/羊/变压器本来的正负」。
const cards = [ const cards = [

View File

@@ -226,8 +226,22 @@ export function scoreRound(taken: Record<PlayerID, Card[]>, numPlayers: 3 | 4):
} }
} }
// 计算本家红心集合(用于全红/大满贯判定)
const myHearts = cards.filter((c) => c.suit === '♥');
// 全红(文档 2.7 / 2.8)必须在变压器 ×2 **之前**处理,
// 否则 s 已被翻倍,s - heartsBase + 200 公式会把"红心也加倍"了(错)。
// 红心基础分合计 -200,把红心分从 -200 翻成 +200 等价于 s += 400。
// 必须在变压器 ×2 之前:让红心部分也参与变压器的整体翻倍。
if (myHearts.length === 13) {
let heartsBase = 0;
for (const h of myHearts) heartsBase += cardScore(h); // = -200
s = s - heartsBase + 200;
messages.push(`玩家 ${p} 收全红 +200`);
}
// 变压器处理(文档 2.4 / 2.7 步骤 2-3): // 变压器处理(文档 2.4 / 2.7 步骤 2-3):
// - 同吃其他分牌 → 本副牌所有分整体 ×2 // - 同吃其他分牌 → 本副牌所有分(含翻转后的红心)整体 ×2
// - 只吃到变压器 → 单独 +50 // - 只吃到变压器 → 单独 +50
if (hasTransformer) { if (hasTransformer) {
if (hasOtherScore) { if (hasOtherScore) {
@@ -237,26 +251,21 @@ export function scoreRound(taken: Record<PlayerID, Card[]>, numPlayers: 3 | 4):
} }
} }
// 全红(文档 2.7 / 2.8):本家吃齐全部 13 张红心 → 红心全变正分 +200。 // 大满贯(文档 2.7):一家吃齐全部 16 张分牌 → 猪也变正分 +200 + 独立奖池 +800。
// 注意:全红「只翻转红心」,不影响猪/羊/变压器本来的正负(文档原文)。 // 在变压器 ×2 **之后**处理:若大满贯仍走"累加 + ×2"流程会重复算(变成 1200)。
// 红心基础分合计恰为 -200,故把已累加的红心分替换成 +200 等价于 s += 400。 // 大满贯是独立公式:清零之前的累加/翻转/变压器结果,按 += 累加(避免覆盖丢分的潜在 bug)。
const myHearts = cards.filter((c) => c.suit === '♥');
if (myHearts.length === 13) {
let heartsBase = 0;
for (const h of myHearts) heartsBase += cardScore(h); // = -200
s = s - heartsBase + 200;
messages.push(`玩家 ${p} 收全红 +200`);
}
// 大满贯(文档 2.7):一家吃齐全部 16 张分牌 → 猪也变正分,总分 +800。
// 比全红更特殊,作为单独奖池直接置 +800,且优先级最高。
const isMyGrandSlam = const isMyGrandSlam =
myHearts.length === 13 && myHearts.length === 13 &&
cards.some((c) => c.suit === '♠' && c.rank === 12) && cards.some((c) => c.suit === '♠' && c.rank === 12) &&
cards.some((c) => c.suit === '♦' && c.rank === 11) && cards.some((c) => c.suit === '♦' && c.rank === 11) &&
cards.some((c) => c.suit === '♣' && c.rank === 10); cards.some((c) => c.suit === '♣' && c.rank === 10);
if (isMyGrandSlam) { if (isMyGrandSlam) {
s = 800; // 大满贯 = 集齐 16 张分牌(含所有红心 + 猪 + 羊 + 变压器),
// 此时"累加 + 全红翻转 + 变压器 ×2"流程必然得到一个被变压器翻倍重复计入的值。
// 按文档公式 (200 红心正分 + 100 羊 + 100 变压器翻倍基数) × 2 = 800 直接定值。
// 用清零 + += 实现"增量赋值"语义,保留 P-1 修复的诉求。
s = 0;
s += 800;
messages.push(`玩家 ${p} 大满贯 +800`); messages.push(`玩家 ${p} 大满贯 +800`);
} }

View File

@@ -30,6 +30,7 @@ import { Holdem } from '@tts-like/engine/games/holdem';
import { Pig } from '@tts-like/engine/games/pig'; import { Pig } from '@tts-like/engine/games/pig';
import { DoubleUp } from '@tts-like/engine/games/double-up'; import { DoubleUp } from '@tts-like/engine/games/double-up';
import { Bridge } from '@tts-like/engine/games/bridge'; import { Bridge } from '@tts-like/engine/games/bridge';
import { Mahjong } from '@tts-like/engine/games/mahjong';
import { mountApiPrefix } from './mount-api-prefix.js'; import { mountApiPrefix } from './mount-api-prefix.js';
import { setupRooms } from './rooms.js'; import { setupRooms } from './rooms.js';
import { getUserRoom } from './rooms.js'; import { getUserRoom } from './rooms.js';
@@ -64,7 +65,7 @@ function parseOrigins(): (string | RegExp)[] {
} }
const server = Server({ const server = Server({
games: [War as any, Mill as any, DragTest as any, Holdem as any, Pig as any, DoubleUp as any, Bridge as any], games: [War as any, Mill as any, DragTest as any, Holdem as any, Pig as any, DoubleUp as any, Bridge as any, Mahjong as any],
origins: parseOrigins() as any, origins: parseOrigins() as any,
}); });
@@ -91,6 +92,7 @@ const GAMES_ENTRIES: GameEntry[] = [
{ name: 'GongZhu', game: Pig as any }, { name: 'GongZhu', game: Pig as any },
{ name: 'DoubleUp', game: DoubleUp as any }, { name: 'DoubleUp', game: DoubleUp as any },
{ name: 'Bridge', game: Bridge as any }, { name: 'Bridge', game: Bridge as any },
{ name: 'Mahjong', game: Mahjong as any },
]; ];
// ============================================================ // ============================================================
@@ -219,7 +221,7 @@ server.run(PORT, () => {
console.log(` WSS: ws://localhost:${PORT}/socket.io/`); console.log(` WSS: ws://localhost:${PORT}/socket.io/`);
console.log(` Web: http://localhost:${PORT}/ (if dist exists)`); console.log(` Web: http://localhost:${PORT}/ (if dist exists)`);
console.log(` origins: ${parseOrigins().length} entries`); console.log(` origins: ${parseOrigins().length} entries`);
console.log(` games: War, Mill, DragTest, Holdem, GongZhu, DoubleUp, Bridge`); console.log(` games: War, Mill, DragTest, Holdem, GongZhu, DoubleUp, Bridge, Mahjong`);
}); });
// 优雅关闭 // 优雅关闭