M5: 마작 규칙·점수 계산·화면·테스트

- 규칙 모듈 packages/games/src/mahjong (tiles·agari·scoring·types·index): 4인 리치 마작(동풍전/반장전)과
  3인 산마, 옵션 전부 한국어 meta, 응답 창(론 > 퐁·깡 > 치, 조기 확정, 최소 400ms, 시간 초과 = 패스),
  리치·일발·더블리치, 후리텐(버림패·동순·리치), 구이가에, 깡 도라 시점, 사깡·사풍·사가리치·구종구패·삼가화,
  유국만관, 책임지불, 북 뽑기, 고점법 점수 계산(역·부·만관~더블 역만, 본장·공탁, 산마 쯔모손/북가절반),
  토비·아가리야메·서입 서든데스, 우마·오카 결과, 초보 모드 힌트, autoPilot·나가기 처리
- 응답 창 타이머는 아직 응답하지 않은 응답 가능자에게만 걸어 연결 끊긴 사람은 바로 패스, 확정 뒤에는 방총자
  타이머로 닫음(끊긴 방총자 때문에 타이머가 헛도는 일 방지). 공개 시계에는 응답 창 남은 시간을 싣지 않음
- 테스트: 점수 계산기 161건(1~4판 × 20~110부 표 전 칸, 만관 이상, 역·부 예시, 산마 지불),
  규칙 시나리오 77건(§10 체크리스트), 무작위 대국 1,000판(4인 반장전 240 · 동풍전 400 · 산마 360,
  옵션 무작위, 패·점수 보존, onTimeout 합법, 재현성) + 엔진 randomPlayout과 매 단계 정보 누출 검사
- 화면 apps/web/src/games/mahjong: 직접 그린 SVG 패, 4방향(3인은 3방향) 버림패 테이블, 탭 두 번 버리기,
  리치·쯔모·깡·북·구종구패, 론·퐁·치(조합 선택)·깡·패스 응답, 국 결과(역·판·부·점수 변화)와 최종 순위,
  초보 모드(텐파이 타패·대기·남은 장수·역 힌트·후리텐 배지), PC 2단 배치·휴대폰 한 줄 손패, 키보드·스크린리더
- 등록: GAMES, 카탈로그(available), 웹 registry, '곧 나와요'에서 마작 제거
- E2E: PC 방장 + 휴대폰 3명 4인 대국으로 한 국을 끝까지(타패·패스·유국 결과 확인·다음 국 시작)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
EJClaw
2026-10-05 14:06:45 +09:00
parent 6321ea8c0d
commit c0fb44e395
18 changed files with 6511 additions and 1 deletions

View File

