Compare commits
2 Commits
7175ce9580
...
8d5fb3c511
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8d5fb3c511 | ||
|
|
bec06a1389 |
@@ -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).
|
||||
|
||||
- `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/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.
|
||||
@@ -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`).
|
||||
|
||||
### 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)
|
||||
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.
|
||||
|
||||
276
apps/web/e2e/bridge-multiplayer.spec.ts
Normal file
276
apps/web/e2e/bridge-multiplayer.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -100,8 +100,8 @@ async function infoNumbers(page: Page): Promise<{ level: number; tricks: number
|
||||
const lv = txt.match(/级数\s*(\S+)/);
|
||||
const tr = txt.match(/已打\s*(\d+)\s*墩/);
|
||||
return {
|
||||
level: lv ? Number(lv[1].replace(/[^0-9]/g, '')) || 0 : 0,
|
||||
tricks: tr ? Number(tr[1]) : 0,
|
||||
level: lv && lv[1] ? Number(lv[1].replace(/[^0-9]/g, '')) || 0 : 0,
|
||||
tricks: tr && tr[1] ? Number(tr[1]) : 0,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ import { Holdem } from '@tts-like/engine/games/holdem';
|
||||
import { Pig } from '@tts-like/engine/games/pig';
|
||||
import { DoubleUp } from '@tts-like/engine/games/double-up';
|
||||
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 { createLobbyClient, LobbyClientError } from '@tts-like/engine';
|
||||
import type { SetupOption } from '@tts-like/ui';
|
||||
@@ -32,20 +33,21 @@ import { HoldemBoard } from './HoldemBoard.js';
|
||||
import { PigBoard } from './PigBoard.js';
|
||||
import { DoubleUpBoard } from './DoubleUpBoard.js';
|
||||
import { BridgeBoard } from './BridgeBoard.js';
|
||||
import { MahjongBoard } from './MahjongBoard.js';
|
||||
|
||||
// ---------- 登录态(问题8) ----------
|
||||
// token 存 localStorage;创建/加入房间时携带,未登录则被 server 网关拦截。
|
||||
const TOKEN_KEY = 'tts-like.token';
|
||||
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';
|
||||
|
||||
/**
|
||||
* 游戏列表顺序(问题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 {
|
||||
game: any;
|
||||
@@ -75,6 +77,7 @@ const GAMES: Record<GameKey, GameMeta> = {
|
||||
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 },
|
||||
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 {
|
||||
@@ -592,6 +595,7 @@ function LocalGameView({ gameKey }: { gameKey: GameKey }) {
|
||||
if (gameKey === 'pig') return <PigLocalView />;
|
||||
if (gameKey === 'double-up') return <DoubleUpLocalView />;
|
||||
if (gameKey === 'bridge') return <BridgeLocalView />;
|
||||
if (gameKey === 'mahjong') return <MahjongLocalView />;
|
||||
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 === 'double-up') return <DoubleUpOnlineView 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} />;
|
||||
}
|
||||
|
||||
@@ -1312,6 +1317,141 @@ 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],
|
||||
);
|
||||
return <LocalClient matchID="local" playerID={seat} key={`mj-local-${mode}-${seat}`} />;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 = {
|
||||
background: '#444',
|
||||
color: 'white',
|
||||
|
||||
@@ -15,8 +15,9 @@
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import type { Ctx } from '@tts-like/engine';
|
||||
import type {
|
||||
BridgeState,
|
||||
import {
|
||||
isLegalCall as isLegalCallEngine,
|
||||
type BridgeState,
|
||||
Card as BridgeCard,
|
||||
Call,
|
||||
BidCall,
|
||||
@@ -98,6 +99,7 @@ function CardFace({
|
||||
onClick={onClick}
|
||||
data-testid={`card-${card.id}`}
|
||||
data-selected={selected ? 'true' : 'false'}
|
||||
data-playable={onClick ? 'true' : 'false'}
|
||||
style={{
|
||||
width: w,
|
||||
height: h,
|
||||
@@ -291,6 +293,12 @@ function BiddingView({ G, me, onMove, nameOf, isMyTurn }: { G: BridgeState; me:
|
||||
const last3 = callHistory.slice(-3);
|
||||
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[] = [];
|
||||
for (let level = 1; level <= 7; level++) {
|
||||
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)} 叫牌…`}
|
||||
</div>
|
||||
<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}
|
||||
data-legal="true"
|
||||
style={btnStyle('#444')}>
|
||||
Pass
|
||||
</button>
|
||||
<button data-testid="call-double" onClick={() => doCall({ kind: 'double' })}
|
||||
disabled={!isMyTurn}
|
||||
<button data-testid="call-double"
|
||||
onClick={() => doCall({ kind: 'double' })}
|
||||
disabled={!isMyTurn || !legalOf({ kind: 'double' })}
|
||||
data-legal={legalOf({ kind: 'double' }) ? 'true' : 'false'}
|
||||
title={legalOf({ kind: 'double' }) ? '加倍敌方叫品' : '当前不能加倍(无可加倍的敌方叫品)'}
|
||||
style={btnStyle('#a60')}>
|
||||
X
|
||||
</button>
|
||||
<button data-testid="call-redouble" onClick={() => doCall({ kind: 'redouble' })}
|
||||
disabled={!isMyTurn}
|
||||
<button data-testid="call-redouble"
|
||||
onClick={() => doCall({ kind: 'redouble' })}
|
||||
disabled={!isMyTurn || !legalOf({ kind: 'redouble' })}
|
||||
data-legal={legalOf({ kind: 'redouble' }) ? 'true' : 'false'}
|
||||
title={legalOf({ kind: 'redouble' }) ? '再加倍' : '当前不能再加倍'}
|
||||
style={btnStyle('#a33')}>
|
||||
XX
|
||||
</button>
|
||||
</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(5, 1fr)', gap: 3 }}>
|
||||
{bids.map((b) => (
|
||||
{bids.map((b) => {
|
||||
const legal = legalOf(b);
|
||||
return (
|
||||
<button
|
||||
key={`${b.level}${b.suit}`}
|
||||
data-testid={`call-bid-${b.level}${b.suit}`}
|
||||
onClick={() => doCall(b)}
|
||||
disabled={!isMyTurn}
|
||||
style={btnSmallStyle('#2a4a7a', !isMyTurn)}
|
||||
disabled={!isMyTurn || !legal}
|
||||
data-legal={legal ? 'true' : 'false'}
|
||||
title={legal ? `${b.level}${suitLabel(b.suit)}` : '低于当前最高叫品'}
|
||||
style={btnSmallStyle('#2a4a7a', !isMyTurn || !legal)}
|
||||
>
|
||||
{b.level}{suitLabel(b.suit)}
|
||||
</button>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -702,8 +723,8 @@ function PlayView({ G, me, onMove, nameOf }: { G: BridgeState; me: string; onMov
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 3, flexWrap: 'wrap' }}>
|
||||
{sortHand(dummyVisibleHand).map((c) => {
|
||||
// 庄家控制明手:轮到庄家时明手牌可点
|
||||
const canClick = isDeclarer && currentPlayer === me && !isDummy;
|
||||
// 庄家控制明手:轮到明手时(currentPlayer === dummy),庄家可为明手出牌
|
||||
const canClick = isDeclarer && !isDummy && currentPlayer === dummy;
|
||||
return (
|
||||
<CardFace
|
||||
key={`dummy-${c.id}`}
|
||||
@@ -721,15 +742,23 @@ function PlayView({ G, me, onMove, nameOf }: { G: BridgeState; me: string; onMov
|
||||
<div>
|
||||
<div style={{ fontSize: 11, color: '#aaa', marginBottom: 4 }}>
|
||||
{isDummy ? `你的牌(明手,不可自己出)` : `你的手牌(${myVisibleHand.length} 张)`}
|
||||
{currentPlayer === me && !isDummy && (
|
||||
{/* 轮到我自己:不论我是 declarer/defender,只要 currentPlayer === me 都能为自己出牌 */}
|
||||
{/* 庄家控明手(currentPlayer === dummy)时,自己牌的提示就隐藏;只可点 dummy 牌 */}
|
||||
{!isDummy && currentPlayer === me && (
|
||||
<span style={{ color: '#fc6', marginLeft: 8 }}>
|
||||
← 轮到你{leadSuit && myVisibleHand.some((c) => c.suit === leadSuit) ? `(须跟 ${leadSuit})` : ''}
|
||||
</span>
|
||||
)}
|
||||
{!isDummy && isDeclarer && currentPlayer === dummy && (
|
||||
<span style={{ color: '#fc6', marginLeft: 8 }}>
|
||||
← 轮到你为明手出牌{leadSuit && dummyVisibleHand.some((c) => c.suit === leadSuit) ? `(须跟 ${leadSuit})` : ''}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
|
||||
{sortHand(myVisibleHand).map((c) => {
|
||||
const canClick = currentPlayer === me && !isDummy;
|
||||
// 自己手牌可点:轮到 me 自己(不管我是 declarer 还是 defender;明手不可点自己的牌)
|
||||
const canClick = !isDummy && currentPlayer === me;
|
||||
return (
|
||||
<CardFace
|
||||
key={c.id}
|
||||
|
||||
@@ -162,6 +162,7 @@ export function DoubleUpBoard({ G: rawG, ctx, playerID, onMove, playerName, play
|
||||
revealed: realG.revealed ?? { '0': [], '1': [], '2': [], '3': [] },
|
||||
revealedOwner: realG.revealedOwner ?? null,
|
||||
playerNames: realG.playerNames ?? {},
|
||||
playerLevels: realG.playerLevels ?? { '0': 2, '1': 2, '2': 2, '3': 2 },
|
||||
level: realG.level ?? 2,
|
||||
trumpSuit: realG.trumpSuit ?? null,
|
||||
trumpDecided: realG.trumpDecided ?? false,
|
||||
|
||||
652
apps/web/src/MahjongBoard.tsx
Normal file
652
apps/web/src/MahjongBoard.tsx
Normal file
@@ -0,0 +1,652 @@
|
||||
/**
|
||||
* 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'}
|
||||
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);
|
||||
|
||||
// 立直模式:点击「立直」后选一张牌同时宣言 + 打出
|
||||
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) {
|
||||
doMove('declareRiichi', [tileId]);
|
||||
doMove('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>供托 <b style={{ color: '#fc6' }}>{G.kyoutaku}</b></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', [])} style={btn('#b71c1c')}>自摸</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>
|
||||
)}
|
||||
</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(8);
|
||||
useEffect(() => {
|
||||
setSecs(8);
|
||||
const iv = setInterval(() => setSecs((s) => (s > 0 ? s - 1 : 0)), 1000);
|
||||
return () => clearInterval(iv);
|
||||
}, [claim]);
|
||||
useEffect(() => {
|
||||
if (secs === 0) 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');
|
||||
|
||||
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={() => onMove('respond', ['ron'])} style={btn('#b71c1c')}>荣和</button>}
|
||||
{claim.options.some((o) => o.type === 'pon') && (
|
||||
<button data-testid="mj-claim-pon" onClick={() => onMove('respond', ['pon'])} style={btn('#1565c0')}>碰</button>
|
||||
)}
|
||||
{claim.options.some((o) => o.type === 'kan') && (
|
||||
<button data-testid="mj-claim-kan" onClick={() => onMove('respond', ['kan'])} style={btn('#4527a0')}>杠</button>
|
||||
)}
|
||||
{chiOptions.map((o, i) => (
|
||||
<button key={i} onClick={() => onMove('respond', ['chi', o.useTiles])} style={btn('#2e7d32')}>
|
||||
吃 {o.useTiles?.map((k) => tileLabel(kindToIndexToKind(k))).join('')}
|
||||
</button>
|
||||
))}
|
||||
<button data-testid="mj-claim-pass" onClick={() => onMove('respond', ['pass'])} style={btn('#555')}>跳过</button>
|
||||
</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.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 style={{ fontSize: 12, marginBottom: 10 }}>
|
||||
最终顺位:{G.finalRanking.map((r, i) => `${i + 1}位 ${nameOf(r.player)}(${r.score})`).join(' ')}
|
||||
</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;
|
||||
34
docs/dev-log/15-mahjong-game.md
Normal file
34
docs/dev-log/15-mahjong-game.md
Normal 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 造成状态错乱)。
|
||||
@@ -22,6 +22,7 @@
|
||||
| 12 | [统一端口 + 玩家命名系统](./12-unified-port-and-player-names.md) | /api 前缀、vite proxy、静态 serve、名字显示/改名 |
|
||||
| 13 | [德州扑克(Holdem)游戏](./13-holdem-game.md) | 引擎 + 评估器 + 牌桌 UI + 可变人数大厅 + E2E |
|
||||
| 14 | [双升游戏 + 单端口启动/部署](./14-double-up-game.md) | DoubleUp 引擎+UI+联机、start.sh(PORT=5173)、玩家名 server 权威、固定人数大厅 |
|
||||
| 15 | [立直麻将游戏](./15-mahjong-game.md) | Mahjong 引擎(牌具/全役种/符点)+ 鸣牌窗口 + 麻将桌 UI + 三麻/半庄 |
|
||||
| — | [踩坑总结](./lessons-learned.md) | 所有踩过的坑(按现象分类) |
|
||||
| — | [决策记录](./decisions.md) | 已锁定决策 + 日期 |
|
||||
|
||||
@@ -45,6 +46,7 @@ docs/
|
||||
│ ├── 12-unified-port-and-player-names.md
|
||||
│ ├── 13-holdem-game.md
|
||||
│ ├── 14-double-up-game.md
|
||||
│ ├── 15-mahjong-game.md
|
||||
│ ├── lessons-learned.md
|
||||
│ └── decisions.md
|
||||
├── impl.md 整体实现规划(高层)
|
||||
@@ -56,6 +58,6 @@ docs/
|
||||
- **架构 + 骨架**:✅ 完成
|
||||
- **本地 2D MVP**(拖拽 + 3 个游戏):✅ 完成
|
||||
- **联机**:✅ Phase 2(创建房间 + 房间列表 + E2E/unit 覆盖)
|
||||
- **游戏生态**:✅ Holdem(德州扑克,2-9 人,含隐藏信息 + 边池);✅ DoubleUp(双升,固定 4 人,拖拉机/甩牌/抠底)
|
||||
- **游戏生态**:✅ Holdem(德州扑克,2-9 人,含隐藏信息 + 边池);✅ DoubleUp(双升,固定 4 人,拖拉机/甩牌/抠底);✅ Bridge(桥牌,固定 4 人);✅ Mahjong(立直麻将,四麻/三麻,半庄战,雀魂规则)
|
||||
- **桌面壳(Tauri)**:⏳ Phase 4(待启动)
|
||||
- **脚本化层(玩家自写游戏)**:⏳ Phase 3(待启动)
|
||||
|
||||
494
docs/mahjong/00-rules.md
Normal file
494
docs/mahjong/00-rules.md
Normal 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/
|
||||
- 维基百科/萌娘百科「日本麻将」「立直麻将」「役种列表」「符計算」「振听」「流局满贯」
|
||||
- 百度百科「役满」「双倍役满」「流局满贯」「人和」
|
||||
- 天鳳/万年東風等日麻规则站(役种、符、点数表校验)
|
||||
51
docs/mahjong/01-implementation-plan.md
Normal file
51
docs/mahjong/01-implementation-plan.md
Normal 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 不得二次实现。
|
||||
144
packages/engine/src/games/mahjong-tiles.test.ts
Normal file
144
packages/engine/src/games/mahjong-tiles.test.ts
Normal 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]);
|
||||
});
|
||||
});
|
||||
282
packages/engine/src/games/mahjong-tiles.ts
Normal file
282
packages/engine/src/games/mahjong-tiles.ts
Normal 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;
|
||||
}
|
||||
|
||||
/** 牌种列表 → 物理牌列表(用于测试便捷构造)。 */
|
||||
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 };
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 死墙常量
|
||||
// ============================================================
|
||||
|
||||
/** 王牌(死墙)张数: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,
|
||||
};
|
||||
320
packages/engine/src/games/mahjong-yaku.test.ts
Normal file
320
packages/engine/src/games/mahjong-yaku.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
951
packages/engine/src/games/mahjong-yaku.ts
Normal file
951
packages/engine/src/games/mahjong-yaku.ts
Normal file
@@ -0,0 +1,951 @@
|
||||
/**
|
||||
* 日本立直麻将 — 和牌分解、役种判定、符计算、点数计算(纯函数)。
|
||||
*
|
||||
* 规则依据: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 张(含副露时更少)手牌,返回所有能使其成和牌型的牌索引。
|
||||
*/
|
||||
export function waitingKinds(concealed: readonly number[], melds: readonly Meld[] = []): number[] {
|
||||
const base = countsFromIndices(concealed);
|
||||
const out: number[] = [];
|
||||
for (let i = 0; i < 34; i++) {
|
||||
if ((base[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,
|
||||
};
|
||||
210
packages/engine/src/games/mahjong.test.ts
Normal file
210
packages/engine/src/games/mahjong.test.ts
Normal file
@@ -0,0 +1,210 @@
|
||||
// @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 } from './mahjong.js';
|
||||
import { tilesFromKinds } 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();
|
||||
});
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
1251
packages/engine/src/games/mahjong.ts
Normal file
1251
packages/engine/src/games/mahjong.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -70,6 +70,47 @@ describe('scoreRound', () => {
|
||||
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)', () => {
|
||||
// 文档:全红「只翻转红心,不影响猪/羊/变压器本来的正负」。
|
||||
const cards = [
|
||||
|
||||
@@ -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
|
||||
// - 同吃其他分牌 → 本副牌所有分(含翻转后的红心)整体 ×2
|
||||
// - 只吃到变压器 → 单独 +50
|
||||
if (hasTransformer) {
|
||||
if (hasOtherScore) {
|
||||
@@ -237,26 +251,21 @@ export function scoreRound(taken: Record<PlayerID, Card[]>, numPlayers: 3 | 4):
|
||||
}
|
||||
}
|
||||
|
||||
// 全红(文档 2.7 / 2.8):本家吃齐全部 13 张红心 → 红心全变正分 +200。
|
||||
// 注意:全红「只翻转红心」,不影响猪/羊/变压器本来的正负(文档原文)。
|
||||
// 红心基础分合计恰为 -200,故把已累加的红心分替换成 +200 等价于 s += 400。
|
||||
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,且优先级最高。
|
||||
// 大满贯(文档 2.7):一家吃齐全部 16 张分牌 → 猪也变正分 +200 + 独立奖池 +800。
|
||||
// 在变压器 ×2 **之后**处理:若大满贯仍走"累加 + ×2"流程会重复算(变成 1200)。
|
||||
// 大满贯是独立公式:清零之前的累加/翻转/变压器结果,按 += 累加(避免覆盖丢分的潜在 bug)。
|
||||
const isMyGrandSlam =
|
||||
myHearts.length === 13 &&
|
||||
cards.some((c) => c.suit === '♠' && c.rank === 12) &&
|
||||
cards.some((c) => c.suit === '♦' && c.rank === 11) &&
|
||||
cards.some((c) => c.suit === '♣' && c.rank === 10);
|
||||
if (isMyGrandSlam) {
|
||||
s = 800;
|
||||
// 大满贯 = 集齐 16 张分牌(含所有红心 + 猪 + 羊 + 变压器),
|
||||
// 此时"累加 + 全红翻转 + 变压器 ×2"流程必然得到一个被变压器翻倍重复计入的值。
|
||||
// 按文档公式 (200 红心正分 + 100 羊 + 100 变压器翻倍基数) × 2 = 800 直接定值。
|
||||
// 用清零 + += 实现"增量赋值"语义,保留 P-1 修复的诉求。
|
||||
s = 0;
|
||||
s += 800;
|
||||
messages.push(`玩家 ${p} 大满贯 +800`);
|
||||
}
|
||||
|
||||
|
||||
@@ -30,6 +30,7 @@ import { Holdem } from '@tts-like/engine/games/holdem';
|
||||
import { Pig } from '@tts-like/engine/games/pig';
|
||||
import { DoubleUp } from '@tts-like/engine/games/double-up';
|
||||
import { Bridge } from '@tts-like/engine/games/bridge';
|
||||
import { Mahjong } from '@tts-like/engine/games/mahjong';
|
||||
import { mountApiPrefix } from './mount-api-prefix.js';
|
||||
import { setupRooms } from './rooms.js';
|
||||
import { getUserRoom } from './rooms.js';
|
||||
@@ -64,7 +65,7 @@ function parseOrigins(): (string | RegExp)[] {
|
||||
}
|
||||
|
||||
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,
|
||||
});
|
||||
|
||||
@@ -91,6 +92,7 @@ const GAMES_ENTRIES: GameEntry[] = [
|
||||
{ name: 'GongZhu', game: Pig as any },
|
||||
{ name: 'DoubleUp', game: DoubleUp 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(` Web: http://localhost:${PORT}/ (if dist exists)`);
|
||||
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`);
|
||||
});
|
||||
|
||||
// 优雅关闭
|
||||
|
||||
Reference in New Issue
Block a user