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 = ['고스톱', '뱅', '할리갈리'];