@@ -0,0 +1,323 @@
/**
* 마작패 그림 (docs/games/mahjong.md §9): 직접 그린 SVG. 만수 = 숫자 + "만", 통수 = 동그라미, 삭수 = 막대, 자패 = 한글.
* 작은 크기(버림패)에서는 숫자를 크게 쓴 간단한 그림으로 바꿔 휴대폰에서도 읽히게 한다. 수패는 종류마다 안쪽 테두리
* 무늬(실선·점선·긴 점선)가 달라 색만으로 구별하지 않아도 된다.
*/
import type { CSSProperties } from 'react';
const SUIT_KO: Record<string, string> = { m: '만수', p: '통수', s: '삭수' };
const SUIT_SHORT: Record<string, string> = { m: '만', p: '통', s: '삭' };
const HONOR_KO = ['동', '남', '서', '북', '백', '발', '중'];
const INK = '#1c1e21';
const RED = '#c8202a';
const BLUE = '#1f5fbf';
const GREEN = '#17804a';
/** "5m0r" → { n: 5, suit: 'm', red: true }; honors use suit 'z' and n 1..7. */
export function parseCode(code: string): { n: number; suit: 'm' | 'p' | 's' | 'z'; red: boolean; kind: string } {
return { n: Number(code[0]), suit: code[1] as 'm' | 'p' | 's' | 'z', red: code.endsWith('r'), kind: code.slice(0, 2) };
}
/** Screen-reader name: "만수 5", "적도라 통수 5", "동", "중". */
export function tileName(code: string): string {
const t = parseCode(code);
if (t.suit === 'z') return HONOR_KO[t.n - 1]!;
return `${t.red ? '적도라 ' : ''}${SUIT_KO[t.suit]} ${t.n}`;
}
/** Short name: "5만", "3통", "7삭", "동" (kind codes like "5m" work too). */
export function tileShort(code: string): string {
const t = parseCode(code);
return t.suit === 'z' ? HONOR_KO[t.n - 1]! : `${t.n}${SUIT_SHORT[t.suit]}`;
}
/** Sort key: 만 → 통 → 삭 → 자, by number, red five first. */
export function tileOrder(code: string): number {
const t = parseCode(code);
return 'mpsz'.indexOf(t.suit) * 100 + t.n * 10 + (t.red ? 0 : 1) + Number(code[2] ?? 0) / 10;
}
const PIN_POS: Record<number, [number, number][]> = {
1: [[30, 38]],
2: [
[30, 22],
[30, 54],
],
3: [
[16, 18],
[30, 38],
[44, 58],
],
4: [
[18, 22],
[42, 22],
[18, 54],
[42, 54],
],
5: [
[17, 18],
[43, 18],
[30, 38],
[17, 58],
[43, 58],
],
6: [
[19, 16],
[41, 16],
[19, 38],
[41, 38],
[19, 60],
[41, 60],
],
7: [
[14, 13],
[30, 21],
[46, 29],
[19, 46],
[41, 46],
[19, 63],
[41, 63],
],
8: [
[19, 12],
[41, 12],
[19, 30],
[41, 30],
[19, 47],
[41, 47],
[19, 64],
[41, 64],
],
9: [
[14, 16],
[30, 16],
[46, 16],
[14, 38],
[30, 38],
[46, 38],
[14, 60],
[30, 60],
[46, 60],
],
};
function pinColor(n: number, i: number, red: boolean): string {
if (red) return RED;
if (n === 1) return RED;
if (n === 2) return i === 0 ? GREEN : BLUE;
if (n === 3) return [BLUE, RED, GREEN][i]!;
if (n === 4) return i === 0 || i === 3 ? BLUE : GREEN;
if (n === 5) return i === 2 ? RED : i === 0 || i === 4 ? BLUE : GREEN;
if (n === 6) return i < 2 ? GREEN : RED;
if (n === 7) return i < 3 ? GREEN : RED;
if (n === 8) return BLUE;
return i < 3 ? BLUE : i < 6 ? RED : GREEN;
}
function Pins({ n, red }: { n: number; red: boolean }) {
const pos = PIN_POS[n]!;
const r = n === 1 ? 17 : n <= 3 ? 9.5 : n <= 5 ? 8.5 : n <= 7 ? 7 : 7;
return (
<>
{pos.map(([x, y], i) => {
const c = pinColor(n, i, red);
return (
<g key={i}>
<circle cx={x} cy={y} r={r} fill="none" stroke={c} strokeWidth={n === 1 ? 4 : 2.6} />
<circle cx={x} cy={y} r={r * 0.42} fill={c} />
</g>
);
})}
</>
);
}
/** Bamboo sticks: rows of [x positions], with red sticks for some centres. */
const SOU_ROWS: Record<number, number[][]> = {
2: [[30], [30]],
3: [[30], [18, 42]],
4: [
[20, 40],
[20, 40],
],
5: [[18, 42], [30], [18, 42]],
6: [
[15, 30, 45],
[15, 30, 45],
],
7: [[30], [15, 30, 45], [15, 30, 45]],
8: [
[12, 24, 36, 48],
[12, 24, 36, 48],
],
9: [
[15, 30, 45],
[15, 30, 45],
[15, 30, 45],
],
};
function Sticks({ n, red }: { n: number; red: boolean }) {
if (n === 1) {
// 1삭: one big stick with knots and a red top.
return (
<g>
<rect x={24} y={12} width={12} height={54} rx={6} fill={GREEN} />
<rect x={24} y={12} width={12} height={12} rx={6} fill={RED} />
<line x1={24} y1={39} x2={36} y2={39} stroke="#fff" strokeWidth={2} />
<line x1={24} y1={52} x2={36} y2={52} stroke="#fff" strokeWidth={2} />
</g>
);
}
const rows = SOU_ROWS[n]!;
const top = 8;
const bottom = 70;
const gap = 4;
const h = (bottom - top - gap * (rows.length - 1)) / rows.length;
const w = rows.some((r) => r.length === 4) ? 7 : 8;
return (
<>
{rows.map((xs, ri) =>
xs.map((x, ci) => {
const y = top + ri * (h + gap);
const center = (n === 5 && ri === 1) || (n === 7 && ri === 0) || (n === 9 && ci === 1);
const c = red ? RED : center ? RED : GREEN;
return (
<g key={`${ri}-${ci}`}>
<rect x={x - w / 2} y={y} width={w} height={h} rx={w / 2} fill={c} />
<line x1={x - w / 2} y1={y + h / 2} x2={x + w / 2} y2={y + h / 2} stroke="#fff" strokeWidth={1.4} />
</g>
);
}),
)}
</>
);
}
/** Inner frame pattern per suit (colour-blind help, §9). */
const FRAME_DASH: Record<string, string | undefined> = { m: undefined, p: '1.5 3', s: '6 3' };
function Face({ code, compact }: { code: string; compact: boolean }) {
const t = parseCode(code);
const frame = t.suit !== 'z' && (
<rect x={5} y={5} width={50} height={66} rx={5} fill="none" stroke="#c9bfa8" strokeWidth={1.6} strokeDasharray={FRAME_DASH[t.suit]} />
);
if (t.suit === 'z') {
const ch = HONOR_KO[t.n - 1]!;
if (t.n === 5) {
return (
<>
<rect x={10} y={10} width={40} height={56} rx={4} fill="none" stroke={BLUE} strokeWidth={4} />
<text x={30} y={48} textAnchor="middle" fontSize={compact ? 28 : 22} fontWeight={800} fill={BLUE}>
{ch}
</text>
</>
);
}
const color = t.n === 6 ? GREEN : t.n === 7 ? RED : INK;
return (
<text x={30} y={compact ? 53 : 52} textAnchor="middle" fontSize={compact ? 40 : 36} fontWeight={900} fill={color}>
{ch}
</text>
);
}
const numColor = t.red ? RED : INK;
if (compact) {
if (t.suit === 'm') {
return (
<>
{frame}
<text x={30} y={50} textAnchor="middle" fontSize={44} fontWeight={900} fill={numColor}>
{t.n}
</text>
<rect x={14} y={58} width={32} height={7} rx={3.5} fill={RED} />
</>
);
}
if (t.suit === 'p') {
return (
<>
{frame}
<circle cx={30} cy={38} r={23} fill="none" stroke={t.red ? RED : BLUE} strokeWidth={4} />
<text x={30} y={53} textAnchor="middle" fontSize={40} fontWeight={900} fill={t.red ? RED : BLUE}>
{t.n}
</text>
</>
);
}
return (
<>
{frame}
<rect x={7} y={12} width={6} height={52} rx={3} fill={t.red ? RED : GREEN} />
<rect x={47} y={12} width={6} height={52} rx={3} fill={t.red ? RED : GREEN} />
<text x={30} y={53} textAnchor="middle" fontSize={40} fontWeight={900} fill={t.red ? RED : GREEN}>
{t.n}
</text>
</>
);
}
if (t.suit === 'm') {
return (
<>
{frame}
<text x={30} y={40} textAnchor="middle" fontSize={34} fontWeight={900} fill={numColor}>
{t.n}
</text>
<text x={30} y={66} textAnchor="middle" fontSize={21} fontWeight={800} fill={RED}>
만
</text>
</>
);
}
return (
<>
{frame}
{t.suit === 'p' ? <Pins n={t.n} red={t.red} /> : <Sticks n={t.n} red={t.red} />}
<text x={52} y={14} textAnchor="end" fontSize={11} fontWeight={800} fill={t.red ? RED : '#6b6352'}>
{t.n}
</text>
</>
);
}
export interface TileProps {
/** Tile code ("5m0r") or kind code ("5m"); null draws the back. */
code: string | null;
/** Width in px (height is 4:3). */
w: number;
sideways?: boolean;
dim?: boolean;
className?: string;
style?: CSSProperties;
/** aria-hidden by default: the parent describes the tile. */
label?: string;
}
export function Tile({ code, w, sideways, dim, className, style, label }: TileProps) {
const h = Math.round((w * 4) / 3);
const compact = w < 30;
const svg = (
<svg viewBox="0 0 60 80" width={w} height={h} style={{ display: 'block', ...(sideways ? { transform: 'rotate(-90deg)' } : {}) }} aria-hidden>
{code === null ? (
<>
<rect x={1} y={1} width={58} height={78} rx={8} fill="#1d5a8c" stroke="#123b5c" strokeWidth={2} />
<rect x={7} y={7} width={46} height={62} rx={5} fill="#2a73ad" />
</>
) : (
<>
<rect x={1} y={1} width={58} height={78} rx={8} fill="#d9cdb0" stroke="#9c8f72" strokeWidth={2} />
<rect x={2} y={2} width={56} height={71} rx={7} fill="#fffcf3" />
<Face code={code} compact={compact} />
</>
)}
</svg>
);
const box: CSSProperties = sideways
? { width: h, height: w, display: 'inline-flex', alignItems: 'center', justifyContent: 'center', flex: 'none', ...style }
: { width: w, height: h, display: 'inline-block', flex: 'none', ...style };
return (
<span className={`mj-tile${dim ? ' dim' : ''}${className ? ` ${className}` : ''}`} style={box} role={label ? 'img' : undefined} aria-label={label} aria-hidden={label ? undefined : true}>
{svg}
</span>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -14,6 +14,7 @@ export const GAME_UI: Record<string, () => Promise<{ default: GameUI }>> = {
'one-card': () => import('./one-card'),
rummikub: () => import('./rummikub'),
'davinci-code': () => import('./davinci-code'),
mahjong: () => import('./mahjong'),
};
const cache = new Map<string, GameUI>();

View File

@@ -15,4 +15,4 @@ export const GAME_ICON: Record<string, string> = {
};
/** Shown on the home screen so users know what's coming (docs/12-roadmap.md). */
export const UPCOMING = ['마작', '고스톱', '뱅', '할리갈리'];
export const UPCOMING = ['고스톱', '뱅', '할리갈리'];

View File

@@ -249,6 +249,78 @@ const PLANS: Record<string, Plan> = {
console.log(` one-card: ${turns} turns (${plays} plays, ${draws} draws)${finished ? ', game finished' : ''}`);
},
},
mahjong: {
// 4인 리치 마작 (PC host + 3 phones): whoever has the turn taps the right-most legal tile (lift) and presses
// the "… 버리기" button; claim windows are answered with 패스 (론/쯔모 when offered). Plays until the first hand
// ends (win or exhaustive draw, ~70 discards), checks the result screen on every device and confirms it.
players: 4,
drive: async (pages) => {
const statusOf = (p: Page) => p.locator('.turn-status').innerText().catch(() => '');
let discards = 0;
let passes = 0;
let wins = 0;
let results = 0;
const shotClaim = new Set<string>();
for (let t = 0; t < 4000 && discards < 100 && results < 1; t++) {
let acted = false;
for (const [pi, p] of pages.entries()) {
const status = await statusOf(p);
const win = p.getByRole('button', { name: /^(쯔모|론)!$/ });
if ((await win.count()) && (await win.first().isEnabled().catch(() => false))) {
await win.first().click({ timeout: 1000 }).catch(() => {});
wins++;
acted = true;
break;
}
const pass = p.getByRole('group', { name: '울기·론 선택' }).getByRole('button', { name: '패스' });
if (await pass.count()) {
const dev = pi === 0 ? 'pc' : 'phone';
if (!shotClaim.has(dev)) {
await p.screenshot({ path: join(SHOTS, `mahjong-3-claim-${dev}.png`) });
shotClaim.add(dev);
}
await pass.click({ timeout: 1000 }).catch(() => {});
passes++;
acted = true;
break;
}
const next = p.getByRole('button', { name: /^(다음 국으로|최종 결과 보기)$/ });
if ((await next.count()) && (await next.isEnabled().catch(() => false))) {
// The hand is over: every device shows the result, then everyone confirms → the next hand starts.
await pages[0]!.screenshot({ path: join(SHOTS, 'mahjong-4-result-pc.png') });
await pages[1]!.screenshot({ path: join(SHOTS, 'mahjong-4-result-phone.png'), fullPage: true });
for (const q of pages) {
await q.getByRole('region', { name: /결과$/ }).waitFor({ timeout: 5000 });
const b = q.getByRole('button', { name: /^(다음 국으로|최종 결과 보기)$/ });
if (await b.isEnabled().catch(() => false)) await b.click({ timeout: 1000 }).catch(() => {});
}
await pages[0]!.getByText(/^동1국|^동2국/).first().waitFor({ timeout: 10_000 });
results++;
acted = true;
break;
}
if (!status.includes('내 차례예요')) continue;
const tiles = p.locator('button.mj-htile[data-legal="true"]');
const n = await tiles.count();
if (!n) continue;
const tile = tiles.nth(n - 1);
await tile.click({ timeout: 1000 }).catch(() => {});
if (discards === 4 && pi > 0) await p.screenshot({ path: join(SHOTS, 'mahjong-2-selected-phone.png') });
const send = p.getByRole('button', { name: /버리기$/ });
if ((await send.count()) && (await send.isEnabled().catch(() => false))) await send.click({ timeout: 1000 }).catch(() => {});
await p.waitForFunction((s) => document.querySelector('.turn-status')?.textContent?.trim() !== s, status.trim(), { timeout: 5000 }).catch(() => {});
discards++;
acted = true;
break;
}
if (!acted) await pages[0]!.waitForTimeout(120);
}
if (results === 0) throw new Error(`no hand finished after ${discards} discards`);
// The next hand has started: the table and my hand are back on every screen.
for (const q of pages) await q.locator('.mj-table').waitFor({ timeout: 10_000 });
console.log(` mahjong: ${discards} discards, ${passes} passes, ${wins} wins, ${results} hand finished → next hand started`);
},
},
'davinci-code': {
// 2 players, default options (choose colours): both pick 2+2 start tiles, then several real turns:
// draw from a pile, tap an opponent's hidden tile, pick a number, confirm; after a hit press "여기서 멈추기".

View File

@@ -11,6 +11,7 @@ import { yut } from './yut';
import { oneCard } from './one-card';
import { rummikub } from './rummikub';
import { davinci } from './davinci-code';
import { mahjong } from './mahjong';
export const GAMES: Record<string, AnyGameDefinition> = {
[omok.id]: omok,
@@ -25,4 +26,5 @@ export const GAMES: Record<string, AnyGameDefinition> = {
[oneCard.id]: oneCard,
[rummikub.id]: rummikub,
[davinci.id]: davinci,
[mahjong.id]: mahjong,
};

View File

@@ -0,0 +1,154 @@
/**
* Hand shape analysis on 34-kind count arrays (§5.6): standard 4 sets + pair, 칠대자, 국사무쌍; all decompositions
* for 고점법 (§6.2) and tenpai waits (§5.5).
*/
import { YAOCHU, type Kind } from './tiles';
export interface SetPart {
t: 'shuntsu' | 'koutsu';
/** Lowest kind of a sequence, or the triplet kind. */
k: Kind;
}
export interface Decomposition {
pair: Kind;
sets: SetPart[];
}
/** Can the counts be split into exactly `need` sets (no pair)? Mutates and restores `c`. */
function canSets(c: number[], need: number, from = 0): boolean {
let i = from;
while (i < 34 && c[i] === 0) i++;
if (i === 34) return need === 0;
if (need === 0) return false;
if (c[i]! >= 3) {
c[i]! -= 3;
const ok = canSets(c, need - 1, i);
c[i]! += 3;
if (ok) return true;
}
if (i < 27 && i % 9 <= 6 && c[i + 1]! > 0 && c[i + 2]! > 0) {
c[i]!--;
c[i + 1]!--;
c[i + 2]!--;
const ok = canSets(c, need - 1, i);
c[i]!++;
c[i + 1]!++;
c[i + 2]!++;
if (ok) return true;
}
return false;
}
export function isStandardAgari(c: number[], sets: number): boolean {
for (let p = 0; p < 34; p++) {
if (c[p]! < 2) continue;
c[p]! -= 2;
const ok = canSets(c, sets);
c[p]! += 2;
if (ok) return true;
}
return false;
}
export function isChiitoi(c: number[]): boolean {
let pairs = 0;
for (let k = 0; k < 34; k++) {
if (c[k] === 2) pairs++;
else if (c[k] !== 0) return false;
}
return pairs === 7;
}
export function isKokushi(c: number[]): boolean {
let total = 0;
let pair = false;
for (const k of YAOCHU) {
const n = c[k]!;
if (n === 0 || n > 2) return false;
if (n === 2) {
if (pair) return false;
pair = true;
}
total += n;
}
return pair && total === 14;
}
/** Winning shape for the concealed counts given `meldCount` called/kan sets. */
export function isAgariShape(c: number[], meldCount: number): boolean {
if (meldCount === 0 && (isChiitoi(c) || isKokushi(c))) return true;
return isStandardAgari(c, 4 - meldCount);
}
function collect(c: number[], need: number, from: number, acc: SetPart[], out: SetPart[][]): void {
let i = from;
while (i < 34 && c[i] === 0) i++;
if (i === 34) {
if (need === 0) out.push([...acc]);
return;
}
if (need === 0) return;
if (c[i]! >= 3) {
c[i]! -= 3;
acc.push({ t: 'koutsu', k: i });
collect(c, need - 1, i, acc, out);
acc.pop();
c[i]! += 3;
}
if (i < 27 && i % 9 <= 6 && c[i + 1]! > 0 && c[i + 2]! > 0) {
c[i]!--;
c[i + 1]!--;
c[i + 2]!--;
acc.push({ t: 'shuntsu', k: i });
collect(c, need - 1, i, acc, out);
acc.pop();
c[i]!++;
c[i + 1]!++;
c[i + 2]!++;
}
}
/** Every standard decomposition of the concealed counts into `sets` sets + 1 pair. */
export function decompositions(c: number[], sets: number): Decomposition[] {
const out: Decomposition[] = [];
const work = [...c];
for (let p = 0; p < 34; p++) {
if (work[p]! < 2) continue;
work[p]! -= 2;
const found: SetPart[][] = [];
collect(work, sets, 0, [], found);
for (const s of found) out.push({ pair: p, sets: s });
work[p]! += 2;
}
return out;
}
/**
* Tenpai waits of a hand that needs one tile (13, 10, 7, 4 or 1 concealed tiles). A kind whose four copies the
* player already holds (hand + melds, `owned`) is not a wait (§5.5).
*/
export function waitsOf(c: number[], meldCount: number, owned?: number[]): Kind[] {
const out: Kind[] = [];
for (let k = 0; k < 34; k++) {
if ((owned ? owned[k]! : c[k]!) >= 4) continue;
// Prune: a wait must touch a held tile (or be any yaochu kind for 국사무쌍).
if (!near(c, k) && !(meldCount === 0 && YAOCHU.includes(k))) continue;
c[k]!++;
const ok = isAgariShape(c, meldCount);
c[k]!--;
if (ok) out.push(k);
}
return out;
}
function near(c: number[], k: Kind): boolean {
if (c[k]! > 0) return true;
if (k >= 27) return false;
const n = k % 9;
for (let d = -2; d <= 2; d++) {
if (d === 0) continue;
const m = n + d;
if (m >= 0 && m <= 8 && c[k + d]! > 0) return true;
}
return false;
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,69 @@
/**
* 마작 무작위 대국 ③ 3인(산마) 360판 + 엔진 randomPlayout(직렬화 왕복·정보 누출 검사 내장) + 매 단계 누출 검사
* + 재현성 (docs/09-game-engine.md §7).
*/
import { describe, expect, test } from 'bun:test';
import { randomPlayout, seedN } from '@bg/engine';
import { mahjong } from './index';
import { checkPoints, checkTiles, emptyStats, playGame, variedOptions } from './playout';
const stats = emptyStats();
describe('무작위 대국: 3인 산마', () => {
for (let chunk = 0; chunk < 18; chunk++) {
test(`시드 ${2000 + chunk * 20 + 1}~${2000 + chunk * 20 + 20}`, () => {
for (let i = 1; i <= 20; i++) {
const seed = 2000 + chunk * 20 + i;
playGame({ seed, players: 3, options: { length: seed % 2 ? 'tonpuu' : 'hanchan', ...variedOptions(seed, 3) }, prefs: seed % 4 === 0, leaveAt: seed % 11 === 0 ? 150 : undefined, stats });
}
});
}
test('북 뽑기·화료·유국이 모두 나왔다', () => {
expect(stats.kita).toBeGreaterThan(500);
expect(stats.tsumo).toBeGreaterThan(50);
expect(stats.ron).toBeGreaterThan(50);
expect(stats.exhaustive).toBeGreaterThan(50);
expect(stats.maxApplyMs).toBeLessThan(25);
});
});
describe('엔진 randomPlayout (매 단계 직렬화 왕복 + 정보 누출 검사)', () => {
for (const [players, seed] of [
[['A', 'B', 'C', 'D'], 1],
[['A', 'B', 'C'], 2],
] as const) {
test(`${players.length}인 동풍전 시드 ${seed}`, () => {
const r = randomPlayout(mahjong, {
players: [...players],
options: mahjong.optionsSchema.parse({ length: 'tonpuu', westEntry: false }),
seed: seedN(seed),
maxSteps: 4000,
maxApplyMs: 25,
invariant: (s) => {
checkTiles(s as never);
checkPoints(s as never);
},
});
expect(r.steps).toBeGreaterThan(100);
});
}
});
describe('매 단계 누출 검사 (울기·리치·화료가 있는 대국)', () => {
test('4인 1판 + 3인 1판, 매 단계 모든 시점의 view 검사', () => {
const a = emptyStats();
playGame({ seed: 7, players: 4, options: { length: 'tonpuu', westEntry: false }, deep: true, stats: a });
playGame({ seed: 8, players: 3, options: { length: 'tonpuu', westEntry: false }, deep: true, stats: a });
expect(a.calls + a.riichi).toBeGreaterThan(0);
});
});
describe('재현성', () => {
test('같은 시드면 대국 전체가 같다', () => {
for (const seed of [3, 4]) {
const a = JSON.stringify(playGame({ seed, players: 4, options: variedOptions(seed, 4) }));
const b = JSON.stringify(playGame({ seed, players: 4, options: variedOptions(seed, 4) }));
expect(a).toBe(b);
}
});
});

View File

@@ -0,0 +1,27 @@
/** 마작 무작위 대국 ① 4인 반장전 240판 (docs/09-game-engine.md §7.2): 끝까지 합법 진행 + 불변식. */
import { describe, expect, test } from 'bun:test';
import { emptyStats, playGame, variedOptions } from './playout';
const stats = emptyStats();
describe('무작위 대국: 4인 반장전', () => {
for (let chunk = 0; chunk < 12; chunk++) {
test(`시드 ${0 + chunk * 20 + 1}~${0 + chunk * 20 + 20}`, () => {
for (let i = 1; i <= 20; i++) {
const seed = 0 + chunk * 20 + i;
playGame({ seed, players: 4, options: { length: 'hanchan', ...variedOptions(seed, 4) }, prefs: seed % 5 === 0, leaveAt: seed % 17 === 0 ? 400 : undefined, stats });
}
});
}
test('다양한 결과가 나왔고 apply는 빠르다', () => {
expect(stats.tsumo).toBeGreaterThan(50);
expect(stats.ron).toBeGreaterThan(100);
expect(stats.exhaustive).toBeGreaterThan(100);
expect(stats.abortive).toBeGreaterThan(0);
expect(stats.riichi).toBeGreaterThan(200);
expect(stats.kans).toBeGreaterThan(50);
expect(Object.keys(stats.endReasons).length).toBeGreaterThanOrEqual(3);
expect(stats.applyMs / stats.applies).toBeLessThan(1);
expect(stats.maxApplyMs).toBeLessThan(25);
});
});

View File

@@ -0,0 +1,22 @@
/** 마작 무작위 대국 ② 4인 동풍전 400판 (docs/09-game-engine.md §7.2). */
import { describe, expect, test } from 'bun:test';
import { emptyStats, playGame, variedOptions } from './playout';
const stats = emptyStats();
describe('무작위 대국: 4인 동풍전', () => {
for (let chunk = 0; chunk < 20; chunk++) {
test(`시드 ${1000 + chunk * 20 + 1}~${1000 + chunk * 20 + 20}`, () => {
for (let i = 1; i <= 20; i++) {
const seed = 1000 + chunk * 20 + i;
playGame({ seed, players: 4, options: { length: 'tonpuu', ...variedOptions(seed, 4) }, prefs: seed % 4 === 0, leaveAt: seed % 13 === 0 ? 200 : undefined, stats });
}
});
}
test('다양한 결과가 나왔고 apply는 빠르다', () => {
expect(stats.tsumo + stats.ron).toBeGreaterThan(150);
expect(stats.doubleRon).toBeGreaterThan(0);
expect(stats.applyMs / stats.applies).toBeLessThan(1);
expect(stats.maxApplyMs).toBeLessThan(25);
});
});

View File

@@ -0,0 +1,302 @@
/**
* Test-only fast playout driver for 마작 (docs/09-game-engine.md §7): seeded semi-random but legal play with
* invariant checks (tiles conserved, points + 공탁 conserved, onTimeout legal, JSON round trip, no leaks).
* Not imported by the game module itself.
*/
import { SeededRng, checkNoLeak, seedN } from '@bg/engine';
import { mahjong, type MahjongAction, type MahjongOptions, type MahjongState } from './index';
import { kindOf, parseTileCode } from './tiles';
export interface PlayoutStats {
steps: number;
hands: number;
tsumo: number;
ron: number;
doubleRon: number;
exhaustive: number;
abortive: number;
riichi: number;
calls: number;
kans: number;
kita: number;
yakuman: number;
maxApplyMs: number;
applyMs: number;
applies: number;
timeouts: number;
endReasons: Record<string, number>;
}
export const emptyStats = (): PlayoutStats => ({
steps: 0,
hands: 0,
tsumo: 0,
ron: 0,
doubleRon: 0,
exhaustive: 0,
abortive: 0,
riichi: 0,
calls: 0,
kans: 0,
kita: 0,
yakuman: 0,
maxApplyMs: 0,
applyMs: 0,
applies: 0,
timeouts: 0,
endReasons: {},
});
function check(cond: boolean, msg: string): void {
if (!cond) throw new Error(msg);
}
/** Every tile is in exactly one place (§3: 136 / 108). */
export function checkTiles(s: MahjongState): void {
const h = s.hand;
if (!h) return;
const out = [...h.wall, ...h.rinshan, ...h.doraInd, ...h.uraInd];
for (let i = 0; i < s.players.length; i++) {
out.push(...h.hands[i]!, ...h.kita[i]!);
for (const m of h.melds[i]!) out.push(...m.tiles);
for (const d of h.discards[i]!) if (d.calledBy === null) out.push(d.tile);
}
const n = s.mode === 'sanma' ? 108 : 136;
check(out.length === n, `tile count ${out.length} != ${n}`);
check(new Set(out).size === n, 'duplicate tile');
check(h.rinshan.length === 4 && h.doraInd.length === 5 && h.uraInd.length === 5, 'dead wall must stay 14 tiles');
if (h.stage !== 'ended') {
for (let i = 0; i < s.players.length; i++) {
const size = h.hands[i]!.length + 3 * h.melds[i]!.length;
const turnHolds = h.turn === i && (h.stage === 'afterDraw' || h.stage === 'afterCall');
check(size === (turnHolds ? 14 : 13), `seat ${i} holds ${size} tiles at stage ${h.stage}`);
}
}
}
export function checkPoints(s: MahjongState): void {
const total = s.points.reduce((a, b) => a + b, 0) + s.riichiPool;
check(total === s.options.startPoints * s.players.length, `points not conserved: ${total}`);
check(s.riichiPool >= 0 && s.riichiPool % 1000 === 0, 'bad riichi pool');
check(s.points.every((p) => Number.isInteger(p) && p % (s.mode === "sanma" ? 50 : 100) === 0), `points must be multiples of 100 (산마 본장 300 → 50): ${s.points}`);
}
/** How well a tile fits the hand: same kinds and close neighbours (honors only pair up). */
function fit(hand: number[], t: number): number {
const k = kindOf(t);
let score = 0;
for (const x of hand) {
if (x === t) continue;
const j = kindOf(x);
if (j === k) score += 3;
else if (k < 27 && j < 27 && Math.floor(j / 9) === Math.floor(k / 9)) {
const d = Math.abs(j - k);
if (d === 1) score += 2;
else if (d === 2) score += 1;
}
}
return score;
}
/** Picks an action for `seat`: wins and riichi when possible, some calls and kans, sensible discards. */
function choose(s: MahjongState, seat: number, rng: SeededRng): MahjongAction | null {
const p = s.players[seat]!;
const legal = mahjong.legalActions!(s, p);
if (legal.length === 0) return null;
const r = rng.next();
const of = (t: MahjongAction['type']) => legal.filter((a) => a.type === t);
if (s.phase === 'handResult') return legal[0]!;
const h = s.hand!;
if (h.stage === 'claim') {
const ron = legal.find((a) => a.type === 'claim' && a.call === 'ron');
if (ron && r < 0.9) return ron;
const calls = legal.filter((a) => a.type === 'claim' && a.call !== 'ron');
if (calls.length && r < 0.35) return calls[rng.int(calls.length)]!;
return { type: 'pass' };
}
if (of('tsumo').length && r < 0.92) return of('tsumo')[0]!;
if (of('kyuushu').length && r < 0.5) return of('kyuushu')[0]!;
const kans = [...of('ankan'), ...of('kakan')];
if (kans.length && r < 0.6) return kans[rng.int(kans.length)]!;
if (of('kita').length && r < 0.8) return of('kita')[0]!;
const discards = of('discard');
const riichi = discards.filter((a) => a.type === 'discard' && a.riichi);
if (riichi.length && rng.next() < 0.7) return riichi[rng.int(riichi.length)]!;
const plain = discards.filter((a) => a.type === 'discard' && !a.riichi);
if (rng.next() < 0.25) return plain[rng.int(plain.length)]!;
const hand = h.hands[seat]!;
let best = plain[0]!;
let bestScore = Infinity;
for (const a of plain) {
if (a.type !== 'discard') continue;
const sc = fit(hand, parseTileCode(a.tile)!) + rng.next();
if (sc < bestScore) {
bestScore = sc;
best = a;
}
}
return best;
}
/** House-rule variety for playouts: seed → option overrides (always legal combinations). */
export function variedOptions(seed: number, players: number): Partial<MahjongOptions> {
const r = SeededRng.fromSeed(seedN(seed + 31_337));
const pick = <T,>(xs: readonly T[]): T => xs[r.int(xs.length)]!;
if (r.next() < 0.4) return {};
return {
doubleRon: pick(['allow', 'headBump'] as const),
tripleRon: pick(['abort', 'allow'] as const),
kuitan: r.next() < 0.8,
akaDora: pick(['auto', 0, 2, 3] as const),
kiriageMangan: r.next() < 0.3,
kazoeYakuman: r.next() < 0.8,
doubleYakuman: r.next() < 0.8,
abortiveDraws: r.next() < 0.8,
nagashiMangan: r.next() < 0.8,
pao: r.next() < 0.8,
renchanOn: pick(['tenpai', 'agari'] as const),
renpuuPairFu: pick([2, 4] as const),
tobi: r.next() < 0.8,
agariYame: r.next() < 0.8,
westEntry: r.next() < 0.8,
uma: players === 3 ? pick(['auto', '0', '15'] as const) : pick(['auto', 'none', '5-10', '10-20', '10-30'] as const),
startPoints: pick(['auto', 20000, 25000, 30000] as const),
sanmaTsumo: pick(['tsumoLoss', 'northBisection'] as const),
sanmaHonba: pick([200, 300, 1000] as const),
sanmaNotenPool: pick([2000, 3000] as const),
};
}
export interface PlayoutOpts {
seed: number;
players: number;
options?: Partial<MahjongOptions>;
/** Run the (slower) JSON round-trip and view leak checks on every step. */
deep?: boolean;
/** One player leaves the room after this many steps (then plays on autopilot). */
leaveAt?: number;
/** Random 자동 화료 / 울기 안 함 preferences at the start. */
prefs?: boolean;
maxSteps?: number;
stats?: PlayoutStats;
}
/** Plays one game to the end; returns the final state. Throws on any broken invariant. */
export function playGame(o: PlayoutOpts): MahjongState {
const stats = o.stats ?? emptyStats();
const players = ['A', 'B', 'C', 'D'].slice(0, o.players);
const rng = SeededRng.fromSeed(seedN(o.seed));
const chooser = SeededRng.fromSeed(seedN(o.seed + 7_777_777));
let now = 1_700_000_000_000;
let s = mahjong.setup({ players, options: mahjong.optionsSchema.parse(o.options ?? {}), rng, now });
const maxSteps = o.maxSteps ?? 20_000;
let lastHands = 0;
if (o.prefs) {
for (const p of players) {
if (chooser.next() < 0.3) step0(p, { type: 'setPref', autoWin: true });
if (chooser.next() < 0.15) step0(p, { type: 'setPref', noCalls: true });
}
}
function step0(p: string, a: MahjongAction) {
s = mahjong.apply(s, p, a, { rng, now }).state;
}
const step = (actor: string, a: MahjongAction) => {
const v = mahjong.validate(s, actor, a);
check(v.ok, `rejected ${JSON.stringify(a)} by ${actor}: ${v.ok ? '' : v.reason}`);
now += 500;
const t0 = performance.now();
s = mahjong.apply(s, actor, a, { rng, now }).state;
const ms = performance.now() - t0;
stats.applyMs += ms;
stats.applies++;
if (ms > stats.maxApplyMs) stats.maxApplyMs = ms;
stats.steps++;
};
for (let i = 0; i < maxSteps; i++) {
if (i === o.leaveAt && s.phase !== 'finished') {
const p = players[chooser.int(players.length)]!;
const a = mahjong.onLeave!(s, p);
if (a) step(p, a);
}
checkTiles(s);
checkPoints(s);
// onTimeout is legal for everyone the timer applies to (§7.4).
for (const p of mahjong.timeoutPlayers!(s)) {
const v = mahjong.validate(s, p, mahjong.onTimeout(s, p));
check(v.ok, `onTimeout illegal for ${p}: ${v.ok ? '' : v.reason}`);
}
if (o.deep) {
const json = JSON.stringify(s);
check(JSON.stringify(JSON.parse(json)) === json, 'state is not JSON round-trippable');
for (const p of players) checkNoLeak(mahjong, s, p, players);
}
if (s.handsPlayed > lastHands) {
lastHands = s.handsPlayed;
const r = s.lastHandResult!;
stats.hands++;
if (r.kind === 'agari') {
if (r.winners[0]!.from === null) stats.tsumo++;
else stats.ron++;
if (r.winners.length > 1) stats.doubleRon++;
if (r.winners.some((w) => w.yakuman > 0)) stats.yakuman++;
for (const w of r.winners) check(w.han >= 1 && (w.fu >= 20 || w.yakuman > 0), `bad winner record ${JSON.stringify(w)}`);
} else if (r.kind === 'exhaustive') stats.exhaustive++;
else stats.abortive++;
check(r.deltas.every((d) => d % (s.mode === "sanma" ? 50 : 100) === 0), "deltas must be multiples of 100");
}
const res = mahjong.result(s);
if (res) {
check(s.phase === 'finished', 'result before the end');
const sum = Object.values(res.scores!).reduce((a, b) => a + b, 0);
check(Math.abs(sum) < 1e-9, `final scores do not sum to 0: ${sum}`);
check(res.ranking.flat().length === players.length, 'ranking must list everyone');
stats.endReasons[s.final!.endReason] = (stats.endReasons[s.final!.endReason] ?? 0) + 1;
for (const st of s.stats) {
stats.riichi += st.riichi;
stats.calls += st.calls;
}
return s;
}
if (s.phase === 'handResult') {
const waiting = s.players.filter((p, idx) => !s.readyForNext.includes(idx));
const p = waiting[chooser.int(waiting.length)]!;
if (chooser.next() < 0.1) {
stats.timeouts++;
now = Math.max(now, s.resultAt + 15_000);
step(p, mahjong.onTimeout(s, p));
} else step(p, { type: 'ready' });
continue;
}
const h = s.hand!;
if (h.stage === 'claim') {
const c = h.claim!;
if (c.decided) {
step(s.players[c.from]!, mahjong.onTimeout(s, s.players[c.from]!));
continue;
}
const pending = c.eligible.flatMap((e, seat) => (e && !c.responses[seat] ? [seat] : []));
const seat = pending[chooser.int(pending.length)]!;
if (chooser.next() < 0.05) {
stats.timeouts++;
step(s.players[seat]!, mahjong.onTimeout(s, s.players[seat]!));
continue;
}
const a = choose(s, seat, chooser)!;
step(s.players[seat]!, a);
continue;
}
const seat = h.turn;
if (s.left[seat] || chooser.next() < 0.03) {
stats.timeouts++;
now += 200_000;
step(s.players[seat]!, mahjong.onTimeout(s, s.players[seat]!));
continue;
}
const a = choose(s, seat, chooser);
check(a !== null, `no legal action for the turn seat ${seat} at stage ${h.stage}`);
if (a!.type === 'ankan' || a!.type === 'kakan') stats.kans++;
if (a!.type === 'kita') stats.kita++;
step(s.players[seat]!, a!);
}
throw new Error(`game did not finish in ${maxSteps} steps (seed ${o.seed})`);
}

View File

@@ -0,0 +1,575 @@
/** 마작 점수 계산기 (docs/games/mahjong.md §6): 역 판정, 부, 판·부 → 점수, 만관 이상, 산마 지불. */
import { describe, expect, test } from 'bun:test';
import { basicPoints, payTable, scoreWin, DEFAULT_RULES, type MeldShape, type MeldType, type WinInput, type WinScore } from './scoring';
import { doraFromIndicator, kindFromCode, type Kind } from './tiles';
/** "234m55z" → kinds. */
function k(str: string): Kind[] {
const out: Kind[] = [];
for (const m of str.matchAll(/(\d+)([mpsz])/g)) for (const d of m[1]!) out.push(kindFromCode(`${d}${m[2]}`)!);
return out;
}
const one = (s: string) => k(s)[0]!;
const meld = (type: MeldType, s: string): MeldShape => ({ type, k: Math.min(...k(s)) });
const E = 27;
const S = 28;
const W = 29;
interface Case {
hand: string;
win: string;
melds?: MeldShape[];
tsumo?: boolean;
dealer?: boolean;
extra?: Partial<WinInput>;
}
function score(c: Case): WinScore | null {
return scoreWin({
hand: k(c.hand),
win: one(c.win),
melds: c.melds ?? [],
tsumo: !!c.tsumo,
seatWind: c.dealer ? E : S,
roundWind: E,
...c.extra,
});
}
/** Points the winner receives (ron total or tsumo total) with the standard 4-player table. */
function total(sc: WinScore, dealer: boolean, tsumo: boolean): number {
const t = payTable(sc.basic, dealer);
if (!tsumo) return t.ron;
return dealer ? t.tsumoDealer * 3 : t.tsumoDealer + t.tsumoNonDealer * 2;
}
const keys = (sc: WinScore) => sc.yaku.map((y) => y.key).sort();
// ---------------------------------------------------------------- §6.4 score tables
/** 자(비친) 1~4판 표: [fu, [ron, tsumo-nondealer/tsumo-dealer] per han]; null = 불가능 칸. */
const NON_DEALER: [number, ([number | null, [number, number] | null])[]][] = [
[20, [[null, null], [null, [400, 700]], [null, [700, 1300]], [null, [1300, 2600]]]],
[25, [[null, null], [1600, null], [3200, [800, 1600]], [6400, [1600, 3200]]]],
[30, [[1000, [300, 500]], [2000, [500, 1000]], [3900, [1000, 2000]], [7700, [2000, 3900]]]],
[40, [[1300, [400, 700]], [2600, [700, 1300]], [5200, [1300, 2600]], [8000, [2000, 4000]]]],
[50, [[1600, [400, 800]], [3200, [800, 1600]], [6400, [1600, 3200]], [8000, [2000, 4000]]]],
[60, [[2000, [500, 1000]], [3900, [1000, 2000]], [7700, [2000, 3900]], [8000, [2000, 4000]]]],
[70, [[2300, [600, 1200]], [4500, [1200, 2300]], [8000, [2000, 4000]], [8000, [2000, 4000]]]],
[80, [[2600, [700, 1300]], [5200, [1300, 2600]], [8000, [2000, 4000]], [8000, [2000, 4000]]]],
[90, [[2900, [800, 1500]], [5800, [1500, 2900]], [8000, [2000, 4000]], [8000, [2000, 4000]]]],
[100, [[3200, [800, 1600]], [6400, [1600, 3200]], [8000, [2000, 4000]], [8000, [2000, 4000]]]],
[110, [[3600, [900, 1800]], [7100, [1800, 3600]], [8000, [2000, 4000]], [8000, [2000, 4000]]]],
];
/** 친 1~4판 표: [fu, [ron, tsumo 올] per han]. */
const DEALER: [number, ([number | null, number | null])[]][] = [
[20, [[null, null], [null, 700], [null, 1300], [null, 2600]]],
[25, [[null, null], [2400, null], [4800, 1600], [9600, 3200]]],
[30, [[1500, 500], [2900, 1000], [5800, 2000], [11600, 3900]]],
[40, [[2000, 700], [3900, 1300], [7700, 2600], [12000, 4000]]],
[50, [[2400, 800], [4800, 1600], [9600, 3200], [12000, 4000]]],
[60, [[2900, 1000], [5800, 2000], [11600, 3900], [12000, 4000]]],
[70, [[3400, 1200], [6800, 2300], [12000, 4000], [12000, 4000]]],
[80, [[3900, 1300], [7700, 2600], [12000, 4000], [12000, 4000]]],
[90, [[4400, 1500], [8700, 2900], [12000, 4000], [12000, 4000]]],
[100, [[4800, 1600], [9600, 3200], [12000, 4000], [12000, 4000]]],
[110, [[5300, 1800], [10600, 3600], [12000, 4000], [12000, 4000]]],
];
describe('점수표 §6.4: 1~4판 × 20~110부 (공식 = 표)', () => {
for (const [fu, row] of NON_DEALER) {
row.forEach(([ron, tsumo], i) => {
const han = i + 1;
if (ron === null && tsumo === null) return;
test(`자 ${han}판 ${fu}부 → 론 ${ron ?? '-'} / 쯔모 ${tsumo ? tsumo.join('/') : '-'}`, () => {
const { basic } = basicPoints(han, fu);
const t = payTable(basic, false);
if (ron !== null) expect(t.ron).toBe(ron);
if (tsumo) expect([t.tsumoNonDealer, t.tsumoDealer]).toEqual(tsumo);
});
});
}
for (const [fu, row] of DEALER) {
row.forEach(([ron, tsumo], i) => {
const han = i + 1;
if (ron === null && tsumo === null) return;
test(`친 ${han}판 ${fu}부 → 론 ${ron ?? '-'} / 쯔모 ${tsumo ?? '-'} 올`, () => {
const { basic } = basicPoints(han, fu);
const t = payTable(basic, true);
if (ron !== null) expect(t.ron).toBe(ron);
if (tsumo !== null) expect(t.tsumoDealer).toBe(tsumo);
});
});
}
});
describe('만관 이상 표 §6.4', () => {
const rows: [string, number, number, number, [number, number], number, number][] = [
['만관 5판', 5, 2000, 8000, [2000, 4000], 12000, 4000],
['하네만 6판', 6, 3000, 12000, [3000, 6000], 18000, 6000],
['하네만 7판', 7, 3000, 12000, [3000, 6000], 18000, 6000],
['배만 8판', 8, 4000, 16000, [4000, 8000], 24000, 8000],
['배만 10판', 10, 4000, 16000, [4000, 8000], 24000, 8000],
['삼배만 11판', 11, 6000, 24000, [6000, 12000], 36000, 12000],
['삼배만 12판', 12, 6000, 24000, [6000, 12000], 36000, 12000],
['헤아림 역만 13판', 13, 8000, 32000, [8000, 16000], 48000, 16000],
];
for (const [name, han, basic, ron, tsumo, dRon, dTsumo] of rows) {
test(name, () => {
expect(basicPoints(han, 30).basic).toBe(basic);
const nd = payTable(basic, false);
const d = payTable(basic, true);
expect(nd.ron).toBe(ron);
expect([nd.tsumoNonDealer, nd.tsumoDealer]).toEqual(tsumo);
expect(d.ron).toBe(dRon);
expect(d.tsumoDealer).toBe(dTsumo);
});
}
test('역만 / 더블 역만 지불', () => {
expect(payTable(8000, false).ron).toBe(32000);
expect(payTable(16000, false).ron).toBe(64000);
expect([payTable(16000, false).tsumoNonDealer, payTable(16000, false).tsumoDealer]).toEqual([16000, 32000]);
expect(payTable(16000, true).ron).toBe(96000);
expect(payTable(16000, true).tsumoDealer).toBe(32000);
});
test('3판 70부·4판 40부 이상은 만관, 헤아림 끄면 13판은 삼배만', () => {
expect(basicPoints(3, 70)).toEqual({ basic: 2000, limit: 'mangan' });
expect(basicPoints(4, 40)).toEqual({ basic: 2000, limit: 'mangan' });
expect(basicPoints(3, 60)).toEqual({ basic: 1920, limit: 'none' });
expect(basicPoints(13, 30, { ...DEFAULT_RULES, kazoeYakuman: false })).toEqual({ basic: 6000, limit: 'sanbaiman' });
});
test('절상만관: 4판30부·3판60부 → 만관 (옵션)', () => {
const kiri = { ...DEFAULT_RULES, kiriageMangan: true };
expect(basicPoints(4, 30, kiri).basic).toBe(2000);
expect(basicPoints(3, 60, kiri).basic).toBe(2000);
expect(basicPoints(3, 50, kiri).basic).toBe(1600);
});
});
describe('산마 지불 §5.7', () => {
test('쯔모손: 자 만관 쯔모 = 친 4000 + 자 2000 = 6000', () => {
const t = payTable(2000, false, true, 'tsumoLoss');
expect(t.tsumoDealer + t.tsumoNonDealer).toBe(6000);
});
test('쯔모손: 친 만관 쯔모 = 4000 × 2', () => {
expect(payTable(2000, true, true, 'tsumoLoss').tsumoDealer * 2).toBe(8000);
});
test('북가절반: 자 만관 쯔모 = 5000 + 3000, 친 만관 쯔모 = 6000 × 2', () => {
const t = payTable(2000, false, true, 'northBisection');
expect([t.tsumoDealer, t.tsumoNonDealer]).toEqual([5000, 3000]);
expect(payTable(2000, true, true, 'northBisection').tsumoDealer).toBe(6000);
});
test('북가절반 100 단위 올림: 자 30부 1판 쯔모 (기본 240) → 친 600, 자 400', () => {
const t = payTable(240, false, true, 'northBisection');
expect([t.tsumoDealer, t.tsumoNonDealer]).toEqual([600, 400]);
});
test('산마 론은 4인과 같다', () => {
expect(payTable(2000, false, true).ron).toBe(8000);
});
});
// ---------------------------------------------------------------- worked hands
describe('계산 예시 §6.6', () => {
test('1. 자 멘젠 론 핑후 30부 → 1000점', () => {
const sc = score({ hand: '234m567p789s22s45p', win: '3p' })!;
expect(keys(sc)).toEqual(['pinfu']);
expect([sc.han, sc.fu]).toEqual([1, 30]);
expect(total(sc, false, false)).toBe(1000);
});
test('2. 친 쯔모 리치·멘젠쯔모·탕야오·도라1, 28 → 30부, 3900 올', () => {
const sc = score({ hand: '234m678p444s33p57s', win: '6s', tsumo: true, dealer: true, extra: { riichi: 1, doraIndicators: k('1m') } })!;
expect(keys(sc)).toEqual(['dora', 'menzenTsumo', 'riichi', 'tanyao']);
expect([sc.han, sc.fu]).toEqual([4, 30]);
expect(payTable(sc.basic, true).tsumoDealer).toBe(3900);
expect(total(sc, true, true)).toBe(11700);
});
test('3. 자 론 혼일색(울기 2) + 백 + 도라 2 = 5판 만관 8000', () => {
const sc = score({ hand: '234m678m444z9m', win: '9m', melds: [meld('pon', '555z')], extra: { doraIndicators: k('8m') } })!;
expect(keys(sc)).toEqual(['dora', 'haku', 'honitsu']);
expect(sc.han).toBe(5);
expect(total(sc, false, false)).toBe(8000);
});
test('4. 칠대자 리치 론 도라 2 → 만관, 도라 0 → 3판 25부 3200', () => {
const a = score({ hand: '1133m5577p99s22z4z', win: '4z', extra: { riichi: 1, doraIndicators: k('4p') } })!;
expect(a.han).toBe(5);
expect(total(a, false, false)).toBe(8000);
const b = score({ hand: '1133m5577p99s22z4z', win: '4z', extra: { riichi: 1 } })!;
expect([b.han, b.fu, b.wait]).toEqual([3, 25, 'chiitoi']);
expect(total(b, false, false)).toBe(3200);
});
});
describe('1판 역', () => {
test('리치 + 핑후 + 일발 (론)', () => {
const sc = score({ hand: '234m567p789s22s45p', win: '6p', extra: { riichi: 1, ippatsu: true } })!;
expect(keys(sc)).toEqual(['ippatsu', 'pinfu', 'riichi']);
expect(total(sc, false, false)).toBe(3900);
});
test('일발은 리치 없이 붙지 않는다', () => {
const sc = score({ hand: '234m567p789s22s45p', win: '6p', extra: { ippatsu: true } })!;
expect(keys(sc)).toEqual(['pinfu']);
});
test('핑후 쯔모 20부: 핑후+쯔모+탕야오 3판 → 700/1300', () => {
const sc = score({ hand: '234m567p678s22p34s', win: '5s', tsumo: true })!;
expect(keys(sc)).toEqual(['menzenTsumo', 'pinfu', 'tanyao']);
expect(sc.fu).toBe(20);
const t = payTable(sc.basic, false);
expect([t.tsumoNonDealer, t.tsumoDealer]).toEqual([700, 1300]);
});
test('울기 탕야오(쿠이탕) 샤보 론 22 → 30부 1000, 쿠이탕 끄면 역 없음', () => {
const sc = score({ hand: '567p678s55s44p', win: '4p', melds: [meld('chi', '234m')] })!;
expect(keys(sc)).toEqual(['tanyao']);
expect([sc.han, sc.fu]).toEqual([1, 30]);
expect(score({ hand: '567p678s55s44p', win: '4p', melds: [meld('chi', '234m')], extra: { rules: { ...DEFAULT_RULES, kuitan: false } } })).toBeNull();
});
test('울기 핑후형 20부 → 30부', () => {
const sc = score({ hand: '567p678s22p34s', win: '5s', melds: [meld('chi', '234m')] })!;
expect([sc.han, sc.fu]).toEqual([1, 30]);
expect(total(sc, false, false)).toBe(1000);
});
test('이페코 + 핑후 2판 30부 → 2000', () => {
const sc = score({ hand: '223344m678p99p34s', win: '5s' })!;
expect(keys(sc)).toEqual(['iipeikou', 'pinfu']);
expect(total(sc, false, false)).toBe(2000);
});
test('역패 백·발·중 각 1판 (울어도)', () => {
for (const [z, key] of [
['5z', 'haku'],
['6z', 'hatsu'],
['7z', 'chun'],
] as const) {
const sc = score({ hand: '234m567p88s34s', win: '2s', melds: [meld('pon', `${z[0]}${z[0]}${z[0]}z`)] })!;
expect(keys(sc)).toEqual([key]);
expect([sc.han, sc.fu]).toEqual([1, 30]);
}
});
test('장풍 동 각쯔 1판, 자풍 남 각쯔 1판, 손님 바람 서 각쯔는 역 없음', () => {
expect(keys(score({ hand: '234m567p88s34s', win: '2s', melds: [meld('pon', '111z')] })!)).toEqual(['roundWind']);
expect(keys(score({ hand: '234m567p88s34s', win: '2s', melds: [meld('pon', '222z')] })!)).toEqual(['seatWind']);
expect(score({ hand: '234m567p88s34s', win: '2s', melds: [meld('pon', '333z')] })).toBeNull();
});
test('친의 동(장풍=자풍) 각쯔 = 2판, 30부 친 론 2900', () => {
const sc = score({ hand: '234m567p88s34s', win: '5s', melds: [meld('pon', '111z')], dealer: true })!;
expect(keys(sc)).toEqual(['roundWind', 'seatWind']);
expect([sc.han, sc.fu]).toEqual([2, 30]);
expect(total(sc, true, false)).toBe(2900);
});
test('해저로월 / 하저로어 / 영상개화 / 창깡', () => {
const base = { hand: '567p678s22p34s', melds: [meld('pon', '555z')] };
expect(keys(score({ ...base, win: '5s', tsumo: true, extra: { haitei: true } })!)).toEqual(['haitei', 'haku']);
expect(keys(score({ ...base, win: '5s', extra: { houtei: true } })!)).toEqual(['haku', 'houtei']);
expect(keys(score({ ...base, win: '5s', tsumo: true, extra: { rinshan: true } })!)).toEqual(['haku', 'rinshan']);
expect(keys(score({ ...base, win: '5s', extra: { chankan: true } })!)).toEqual(['chankan', 'haku']);
// 해저는 쯔모에만, 하저는 론에만 붙는다.
expect(keys(score({ ...base, win: '5s', extra: { haitei: true } })!)).toEqual(['haku']);
});
test('영상개화 쯔모도 +2부 (핑후 쯔모가 아님)', () => {
const sc = score({ hand: '234m567p678s22p34s', win: '5s', tsumo: true, melds: [], extra: { rinshan: true } })!;
// 멘젠 핑후형: 핑후 20부가 유지되는 해석이 더 높다.
expect(sc.fu).toBe(20);
const open = score({ hand: '567p678s22p34s', win: '5s', tsumo: true, melds: [meld('ankan', '1111m')], extra: { rinshan: true } })!;
// 20 + 쯔모 2 + 암깡(요구) 32 = 54 → 60
expect(open.fu).toBe(60);
});
});
describe('2판 역', () => {
test('더블리치 2판 (리치 대신)', () => {
const sc = score({ hand: '234m567p789s22s45p', win: '3p', extra: { riichi: 2 } })!;
expect(keys(sc)).toEqual(['doubleRiichi', 'pinfu']);
expect(sc.han).toBe(3);
});
test('칠대자 25부 고정, 같은 패 4장은 2쌍이 아니다', () => {
const sc = score({ hand: '1133m5577p99s22z4z', win: '4z' })!;
expect(keys(sc)).toEqual(['chiitoi']);
expect(sc.fu).toBe(25);
expect(score({ hand: '1111m5577p99s22z4z', win: '4z' })).toBeNull();
});
test('삼색동순 멘젠 2판 / 울면 1판', () => {
const a = score({ hand: '123m123p123s789m5s', win: '5s' })!;
expect(keys(a)).toEqual(['sanshoku']);
expect([a.han, a.fu]).toEqual([2, 40]);
const b = score({ hand: '123p123s789m5s', win: '5s', melds: [meld('chi', '123m')] })!;
expect(keys(b)).toEqual(['sanshoku']);
expect(b.han).toBe(1);
});
test('일기통관 + 핑후 3판 30부 → 3900, 울면 일통 1판', () => {
const a = score({ hand: '123456789m22p45s', win: '3s' })!;
expect(keys(a)).toEqual(['ittsu', 'pinfu']);
expect(total(a, false, false)).toBe(3900);
const b = score({ hand: '456789m22p45s', win: '3s', melds: [meld('chi', '123m')] })!;
expect(keys(b)).toEqual(['ittsu']);
expect(b.han).toBe(1);
});
test('찬타 멘젠 2판, 장풍 머리 +2부, 단기 → 50부 3200', () => {
const sc = score({ hand: '123m789p999s789s1z', win: '1z' })!;
expect(keys(sc)).toEqual(['chanta']);
expect([sc.han, sc.fu]).toEqual([2, 50]);
expect(total(sc, false, false)).toBe(3200);
});
test('또이또이 (울기) 2판 40부 → 2600', () => {
const sc = score({ hand: '555s777p9m', win: '9m', melds: [meld('pon', '222m'), meld('pon', '888p')] })!;
expect(keys(sc)).toEqual(['toitoi']);
expect([sc.han, sc.fu]).toEqual([2, 40]);
expect(total(sc, false, false)).toBe(2600);
});
test('산안커 2판 50부 → 3200 (론으로 만든 각쯔는 명각)', () => {
const sc = score({ hand: '222m444p666s78s99p', win: '9s' })!;
expect(keys(sc)).toEqual(['sanankou']);
expect([sc.han, sc.fu]).toEqual([2, 50]);
expect(total(sc, false, false)).toBe(3200);
});
test('샤보 대기 론: 론 각쯔는 명각이라 산안커가 아니다', () => {
const sc = score({ hand: '222m444p66s99p789s', win: '6s', extra: { riichi: 1 } })!;
expect(keys(sc)).toEqual(['riichi']);
// 20 + 멘젠론 10 + 암각 4 + 암각 4 + 명각 2 = 40
expect(sc.fu).toBe(40);
});
test('삼색동각 2판', () => {
const sc = score({ hand: '333p333s456m7z', win: '7z', melds: [meld('pon', '333m')] })!;
expect(keys(sc)).toEqual(['sanshokuDoukou']);
expect([sc.han, sc.fu]).toEqual([2, 40]);
});
test('산깡쯔 + 탕야오 3판 70부 → 만관', () => {
const sc = score({ hand: '345s6p', win: '6p', melds: [meld('ankan', '2222m'), meld('ankan', '5555p'), meld('daiminkan', '8888s')] })!;
expect(keys(sc)).toEqual(['sankantsu', 'tanyao']);
expect([sc.han, sc.fu]).toEqual([3, 70]);
expect(total(sc, false, false)).toBe(8000);
});
test('소삼원 2판 + 백·발 = 4판 30부 → 7700', () => {
const sc = score({ hand: '77z234m56p', win: '7p', melds: [meld('pon', '555z'), meld('pon', '666z')] })!;
expect(keys(sc)).toEqual(['haku', 'hatsu', 'shousangen']);
expect([sc.han, sc.fu]).toEqual([4, 30]);
expect(total(sc, false, false)).toBe(7700);
});
test('혼노두 + 또이또이 4판 40부 → 만관', () => {
const sc = score({ hand: '111s99s33z', win: '9s', melds: [meld('pon', '111m'), meld('pon', '999p')] })!;
expect(keys(sc)).toEqual(['honroutou', 'toitoi']);
expect([sc.han, sc.fu]).toEqual([4, 40]);
expect(total(sc, false, false)).toBe(8000);
});
test('혼노두 + 칠대자', () => {
const sc = score({ hand: '1199m1199p11s22z3z', win: '3z' })!;
expect(keys(sc)).toEqual(['chiitoi', 'honroutou']);
expect(sc.han).toBe(4);
});
});
describe('3판 / 6판 역', () => {
test('혼일색 + 일기통관 (멘젠) = 5판 만관', () => {
const sc = score({ hand: '123p456p789p333z9p', win: '9p' })!;
expect(keys(sc)).toEqual(['honitsu', 'ittsu']);
expect(sc.han).toBe(5);
expect(total(sc, false, false)).toBe(8000);
});
test('준찬타 멘젠 3판 단기 40부 → 5200', () => {
const sc = score({ hand: '123m789m123p789s9p', win: '9p' })!;
expect(keys(sc)).toEqual(['junchan']);
expect([sc.han, sc.fu]).toEqual([3, 40]);
expect(total(sc, false, false)).toBe(5200);
});
test('준찬타 + 핑후: 23p+1p는 양면이라 핑후가 붙는 해석(4판 30부 7700)이 단기보다 높다', () => {
const sc = score({ hand: '123m789m123p789s1p', win: '1p' })!;
expect(keys(sc)).toEqual(['junchan', 'pinfu']);
expect([sc.han, sc.fu]).toEqual([4, 30]);
expect(total(sc, false, false)).toBe(7700);
});
test('준찬타 울기 2판', () => {
const sc = score({ hand: '789m123p789s1p', win: '1p', melds: [meld('chi', '123m')] })!;
expect(keys(sc)).toEqual(['junchan']);
expect(sc.han).toBe(2);
});
test('량페코 vs 칠대자 (223344m 556677p 88s): 량페코 쪽이 높다', () => {
const sc = score({ hand: '223344m556677p8s', win: '8s' })!;
expect(keys(sc)).toEqual(['ryanpeikou', 'tanyao']);
expect([sc.han, sc.fu]).toEqual([4, 40]);
expect(total(sc, false, false)).toBe(8000);
});
test('청일색 멘젠 6판 + 핑후 = 7판 하네만 12000 (핑후 해석이 판수가 높다)', () => {
const sc = score({ hand: '1233455677899m', win: '9m' })!;
expect(keys(sc)).toEqual(['chinitsu', 'pinfu']);
expect(sc.limit).toBe('haneman');
expect(total(sc, false, false)).toBe(12000);
});
test('청일색 울기 5판 만관', () => {
const sc = score({ hand: '345p678p999p5p', win: '5p', melds: [meld('chi', '123p')] })!;
expect(keys(sc)).toEqual(['chinitsu']);
expect(sc.han).toBe(5);
});
test('혼일색 울기 2판', () => {
const sc = score({ hand: '345p678p444z2p', win: '2p', melds: [meld('chi', '123p')] })!;
expect(keys(sc)).toEqual(['honitsu']);
expect(sc.han).toBe(2);
});
});
describe('역만', () => {
test('국사무쌍 32000, 13면 대기는 더블(옵션)', () => {
const a = score({ hand: '19m19p19s1234566z', win: '7z' })!;
expect(keys(a)).toEqual(['kokushi']);
expect(total(a, false, false)).toBe(32000);
const b = score({ hand: '19m19p19s1234567z', win: '1m' })!;
expect(keys(b)).toEqual(['kokushi13']);
expect(b.basic).toBe(16000);
expect(total(b, false, false)).toBe(64000);
const c = score({ hand: '19m19p19s1234567z', win: '1m', extra: { rules: { ...DEFAULT_RULES, doubleYakuman: false } } })!;
expect(c.basic).toBe(8000);
});
test('사암각 쯔모 = 역만, 단기 론 = 더블, 샤보 론 = 산안커+또이또이', () => {
const tsumo = score({ hand: '222m444p666s99p88s', win: '8s', tsumo: true })!;
expect(keys(tsumo)).toEqual(['suuankou']);
const tanki = score({ hand: '222m444p666s888s9p', win: '9p' })!;
expect(keys(tanki)).toEqual(['suuankouTanki']);
expect(tanki.basic).toBe(16000);
const ron = score({ hand: '222m444p666s99p88s', win: '8s' })!;
expect(keys(ron)).toEqual(['sanankou', 'toitoi']);
});
test('대삼원', () => {
const sc = score({ hand: '777z234m5p', win: '5p', melds: [meld('pon', '555z'), meld('pon', '666z')] })!;
expect(keys(sc)).toEqual(['daisangen']);
expect(sc.basic).toBe(8000);
});
test('소사희 / 대사희(더블)', () => {
const shou = score({ hand: '333z44z23m', win: '1m', melds: [meld('pon', '111z'), meld('pon', '222z')] })!;
expect(keys(shou)).toEqual(['shousuushii']);
const dai = score({ hand: '333z5m', win: '5m', melds: [meld('pon', '111z'), meld('pon', '222z'), meld('pon', '444z')] })!;
expect(keys(dai)).toEqual(['daisuushii']);
expect(dai.basic).toBe(16000);
});
test('자일색 (칠대자형 포함)', () => {
expect(keys(score({ hand: '111z222z333z55z66z', win: '6z', tsumo: true })!)).toContain('tsuuiisou');
expect(keys(score({ hand: '1122334455667z', win: '7z' })!)).toEqual(['tsuuiisou']);
});
test('청노두', () => {
expect(keys(score({ hand: '111m999m111p99s99p', win: '9p' })!)).toEqual(['chinroutou']);
});
test('녹일색: 발 있음 / 없음 모두 인정', () => {
expect(keys(score({ hand: '234s234s666s66z88s', win: '8s' })!)).toEqual(['ryuuiisou']);
expect(keys(score({ hand: '234s234s666s888s2s', win: '2s' })!)).toEqual(['ryuuiisou']);
});
test('구련보등 / 순정구련보등(9면 대기, 더블)', () => {
const pure = score({ hand: '1112345678999m', win: '5m' })!;
expect(keys(pure)).toEqual(['junseiChuuren']);
expect(pure.basic).toBe(16000);
const plain = score({ hand: '1112345678899m', win: '9m' })!;
expect(keys(plain)).toEqual(['chuuren']);
});
test('사깡쯔', () => {
const sc = score({ hand: '5p', win: '5p', melds: [meld('ankan', '2222m'), meld('daiminkan', '6666p'), meld('kakan', '8888s'), meld('ankan', '1111z')] })!;
expect(keys(sc)).toEqual(['suukantsu']);
});
test('천화 / 지화 (역만끼리 합산: 천화 + 사암각)', () => {
const t = score({ hand: '234m567p789s22s45p', win: '3p', tsumo: true, dealer: true, extra: { tenhou: true } })!;
expect(keys(t)).toEqual(['tenhou']);
expect(total(t, true, true)).toBe(48000);
const c = score({ hand: '234m567p789s22s45p', win: '3p', tsumo: true, extra: { chiihou: true } })!;
expect(keys(c)).toEqual(['chiihou']);
const both = score({ hand: '222m444p666s99p88s', win: '8s', tsumo: true, dealer: true, extra: { tenhou: true } })!;
expect(both.yakuman).toBe(2);
});
test('헤아림 역만: 13판 이상 (옵션 끄면 삼배만)', () => {
// 리치·쯔모·핑후·이페코·일통·청일색 = 12판 + 도라 3 = 15판
const big: Case = { hand: '123456789m22m45m', win: '6m', tsumo: true, extra: { riichi: 1, doraIndicators: k('1m') } };
const sc = score(big)!;
expect(keys(sc)).toEqual(['chinitsu', 'dora', 'iipeikou', 'ittsu', 'menzenTsumo', 'pinfu', 'riichi']);
expect(sc.han).toBe(15);
expect(sc.limit).toBe('kazoe');
expect(sc.basic).toBe(8000);
const off = score({ ...big, extra: { ...big.extra, rules: { ...DEFAULT_RULES, kazoeYakuman: false } } })!;
expect(off.limit).toBe('sanbaiman');
expect(off.basic).toBe(6000);
});
test('역만이 있으면 일반 역·도라는 더하지 않는다', () => {
const sc = score({ hand: '19m19p19s1234566z', win: '7z', extra: { riichi: 1, doraIndicators: k('6z') } })!;
expect(keys(sc)).toEqual(['kokushi']);
});
});
describe('고점법 §6.2', () => {
test('34555m에 5m: 양면(핑후) 해석과 단기 해석 중 높은 쪽(핑후 2판 30부 2000)', () => {
const sc = score({ hand: '3455m234p678p345s', win: '5m', extra: { riichi: 1 } })!;
expect(keys(sc)).toEqual(['pinfu', 'riichi', 'tanyao']);
expect([sc.han, sc.fu, sc.wait]).toEqual([3, 30, 'ryanmen']);
expect(total(sc, false, false)).toBe(3900);
});
test('2344m에 4m: 핑후 없이(역패 머리 아님이지만 각쯔 있음) 간짱·단기 등 부가 높은 해석', () => {
// 444m 각쯔가 있으면 핑후 불가 → 단기(+2)가 양면보다 높다.
const sc = score({ hand: '2344m777p678p345s', win: '4m', extra: { riichi: 1 } })!;
expect(sc.wait).toBe('tanki');
expect(sc.fu).toBe(40);
});
test('가타아가리: 역이 생기는 대기(4p 탕야오)로만 화료, 1p는 역 없음', () => {
const base = { hand: '567p678s55s23p', melds: [meld('chi', '234m')] };
expect(score({ ...base, win: '1p' })).toBeNull();
expect(keys(score({ ...base, win: '4p' })!)).toEqual(['tanyao']);
});
test('역 없는 손: 도라만으로는 화료 불가', () => {
expect(score({ hand: '567p678s55s44p', win: '4p', melds: [meld('chi', '123m')], extra: { doraIndicators: k('4p'), aka: 2 } })).toBeNull();
});
});
describe('부 계산 §6.3', () => {
test('예: 멘젠 론, 중 암각 + 9s 암각 + 간짱 → 48 → 50부', () => {
const sc = score({ hand: '777z999s234m4p6p22m', win: '5p' })!;
expect(sc.fu).toBe(50);
expect(keys(sc)).toEqual(['chun']);
});
test('명각 중장 2 / 요구 4, 암각 4 / 8, 명깡 8 / 16, 암깡 16 / 32', () => {
// 울기 (명각 5m), 나머지 순쯔 → 20 + 2 = 22 → 30 (탕야오)
expect(score({ hand: '234p678s22s34m', win: '5m', melds: [meld('pon', '555m')] })!.fu).toBe(30);
// 명깡 중장 8: 20 + 8 = 28 → 30
expect(score({ hand: '234p678s22s34m', win: '5m', melds: [meld('daiminkan', '5555m')] })!.fu).toBe(30);
// 명깡 요구(백) 16: 20 + 16 = 36 → 40
expect(score({ hand: '234p678s22s34m', win: '5m', melds: [meld('daiminkan', '5555z')] })!.fu).toBe(40);
// 암깡 요구(백) 32 + 멘젠론 10 = 62 → 70 (백 1판)
expect(score({ hand: '234p678s22s34m', win: '5m', melds: [meld('ankan', '5555z')] })!.fu).toBe(70);
// 암깡 중장 16 + 멘젠론: 20+10+16 = 46 → 50 (리치)
expect(score({ hand: '234p678s22s34m', win: '5m', melds: [meld('ankan', '5555m')], extra: { riichi: 1 } })!.fu).toBe(50);
});
test('머리 부수: 삼원패 2, 자풍 2, 연풍패 4 (옵션 2)', () => {
const base = { hand: '234m567p999s34s', win: '2s', extra: { riichi: 1 as const } };
// 20 + 10 + 9s 암각 8 + 머리
expect(score({ ...base, hand: base.hand + '55z' })!.fu).toBe(40);
expect(score({ ...base, hand: base.hand + '22z' })!.fu).toBe(40);
expect(score({ ...base, hand: base.hand + '11z', dealer: true })!.fu).toBe(50);
expect(score({ ...base, hand: base.hand + '11z', dealer: true, extra: { riichi: 1, rules: { ...DEFAULT_RULES, renpuuPairFu: 2 } } })!.fu).toBe(40);
});
test('변짱 12_ / _89 +2부', () => {
const pen = score({ hand: '12m567p999s22s678p', win: '3m', extra: { riichi: 1 } })!;
expect(pen.wait).toBe('penchan');
expect(pen.fu).toBe(40);
const pen9 = score({ hand: '89m567p999s22s678p', win: '7m', extra: { riichi: 1 } })!;
expect(pen9.wait).toBe('penchan');
expect(pen9.fu).toBe(40);
});
});
describe('도라 §6.2', () => {
test('표시패 다음 패: 9m → 1m, 북 → 동, 중 → 백, 산마 1m → 9m / 9m → 1m', () => {
expect(doraFromIndicator(one('9m'), false)).toBe(one('1m'));
expect(doraFromIndicator(one('4z'), false)).toBe(one('1z'));
expect(doraFromIndicator(one('7z'), false)).toBe(one('5z'));
expect(doraFromIndicator(one('1m'), true)).toBe(one('9m'));
expect(doraFromIndicator(one('9m'), true)).toBe(one('1m'));
expect(doraFromIndicator(one('3p'), true)).toBe(one('4p'));
});
test('같은 도라가 두 번 표시되면 장당 2', () => {
const sc = score({ hand: '234m567p789s22s45p', win: '3p', extra: { doraIndicators: k('1m1m') } })!;
expect(sc.yaku.find((y) => y.key === 'dora')!.han).toBe(2);
});
test('우라도라는 리치 화료자만', () => {
const a = score({ hand: '234m567p789s22s45p', win: '3p', extra: { uraIndicators: k('1s') } })!;
expect(keys(a)).toEqual(['pinfu']);
const b = score({ hand: '234m567p789s22s45p', win: '3p', extra: { riichi: 1, uraIndicators: k('1s') } })!;
expect(b.yaku.find((y) => y.key === 'ura')!.han).toBe(2);
});
test('적도라 장당 +1, 깡의 도라는 4장', () => {
const sc = score({ hand: '234p678s22s34m', win: '5m', melds: [meld('ankan', '5555m')], extra: { riichi: 1, aka: 1, doraIndicators: k('4m') } })!;
expect(sc.yaku.find((y) => y.key === 'aka')!.han).toBe(1);
expect(sc.yaku.find((y) => y.key === 'dora')!.han).toBe(5);
});
test('북 뽑기: 장당 +1, 북이 도라면 뺀 북도 도라', () => {
const sc = score({ hand: '234p567p678s22s34p', win: '5p', extra: { riichi: 1, kita: 2, doraIndicators: k('3z'), sanma: true } })!;
expect(sc.yaku.find((y) => y.key === 'kita')!.han).toBe(2);
expect(sc.yaku.find((y) => y.key === 'dora')!.han).toBe(2);
});
});

View File

@@ -0,0 +1,395 @@
/**
* 역·부·점수 계산 (docs/games/mahjong.md §6). Pure functions on tile kinds so the calculator can be tested on its own.
* Every reading of the hand (decomposition × wait position, 칠대자, 국사) is scored and the highest one is kept (§6.2).
*/
import { decompositions, isChiitoi, isKokushi } from './agari';
import { GREEN, YAOCHU, counts34, doraFromIndicator, isDragon, isHonor, isTerminal, isWind, isYaochu, NORTH, type Kind } from './tiles';
export type MeldType = 'chi' | 'pon' | 'daiminkan' | 'kakan' | 'ankan';
/** A called / kan set by kind (chi: lowest kind). */
export interface MeldShape {
type: MeldType;
k: Kind;
}
export interface ScoreRules {
kuitan: boolean;
kiriageMangan: boolean;
kazoeYakuman: boolean;
doubleYakuman: boolean;
renpuuPairFu: number;
}
export const DEFAULT_RULES: ScoreRules = { kuitan: true, kiriageMangan: false, kazoeYakuman: true, doubleYakuman: true, renpuuPairFu: 4 };
export interface WinInput {
/** Concealed tiles without the winning tile. */
hand: Kind[];
win: Kind;
melds: MeldShape[];
tsumo: boolean;
seatWind: Kind;
roundWind: Kind;
/** 0 none, 1 리치, 2 더블리치. */
riichi?: 0 | 1 | 2;
ippatsu?: boolean;
haitei?: boolean;
houtei?: boolean;
rinshan?: boolean;
chankan?: boolean;
tenhou?: boolean;
chiihou?: boolean;
doraIndicators?: Kind[];
uraIndicators?: Kind[];
aka?: number;
kita?: number;
sanma?: boolean;
rules?: ScoreRules;
}
export type WaitType = 'ryanmen' | 'kanchan' | 'penchan' | 'tanki' | 'shanpon' | 'chiitoi' | 'kokushi';
export type LimitName = 'none' | 'mangan' | 'haneman' | 'baiman' | 'sanbaiman' | 'kazoe' | 'yakuman';
export interface YakuEntry {
key: string;
name: string;
han: number;
/** Yakuman multiplier (1 or 2) for 역만 entries. */
yakuman?: number;
}
export interface WinScore {
/** Yaku first, then dora entries (도라·적도라·우라도라·북). */
yaku: YakuEntry[];
han: number;
fu: number;
/** Total yakuman multiplier (0 = none; 헤아림 역만 counts 1). */
yakuman: number;
basic: number;
limit: LimitName;
wait: WaitType;
}
export const LIMIT_KO: Record<LimitName, string> = {
none: '',
mangan: '만관',
haneman: '하네만',
baiman: '배만',
sanbaiman: '삼배만',
kazoe: '헤아림 역만',
yakuman: '역만',
};
const ceil100 = (n: number) => Math.ceil(n / 100) * 100;
// ---------------------------------------------------------------- basic points / payments
export function basicPoints(han: number, fu: number, rules: ScoreRules = DEFAULT_RULES): { basic: number; limit: LimitName } {
if (han >= 13) return rules.kazoeYakuman ? { basic: 8000, limit: 'kazoe' } : { basic: 6000, limit: 'sanbaiman' };
if (han >= 11) return { basic: 6000, limit: 'sanbaiman' };
if (han >= 8) return { basic: 4000, limit: 'baiman' };
if (han >= 6) return { basic: 3000, limit: 'haneman' };
if (han >= 5) return { basic: 2000, limit: 'mangan' };
const b = fu * 2 ** (han + 2);
if (b >= 2000) return { basic: 2000, limit: 'mangan' };
if (rules.kiriageMangan && ((han === 4 && fu === 30) || (han === 3 && fu === 60))) return { basic: 2000, limit: 'mangan' };
return { basic: b, limit: 'none' };
}
export interface PayTable {
ron: number;
/** Tsumo: what the dealer pays (non-dealer win) — or what each player pays (dealer win). */
tsumoDealer: number;
/** Tsumo: what each other non-dealer pays (non-dealer win). */
tsumoNonDealer: number;
}
/**
* 지불액 (§6.4, 산마 §5.7). Sanma `northBisection`: the missing North share is split between the two payers
* (each payment rounded up to 100 separately).
*/
export function payTable(basic: number, dealer: boolean, sanma = false, sanmaTsumo: 'tsumoLoss' | 'northBisection' = 'tsumoLoss'): PayTable {
if (dealer) {
const extra = sanma && sanmaTsumo === 'northBisection' ? basic : 0;
return { ron: ceil100(basic * 6), tsumoDealer: ceil100(basic * 2 + extra), tsumoNonDealer: ceil100(basic * 2 + extra) };
}
const extra = sanma && sanmaTsumo === 'northBisection' ? basic / 2 : 0;
return { ron: ceil100(basic * 4), tsumoDealer: ceil100(basic * 2 + extra), tsumoNonDealer: ceil100(basic + extra) };
}
// ---------------------------------------------------------------- scoring
interface FullSet {
t: 'shuntsu' | 'koutsu' | 'kantsu';
k: Kind;
open: boolean;
/** Concealed triplet / ankan for 암각 counting (a triplet completed by ron is not). */
ankou: boolean;
}
interface Candidate {
yaku: YakuEntry[];
fu: number;
wait: WaitType;
}
const YAKUMAN_NAME: Record<string, string> = {
kokushi: '국사무쌍',
kokushi13: '국사무쌍 13면 대기',
suuankou: '사암각',
suuankouTanki: '사암각 단기',
daisangen: '대삼원',
shousuushii: '소사희',
daisuushii: '대사희',
tsuuiisou: '자일색',
chinroutou: '청노두',
ryuuiisou: '녹일색',
chuuren: '구련보등',
junseiChuuren: '순정구련보등',
suukantsu: '사깡쯔',
tenhou: '천화',
chiihou: '지화',
};
const DOUBLE_YAKUMAN = new Set(['kokushi13', 'suuankouTanki', 'junseiChuuren', 'daisuushii']);
function yakuman(key: string, rules: ScoreRules): YakuEntry {
const mult = DOUBLE_YAKUMAN.has(key) && rules.doubleYakuman ? 2 : 1;
return { key, name: YAKUMAN_NAME[key]!, han: 13 * mult, yakuman: mult };
}
const y = (key: string, name: string, han: number): YakuEntry => ({ key, name, han });
function contextYakuman(inp: WinInput, rules: ScoreRules): YakuEntry[] {
const out: YakuEntry[] = [];
if (inp.tenhou) out.push(yakuman('tenhou', rules));
if (inp.chiihou) out.push(yakuman('chiihou', rules));
return out;
}
/** Yaku that only depend on the situation (riichi, tsumo, last tile, …). */
function contextYaku(inp: WinInput, menzen: boolean): YakuEntry[] {
const out: YakuEntry[] = [];
if (inp.riichi === 2) out.push(y('doubleRiichi', '더블리치', 2));
else if (inp.riichi === 1) out.push(y('riichi', '리치', 1));
if (inp.riichi && inp.ippatsu) out.push(y('ippatsu', '일발', 1));
if (menzen && inp.tsumo) out.push(y('menzenTsumo', '멘젠쯔모', 1));
if (inp.tsumo && inp.haitei) out.push(y('haitei', '해저로월', 1));
if (!inp.tsumo && inp.houtei) out.push(y('houtei', '하저로어', 1));
if (inp.tsumo && inp.rinshan) out.push(y('rinshan', '영상개화', 1));
if (!inp.tsumo && inp.chankan) out.push(y('chankan', '창깡', 1));
return out;
}
/** 혼일색 / 청일색 / 탕야오 / 혼노두 over all tiles. */
function colourYaku(all: Kind[], menzen: boolean, rules: ScoreRules): YakuEntry[] {
const out: YakuEntry[] = [];
if (all.every((k) => !isYaochu(k)) && (menzen || rules.kuitan)) out.push(y('tanyao', '탕야오', 1));
const suits = new Set(all.filter((k) => k < 27).map((k) => Math.floor(k / 9)));
const honors = all.some(isHonor);
if (suits.size === 1) {
if (honors) out.push(y('honitsu', '혼일색', menzen ? 3 : 2));
else out.push(y('chinitsu', '청일색', menzen ? 6 : 5));
}
if (all.every(isYaochu) && honors && all.some(isTerminal)) out.push(y('honroutou', '혼노두', 2));
return out;
}
function allTileYakuman(all: Kind[], rules: ScoreRules): YakuEntry[] {
const out: YakuEntry[] = [];
if (all.every(isHonor)) out.push(yakuman('tsuuiisou', rules));
if (all.every(isTerminal)) out.push(yakuman('chinroutou', rules));
if (all.every((k) => GREEN.includes(k))) out.push(yakuman('ryuuiisou', rules));
return out;
}
function meldKinds(m: MeldShape): Kind[] {
if (m.type === 'chi') return [m.k, m.k + 1, m.k + 2];
if (m.type === 'pon') return [m.k, m.k, m.k];
return [m.k, m.k, m.k, m.k];
}
function scoreStandard(inp: WinInput, rules: ScoreRules, pair: Kind, concealed: { t: 'shuntsu' | 'koutsu'; k: Kind }[], winSet: number, all: Kind[]): Candidate {
const menzen = inp.melds.every((m) => m.type === 'ankan');
const sets: FullSet[] = concealed.map((s, i) => ({ t: s.t, k: s.k, open: false, ankou: s.t === 'koutsu' && !(i === winSet && !inp.tsumo) }));
for (const m of inp.melds) {
if (m.type === 'chi') sets.push({ t: 'shuntsu', k: m.k, open: true, ankou: false });
else if (m.type === 'pon') sets.push({ t: 'koutsu', k: m.k, open: true, ankou: false });
else sets.push({ t: 'kantsu', k: m.k, open: m.type !== 'ankan', ankou: m.type === 'ankan' });
}
const w = inp.win;
let wait: WaitType;
if (winSet < 0) wait = 'tanki';
else {
const s = concealed[winSet]!;
if (s.t === 'koutsu') wait = 'shanpon';
else if (w === s.k + 1) wait = 'kanchan';
else if ((w === s.k && s.k % 9 === 6) || (w === s.k + 2 && s.k % 9 === 0)) wait = 'penchan';
else wait = 'ryanmen';
}
const triplets = sets.filter((s) => s.t !== 'shuntsu');
const shuntsu = sets.filter((s) => s.t === 'shuntsu');
const ankou = triplets.filter((s) => s.ankou).length;
const kans = sets.filter((s) => s.t === 'kantsu').length;
const dragonSets = triplets.filter((s) => isDragon(s.k)).length;
const windSets = triplets.filter((s) => isWind(s.k)).length;
// ---- yakuman
const ym: YakuEntry[] = contextYakuman(inp, rules);
if (ankou === 4) ym.push(yakuman(wait === 'tanki' ? 'suuankouTanki' : 'suuankou', rules));
if (dragonSets === 3) ym.push(yakuman('daisangen', rules));
if (windSets === 4) ym.push(yakuman('daisuushii', rules));
else if (windSets === 3 && isWind(pair)) ym.push(yakuman('shousuushii', rules));
ym.push(...allTileYakuman(all, rules));
if (kans === 4) ym.push(yakuman('suukantsu', rules));
if (inp.melds.length === 0) {
const c = counts34(all);
const suit = all[0]! < 27 ? Math.floor(all[0]! / 9) : -1;
if (suit >= 0 && all.every((k) => k < 27 && Math.floor(k / 9) === suit)) {
const base = suit * 9;
const ok = c[base]! >= 3 && c[base + 8]! >= 3 && [1, 2, 3, 4, 5, 6, 7].every((i) => c[base + i]! >= 1);
if (ok) {
const before = counts34(inp.hand);
const pure = [3, 1, 1, 1, 1, 1, 1, 1, 3].every((n, i) => before[base + i] === n);
ym.push(yakuman(pure ? 'junseiChuuren' : 'chuuren', rules));
}
}
}
if (ym.length) return { yaku: ym, fu: 0, wait };
// ---- normal yaku
const yaku = contextYaku(inp, menzen);
const yakuhaiPair = isDragon(pair) || pair === inp.seatWind || pair === inp.roundWind;
const pinfu = menzen && inp.melds.length === 0 && shuntsu.length === 4 && !yakuhaiPair && wait === 'ryanmen';
if (pinfu) yaku.push(y('pinfu', '핑후', 1));
yaku.push(...colourYaku(all, menzen, rules).filter((e) => e.key !== 'honroutou' || triplets.length === 4));
if (menzen) {
const cnt = new Map<Kind, number>();
for (const s of shuntsu) cnt.set(s.k, (cnt.get(s.k) ?? 0) + 1);
let pairs = 0;
for (const n of cnt.values()) pairs += Math.floor(n / 2);
if (pairs >= 2) yaku.push(y('ryanpeikou', '량페코', 3));
else if (pairs === 1) yaku.push(y('iipeikou', '이페코', 1));
}
for (const s of triplets) {
if (s.k === 31) yaku.push(y('haku', '역패: 백', 1));
if (s.k === 32) yaku.push(y('hatsu', '역패: 발', 1));
if (s.k === 33) yaku.push(y('chun', '역패: 중', 1));
if (s.k === inp.roundWind) yaku.push(y('roundWind', '역패: 장풍', 1));
if (s.k === inp.seatWind) yaku.push(y('seatWind', '역패: 자풍', 1));
}
const shStarts = new Set(shuntsu.map((s) => s.k));
for (let n = 0; n < 7; n++) {
if (shStarts.has(n) && shStarts.has(9 + n) && shStarts.has(18 + n)) {
yaku.push(y('sanshoku', '삼색동순', menzen ? 2 : 1));
break;
}
}
for (let s = 0; s < 3; s++) {
if (shStarts.has(s * 9) && shStarts.has(s * 9 + 3) && shStarts.has(s * 9 + 6)) {
yaku.push(y('ittsu', '일기통관', menzen ? 2 : 1));
break;
}
}
const outside = (s: FullSet) => (s.t === 'shuntsu' ? s.k % 9 === 0 || s.k % 9 === 6 : isYaochu(s.k));
if (shuntsu.length > 0 && sets.every(outside) && isYaochu(pair)) {
const honors = isHonor(pair) || sets.some((s) => isHonor(s.k));
if (honors) yaku.push(y('chanta', '찬타', menzen ? 2 : 1));
else yaku.push(y('junchan', '준찬타', menzen ? 3 : 2));
}
if (triplets.length === 4) yaku.push(y('toitoi', '또이또이', 2));
if (ankou === 3) yaku.push(y('sanankou', '산안커', 2));
const tripNums = triplets.filter((s) => s.k < 27);
for (let n = 0; n < 9; n++) {
if ([n, 9 + n, 18 + n].every((k) => tripNums.some((s) => s.k === k))) {
yaku.push(y('sanshokuDoukou', '삼색동각', 2));
break;
}
}
if (kans === 3) yaku.push(y('sankantsu', '산깡쯔', 2));
if (dragonSets === 2 && isDragon(pair)) yaku.push(y('shousangen', '소삼원', 2));
// ---- fu (§6.3)
let fu: number;
if (pinfu) fu = inp.tsumo ? 20 : 30;
else {
let raw = 20 + (menzen && !inp.tsumo ? 10 : 0) + (inp.tsumo ? 2 : 0);
for (const s of triplets) {
let f = isYaochu(s.k) ? 4 : 2;
if (s.t === 'kantsu') f *= 4;
if (s.ankou) f *= 2;
raw += f;
}
if (isDragon(pair)) raw += 2;
else if (pair === inp.seatWind && pair === inp.roundWind) raw += rules.renpuuPairFu;
else if (pair === inp.seatWind || pair === inp.roundWind) raw += 2;
if (wait === 'kanchan' || wait === 'penchan' || wait === 'tanki') raw += 2;
if (!menzen && raw === 20) raw = 30;
fu = Math.ceil(raw / 10) * 10;
}
return { yaku, fu, wait };
}
function doraEntries(inp: WinInput, all: Kind[]): YakuEntry[] {
const sanma = !!inp.sanma;
const kita = inp.kita ?? 0;
const withKita = [...all, ...Array<Kind>(kita).fill(NORTH)];
const countFor = (inds: Kind[]) => inds.reduce((n, ind) => n + withKita.filter((k) => k === doraFromIndicator(ind, sanma)).length, 0);
const out: YakuEntry[] = [];
const dora = countFor(inp.doraIndicators ?? []);
if (dora) out.push(y('dora', `도라 ${dora}`, dora));
if (inp.aka) out.push(y('aka', `적도라 ${inp.aka}`, inp.aka));
if (inp.riichi) {
const ura = countFor(inp.uraIndicators ?? []);
if (ura) out.push(y('ura', `우라도라 ${ura}`, ura));
}
if (kita) out.push(y('kita', `북 ${kita}`, kita));
return out;
}
/** Scores a finished hand; null if it is not a winning shape or has no yaku (dora alone is not a yaku). */
export function scoreWin(inp: WinInput): WinScore | null {
const rules = inp.rules ?? DEFAULT_RULES;
const concealed = [...inp.hand, inp.win];
const all = [...concealed, ...inp.melds.flatMap(meldKinds)];
const c = counts34(concealed);
const cands: Candidate[] = [];
const menzen = inp.melds.every((m) => m.type === 'ankan');
if (inp.melds.length === 0 && isKokushi(c)) {
const before = counts34(inp.hand);
const thirteen = YAOCHU.every((k) => before[k] === 1);
cands.push({ yaku: [...contextYakuman(inp, rules), yakuman(thirteen ? 'kokushi13' : 'kokushi', rules)], fu: 0, wait: 'kokushi' });
}
if (inp.melds.length === 0 && isChiitoi(c)) {
const ym = [...contextYakuman(inp, rules), ...allTileYakuman(all, rules)];
if (ym.length) cands.push({ yaku: ym, fu: 25, wait: 'chiitoi' });
else cands.push({ yaku: [...contextYaku(inp, menzen), y('chiitoi', '칠대자', 2), ...colourYaku(all, menzen, rules)], fu: 25, wait: 'chiitoi' });
}
for (const dec of decompositions(c, 4 - inp.melds.length)) {
if (dec.pair === inp.win) cands.push(scoreStandard(inp, rules, dec.pair, dec.sets, -1, all));
const seen = new Set<string>();
dec.sets.forEach((s, i) => {
const has = s.t === 'koutsu' ? s.k === inp.win : inp.win >= s.k && inp.win <= s.k + 2;
const key = `${s.t}${s.k}`;
if (!has || seen.has(key)) return;
seen.add(key);
cands.push(scoreStandard(inp, rules, dec.pair, dec.sets, i, all));
});
}
let best: WinScore | null = null;
const dora = doraEntries(inp, all);
const doraHan = dora.reduce((n, e) => n + e.han, 0);
for (const cand of cands) {
if (cand.yaku.length === 0) continue;
let res: WinScore;
const ymult = cand.yaku.reduce((n, e) => n + (e.yakuman ?? 0), 0);
if (ymult > 0) {
res = { yaku: cand.yaku, han: cand.yaku.reduce((n, e) => n + e.han, 0), fu: cand.fu, yakuman: ymult, basic: 8000 * ymult, limit: 'yakuman', wait: cand.wait };
} else {
const han = cand.yaku.reduce((n, e) => n + e.han, 0) + doraHan;
const { basic, limit } = basicPoints(han, cand.fu, rules);
res = { yaku: [...cand.yaku, ...dora], han, fu: cand.fu, yakuman: limit === 'kazoe' ? 1 : 0, basic, limit, wait: cand.wait };
}
if (!best || res.basic > best.basic || (res.basic === best.basic && (res.han > best.han || (res.han === best.han && res.fu > best.fu)))) best = res;
}
return best;
}

View File

@@ -0,0 +1,97 @@
/**
* 마작패 (docs/games/mahjong.md §3, §8.1). Tile ids 0..135, kind = id >> 2 (0..33):
* 0-8 1m-9m, 9-17 1p-9p, 18-26 1s-9s, 27-30 동남서북, 31-33 백발중.
* Over the wire a tile is a short code "<kind><copy>[r]", e.g. "5m0r" (red five), "3p2", "7z1" (중) — unique per
* tile so hidden-information leak checks stay meaningful.
*/
export type TileId = number;
export type Kind = number;
export const EAST = 27;
export const SOUTH = 28;
export const WEST = 29;
export const NORTH = 30;
export const HAKU = 31;
export const HATSU = 32;
export const CHUN = 33;
export const KIND_CODES: string[] = [
...['m', 'p', 's'].flatMap((s) => Array.from({ length: 9 }, (_, i) => `${i + 1}${s}`)),
...Array.from({ length: 7 }, (_, i) => `${i + 1}z`),
];
const KIND_BY_CODE: Record<string, Kind> = Object.fromEntries(KIND_CODES.map((c, i) => [c, i]));
export const kindOf = (id: TileId): Kind => id >> 2;
export const isHonor = (k: Kind): boolean => k >= 27;
export const isDragon = (k: Kind): boolean => k >= 31;
export const isWind = (k: Kind): boolean => k >= 27 && k <= 30;
export const isTerminal = (k: Kind): boolean => k < 27 && (k % 9 === 0 || k % 9 === 8);
export const isYaochu = (k: Kind): boolean => k >= 27 || k % 9 === 0 || k % 9 === 8;
export const suitOf = (k: Kind): number => (k < 27 ? Math.floor(k / 9) : 3);
export const numOf = (k: Kind): number => (k < 27 ? (k % 9) + 1 : 0);
export const YAOCHU: Kind[] = [0, 8, 9, 17, 18, 26, 27, 28, 29, 30, 31, 32, 33];
export const GREEN: Kind[] = [19, 20, 21, 23, 25, HATSU];
/** Kinds missing in 3-player (산마): 2m~8m. */
export const isSanmaRemoved = (k: Kind): boolean => k >= 1 && k <= 7;
export function allTiles(sanma: boolean): TileId[] {
const out: TileId[] = [];
for (let id = 0; id < 136; id++) if (!(sanma && isSanmaRemoved(kindOf(id)))) out.push(id);
return out;
}
/** Red fives in use: 4인 3 → 5m·5p·5s, 2 → 5p·5s; 산마 → 5p·5s (or none). Copy 0 of each five is the red one. */
export function redTiles(akaDora: number, sanma: boolean): TileId[] {
if (akaDora <= 0) return [];
if (sanma || akaDora === 2) return [52, 88];
return [16, 52, 88];
}
export function tileCode(id: TileId, red: readonly TileId[]): string {
return `${KIND_CODES[kindOf(id)]}${id & 3}${red.includes(id) ? 'r' : ''}`;
}
/** Inverse of tileCode (the red marker is ignored); null when malformed. */
export function parseTileCode(code: string): TileId | null {
const m = /^([1-9][mps]|[1-7]z)([0-3])r?$/.exec(code);
if (!m) return null;
return KIND_BY_CODE[m[1]!]! * 4 + Number(m[2]);
}
export const kindCode = (k: Kind): string => KIND_CODES[k]!;
export const kindFromCode = (code: string): Kind | null => KIND_BY_CODE[code] ?? null;
/** Dora shown by an indicator (§6.2, 산마: 1m ↔ 9m). */
export function doraFromIndicator(ind: Kind, sanma: boolean): Kind {
if (ind < 27) {
if (sanma && ind === 0) return 8;
if (sanma && ind === 8) return 0;
return ind - (ind % 9) + (((ind % 9) + 1) % 9);
}
if (ind <= 30) return ind === 30 ? 27 : ind + 1;
return ind === 33 ? 31 : ind + 1;
}
const SUIT_KO = ['만수', '통수', '삭수'];
const HONOR_KO = ['동', '남', '서', '북', '백', '발', '중'];
/** Screen-reader / log name, e.g. "만수 5", "동", "중". */
export function kindNameKo(k: Kind): string {
return k < 27 ? `${SUIT_KO[suitOf(k)]} ${numOf(k)}` : HONOR_KO[k - 27]!;
}
/** Short name, e.g. "5만", "3통", "7삭", "동". */
export function kindShortKo(k: Kind): string {
return k < 27 ? `${numOf(k)}${['만', '통', '삭'][suitOf(k)]}` : HONOR_KO[k - 27]!;
}
export const WIND_KO = ['동', '남', '서', '북'];
/** Sort key: 만→통→삭→자, by number, red five before plain fives. */
export function compareTiles(a: TileId, b: TileId): number {
return a - b;
}
export function counts34(kinds: Iterable<Kind>): number[] {
const c = new Array<number>(34).fill(0);
for (const k of kinds) c[k]!++;
return c;
}

View File

@@ -0,0 +1,363 @@
/** 마작 options, actions, state and view types (docs/games/mahjong.md §2, §8). */
import { z } from 'zod';
import type { PlayerId } from '@bg/engine';
import type { MeldType, WaitType, LimitName } from './scoring';
import type { Kind, TileId } from './tiles';
const lit = <T extends string | number>(...vals: T[]) =>
z.union(vals.map((v) => z.literal(v)) as unknown as [z.ZodLiteral<T>, z.ZodLiteral<T>, ...z.ZodLiteral<T>[]]);
const POINT_CHOICES = [15000, 20000, 25000, 30000, 35000, 40000, 50000] as const;
const pointLabels = Object.fromEntries(POINT_CHOICES.map((p) => [p, `${p.toLocaleString('en-US')}점`]));
export const mahjongOptionsSchema = z.object({
length: z.enum(['tonpuu', 'hanchan']).default('hanchan').meta({ title: '대국 길이', labels: { tonpuu: '동풍전(동1~동4)', hanchan: '반장전(동1~남4)' } }),
startPoints: lit<'auto' | (typeof POINT_CHOICES)[number]>('auto', ...POINT_CHOICES)
.default('auto')
.meta({ title: '시작 점수', labels: { auto: '인원 기본(4인 25,000 · 3인 35,000)', ...pointLabels } }),
returnPoints: lit<'auto' | (typeof POINT_CHOICES)[number]>('auto', ...POINT_CHOICES)
.default('auto')
.meta({ title: '반환점(오카·서입 기준)', labels: { auto: '인원 기본(4인 30,000 · 3인 40,000)', ...pointLabels } }),
uma: z
.enum(['auto', 'none', '5-10', '10-20', '10-30', '0', '15'])
.default('auto')
.meta({
title: '순위 우마',
labels: { auto: '인원 기본(4인 10-20 · 3인 15)', none: '없음(4인)', '5-10': '5-10(4인)', '10-20': '10-20(4인)', '10-30': '10-30(4인)', '0': '없음(3인)', '15': '15(3인)' },
}),
akaDora: lit<'auto' | 0 | 2 | 3>('auto', 0, 2, 3)
.default('auto')
.meta({ title: '적도라(빨간 5)', labels: { auto: '인원 기본(4인 3장 · 3인 2장)', 0: '없음', 2: '2장(5통·5삭)', 3: '3장(5만·5통·5삭, 3인은 2장)' } }),
kuitan: z.boolean().default(true).meta({ title: '울어도 탕야오 인정(쿠이탕)' }),
doubleRon: z.enum(['allow', 'headBump']).default('allow').meta({ title: '동시 론', labels: { allow: '더블론 허용', headBump: '머리튀기(가까운 1명만)' } }),
tripleRon: z.enum(['abort', 'allow']).default('abort').meta({ title: '3명 동시 론', labels: { abort: '삼가화 유국', allow: '모두 화료' } }),
tobi: z.boolean().default(true).meta({ title: '0점 미만이면 바로 종료(토비)' }),
agariYame: z.boolean().default(true).meta({ title: '올라스트 친이 1위면 종료(아가리야메)' }),
westEntry: z.boolean().default(true).meta({ title: '반환점 넘는 사람이 없으면 연장(서입·남입)' }),
renchanOn: z.enum(['tenpai', 'agari']).default('tenpai').meta({ title: '친 연장 조건', labels: { tenpai: '친 화료 또는 유국 때 친 텐파이', agari: '친 화료 때만' } }),
kiriageMangan: z.boolean().default(false).meta({ title: '절상만관(4판30부·3판60부 → 만관)' }),
kazoeYakuman: z.boolean().default(true).meta({ title: '헤아림 역만(13판 이상)' }),
doubleYakuman: z.boolean().default(true).meta({ title: '더블 역만' }),
abortiveDraws: z.boolean().default(true).meta({ title: '도중유국(구종구패·사풍연타·사깡유국·사가리치)' }),
nagashiMangan: z.boolean().default(true).meta({ title: '유국만관' }),
pao: z.boolean().default(true).meta({ title: '책임지불(대삼원·대사희)' }),
ankanChankanKokushi: z.boolean().default(true).meta({ title: '국사무쌍은 안깡도 창깡 가능' }),
renpuuPairFu: lit(2, 4).default(4).meta({ title: '연풍패 머리 부수', labels: { 2: '2부', 4: '4부' } }),
sanmaTsumo: z.enum(['tsumoLoss', 'northBisection']).default('tsumoLoss').meta({ title: '(3인) 쯔모 지불', labels: { tsumoLoss: '쯔모손(북가 몫 없음)', northBisection: '북가 몫을 둘이 반씩' } }),
sanmaHonba: lit(200, 300, 1000).default(200).meta({ title: '(3인) 본장 1개당 점수', labels: { 200: '200점', 300: '300점', 1000: '1,000점' } }),
sanmaNotenPool: lit(2000, 3000).default(2000).meta({ title: '(3인) 유국 텐파이료 합계', labels: { 2000: '2,000점', 3000: '3,000점' } }),
turnSeconds: z.number().int().min(5).max(60).default(15).meta({ title: '타패 제한 시간(초, 5~60)' }),
claimSeconds: z.number().int().min(3).max(20).default(8).meta({ title: '울기·론 응답 시간(초, 3~20)' }),
timeBankSeconds: z.number().int().min(0).max(300).default(60).meta({ title: '개인 예비 시간(초, 0~300)' }),
beginnerMode: z.boolean().default(true).meta({ title: '초보 모드 허용(대기패·역 힌트)' }),
});
export type MahjongOptions = z.infer<typeof mahjongOptionsSchema>;
/** Options with per-mode defaults filled in (§2: 3인은 3인 기본값). */
export interface ResolvedOptions extends Omit<MahjongOptions, 'startPoints' | 'returnPoints' | 'uma' | 'akaDora'> {
startPoints: number;
returnPoints: number;
uma: number[];
umaKey: string;
akaDora: number;
}
const code = z.string().regex(/^([1-9][mps]|[1-7]z)[0-3]r?$/);
export const mahjongActionSchema = z.discriminatedUnion('type', [
z.object({ type: z.literal('discard'), tile: code, riichi: z.boolean().optional() }),
z.object({ type: z.literal('tsumo') }),
z.object({ type: z.literal('ankan'), tile: code }),
z.object({ type: z.literal('kakan'), tile: code }),
z.object({ type: z.literal('kita') }),
z.object({ type: z.literal('kyuushu') }),
z.object({ type: z.literal('claim'), call: z.enum(['ron', 'pon', 'kan', 'chi']), tiles: z.array(code).max(3).optional() }),
z.object({ type: z.literal('pass') }),
z.object({ type: z.literal('ready') }),
z.object({ type: z.literal('setPref'), autoWin: z.boolean().optional(), noCalls: z.boolean().optional(), beginner: z.boolean().optional() }),
]);
export type MahjongClientAction = z.infer<typeof mahjongActionSchema>;
/** Client actions plus system-only ones (timer, leaving) that are not in the schema. */
export type MahjongAction = MahjongClientAction | { type: 'timeout' } | { type: 'leave' };
// ---------------------------------------------------------------- state
export type Seat = number;
export interface Meld {
type: MeldType;
tiles: TileId[];
called: TileId | null;
from: Seat | null;
}
export interface DiscardEntry {
tile: TileId;
tsumogiri: boolean;
/** Shown sideways (리치 선언 패, or the next one if that was called). */
riichi: boolean;
calledBy: Seat | null;
}
export interface ClaimChoice {
ron: boolean;
pon: TileId[][];
kan: TileId[][];
chi: TileId[][];
}
export type ClaimResponse = { type: 'pass' } | { type: 'ron' } | { type: 'pon' | 'kan' | 'chi'; tiles: TileId[] };
export interface ClaimWindow {
source: 'discard' | 'kakan' | 'ankan' | 'kita';
tile: TileId;
from: Seat;
/** Server-only: legal responses per seat (null = none). */
eligible: (ClaimChoice | null)[];
/** Server-only: whether the tile completes each seat's hand (for 동순·리치 후리텐). */
winning: boolean[];
responses: (ClaimResponse | null)[];
openedAt: number;
deadline: number;
/** The window never closes before this (information-leak mitigation, §5.3.2). */
minUntil: number;
/** The outcome is settled; only the minimum delay is left. */
decided: boolean;
/** The tile is a riichi declaration (paid when the window closes without ron). */
riichiPending: { double: boolean } | null;
}
export interface RiichiState {
discardIndex: number;
double: boolean;
ippatsu: boolean;
}
export interface HandState {
wall: TileId[];
/** 영상패 (always 4; refilled from the end of the live wall). */
rinshan: TileId[];
doraInd: TileId[];
uraInd: TileId[];
doraRevealed: number;
pendingKanDora: number;
hands: TileId[][];
drawn: (TileId | null)[];
melds: Meld[][];
kita: TileId[][];
discards: DiscardEntry[][];
riichi: (RiichiState | null)[];
/** Next discard of this seat is shown sideways (the riichi tile was called). */
sidewaysNext: boolean[];
tempFuriten: boolean[];
riichiFuriten: boolean[];
/** Cached tenpai waits of the 13-tile hand (updated whenever a seat's hand settles). */
waits: Kind[][];
turn: Seat;
stage: 'afterDraw' | 'afterCall' | 'claim' | 'ended';
/** Any call (incl. 안깡) happened this hand — ends 첫 순 effects. */
interrupted: boolean;
kanCount: number;
kanBySeat: number[];
lastDrawRinshan: boolean;
/** 4 kans by 2+ players: the next discard that is not ronned aborts the hand. */
suukanPending: boolean;
/** 구이가에: kinds the caller may not discard right after the call. */
kuikae: Kind[];
pao: ({ yakuman: 'daisangen' | 'daisuushii'; liable: Seat } | null)[];
claim: ClaimWindow | null;
turnStartedAt: number;
}
export interface WinnerRecord {
seat: Seat;
from: Seat | null;
han: number;
fu: number;
yaku: { name: string; han: number; yakuman?: number }[];
yakuman: number;
limit: LimitName;
wait: WaitType;
/** Hand value received (without 본장·공탁). */
points: number;
hand: TileId[];
melds: Meld[];
winTile: TileId;
uraIndicators: TileId[];
paoSeat: Seat | null;
}
export type AbortReason = 'kyuushu' | 'suufon' | 'suukan' | 'suucha' | 'sanchaho';
export interface HandResultRecord {
/** Hand label: wind (0..3) + number (0..3) + honba. */
roundWind: number;
handNumber: number;
honba: number;
kind: 'agari' | 'exhaustive' | 'abortive';
winners: WinnerRecord[];
tenpai: Seat[];
/** Revealed hands at the end (winners / tenpai players). */
shown: { seat: Seat; hand: TileId[] }[];
nagashi: Seat[];
abortiveReason: AbortReason | null;
/** Who declared the abort (구종구패). */
abortiveSeat: Seat | null;
deltas: number[];
pointsAfter: number[];
doraIndicators: TileId[];
/** 공탁 taken by the winner closest to the discarder. */
poolTaken: number;
}
export type EndReason = 'normal' | 'tobi' | 'agariYame' | 'westEntryEnd';
export interface MahjongFinal {
ranking: { player: PlayerId; seat: Seat; rank: number; points: number; score: number }[];
rounds: number;
bestHand: { player: PlayerId; han: number; fu: number; yaku: string[]; points: number; limit: LimitName } | null;
perPlayer: { player: PlayerId; wins: number; dealIns: number; riichi: number; calls: number }[];
endReason: EndReason;
summary: string;
}
export interface MahjongState {
options: ResolvedOptions;
mode: 'yonma' | 'sanma';
players: PlayerId[];
red: TileId[];
points: number[];
roundWind: number;
handNumber: number;
honba: number;
riichiPool: number;
hand: HandState | null;
phase: 'playing' | 'handResult' | 'finished';
lastHandResult: HandResultRecord | null;
/** The game ends when everybody has seen the last hand's result. */
gameOver: EndReason | null;
/** Next hand after the result screen (null when the game is over). */
nextHand: { roundWind: number; handNumber: number; honba: number } | null;
best: { seat: Seat; han: number; fu: number; yaku: string[]; points: number; limit: LimitName } | null;
resultAt: number;
readyForNext: Seat[];
timeBank: number[];
timeoutsInRow: number[];
autoPilot: boolean[];
left: boolean[];
prefs: { autoWin: boolean; noCalls: boolean; beginner: boolean }[];
history: { label: string; kind: HandResultRecord['kind']; deltas: number[]; pointsAfter: number[] }[];
stats: { wins: number; dealIns: number; riichi: number; calls: number }[];
handsPlayed: number;
/** Public action counter / last loud event for the screen ("퐁!"). */
seq: number;
lastCall: { seq: number; seat: Seat; call: 'chi' | 'pon' | 'kan' | 'riichi' | 'kita' | 'tsumo' | 'ron' } | null;
final: MahjongFinal | null;
}
// ---------------------------------------------------------------- view (tiles are codes)
export interface MeldView {
type: MeldType;
tiles: string[];
called: string | null;
from: Seat | null;
}
export interface DiscardView {
tile: string;
tsumogiri: boolean;
riichi: boolean;
called: boolean;
}
export interface SeatView {
id: PlayerId;
seat: Seat;
/** Seat wind 0..3 (동남서북). */
wind: number;
points: number;
handCount: number;
hasDrawn: boolean;
melds: MeldView[];
kita: string[];
discards: DiscardView[];
riichi: boolean;
isDealer: boolean;
away: boolean;
left: boolean;
ready: boolean;
}
export interface WaitView {
kind: string;
left: number;
/** Winning on it has a yaku (ron, with the current situation). */
yaku: boolean;
}
export interface MyView {
seat: Seat;
hand: string[];
drawn: string | null;
canDiscard: boolean;
discardable: string[];
/** 구이가에로 못 버리는 패. */
locked: string[];
riichiDiscards: string[];
canTsumo: boolean;
ankan: string[];
kakan: string[];
canKita: boolean;
canKyuushu: boolean;
claim: { tile: string; source: ClaimWindow['source']; ron: boolean; pon: string[][]; kan: string[][]; chi: string[][]; responded: boolean; deadline: number; totalMs: number } | null;
riichi: boolean;
furiten: boolean;
/** Current waits (13-tile state), with tiles left from what I can see. */
waits: WaitView[];
/** Beginner: discard code → waits after discarding it. */
tenpaiAfter: Record<string, WaitView[]>;
yakuHints: string[];
timeBankMs: number;
autoPilot: boolean;
prefs: { autoWin: boolean; noCalls: boolean; beginner: boolean };
}
export interface WinnerView extends Omit<WinnerRecord, 'hand' | 'melds' | 'winTile' | 'uraIndicators'> {
hand: string[];
melds: MeldView[];
winTile: string;
uraIndicators: string[];
}
export interface HandResultView extends Omit<HandResultRecord, 'winners' | 'shown' | 'doraIndicators'> {
winners: WinnerView[];
shown: { seat: Seat; hand: string[] }[];
doraIndicators: string[];
}
export interface MahjongView {
mode: 'yonma' | 'sanma';
n: number;
options: ResolvedOptions;
me: PlayerId | null;
seats: SeatView[];
roundWind: number;
handNumber: number;
honba: number;
riichiPool: number;
dealer: Seat;
phase: MahjongState['phase'];
stage: HandState['stage'] | null;
turn: Seat | null;
wallCount: number;
doraIndicators: string[];
/** Public: a claim window is open on this tile (who may respond is never shown). */
claimOn: { tile: string; from: Seat; source: ClaimWindow['source'] } | null;
lastDiscard: { seat: Seat; index: number } | null;
lastCall: MahjongState['lastCall'];
turnStartedAt: number;
deadlineTotalMs: number;
my: MyView | null;
result: HandResultView | null;
/** On the result screen: the next hand (null when the game is over) and why the game ends. */
nextHand: MahjongState['nextHand'];
gameOver: EndReason | null;
history: MahjongState['history'];
final: MahjongFinal | null;
seq: number;
handsPlayed: number;
}

View File

@@ -23,6 +23,7 @@ export const CATALOG: CatalogEntry[] = [
{ id: 'one-card', nameKo: '원카드', minPlayers: 2, maxPlayers: 10, minutes: '10~15', level: '쉬움', blurb: '같은 무늬·숫자를 내고 공격 카드로 먹이기! · 색깔 카드 모드', available: true },
{ id: 'rummikub', nameKo: '숫자 타일', minPlayers: 2, maxPlayers: 4, minutes: '30~60', level: '보통', blurb: '같은 숫자, 이어지는 숫자로 묶어 먼저 다 내려놓기!', available: true },
{ id: 'davinci-code', nameKo: '다빈치 코드', minPlayers: 2, maxPlayers: 4, minutes: '10~15', level: '쉬움', blurb: '검은·흰 숫자 타일의 숫자를 추리해 맞히기', available: true },
{ id: 'mahjong', nameKo: '마작', minPlayers: 3, maxPlayers: 4, minutes: '25~50', level: '어려움', blurb: '리치 마작 4인 · 3인 산마 · 초보 모드로 대기패 힌트', available: true },
];
export const catalogById = (id: string): CatalogEntry | undefined => CATALOG.find((g) => g.id === id);