diff --git a/apps/web/src/games/mahjong/Tile.tsx b/apps/web/src/games/mahjong/Tile.tsx new file mode 100644 index 0000000..04e3742 --- /dev/null +++ b/apps/web/src/games/mahjong/Tile.tsx @@ -0,0 +1,323 @@ +/** + * 마작패 그림 (docs/games/mahjong.md §9): 직접 그린 SVG. 만수 = 숫자 + "만", 통수 = 동그라미, 삭수 = 막대, 자패 = 한글. + * 작은 크기(버림패)에서는 숫자를 크게 쓴 간단한 그림으로 바꿔 휴대폰에서도 읽히게 한다. 수패는 종류마다 안쪽 테두리 + * 무늬(실선·점선·긴 점선)가 달라 색만으로 구별하지 않아도 된다. + */ +import type { CSSProperties } from 'react'; + +const SUIT_KO: Record = { m: '만수', p: '통수', s: '삭수' }; +const SUIT_SHORT: Record = { 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 = { + 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 ( + + + + + ); + })} + + ); +} + +/** Bamboo sticks: rows of [x positions], with red sticks for some centres. */ +const SOU_ROWS: Record = { + 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 ( + + + + + + + ); + } + 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 ( + + + + + ); + }), + )} + + ); +} + +/** Inner frame pattern per suit (colour-blind help, §9). */ +const FRAME_DASH: Record = { 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' && ( + + ); + if (t.suit === 'z') { + const ch = HONOR_KO[t.n - 1]!; + if (t.n === 5) { + return ( + <> + + + {ch} + + + ); + } + const color = t.n === 6 ? GREEN : t.n === 7 ? RED : INK; + return ( + + {ch} + + ); + } + const numColor = t.red ? RED : INK; + if (compact) { + if (t.suit === 'm') { + return ( + <> + {frame} + + {t.n} + + + + ); + } + if (t.suit === 'p') { + return ( + <> + {frame} + + + {t.n} + + + ); + } + return ( + <> + {frame} + + + + {t.n} + + + ); + } + if (t.suit === 'm') { + return ( + <> + {frame} + + {t.n} + + + 만 + + + ); + } + return ( + <> + {frame} + {t.suit === 'p' ? : } + + {t.n} + + + ); +} + +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 = ( + + {code === null ? ( + <> + + + + ) : ( + <> + + + + + )} + + ); + 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 ( + + {svg} + + ); +} diff --git a/apps/web/src/games/mahjong/index.tsx b/apps/web/src/games/mahjong/index.tsx new file mode 100644 index 0000000..330e272 --- /dev/null +++ b/apps/web/src/games/mahjong/index.tsx @@ -0,0 +1,1271 @@ +/** 마작 화면 (docs/games/mahjong.md §9). */ +import { useEffect, useLayoutEffect, useRef, useState, type CSSProperties, type KeyboardEvent, type ReactNode } from 'react'; +import type { DiscardView, HandResultView, MahjongOptions, MahjongView, MeldView, MyView, SeatView, WaitView, WinnerView } from '@bg/games/mahjong'; +import type { PublicUser } from '@bg/shared/lite'; +import { Avatar } from '../../components/ui'; +import { TurnTimer } from '../../components/TurnTimer'; +import { haptic, sounds } from '../../lib/feedback'; +import type { BoardProps, GameUI, OptionsProps } from '../types'; +import { Tile, tileName, tileOrder, tileShort } from './Tile'; + +const WIND = ['동', '남', '서', '북']; +const POS_KO = ['나', '오른쪽', '맞은편', '왼쪽']; +const LIMIT_KO: Record = { none: '', mangan: '만관', haneman: '하네만', baiman: '배만', sanbaiman: '삼배만', kazoe: '헤아림 역만', yakuman: '역만' }; +const ABORT_KO: Record = { kyuushu: '구종구패', suufon: '사풍연타', suukan: '사깡유국', suucha: '사가리치', sanchaho: '삼가화' }; +const CALL_KO: Record = { chi: '치!', pon: '퐁!', kan: '깡!', riichi: '리치!', kita: '북!', tsumo: '쯔모!', ron: '론!' }; +const END_KO: Record = { normal: '마지막 국이 끝났어요', tobi: '0점 미만이 나와 끝났어요(토비)', agariYame: '올라스트 친이 1위로 끝냈어요(아가리야메)', westEntryEnd: '연장전에서 끝났어요' }; + +const nick = (players: Record, id: string) => players[id]?.nickname ?? '알 수 없음'; +const userOf = (players: Record, id: string): PublicUser => players[id] ?? { id, nickname: '알 수 없음', avatar: null, kind: 'guest' }; +const fmt = (n: number) => n.toLocaleString('ko-KR'); +const signed = (n: number) => (n > 0 ? `+${fmt(n)}` : n < 0 ? `-${fmt(-n)}` : '0'); +const roundLabel = (v: Pick) => `${WIND[v.roundWind]}${v.handNumber + 1}국${v.honba ? ` ${v.honba}본장` : ''}`; +const sortCodes = (codes: string[]) => [...codes].sort((a, b) => tileOrder(a) - tileOrder(b)); +/** 을/를 by the last syllable's final consonant ("5만을", "서를"). */ +const eulReul = (word: string) => { + const c = word.charCodeAt(word.length - 1); + return c >= 0xac00 && c <= 0xd7a3 && (c - 0xac00) % 28 === 0 ? `${word}를` : `${word}을`; +}; +const waitText = (ws: WaitView[]) => ws.map((w) => `${tileShort(w.kind)}(${w.left}장${w.yaku ? '' : ', 역 없음'})`).join(', '); + +const CSS = ` +.game-main:has(> .mj) { justify-content: flex-start; } +.mj { width: 100%; max-width: 1080px; display: flex; flex-direction: column; gap: 8px; align-self: center; } +.mj.wide { display: grid; grid-template-columns: auto minmax(0, 1fr); grid-template-areas: 'table side' 'me me'; align-items: start; gap: 8px 12px; } +.mj.wide .mj-opps { grid-area: side; flex-direction: column; flex-wrap: nowrap; } +.mj.wide .mj-seat { max-width: none; flex: none; } +.mj.wide .mj-main { grid-area: table; } +.mj.wide .mj-me { grid-area: me; } +.mj-main { display: flex; flex-direction: column; gap: 8px; min-width: 0; } +.mj-main > .mj-panel { overflow: auto; } +.mj-opps { display: flex; gap: 8px; padding: 0; margin: 0; list-style: none; flex-wrap: wrap; justify-content: center; } +.mj-seat { flex: 1 1 0; min-width: 0; max-width: 340px; background: var(--surface); color: var(--text); border-radius: 12px; padding: 6px 8px; + border: 3px solid transparent; box-shadow: var(--shadow); display: flex; flex-direction: column; gap: 4px; font-size: .88rem; } +.mj-seat.turn { border-color: var(--focus); } +.mj-seat.gone { opacity: .6; } +.mj-seat .nm { font-weight: 800; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1; min-width: 0; } +.mj-seat .pos { color: var(--text-sub); font-size: .78rem; white-space: nowrap; } +.mj-badge { display: inline-block; border-radius: 999px; padding: 0 7px; font-weight: 800; font-size: .75rem; line-height: 1.6; white-space: nowrap; } +.mj-b-dealer { background: #b91c1c; color: #fff; } +.mj-b-riichi { background: #fde68a; color: #7c2d12; border: 1px solid #b45309; } +.mj-b-away { background: var(--warning); color: #fff; } +.mj-b-furiten { background: var(--danger); color: #fff; } +.mj-b-wind { background: var(--surface-2); color: var(--text); border: 1px solid var(--border); } +.mj-melds { display: flex; flex-wrap: wrap; gap: 6px; align-items: flex-end; } +.mj-meld { display: inline-flex; align-items: flex-end; gap: 1px; } +.mj-back-count { display: inline-flex; align-items: center; gap: 4px; font-weight: 800; font-variant-numeric: tabular-nums; } +.mj-tile.dim { opacity: .38; } +.mj-table-wrap { width: 100%; display: flex; justify-content: center; } +.mj-table { position: relative; display: grid; border-radius: 22px; background: radial-gradient(ellipse at center, rgba(255,255,255,.10), rgba(255,255,255,0) 65%), var(--felt); + border: 7px solid var(--wood); box-shadow: var(--shadow); padding: 6px; box-sizing: content-box; } +.mj-cell { position: relative; } +.mj-pond { position: absolute; left: 50%; top: 50%; display: flex; flex-direction: column; gap: 1px; align-items: flex-start; } +.mj-pond-row { display: flex; gap: 1px; align-items: flex-end; } +.mj-pond .last { outline: 3px solid #fde047; outline-offset: 0; border-radius: 3px; } +.mj-pond.turn { filter: drop-shadow(0 0 5px rgba(253,224,71,.9)); } +.mj-center { position: relative; background: rgba(0,0,0,.28); border-radius: 12px; color: #fff; display: flex; flex-direction: column; align-items: center; justify-content: center; + gap: 2px; text-align: center; text-shadow: 0 1px 2px rgba(0,0,0,.5); } +.mj-center .rl { font-weight: 900; font-size: max(13px, calc(var(--tw) * .62)); line-height: 1.1; } +.mj-center .sub { font-size: max(10px, calc(var(--tw) * .4)); font-weight: 700; line-height: 1.25; } +.mj-dora { display: flex; gap: 1px; justify-content: center; } +.mj-edge { position: absolute; display: flex; align-items: center; gap: 3px; font-size: max(10px, calc(var(--tw) * .4)); font-weight: 800; white-space: nowrap; + background: rgba(0,0,0,.35); border-radius: 999px; padding: 0 6px; line-height: 1.5; } +.mj-edge.turn { background: #fde047; color: #1c1e21; text-shadow: none; } +.mj-edge .stick { display: inline-block; inline-size: calc(var(--tw) * .9); block-size: 5px; background: #fff; border-radius: 3px; position: relative; } +.mj-edge .stick::after { content: ''; position: absolute; left: 50%; top: 50%; width: 4px; height: 4px; margin: -2px; border-radius: 50%; background: #dc2626; } +.mj-edge.b { bottom: 3px; left: 50%; transform: translateX(-50%); } +.mj-edge.t { top: 3px; left: 50%; transform: translateX(-50%) rotate(180deg); } +.mj-edge.r { right: 3px; top: 50%; transform: translateY(-50%) rotate(180deg); writing-mode: vertical-rl; text-orientation: sideways; padding: 6px 0; } +.mj-edge.l { left: 3px; top: 50%; transform: translateY(-50%); writing-mode: vertical-rl; text-orientation: sideways; padding: 6px 0; } +.mj-callout { position: absolute; left: 50%; top: 50%; transform: translate(-50%, -50%); z-index: 5; background: #b91c1c; color: #fff; font-weight: 900; + font-size: 2rem; padding: 4px 18px; border-radius: 14px; box-shadow: 0 6px 18px rgba(0,0,0,.35); pointer-events: none; animation: mj-pop .35s ease; white-space: nowrap; } +.mj-panel { background: var(--surface); border-radius: 16px; box-shadow: var(--shadow); padding: 10px 12px; display: flex; flex-direction: column; gap: 8px; } +.mj-panel.claim { border: 3px solid var(--primary); box-shadow: 0 6px 20px rgba(0,0,0,.25); } +.mj-panel.win { border: 3px solid var(--success); } +.mj-myhead { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; justify-content: space-between; } +.mj-hand { display: flex; align-items: flex-end; gap: 1px; padding-top: 12px; min-height: 64px; } +.mj-hand .gap { width: 10px; flex: none; } +.mj-htile { position: relative; padding: 0; border: 0; background: none; cursor: pointer; border-radius: 6px; transition: transform .12s ease; flex: none; } +.mj-htile[aria-pressed='true'] { transform: translateY(-10px); box-shadow: 0 0 0 3px var(--primary); } +.mj-htile[data-legal='false'] { cursor: not-allowed; } +.mj-htile[data-legal='false'] .mj-tile { opacity: .45; filter: grayscale(.5); } +.mj-htile:focus-visible { outline: 3px solid var(--focus); outline-offset: 2px; z-index: 2; } +.mj-htile .mark { position: absolute; top: -12px; left: 50%; transform: translateX(-50%); font-size: .68rem; font-weight: 900; background: var(--success); color: #fff; + border-radius: 999px; padding: 0 4px; line-height: 1.4; white-space: nowrap; } +.mj-htile .lock { position: absolute; bottom: 2px; right: 1px; font-size: .7rem; } +.mj-actions { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; } +.mj-actions .btn { flex: 1 1 auto; min-height: 52px; } +.mj-big { font-size: 1.25rem !important; font-weight: 900 !important; min-height: 60px !important; } +.mj-win-btn { background: #dc2626 !important; color: #fff !important; border-color: #dc2626 !important; animation: mj-pulse 1s ease-in-out infinite; } +.mj-choice { display: inline-flex; align-items: center; gap: 2px; } +.mj-hint { margin: 0; font-size: .9rem; } +.mj-hint.warn { color: var(--danger); font-weight: 700; } +.mj-preview { display: flex; align-items: center; gap: 8px; } +.mj-res-hand { display: flex; flex-wrap: wrap; gap: 1px; align-items: flex-end; } +.mj-res-hand .win { margin-left: 8px; box-shadow: 0 0 0 3px var(--success); border-radius: 4px; } +.mj-yaku { margin: 0; padding: 0; list-style: none; display: grid; grid-template-columns: 1fr auto; gap: 2px 16px; max-width: 360px; } +.mj-yaku li { display: contents; } +.mj-total { font-size: 1.15rem; font-weight: 900; } +.mj-deltas { width: 100%; border-collapse: collapse; } +.mj-deltas td, .mj-deltas th { padding: 3px 6px; text-align: left; border-bottom: 1px solid var(--border); } +.mj-deltas .num { text-align: right; font-variant-numeric: tabular-nums; } +.mj-plus { color: var(--success); font-weight: 800; } +.mj-minus { color: var(--danger); font-weight: 800; } +.mj-toggles { display: flex; flex-direction: column; gap: 6px; } +.mj-toggles label { display: flex; gap: 8px; align-items: center; cursor: pointer; min-height: 32px; } +.mj-toggles input { width: 22px; height: 22px; } +.mj-hist { list-style: none; margin: 0; padding: 0; display: grid; gap: 2px; max-height: 220px; overflow: auto; font-size: .85rem; } +.mj-rules td, .mj-rules th { padding: 3px 6px; text-align: left; border-bottom: 1px solid var(--border); vertical-align: top; } +@keyframes mj-pop { 0% { transform: translate(-50%, -50%) scale(.5); opacity: 0; } 70% { transform: translate(-50%, -50%) scale(1.12); opacity: 1; } 100% { transform: translate(-50%, -50%) scale(1); } } +@keyframes mj-pulse { 0%,100% { transform: scale(1); } 50% { transform: scale(1.04); } } +@media (prefers-reduced-motion: reduce) { + .mj-callout, .mj-win-btn { animation: none; } + .mj-htile { transition: none; } +} +@media (max-width: 719px) { + .mj { gap: 6px; } + .mj-seat { padding: 4px 6px; font-size: .8rem; flex-basis: 30%; } + .mj-table { border-width: 5px; padding: 4px; border-radius: 16px; } + .mj-callout { font-size: 1.5rem; } + .mj-actions .btn { min-height: 48px; } +} +`; + +// ---------------------------------------------------------------- helpers + +/** Board width and the height of the game column it lives in (fixed on PC, content-sized on phones). */ +function useBox(): [React.RefObject, number, number] { + const ref = useRef(null); + const [box, setBox] = useState<[number, number]>([0, 0]); + useLayoutEffect(() => { + const el = ref.current; + if (!el) return; + const parent = el.parentElement; + const read = () => setBox([el.clientWidth, parent && window.innerWidth >= 1100 ? parent.clientHeight : 0]); + read(); + const ro = new ResizeObserver(read); + ro.observe(el); + if (parent) ro.observe(parent); + return () => ro.disconnect(); + }, []); + return [ref, box[0], box[1]]; +} + +function useWidth(): [React.RefObject, number] { + const ref = useRef(null); + const [w, setW] = useState(0); + useLayoutEffect(() => { + const el = ref.current; + if (!el) return; + setW(el.clientWidth); + const ro = new ResizeObserver(() => setW(el.clientWidth)); + ro.observe(el); + return () => ro.disconnect(); + }, []); + return [ref, w]; +} + +/** Relative position of a seat on my screen: 0 me (bottom), 1 right (next), 2 across, 3 left. 3인은 0·1·3. */ +function relPos(v: MahjongView, seat: number): number { + const mine = v.my?.seat ?? 0; + const r = (seat - mine + v.n) % v.n; + return v.n === 3 && r === 2 ? 3 : r; +} + +function meldLabel(m: MeldView): string { + const name = m.type === 'chi' ? '치' : m.type === 'pon' ? '퐁' : m.type === 'ankan' ? '안깡' : m.type === 'kakan' ? '가깡' : '대명깡'; + return `${name} ${m.tiles.map(tileShort).join(' ')}`; +} + +function Meld({ m, w }: { m: MeldView; w: number }) { + return ( + + {m.tiles.map((t, i) => { + const hidden = m.type === 'ankan' && (i === 0 || i === 3); + const side = m.called !== null && t === m.called && m.type !== 'ankan'; + return ; + })} + + ); +} + +function Melds({ s, w }: { s: Pick; w: number }) { + if (s.melds.length === 0 && s.kita.length === 0) return null; + return ( +
+ {s.melds.map((m, i) => ( + + ))} + {s.kita.length > 0 && ( + + {s.kita.map((t) => ( + + ))} + + )} +
+ ); +} + +// ---------------------------------------------------------------- seats + +function seatLabel(v: MahjongView, s: SeatView, players: Record, me: string | null): string { + const parts = [`${POS_KO[relPos(v, s.seat)]}`, `${nick(players, s.id)}${s.id === me ? '(나)' : ''}`, `${WIND[s.wind]}${s.isDealer ? '(친)' : ''}`, `${fmt(s.points)}점`]; + if (s.riichi) parts.push('리치'); + if (s.id !== me && v.phase === 'playing') parts.push(`손패 ${s.handCount}장`); + if (s.melds.length) parts.push(`울기: ${s.melds.map(meldLabel).join(', ')}`); + if (s.kita.length) parts.push(`북 ${s.kita.length}장`); + if (s.left) parts.push('나감'); + else if (s.away) parts.push('자리 비움'); + if (v.turn === s.seat && v.stage !== 'claim') parts.push('차례'); + return parts.join(', '); +} + +function SeatChip({ v, s, players, me, deadline, w }: { v: MahjongView; s: SeatView; players: Record; me: string | null; deadline: number | null; w: number }) { + const u = userOf(players, s.id); + const turn = v.phase === 'playing' && v.turn === s.seat && v.stage !== 'claim'; + return ( +
  • +
    + + {u.nickname} + {POS_KO[relPos(v, s.seat)]} + {turn && } +
    +
    + + {WIND[s.wind]} + {s.isDealer ? ' 친' : ''} + + {fmt(s.points)} + {s.riichi && 리치} + {(s.away || s.left) && {s.left ? '나감' : '자리 비움'}} + {v.phase === 'playing' && ( + + + {s.handCount} + + )} +
    + +
  • + ); +} + +// ---------------------------------------------------------------- table + +function Pond({ discards, tw, lastIndex }: { discards: DiscardView[]; tw: number; lastIndex: number | null }) { + const rows: DiscardView[][] = []; + for (let i = 0; i < discards.length; i += 6) rows.push(discards.slice(i, i + 6)); + return ( + <> + {rows.map((row, ri) => ( +
    + {row.map((d, ci) => { + const idx = ri * 6 + ci; + return ( + + ); + })} +
    + ))} + + ); +} + +const ROT = [0, -90, 180, 90]; + +function Table({ v, players, width, callout }: { v: MahjongView; players: Record; width: number; callout: ReactNode }) { + // Pond: 6 tiles per row (+ room for one sideways riichi tile) × 4 rows. Table = H + W + H. + // Total width ≈ 2H + W + padding/border = 17·tw + 37. + const tw = Math.max(14, Math.min(30, Math.floor((width - 37) / 17))); + const th = Math.round((tw * 4) / 3); + const W = 5 * (tw + 1) + th + 2; + const H = 4 * (th + 1) + 2; + const bySeat = (pos: number) => v.seats.find((s) => relPos(v, s.seat) === pos); + const cell = (pos: number) => { + const s = bySeat(pos); + const vertical = pos === 1 || pos === 3; + const style: CSSProperties = { width: vertical ? H : W, height: vertical ? W : H }; + if (!s) return
    ; + const lastIndex = v.lastDiscard && v.lastDiscard.seat === s.seat ? v.lastDiscard.index : null; + const turn = v.phase === 'playing' && v.turn === s.seat && v.stage !== 'claim'; + return ( +
    +
    + +
    +
    + ); + }; + const edge = (pos: number) => { + const s = bySeat(pos); + if (!s) return null; + const turn = v.phase === 'playing' && v.turn === s.seat && v.stage !== 'claim'; + return ( + + {WIND[s.wind]} {fmt(s.points)} + {s.riichi && } + + ); + }; + const dw = Math.max(12, Math.round(tw * 0.8)); + return ( +
    +
    + {cell(2)} +
    + {cell(3)} +
    + {roundLabel(v).split(' ')[0]} + + {v.honba}본장 · 공탁 {v.riichiPool / 1000}개 + + 남은 패 {v.wallCount} +
    + {v.doraIndicators.map((t) => ( + + ))} + {Array.from({ length: Math.max(0, 5 - v.doraIndicators.length) }, (_, i) => ( + + ))} +
    + {[0, 1, 2, 3].map(edge)} + {callout} +
    + {cell(1)} +
    + {cell(0)} +
    +
    + ); +} + +// ---------------------------------------------------------------- my hand + +function MyArea({ v, my, act, pending, players, deadline, tapConfirm }: { v: MahjongView; my: MyView; act: (a: unknown) => void; pending: boolean; players: Record; deadline: number | null; tapConfirm: boolean }) { + const [sel, setSel] = useState(null); + const [riichiMode, setRiichiMode] = useState(false); + const [kanPick, setKanPick] = useState(false); + const [note, setNote] = useState(''); + const [ref, width] = useWidth(); + const turnKey = `${v.seq}-${v.stage}-${v.turn}`; + useEffect(() => { + setSel(null); + setKanPick(false); + setNote(''); + }, [turnKey]); + useEffect(() => { + if (my.riichiDiscards.length === 0) setRiichiMode(false); + }, [my.riichiDiscards.length]); + + const me = v.seats[my.seat]!; + const hand = sortCodes(my.hand); + const all = my.drawn ? [...hand, my.drawn] : hand; + const n = all.length; + const myTurn = my.canDiscard; + const legal = new Set(riichiMode ? my.riichiDiscards : my.discardable); + const locked = new Set(my.locked); + const beginner = my.prefs.beginner && v.options.beginnerMode; + const phone = width > 0 && width < 600; + const meldW = phone ? 18 : 26; + const tw = width > 0 ? Math.max(20, Math.min(phone ? 40 : 48, Math.floor((width - 14 - n) / n))) : 40; + + const send = (code: string) => { + if (pending) return; + act({ type: 'discard', tile: code, ...(riichiMode ? { riichi: true } : {}) }); + setSel(null); + setRiichiMode(false); + }; + const tap = (code: string) => { + if (!myTurn || pending) return; + if (!legal.has(code)) { + setNote(locked.has(code) ? `${tileShort(code)}: 방금 울은 패와 같은 패(또는 스지)라 버릴 수 없어요.` : riichiMode ? '이 패를 버리면 텐파이가 아니라 리치할 수 없어요.' : my.riichi ? '리치 후에는 쯔모한 패만 버릴 수 있어요.' : '지금은 버릴 수 없어요.'); + return; + } + setNote(''); + if (sel === code || !tapConfirm) send(code); + else setSel(code); + }; + const onKey = (e: KeyboardEvent) => { + if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') return; + const btns = Array.from(e.currentTarget.querySelectorAll('button.mj-htile')); + const i = btns.indexOf(document.activeElement as HTMLButtonElement); + if (i < 0) return; + e.preventDefault(); + btns[(i + (e.key === 'ArrowRight' ? 1 : btns.length - 1)) % btns.length]?.focus(); + }; + + const kanOpts = [...my.ankan.map((t) => ({ type: 'ankan', tile: t })), ...my.kakan.map((t) => ({ type: 'kakan', tile: t }))]; + const selAfter = sel ? my.tenpaiAfter[sel] : undefined; + const winNow = my.canTsumo; + return ( +
    +
    +
    + 내 패 + + {WIND[me.wind]} + {me.isDealer ? ' 친' : ''} + + {fmt(me.points)}점 + {my.riichi && 리치} + {my.furiten && {beginner ? '후리텐: 론 불가, 쯔모만 가능' : '후리텐'}} + {my.autoPilot && 자리 비움 · 아무 패나 누르면 돌아와요} +
    + {myTurn && } +
    + +
    + {all.map((code, i) => { + const isDrawn = my.drawn === code && i === n - 1; + const ok = myTurn && legal.has(code); + const picked = sel === code; + const tenpai = beginner && myTurn && !my.riichi && my.tenpaiAfter[code]; + return ( + + {isDrawn && } + + + ); + })} +
    + {(note || selAfter || (sel && beginner && myTurn && !my.riichi)) && ( +

    + {note || + (selAfter + ? `${eulReul(tileShort(sel!))} 버리면 텐파이 · 대기 ${waitText(selAfter)}${selAfter.every((w) => !w.yaku) && !me.melds.some((m) => m.type !== 'ankan') ? ' · 역이 없으면 리치해 주세요' : ''}` + : `${eulReul(tileShort(sel!))} 버리면 아직 텐파이가 아니에요.`)} +

    + )} + {!myTurn && my.waits.length > 0 &&

    대기: {waitText(my.waits)}

    } + {beginner && my.yakuHints.length > 0 && ( +

    + 💡 {my.yakuHints.join(' · ')} +

    + )} + {myTurn && ( +
    + {winNow && ( + + )} + {phone && sel && ( + + + + )} + + {my.riichiDiscards.length > 0 && ( + + )} + {kanOpts.length > 0 && !kanPick && ( + + )} + {kanPick && + kanOpts.map((k) => ( + + ))} + {my.canKita && ( + + )} + {my.canKyuushu && ( + + )} +
    + )} + {riichiMode &&

    밝은 패를 버리면 리치예요. 리치하면 1,000점을 공탁에 내고, 그다음부터는 쯔모한 패만 버려요.

    } + {!myTurn && v.phase === 'playing' && v.turn !== null && v.stage !== 'claim' &&

    {nick(players, v.seats[v.turn]!.id)}님 차례예요.

    } + +
    + ); +} + +function ClaimPanel({ v, my, act, pending, players }: { v: MahjongView; my: MyView; act: (a: unknown) => void; pending: boolean; players: Record }) { + const c = my.claim; + const beginner = my.prefs.beginner && v.options.beginnerMode; + const [chiPick, setChiPick] = useState(false); + const box = useRef(null); + useEffect(() => setChiPick(false), [c?.tile]); + useEffect(() => { + if (!c || c.responded) return; + haptic(c.ron ? [80, 40, 80] : 40); + // Phones: bring the buttons (right below my hand) into view. + const reduce = window.matchMedia?.('(prefers-reduced-motion: reduce)').matches; + box.current?.scrollIntoView?.({ block: 'nearest', behavior: reduce ? 'auto' : 'smooth' }); + }, [c?.tile]); // eslint-disable-line react-hooks/exhaustive-deps + if (!c) { + if (v.claimOn) return

    다른 플레이어 확인 중…

    ; + return null; + } + if (c.responded) return

    응답했어요. 다른 플레이어 확인 중…

    ; + const from = nick(players, v.seats[v.claimOn?.from ?? 0]!.id); + const what = c.source === 'discard' ? '버린' : c.source === 'kakan' ? '가깡한' : c.source === 'ankan' ? '안깡한' : '뽑은 북'; + const call = (type: string, tiles?: string[]) => act({ type: 'claim', call: type, ...(tiles ? { tiles } : {}) }); + const variant = (tiles: string[]) => ( + + {sortCodes([...tiles, c.tile]).map((t, i) => ( + + ))} + + ); + return ( +
    +
    + + + + {from}님이 {what} {tileShort(c.tile)} + + + +
    +
    + {c.ron && ( + + )} + {c.pon.map((tiles, i) => ( + + ))} + {c.kan.map((tiles, i) => ( + + ))} + {c.chi.length > 0 && !chiPick && ( + + )} + {chiPick && + c.chi.map((tiles, i) => ( + + ))} + +
    + {beginner && (c.pon.length > 0 || c.chi.length > 0 || c.kan.length > 0) && !my.riichi && ( +

    + 울면 리치를 못 해요. 역패·탕야오·혼일색 같은 역이 있어야 화료할 수 있어요. +

    + )} + {c.ron && beginner &&

    이 패로 화료할 수 있어요! [론]을 눌러 주세요.

    } +
    + ); +} + +// ---------------------------------------------------------------- results + +function WinnerBlock({ w, v, players }: { w: WinnerView; v: MahjongView; players: Record }) { + const name = nick(players, v.seats[w.seat]!.id); + const how = w.from === null ? '쯔모' : `론 ← ${nick(players, v.seats[w.from]!.id)}`; + const limit = LIMIT_KO[w.limit] ?? ''; + const yakumanTotal = w.yakuman; + return ( +
    + + 🀄 {name}님 {how} + +
    + {sortCodes(w.hand).map((t, i) => ( + + ))} + {w.melds.map((m, i) => ( + + + + ))} + +
    +
      + {w.yaku.map((y, i) => ( +
    • + {y.name} + {y.yakuman ? (y.yakuman > 1 ? '더블 역만' : '역만') : `${y.han}판`} +
    • + ))} +
    + {w.uraIndicators.length > 0 && ( +
    + 우라도라 표시패 + {w.uraIndicators.map((t) => ( + + ))} +
    + )} + + {yakumanTotal > 0 && w.limit === 'yakuman' ? (yakumanTotal > 1 ? `${yakumanTotal}배 역만` : '역만') : `${w.han}판 ${w.fu}부`} + {limit && w.limit !== 'yakuman' ? ` · ${limit}` : ''} · {fmt(w.points)}점 + + {w.paoSeat !== null && 책임지불: {nick(players, v.seats[w.paoSeat]!.id)}님} +
    + ); +} + +function ResultPanel({ v, players, me, act, pending, deadline }: { v: MahjongView; players: Record; me: string | null; act: (a: unknown) => void; pending: boolean; deadline: number | null }) { + const r = v.result as HandResultView; + const mySeat = v.my?.seat; + const ready = mySeat !== undefined && v.seats[mySeat]!.ready; + const readyCount = v.seats.filter((s) => s.ready).length; + let title: string; + if (r.kind === 'agari') title = r.winners.length > 1 ? `${r.winners.length}명 동시 론!` : r.winners[0]!.from === null ? '쯔모 화료' : '론 화료'; + else if (r.kind === 'exhaustive') title = r.nagashi.length ? '유국 · 유국만관' : '유국(패가 다 떨어졌어요)'; + else title = `도중유국 · ${ABORT_KO[r.abortiveReason ?? ''] ?? ''}`; + return ( +
    +
    +

    + {roundLabel(r)} · {title} +

    + +
    +
    + 도라 표시패 + {r.doraIndicators.map((t) => ( + + ))} +
    + {r.winners.map((w) => ( + + ))} + {r.kind === 'exhaustive' && ( +

    + 텐파이: {r.tenpai.length ? r.tenpai.map((s) => nick(players, v.seats[s]!.id)).join(', ') : '없음'} + {r.nagashi.length > 0 && ` · 유국만관: ${r.nagashi.map((s) => nick(players, v.seats[s]!.id)).join(', ')}`} +

    + )} + {r.kind !== 'agari' && + r.shown.map((x) => ( +
    + {nick(players, v.seats[x.seat]!.id)} + + {sortCodes(x.hand).map((t, i) => ( + + ))} + +
    + ))} + + + + + + + + + + {v.seats.map((s) => ( + + + + + + ))} + +
    이름 + 변화 + + 점수 +
    + {WIND[s.wind]} {nick(players, s.id)} + {s.id === me ? ' (나)' : ''} + 0 ? 'mj-plus' : r.deltas[s.seat]! < 0 ? 'mj-minus' : ''}`}>{signed(r.deltas[s.seat]!)}{fmt(r.pointsAfter[s.seat]!)}
    + {r.poolTaken > 0 && 공탁 {fmt(r.poolTaken)}점은 화료한 사람이 가져가요.} +

    {v.gameOver ? `대국이 끝났어요 · ${END_KO[v.gameOver]}` : v.nextHand ? `다음: ${roundLabel(v.nextHand)}` : ''}

    + {mySeat !== undefined && ( + + )} +
    + ); +} + +function FinalPanel({ v, players, me }: { v: MahjongView; players: Record; me: string | null }) { + const f = v.final; + if (!f) return null; + return ( +
    +

    최종 결과 · {END_KO[f.endReason]}

    + + + + + + + + + + + {f.ranking.map((r) => ( + + + + + + + ))} + +
    순위이름 + 점수 + + 결과(우마·오카) +
    {r.rank}위 + {nick(players, r.player)} + {r.player === me ? ' (나)' : ''} + {fmt(r.points)} 0 ? 'mj-plus' : r.score < 0 ? 'mj-minus' : ''}`}> + {r.score > 0 ? '+' : ''} + {r.score.toFixed(1)} +
    + {f.bestHand && ( +

    + 최고 화료: {nick(players, f.bestHand.player)}님 · {f.bestHand.yaku.filter((y) => !/^(도라|적도라|우라도라|북) /.test(y)).join(', ')} · {fmt(f.bestHand.points)}점 +

    + )} + + + + + + + + + + + + {f.perPlayer.map((p) => ( + + + + + + + + ))} + +
    이름 + 화료 + + 방총 + + 리치 + + 울기 +
    {nick(players, p.player)}{p.wins}{p.dealIns}{p.riichi}{p.calls}
    + {f.rounds}국을 했어요. 점수는 이 방에서만 쓰는 놀이용 점수예요. +
    + ); +} + +// ---------------------------------------------------------------- board + +function MahjongBoard({ view: v, me, players, act, pending, deadline, tapConfirm }: BoardProps) { + const [announce, setAnnounce] = useState(''); + const [callout, setCallout] = useState<{ seq: number; text: string } | null>(null); + const [ref, width, mainH] = useBox(); + const mine = isMyTurn(v, me); + const prevMine = useRef(false); + useEffect(() => { + if (mine && !prevMine.current) { + haptic(40); + sounds.myTurn(); + if (v.my?.claim) setAnnounce(v.my.claim.ron ? '론할 수 있어요!' : '울 수 있어요. 고르거나 패스해 주세요.'); + else if (v.phase === 'playing') setAnnounce(v.my?.canTsumo ? '내 차례예요. 쯔모할 수 있어요!' : '내 차례예요.'); + } + prevMine.current = mine; + }, [mine]); // eslint-disable-line react-hooks/exhaustive-deps + useEffect(() => { + const c = v.lastCall; + if (!c) return; + const text = `${nick(players, v.seats[c.seat]!.id)} ${CALL_KO[c.call] ?? ''}`; + setCallout({ seq: c.seq, text }); + setAnnounce(`${nick(players, v.seats[c.seat]!.id)}님 ${CALL_KO[c.call]?.replace('!', '') ?? ''}`); + const t = setTimeout(() => setCallout((x) => (x && x.seq === c.seq ? null : x)), 1300); + return () => clearTimeout(t); + }, [v.lastCall?.seq]); // eslint-disable-line react-hooks/exhaustive-deps + useEffect(() => { + if (v.claimOn && v.claimOn.source === 'discard' && v.claimOn.from !== v.my?.seat) setAnnounce(`${nick(players, v.seats[v.claimOn.from]!.id)}님이 ${eulReul(tileName(v.claimOn.tile))} 버렸어요.`); + }, [v.claimOn?.tile, v.claimOn?.from]); // eslint-disable-line react-hooks/exhaustive-deps + + const mySeat = v.my?.seat ?? -1; + const others = v.seats.filter((s) => s.seat !== mySeat).sort((a, b) => relPos(v, a.seat) - relPos(v, b.seat)); + const phone = width > 0 && width < 600; + // PC: table on the left sized to the screen height, other players on the right, my hand below. + const wide = width >= 900; + const reserve = v.my ? 290 : 20; + const tableWidth = wide ? Math.max(380, Math.min(width - 330, mainH > 0 && wide ? mainH - reserve : 560, 600)) : width || 360; + const showTable = v.phase === 'playing' || (v.phase === 'handResult' && !wide); + return ( +
    + +
      + {others.map((s) => ( + + ))} +
    +
    0 ? mainH - 12 : undefined } : undefined}> + {v.phase === 'handResult' && v.result && } + {v.phase === 'finished' && } + {showTable && ( +
    + {callout.text} : null} /> + + )} + + {v.phase === 'playing' && v.my && } +

    + {announce} +

    + + ); +} + +// ---------------------------------------------------------------- side + +function Toggles({ v, act, pending }: { v: MahjongView; act: (a: unknown) => void; pending: boolean }) { + const my = v.my; + if (!my || v.phase === 'finished') return null; + const p = my.prefs; + return ( +
    + 내 설정 + + + {v.options.beginnerMode && ( + + )} +
    + ); +} + +function MahjongSide({ view: v, me, players, deadline, act, pending }: BoardProps) { + const turnSeat = v.phase === 'playing' && v.stage !== 'claim' ? v.turn : null; + return ( + <> + +
    +
    + + {v.mode === 'sanma' ? '3인 마작(산마)' : '4인 마작'} · {v.options.length === 'tonpuu' ? '동풍전' : '반장전'} + + {turnSeat !== null && } +
    + + {roundLabel(v)} · 공탁 {fmt(v.riichiPool)}점 · 남은 패 {v.wallCount}장 + +
      + {v.seats.map((s) => ( +
    1. + + {WIND[s.wind]} + + + + {turnSeat === s.seat ? '▶ ' : ''} + {nick(players, s.id)} + {s.id === me ? ' (나)' : ''} + {s.left ? ' · 나감' : s.away ? ' · 자리 비움' : ''} + + {s.riichi && ( + + 리치 + + )} + + {fmt(s.points)} + +
    2. + ))} +
    +
    + + {v.history.length > 0 && ( +
    + 국별 점수 변화 +
      + {[...v.history].reverse().map((h, i) => ( +
    1. + {h.label} {h.kind === 'agari' ? '화료' : h.kind === 'exhaustive' ? '유국' : '도중유국'} ·{' '} + {v.seats.map((s) => `${nick(players, s.id)} ${signed(h.deltas[s.seat]!)}`).join(' / ')} +
    2. + ))} +
    +
    + )} +
    + 역 목록 + +
    + + ); +} + +// ---------------------------------------------------------------- status + +function isMyTurn(v: MahjongView, me: string | null): boolean { + const my = v.my; + if (!me || !my) return false; + if (v.phase === 'handResult') return !v.seats[my.seat]!.ready; + if (v.phase !== 'playing') return false; + if (my.claim) return !my.claim.responded; + return my.canDiscard; +} + +function statusText(v: MahjongView, me: string | null, players: Record): string { + if (v.phase === 'finished') return v.final?.summary ?? '대국이 끝났어요'; + const my = v.my; + if (v.phase === 'handResult') { + const done = my && v.seats[my.seat]!.ready; + return `${roundLabel(v.result ?? v)} 결과예요. ${my ? (done ? '다른 사람을 기다리는 중이에요' : v.gameOver ? '[최종 결과 보기]를 눌러 주세요' : '[다음 국으로]를 눌러 주세요') : ''}`.trim(); + } + if (my?.claim && !my.claim.responded) { + if (my.claim.ron) return '론할 수 있어요! [론] 또는 [패스]를 골라 주세요'; + return '울 수 있어요. 고르거나 [패스]를 눌러 주세요'; + } + if (v.stage === 'claim') return '다른 플레이어 확인 중이에요'; + if (me && my?.canDiscard) { + if (my.canTsumo) return '내 차례예요 · 쯔모할 수 있어요!'; + if (my.riichi) return '내 차례예요 · 리치 중이라 쯔모한 패를 버려요'; + if (v.stage === 'afterCall') return '내 차례예요 · 울고 나서 버릴 패를 골라 주세요'; + return '내 차례예요 · 버릴 패를 골라 주세요'; + } + if (v.turn === null) return '잠시만 기다려 주세요'; + return `${nick(players, v.seats[v.turn]!.id)}님 차례예요`; +} + +// ---------------------------------------------------------------- options + +function Seg({ value, options, onChange, label }: { value: T; options: [T, string][]; onChange: (v: T) => void; label: string }) { + return ( +
    + {options.map(([k, l]) => ( + + ))} +
    + ); +} + +function Field({ label, children, help }: { label: string; children: ReactNode; help?: string }) { + return ( +
    + {label} + {children} + {help && {help}} +
    + ); +} + +const onOff: [boolean, string][] = [ + [true, '켜기'], + [false, '끄기'], +]; +const POINTS = ['auto', 15000, 20000, 25000, 30000, 35000, 40000, 50000] as const; +const pointLabel = (p: (typeof POINTS)[number], auto: string) => (p === 'auto' ? auto : `${fmt(p)}점`); + +function Options({ value: o, onChange }: OptionsProps) { + const [more, setMore] = useState(false); + const set = (patch: Partial) => onChange({ ...o, ...patch }); + return ( +
    +

    + 4명이면 4인 마작, 3명이면 3인 마작(산마)으로 해요. +

    + + set({ length })} /> + + + set({ turnSeconds })} /> + + + set({ beginnerMode })} /> + + + set({ akaDora })} /> + + + set({ kuitan })} /> + + + {more && ( + <> + +
    + + +
    +
    + + set({ uma })} + /> + + + set({ doubleRon })} /> + + + set({ tripleRon })} /> + + + set({ renchanOn })} /> + + {( + [ + ['tobi', '0점 미만이면 바로 종료(토비)'], + ['agariYame', '올라스트 친 1위면 종료(아가리야메)'], + ['westEntry', '반환점 넘는 사람이 없으면 연장(서입)'], + ['kiriageMangan', '절상만관(4판30부·3판60부 → 만관)'], + ['kazoeYakuman', '헤아림 역만(13판 이상)'], + ['doubleYakuman', '더블 역만'], + ['abortiveDraws', '도중유국(구종구패 등)'], + ['nagashiMangan', '유국만관'], + ['pao', '책임지불(대삼원·대사희)'], + ['ankanChankanKokushi', '국사무쌍 안깡 창깡'], + ] as const + ).map(([k, label]) => ( + + set({ [k]: val } as Partial)} /> + + ))} + + set({ renpuuPairFu })} /> + + + set({ claimSeconds })} /> + + + set({ timeBankSeconds })} /> + + + set({ sanmaTsumo })} /> + + + set({ sanmaHonba })} /> + + + set({ sanmaNotenPool })} /> + + + )} +
    + ); +} + +function optionsSummary(o: MahjongOptions): string { + const parts = [o.length === 'tonpuu' ? '동풍전' : '반장전']; + parts.push(o.akaDora === 0 ? '적도라 없음' : '적도라'); + if (o.kuitan) parts.push('쿠이탕'); + if (o.doubleRon === 'headBump') parts.push('머리튀기'); + if (o.beginnerMode) parts.push('초보 모드'); + parts.push(`${o.turnSeconds}초`); + return parts.join(' · '); +} + +// ---------------------------------------------------------------- rules + +const YAKU: [string, string, string, string][] = [ + ['리치', '멘젠 텐파이에서 1,000점을 내고 선언', '1', '-'], + ['일발', '리치 후 한 바퀴 안에 화료', '1', '-'], + ['멘젠쯔모', '울지 않고 쯔모 화료', '1', '-'], + ['핑후', '순쯔 4개 + 역패 아닌 머리 + 양면 대기', '1', '-'], + ['탕야오', '2~8 수패만', '1', '1'], + ['이페코', '같은 순쯔 2개', '1', '-'], + ['역패', '백·발·중, 장풍, 자풍 각쯔', '1', '1'], + ['해저·하저', '마지막 패로 쯔모 / 마지막 버림패로 론', '1', '1'], + ['영상개화·창깡', '깡 보충패로 쯔모 / 남의 가깡 패로 론', '1', '1'], + ['칠대자', '서로 다른 대자 7개', '2', '-'], + ['삼색동순', '만·통·삭 같은 숫자 순쯔', '2', '1'], + ['일기통관', '한 종류 123·456·789', '2', '1'], + ['찬타', '모든 면자·머리에 1·9·자패', '2', '1'], + ['또이또이', '각쯔 4개', '2', '2'], + ['산안커', '암각 3개', '2', '2'], + ['소삼원', '삼원패 각쯔 2개 + 머리', '2', '2'], + ['혼노두', '1·9·자패만', '2', '2'], + ['혼일색', '한 종류 수패 + 자패', '3', '2'], + ['준찬타', '모든 면자·머리에 1·9', '3', '2'], + ['량페코', '이페코 2개', '3', '-'], + ['청일색', '한 종류 수패만', '6', '5'], + ['역만', '국사무쌍·사암각·대삼원·소사희·대사희·자일색·청노두·녹일색·구련보등·사깡쯔·천화·지화', '역만', '일부'], +]; + +function YakuTable() { + return ( +
    + + + + + + + + + + {YAKU.map(([name, desc, closed, open]) => ( + + + + + + + ))} + +
    역조건멘젠울기
    + {name} + {desc}{closed === '역만' ? closed : `${closed}판`}{open === '-' ? '안 됨' : open === '일부' ? open : `${open}판`}
    + ); +} + +function Rules({ options }: { options?: MahjongOptions }) { + const o = options; + return ( +
    +

    + 리치 마작이에요. 손패 13장에 1장을 더해 면자 4개 + 머리 1개(면자 = 같은 패 3장 또는 같은 종류 연속 3장, 머리 = 같은 패 2장)를 먼저 만들면 화료예요. 칠대자(대자 7개)와 + 국사무쌍도 화료형이에요. +

    +
    + {['2m', '3m', '4m', '5p', '6p', '7p', '6s', '7s', '8s', '2p', '2p', '3s', '4s', '5s'].map((t, i) => ( + + ))} +
    +

    + 차례가 오면 1장을 가져오고(쯔모) 1장을 버려요. 남이 버린 패로 퐁(같은 패 2장 + 1), 치(왼쪽 사람 패로 연속 3장, 3인은 안 됨), 깡(같은 패 4장)을 할 수 있지만, + 울면 리치를 못 하고 일부 역의 판수가 내려가요. +

    +

    + 역이 1개 이상 있어야 화료할 수 있어요. 도라는 역이 아니에요. 가장 쉬운 역은 리치(울지 않고 텐파이하면 1,000점을 내고 선언), 탕야오(2~8만으로), 역패(백·발·중 + 같은 패 3장)예요. +

    +

    + 후리텐: 내가 기다리는 패를 전에 버린 적이 있거나, 방금 론할 수 있었는데 넘겼다면 론할 수 없어요(쯔모는 돼요). +

    +

    + 점수는 판(역 + 도라)과 부(손 모양)로 계산해요. 5판 만관 8,000점, 6판 하네만 12,000점, 8판 배만, 11판 삼배만, 역만 32,000점(친은 1.5배). 시작 점수{' '} + {o && o.startPoints !== 'auto' ? `${fmt(o.startPoints)}점` : '4인 25,000점 · 3인 35,000점'}, 마지막에 순위 우마와 오카를 더해 결과를 내요. +

    +

    + 3인(산마): 2만~8만이 없고, 치를 못 해요. 북은 옆에 빼고(북 뽑기) 1장을 더 가져올 수 있어요. 뺀 북은 도라 1개예요. +

    + +

    점수는 이 방에서만 쓰는 놀이용 점수예요. 재화나 포인트로 바뀌지 않아요.

    +
    + ); +} + +const ui: GameUI = { + Board: MahjongBoard, + Side: MahjongSide, + statusText, + isMyTurn, + Options, + optionsSummary, + Rules, +}; + +export default ui; diff --git a/apps/web/src/games/registry.ts b/apps/web/src/games/registry.ts index 570d217..3d3a43c 100644 --- a/apps/web/src/games/registry.ts +++ b/apps/web/src/games/registry.ts @@ -14,6 +14,7 @@ export const GAME_UI: Record Promise<{ default: GameUI }>> = { 'one-card': () => import('./one-card'), rummikub: () => import('./rummikub'), 'davinci-code': () => import('./davinci-code'), + mahjong: () => import('./mahjong'), }; const cache = new Map(); diff --git a/apps/web/src/lib/catalog-ui.ts b/apps/web/src/lib/catalog-ui.ts index 0124b59..e1ff7de 100644 --- a/apps/web/src/lib/catalog-ui.ts +++ b/apps/web/src/lib/catalog-ui.ts @@ -15,4 +15,4 @@ export const GAME_ICON: Record = { }; /** Shown on the home screen so users know what's coming (docs/12-roadmap.md). */ -export const UPCOMING = ['마작', '고스톱', '뱅', '할리갈리']; +export const UPCOMING = ['고스톱', '뱅', '할리갈리']; diff --git a/e2e/games.e2e.ts b/e2e/games.e2e.ts index 8a6fd27..adbe3da 100644 --- a/e2e/games.e2e.ts +++ b/e2e/games.e2e.ts @@ -249,6 +249,78 @@ const PLANS: Record = { 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(); + 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 "여기서 멈추기". diff --git a/packages/games/src/index.ts b/packages/games/src/index.ts index a4d2a12..3e22e63 100644 --- a/packages/games/src/index.ts +++ b/packages/games/src/index.ts @@ -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 = { [omok.id]: omok, @@ -25,4 +26,5 @@ export const GAMES: Record = { [oneCard.id]: oneCard, [rummikub.id]: rummikub, [davinci.id]: davinci, + [mahjong.id]: mahjong, }; diff --git a/packages/games/src/mahjong/agari.ts b/packages/games/src/mahjong/agari.ts new file mode 100644 index 0000000..3ad65fd --- /dev/null +++ b/packages/games/src/mahjong/agari.ts @@ -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; +} diff --git a/packages/games/src/mahjong/index.ts b/packages/games/src/mahjong/index.ts new file mode 100644 index 0000000..ea4a078 --- /dev/null +++ b/packages/games/src/mahjong/index.ts @@ -0,0 +1,1646 @@ +/** + * 마작 — 리치 마작 4인 / 3인(산마) (docs/games/mahjong.md). Pure and deterministic; state is JSON. + * Claim windows (§5.3) are simultaneous decisions resolved by priority (론 > 퐁·깡 > 치) with early settlement, + * a minimum delay after every discard, and a system timeout that counts as pass. + */ +import { fail, ok, type GameDefinition, type GameEvent, type GameResult, type PlayerId, type Rng, type Validation } from '@bg/engine'; +import { decompositions, isKokushi, waitsOf } from './agari'; +import { payTable, scoreWin, type MeldShape, type WinScore } from './scoring'; +import { + NORTH, + YAOCHU, + allTiles, + counts34, + doraFromIndicator, + isDragon, + isWind, + isYaochu, + kindCode, + kindOf, + parseTileCode, + redTiles, + tileCode, + type Kind, + type TileId, +} from './tiles'; +import { + mahjongActionSchema, + mahjongOptionsSchema, + type AbortReason, + type ClaimChoice, + type ClaimResponse, + type ClaimWindow, + type EndReason, + type HandResultRecord, + type HandResultView, + type HandState, + type MahjongAction, + type MahjongFinal, + type MahjongOptions, + type MahjongState, + type MahjongView, + type Meld, + type MeldView, + type MyView, + type ResolvedOptions, + type Seat, + type WaitView, + type WinnerRecord, +} from './types'; + +export * from './types'; +export * from './tiles'; +export * from './agari'; +export * from './scoring'; + +export const MIN_CLAIM_MS = 400; +export const RESULT_MS = 15_000; +export const AUTO_MS = 2000; +const BANK_REFILL_MS = 5000; + +interface Ctx { + rng: Rng; + now: number; + events: GameEvent[]; +} + +const UMA: Record = { + none: [0, 0, 0, 0], + '5-10': [10, 5, -5, -10], + '10-20': [20, 10, -10, -20], + '10-30': [30, 10, -10, -30], + '0': [0, 0, 0], + '15': [15, 0, -15], +}; + +export function resolveOptions(o: MahjongOptions, n: number): ResolvedOptions { + const sanma = n === 3; + let umaKey: string = o.uma; + if (sanma && !['0', '15'].includes(umaKey)) umaKey = '15'; + if (!sanma && ['auto', '0', '15'].includes(umaKey)) umaKey = '10-20'; + const aka = o.akaDora === 'auto' ? (sanma ? 2 : 3) : sanma && o.akaDora === 3 ? 2 : o.akaDora; + return { + ...o, + startPoints: o.startPoints === 'auto' ? (sanma ? 35000 : 25000) : o.startPoints, + returnPoints: o.returnPoints === 'auto' ? (sanma ? 40000 : 30000) : o.returnPoints, + uma: UMA[umaKey]!, + umaKey, + akaDora: aka, + }; +} + +// ---------------------------------------------------------------- small helpers + +const nOf = (s: MahjongState) => s.players.length; +const sanmaOf = (s: MahjongState) => s.mode === 'sanma'; +const dealerOf = (s: MahjongState) => s.handNumber; +const H = (s: MahjongState): HandState => { + if (!s.hand) throw new Error('mahjong: no hand in progress'); + return s.hand; +}; +const seatOf = (s: MahjongState, p: PlayerId) => s.players.indexOf(p); +const seatWindKind = (s: MahjongState, seat: Seat) => 27 + ((seat - dealerOf(s) + nOf(s)) % nOf(s)); +const roundWindKind = (s: MahjongState) => 27 + s.roundWind; +const removeTile = (arr: TileId[], t: TileId) => { + const i = arr.indexOf(t); + if (i < 0) throw new Error(`mahjong: tile ${t} not in hand`); + arr.splice(i, 1); +}; +const sortIds = (a: TileId[]) => [...a].sort((x, y) => x - y); +const isMenzen = (h: HandState, seat: Seat) => h.melds[seat]!.every((m) => m.type === 'ankan'); + +function meldShape(m: Meld): MeldShape { + const ks = m.tiles.map(kindOf); + return { type: m.type, k: Math.min(...ks) }; +} + +function ownedCounts(h: HandState, seat: Seat): number[] { + const c = counts34(h.hands[seat]!.map(kindOf)); + for (const m of h.melds[seat]!) for (const t of m.tiles) c[kindOf(t)]!++; + for (const t of h.kita[seat]!) c[kindOf(t)]!++; + return c; +} + +function computeWaits(h: HandState, seat: Seat, hand: TileId[] = h.hands[seat]!): Kind[] { + if (hand.length % 3 !== 1) return []; + const c = counts34(hand.map(kindOf)); + const owned = ownedCounts(h, seat); + if (hand !== h.hands[seat]) { + // `hand` is a hypothetical hand: rebuild owned from it. + const base = counts34(h.hands[seat]!.map(kindOf)); + for (let k = 0; k < 34; k++) owned[k] = owned[k]! - base[k]! + c[k]!; + } + return waitsOf(c, h.melds[seat]!.length, owned); +} + +function discardFuriten(h: HandState, seat: Seat): boolean { + const w = h.waits[seat]!; + return w.length > 0 && h.discards[seat]!.some((d) => w.includes(kindOf(d.tile))); +} +const isFuriten = (h: HandState, seat: Seat) => h.tempFuriten[seat]! || h.riichiFuriten[seat]! || discardFuriten(h, seat); + +function clearIppatsu(h: HandState): void { + for (const r of h.riichi) if (r) r.ippatsu = false; +} + +function turnStart(s: MahjongState, now: number): void { + H(s).turnStartedAt = now; +} + +function pushEvent(ctx: Ctx, e: GameEvent): void { + ctx.events.push(e); +} + +// ---------------------------------------------------------------- scoring adapter + +interface WinOpts { + tsumo: boolean; + chankan?: boolean; + /** Ron on a 북 뽑기 tile (no 창깡, no 하저). */ + source?: ClaimWindow['source']; +} + +function winScoreFor(s: MahjongState, seat: Seat, tile: TileId, o: WinOpts): WinScore | null { + const h = H(s); + const hand = o.tsumo ? h.hands[seat]!.filter((t) => t !== tile) : h.hands[seat]!; + if (hand.length % 3 !== 1) return null; + const melds = h.melds[seat]!; + const r = h.riichi[seat]; + const first = !h.interrupted && h.discards[seat]!.length === 0 && !h.lastDrawRinshan; + const red = s.red; + const aka = [...hand, tile, ...melds.flatMap((m) => m.tiles)].filter((t) => red.includes(t)).length; + const inds = h.doraInd.slice(0, h.doraRevealed).map(kindOf); + const uras = h.uraInd.slice(0, h.doraRevealed).map(kindOf); + const source = o.source ?? 'discard'; + return scoreWin({ + hand: hand.map(kindOf), + win: kindOf(tile), + melds: melds.map(meldShape), + tsumo: o.tsumo, + seatWind: seatWindKind(s, seat), + roundWind: roundWindKind(s), + riichi: r ? (r.double ? 2 : 1) : 0, + ippatsu: !!r?.ippatsu, + haitei: o.tsumo && h.wall.length === 0 && !h.lastDrawRinshan, + houtei: !o.tsumo && h.wall.length === 0 && source === 'discard', + rinshan: o.tsumo && h.lastDrawRinshan, + chankan: !o.tsumo && !!o.chankan, + tenhou: o.tsumo && first && seat === dealerOf(s), + chiihou: o.tsumo && first && seat !== dealerOf(s), + doraIndicators: inds, + uraIndicators: uras, + aka, + kita: h.kita[seat]!.length, + sanma: sanmaOf(s), + rules: s.options, + }); +} + +function canTsumo(s: MahjongState, seat: Seat): boolean { + const h = s.hand; + if (!h || s.phase !== 'playing' || h.stage !== 'afterDraw' || h.turn !== seat) return false; + const d = h.drawn[seat]; + return d != null && winScoreFor(s, seat, d, { tsumo: true }) !== null; +} + +function canRon(s: MahjongState, seat: Seat, tile: TileId, source: ClaimWindow['source']): boolean { + const h = H(s); + if (!h.waits[seat]!.includes(kindOf(tile))) return false; + if (isFuriten(h, seat)) return false; + if (source === 'ankan') { + if (!s.options.ankanChankanKokushi) return false; + const c = counts34([...h.hands[seat]!, tile].map(kindOf)); + if (h.melds[seat]!.length > 0 || !isKokushi(c)) return false; + } + return winScoreFor(s, seat, tile, { tsumo: false, chankan: source === 'kakan' || source === 'ankan', source }) !== null; +} + +// ---------------------------------------------------------------- call options + +/** Distinct ways (by red five) to pick `n` tiles of `kind` from the hand. */ +function pickVariants(hand: TileId[], kind: Kind, n: number, red: TileId[]): TileId[][] { + const ts = sortIds(hand.filter((t) => kindOf(t) === kind)); + if (ts.length < n) return []; + const reds = ts.filter((t) => red.includes(t)); + const plain = ts.filter((t) => !red.includes(t)); + const out: TileId[][] = []; + for (let r = 0; r <= Math.min(reds.length, n); r++) { + if (plain.length < n - r) continue; + out.push([...reds.slice(0, r), ...plain.slice(0, n - r)]); + } + return out; +} + +function hasDiscardAfterCall(hand: TileId[], used: TileId[], forbidden: Kind[]): boolean { + const rest = hand.filter((t) => !used.includes(t)); + return rest.some((t) => !forbidden.includes(kindOf(t))); +} + +/** 구이가에 금지 종류 (§5.2). */ +function kuikaeKinds(call: 'pon' | 'chi', calledKind: Kind, usedKinds: Kind[]): Kind[] { + if (call === 'pon') return [calledKind]; + const out = [calledKind]; + const lo = Math.min(calledKind, ...usedKinds); + const hi = Math.max(calledKind, ...usedKinds); + const suitBase = calledKind - (calledKind % 9); + if (calledKind === lo && hi + 1 < suitBase + 9) out.push(hi + 1); + if (calledKind === hi && lo - 1 >= suitBase) out.push(lo - 1); + return out; +} + +function claimChoiceFor(s: MahjongState, seat: Seat, source: ClaimWindow['source'], tile: TileId, from: Seat): ClaimChoice | null { + const h = H(s); + const kind = kindOf(tile); + const ron = canRon(s, seat, tile, source); + const out: ClaimChoice = { ron, pon: [], kan: [], chi: [] }; + if (source === 'discard' && !h.riichi[seat] && h.wall.length > 0) { + const hand = h.hands[seat]!; + for (const v of pickVariants(hand, kind, 2, s.red)) if (hasDiscardAfterCall(hand, v, [kind])) out.pon.push(v); + if (h.kanCount < 4) out.kan.push(...pickVariants(hand, kind, 3, s.red).slice(0, 1)); + if (nOf(s) === 4 && seat === (from + 1) % 4 && kind < 27) { + const n = kind % 9; + const shapes: [number, number][] = []; + if (n >= 2) shapes.push([kind - 2, kind - 1]); + if (n >= 1 && n <= 7) shapes.push([kind - 1, kind + 1]); + if (n <= 6) shapes.push([kind + 1, kind + 2]); + for (const [a, b] of shapes) { + for (const va of pickVariants(hand, a, 1, s.red)) { + for (const vb of pickVariants(hand, b, 1, s.red)) { + const used = [...va, ...vb]; + if (hasDiscardAfterCall(hand, used, kuikaeKinds('chi', kind, [a, b]))) out.chi.push(used); + } + } + } + } + } + if (!out.ron && !out.pon.length && !out.kan.length && !out.chi.length) return null; + return out; +} + +const PRIORITY = { pass: 0, chi: 1, pon: 2, kan: 2, ron: 3 } as const; +const maxPriority = (c: ClaimChoice) => (c.ron ? 3 : c.pon.length || c.kan.length ? 2 : c.chi.length ? 1 : 0); + +// ---------------------------------------------------------------- hand lifecycle + +function startHand(s: MahjongState, ctx: Ctx): void { + const n = nOf(s); + const sanma = sanmaOf(s); + const tiles = ctx.rng.shuffle(allTiles(sanma)); + const dead = tiles.slice(-14); + const wall = tiles.slice(0, -14); + const dealer = dealerOf(s); + const hands: TileId[][] = Array.from({ length: n }, () => []); + for (let i = 0; i < n; i++) hands[(dealer + i) % n] = wall.splice(0, 13); + const empty = (f: () => T) => Array.from({ length: n }, f); + const h: HandState = { + wall, + rinshan: dead.slice(0, 4), + doraInd: dead.slice(4, 9), + uraInd: dead.slice(9, 14), + doraRevealed: 1, + pendingKanDora: 0, + hands, + drawn: empty(() => null), + melds: empty(() => []), + kita: empty(() => []), + discards: empty(() => []), + riichi: empty(() => null), + sidewaysNext: empty(() => false), + tempFuriten: empty(() => false), + riichiFuriten: empty(() => false), + waits: empty(() => []), + turn: dealer, + stage: 'afterDraw', + interrupted: false, + kanCount: 0, + kanBySeat: empty(() => 0), + lastDrawRinshan: false, + suukanPending: false, + kuikae: [], + pao: empty(() => null), + claim: null, + turnStartedAt: ctx.now, + }; + s.hand = h; + for (let i = 0; i < n; i++) h.waits[i] = computeWaits(h, i); + s.phase = 'playing'; + s.readyForNext = []; + s.lastHandResult = null; + s.nextHand = null; + s.lastCall = null; + pushEvent(ctx, { type: 'handStarted', roundWind: s.roundWind, handNumber: s.handNumber, honba: s.honba }); + drawTile(s, dealer, false, ctx); +} + +function drawTile(s: MahjongState, seat: Seat, rinshan: boolean, ctx: Ctx): void { + const h = H(s); + let t: TileId; + if (rinshan) { + t = h.rinshan.shift()!; + h.rinshan.push(h.wall.pop()!); + } else { + t = h.wall.shift()!; + } + h.hands[seat]!.push(t); + h.drawn[seat] = t; + h.turn = seat; + h.stage = 'afterDraw'; + h.lastDrawRinshan = rinshan; + h.kuikae = []; + turnStart(s, ctx.now); + pushEvent(ctx, { type: 'draw', seat, rinshan }); + // Automatic moves: 자동 화료, and 리치 쯔모기리 when nothing else is possible. + if (s.prefs[seat]!.autoWin && canTsumo(s, seat)) { + doTsumo(s, seat, ctx); + return; + } + if (h.riichi[seat] && !canTsumo(s, seat) && ankanKinds(s, seat).length === 0 && !canKita(s, seat)) { + doDiscard(s, seat, t, false, ctx); + } +} + +function doDiscard(s: MahjongState, seat: Seat, tile: TileId, riichi: boolean, ctx: Ctx): void { + const h = H(s); + const tsumogiri = h.drawn[seat] === tile; + const double = riichi && !h.interrupted && h.discards[seat]!.length === 0; + removeTile(h.hands[seat]!, tile); + h.drawn[seat] = null; + const sideways = riichi || h.sidewaysNext[seat]!; + if (sideways) h.sidewaysNext[seat] = false; + h.discards[seat]!.push({ tile, tsumogiri, riichi: sideways, calledBy: null }); + h.tempFuriten[seat] = false; + const r = h.riichi[seat]; + if (r) r.ippatsu = false; + h.waits[seat] = computeWaits(h, seat); + h.kuikae = []; + if (h.pendingKanDora > 0) { + h.doraRevealed = Math.min(5, h.doraRevealed + h.pendingKanDora); + h.pendingKanDora = 0; + pushEvent(ctx, { type: 'doraRevealed', count: h.doraRevealed }); + } + s.seq++; + if (riichi) s.lastCall = { seq: s.seq, seat, call: 'riichi' }; + pushEvent(ctx, { type: 'discard', seat, tile: tileCode(tile, s.red), riichi }); + openWindow(s, 'discard', tile, seat, riichi ? { double } : null, ctx); +} + +function openWindow(s: MahjongState, source: ClaimWindow['source'], tile: TileId, from: Seat, riichiPending: ClaimWindow['riichiPending'], ctx: Ctx): void { + const h = H(s); + const n = nOf(s); + const eligible: (ClaimChoice | null)[] = []; + const winning: boolean[] = []; + for (let seat = 0; seat < n; seat++) { + if (seat === from) { + eligible.push(null); + winning.push(false); + continue; + } + let c = claimChoiceFor(s, seat, source, tile, from); + if (c && s.prefs[seat]!.noCalls) c = c.ron ? { ron: true, pon: [], kan: [], chi: [] } : null; + eligible.push(c); + winning.push(source === 'ankan' ? !!c?.ron : h.waits[seat]!.includes(kindOf(tile))); + } + h.stage = 'claim'; + h.claim = { + source, + tile, + from, + eligible, + winning, + responses: eligible.map(() => null), + openedAt: ctx.now, + deadline: ctx.now + s.options.claimSeconds * 1000, + minUntil: ctx.now + MIN_CLAIM_MS, + decided: false, + riichiPending, + }; + pushEvent(ctx, { type: 'claimWindowOpened', source }); + // Auto responses: 자동 화료, 자리 비움 / 나간 사람은 바로 패스. + for (let seat = 0; seat < n; seat++) { + const e = eligible[seat]; + if (!e) continue; + if (e.ron && s.prefs[seat]!.autoWin) h.claim.responses[seat] = { type: 'ron' }; + else if (s.autoPilot[seat]) h.claim.responses[seat] = { type: 'pass' }; + } + tryResolve(s, ctx, false); +} + +function tryResolve(s: MahjongState, ctx: Ctx, force: boolean): void { + const h = H(s); + const c = h.claim!; + if (!force) { + const pending: Seat[] = []; + c.eligible.forEach((e, seat) => { + if (e && !c.responses[seat]) pending.push(seat); + }); + let decided = pending.length === 0; + if (!decided) { + const best = Math.max(0, ...c.responses.map((r) => (r ? PRIORITY[r.type] : 0))); + if (best === 3) decided = pending.every((p) => !c.eligible[p]!.ron); + else if (best > 0) decided = pending.every((p) => maxPriority(c.eligible[p]!) < best); + } + if (!decided) return; + c.decided = true; + if (ctx.now < c.minUntil) return; + } + resolveWindow(s, ctx); +} + +function acceptRiichi(s: MahjongState, seat: Seat, double: boolean): void { + const h = H(s); + s.points[seat]! -= 1000; + s.riichiPool += 1000; + h.riichi[seat] = { discardIndex: h.discards[seat]!.length - 1, double, ippatsu: true }; + s.stats[seat]!.riichi++; +} + +function resolveWindow(s: MahjongState, ctx: Ctx): void { + const h = H(s); + const c = h.claim!; + const n = nOf(s); + const order = Array.from({ length: n - 1 }, (_, i) => (c.from + 1 + i) % n); + const rons = order.filter((seat) => c.responses[seat]?.type === 'ron'); + // 동순 / 리치 후 후리텐 for everyone who let a winning tile go (§5.5). + for (const seat of order) { + if (c.winning[seat] && c.responses[seat]?.type !== 'ron') { + h.tempFuriten[seat] = true; + if (h.riichi[seat]) h.riichiFuriten[seat] = true; + } + } + if (rons.length > 0) { + h.claim = null; + if (rons.length === 3 && s.options.tripleRon === 'abort') { + endAbortive(s, 'sanchaho', null, ctx); + return; + } + const winners = rons.length >= 2 && s.options.doubleRon === 'headBump' ? rons.slice(0, 1) : rons; + endAgari( + s, + winners.map((seat) => ({ seat, tile: c.tile, from: c.from as Seat | null, chankan: c.source === 'kakan' || c.source === 'ankan', source: c.source })), + ctx, + ); + return; + } + if (c.riichiPending) acceptRiichi(s, c.from, c.riichiPending.double); + const opts = s.options; + const abortive = opts.abortiveDraws; + const suucha = abortive && n === 4 && !!c.riichiPending && h.riichi.every((r) => r !== null); + let call: { seat: Seat; resp: Extract } | null = null; + for (const seat of order) { + const r = c.responses[seat]; + if (r && r.type !== 'pass' && r.type !== 'ron' && (!call || PRIORITY[r.type] > PRIORITY[call.resp.type])) call = { seat, resp: r }; + } + h.claim = null; + if (suucha) { + endAbortive(s, 'suucha', null, ctx); + return; + } + if (c.source === 'discard' && abortive && h.suukanPending) { + endAbortive(s, 'suukan', null, ctx); + return; + } + if (call) { + performCall(s, call.seat, call.resp, c, ctx); + return; + } + if (c.source === 'discard') { + if (abortive && !h.interrupted && h.discards.every((d) => d.length === 1)) { + const k = kindOf(h.discards[0]![0]!.tile); + if (isWind(k) && h.discards.every((d) => kindOf(d[0]!.tile) === k)) { + endAbortive(s, 'suufon', null, ctx); + return; + } + } + if (h.wall.length === 0) { + endExhaustive(s, ctx); + return; + } + drawTile(s, (c.from + 1) % n, false, ctx); + return; + } + if (c.source === 'kakan') { + h.interrupted = true; + clearIppatsu(h); + h.kanCount++; + h.kanBySeat[c.from]!++; + h.pendingKanDora++; + checkSuukan(h); + } + drawTile(s, c.from, true, ctx); +} + +function checkSuukan(h: HandState): void { + if (h.kanCount === 4 && h.kanBySeat.filter((k) => k > 0).length >= 2) h.suukanPending = true; +} + +function performCall(s: MahjongState, seat: Seat, resp: Extract, c: ClaimWindow, ctx: Ctx): void { + const h = H(s); + const disc = h.discards[c.from]!; + const last = disc[disc.length - 1]!; + last.calledBy = seat; + if (last.riichi && h.riichi[c.from]?.discardIndex === disc.length - 1) h.sidewaysNext[c.from] = true; + for (const t of resp.tiles) removeTile(h.hands[seat]!, t); + const type = resp.type === 'kan' ? 'daiminkan' : resp.type; + h.melds[seat]!.push({ type, tiles: sortIds([...resp.tiles, c.tile]), called: c.tile, from: c.from }); + h.interrupted = true; + clearIppatsu(h); + s.stats[seat]!.calls++; + const kind = kindOf(c.tile); + if (type !== 'chi' && (isDragon(kind) || isWind(kind))) { + const sets = h.melds[seat]!.filter((m) => m.type !== 'chi').map((m) => kindOf(m.tiles[0]!)); + if (isDragon(kind) && sets.filter(isDragon).length === 3) h.pao[seat] = { yakuman: 'daisangen', liable: c.from }; + if (isWind(kind) && sets.filter(isWind).length === 4) h.pao[seat] = { yakuman: 'daisuushii', liable: c.from }; + } + s.seq++; + s.lastCall = { seq: s.seq, seat, call: resp.type }; + pushEvent(ctx, { type: 'call', seat, call: resp.type, tile: tileCode(c.tile, s.red) }); + if (type === 'daiminkan') { + h.kanCount++; + h.kanBySeat[seat]!++; + h.pendingKanDora++; + checkSuukan(h); + drawTile(s, seat, true, ctx); + return; + } + h.turn = seat; + h.stage = 'afterCall'; + h.drawn[seat] = null; + h.lastDrawRinshan = false; + h.kuikae = kuikaeKinds(type, kind, resp.tiles.map(kindOf)); + turnStart(s, ctx.now); +} + +function doTsumo(s: MahjongState, seat: Seat, ctx: Ctx): void { + const h = H(s); + endAgari(s, [{ seat, tile: h.drawn[seat]!, from: null, chankan: false, source: 'discard' }], ctx); +} + +function doAnkan(s: MahjongState, seat: Seat, kind: Kind, ctx: Ctx): void { + const h = H(s); + const tiles = h.hands[seat]!.filter((t) => kindOf(t) === kind); + for (const t of tiles) removeTile(h.hands[seat]!, t); + h.melds[seat]!.push({ type: 'ankan', tiles: sortIds(tiles), called: null, from: null }); + h.drawn[seat] = null; + h.kanCount++; + h.kanBySeat[seat]!++; + h.interrupted = true; + clearIppatsu(h); + h.doraRevealed = Math.min(5, h.doraRevealed + 1); + checkSuukan(h); + s.seq++; + s.lastCall = { seq: s.seq, seat, call: 'kan' }; + pushEvent(ctx, { type: 'kan', seat, kan: 'ankan', kind: kindCode(kind) }); + openWindow(s, 'ankan', tiles[0]!, seat, null, ctx); +} + +function doKakan(s: MahjongState, seat: Seat, tile: TileId, ctx: Ctx): void { + const h = H(s); + const kind = kindOf(tile); + const m = h.melds[seat]!.find((x) => x.type === 'pon' && kindOf(x.tiles[0]!) === kind)!; + removeTile(h.hands[seat]!, tile); + m.type = 'kakan'; + m.tiles = sortIds([...m.tiles, tile]); + h.drawn[seat] = null; + s.seq++; + s.lastCall = { seq: s.seq, seat, call: 'kan' }; + pushEvent(ctx, { type: 'kan', seat, kan: 'kakan', tile: tileCode(tile, s.red) }); + openWindow(s, 'kakan', tile, seat, null, ctx); +} + +function doKita(s: MahjongState, seat: Seat, ctx: Ctx): void { + const h = H(s); + const d = h.drawn[seat]; + const tile = d != null && kindOf(d) === NORTH ? d : h.hands[seat]!.find((t) => kindOf(t) === NORTH)!; + removeTile(h.hands[seat]!, tile); + h.kita[seat]!.push(tile); + h.drawn[seat] = null; + s.seq++; + s.lastCall = { seq: s.seq, seat, call: 'kita' }; + pushEvent(ctx, { type: 'kita', seat }); + openWindow(s, 'kita', tile, seat, null, ctx); +} + +// ---------------------------------------------------------------- hand end + +const ceil100 = (n: number) => Math.ceil(n / 100) * 100; + +function endAgari(s: MahjongState, wins: { seat: Seat; tile: TileId; from: Seat | null; chankan: boolean; source: ClaimWindow['source'] }[], ctx: Ctx): void { + const h = H(s); + const n = nOf(s); + const sanma = sanmaOf(s); + const o = s.options; + const dealer = dealerOf(s); + const deltas = new Array(n).fill(0); + const honbaTotal = s.honba * (sanma ? o.sanmaHonba : 300); + const winners: WinnerRecord[] = []; + wins.forEach((w, i) => { + const tsumo = w.from === null; + const sc = winScoreFor(s, w.seat, w.tile, { tsumo, chankan: w.chankan, source: w.source }); + if (!sc) throw new Error('mahjong: winner without a valid hand'); + const dealerWin = w.seat === dealer; + const pao = o.pao ? h.pao[w.seat] : null; + const paoEntry = pao ? sc.yaku.find((e) => e.key === pao.yakuman) : undefined; + const paoBasic = paoEntry ? 8000 * (paoEntry.yakuman ?? 1) : 0; + const liable = paoEntry ? pao!.liable : null; + const mult = dealerWin ? 6 : 4; + let gained = 0; + const pay = (payer: Seat, amount: number) => { + deltas[payer]! -= amount; + deltas[w.seat]! += amount; + gained += amount; + }; + if (tsumo) { + const normalBasic = sc.basic - paoBasic; + if (normalBasic > 0) { + const pt = payTable(normalBasic, dealerWin, sanma, o.sanmaTsumo); + for (let p = 0; p < n; p++) if (p !== w.seat) pay(p, p === dealer || dealerWin ? pt.tsumoDealer : pt.tsumoNonDealer); + } + if (liable !== null) pay(liable, ceil100(paoBasic * mult)); + if (liable !== null) { + deltas[liable]! -= honbaTotal; + deltas[w.seat]! += honbaTotal; + } else { + for (let p = 0; p < n; p++) { + if (p === w.seat) continue; + const share = honbaTotal / (n - 1); + deltas[p]! -= share; + deltas[w.seat]! += share; + } + } + } else { + const from = w.from!; + const value = ceil100(sc.basic * mult); + if (liable !== null && liable !== from) { + const paoRon = ceil100(paoBasic * mult); + pay(from, value - paoRon / 2); + pay(liable, paoRon / 2); + } else pay(from, value); + if (i === 0) { + deltas[from]! -= honbaTotal; + deltas[w.seat]! += honbaTotal; + } + s.stats[from]!.dealIns++; + } + s.stats[w.seat]!.wins++; + const hand = tsumo ? h.hands[w.seat]!.filter((t) => t !== w.tile) : [...h.hands[w.seat]!]; + winners.push({ + seat: w.seat, + from: w.from, + han: sc.han, + fu: sc.fu, + yaku: sc.yaku.map((e) => ({ name: e.name, han: e.han, ...(e.yakuman ? { yakuman: e.yakuman } : {}) })), + yakuman: sc.yakuman, + limit: sc.limit, + wait: sc.wait, + points: gained, + hand: sortIds(hand), + melds: h.melds[w.seat]!.map((m) => ({ ...m, tiles: [...m.tiles] })), + winTile: w.tile, + uraIndicators: h.riichi[w.seat] ? h.uraInd.slice(0, h.doraRevealed) : [], + paoSeat: liable, + }); + if (!s.best || gained > s.best.points) { + s.best = { seat: w.seat, han: sc.han, fu: sc.fu, yaku: sc.yaku.map((e) => e.name), points: gained, limit: sc.limit }; + } + }); + const poolTaken = s.riichiPool; + deltas[wins[0]!.seat]! += poolTaken; + s.riichiPool = 0; + s.seq++; + s.lastCall = { seq: s.seq, seat: wins[0]!.seat, call: wins[0]!.from === null ? 'tsumo' : 'ron' }; + pushEvent(ctx, { type: 'agari', winners: wins.map((w) => w.seat), tsumo: wins[0]!.from === null }); + const dealerWon = wins.some((w) => w.seat === dealer); + finishHand( + s, + { + kind: 'agari', + winners, + tenpai: [], + shown: winners.map((w) => ({ seat: w.seat, hand: [...w.hand, w.winTile] })), + nagashi: [], + abortiveReason: null, + abortiveSeat: null, + deltas, + poolTaken, + }, + dealerWon ? 'renchan' : 'rotate', + dealerWon, + ctx, + ); +} + +function endExhaustive(s: MahjongState, ctx: Ctx): void { + const h = H(s); + const n = nOf(s); + const sanma = sanmaOf(s); + const o = s.options; + const dealer = dealerOf(s); + const deltas = new Array(n).fill(0); + const tenpai = Array.from({ length: n }, (_, i) => i).filter((i) => h.waits[i]!.length > 0); + const nagashi = o.nagashiMangan + ? Array.from({ length: n }, (_, i) => i).filter((i) => h.discards[i]!.length > 0 && h.discards[i]!.every((d) => isYaochu(kindOf(d.tile)) && d.calledBy === null)) + : []; + if (nagashi.length > 0) { + for (const w of nagashi) { + const pt = payTable(2000, w === dealer, sanma, o.sanmaTsumo); + for (let p = 0; p < n; p++) { + if (p === w) continue; + const amt = p === dealer || w === dealer ? pt.tsumoDealer : pt.tsumoNonDealer; + deltas[p]! -= amt; + deltas[w]! += amt; + } + } + } else if (tenpai.length > 0 && tenpai.length < n) { + const pool = sanma ? o.sanmaNotenPool : 3000; + for (let p = 0; p < n; p++) deltas[p] = tenpai.includes(p) ? pool / tenpai.length : -pool / (n - tenpai.length); + } + s.seq++; + pushEvent(ctx, { type: 'ryuukyoku', tenpai }); + const dealerTenpai = tenpai.includes(dealer); + finishHand( + s, + { + kind: 'exhaustive', + winners: [], + tenpai, + shown: tenpai.map((seat) => ({ seat, hand: sortIds(h.hands[seat]!) })), + nagashi, + abortiveReason: null, + abortiveSeat: null, + deltas, + poolTaken: 0, + }, + dealerTenpai && o.renchanOn === 'tenpai' ? 'renchanHonba' : 'rotateHonba', + dealerTenpai && o.renchanOn === 'tenpai', + ctx, + ); +} + +function endAbortive(s: MahjongState, reason: AbortReason, seat: Seat | null, ctx: Ctx): void { + const n = nOf(s); + s.seq++; + pushEvent(ctx, { type: 'ryuukyoku', abortive: reason }); + finishHand( + s, + { + kind: 'abortive', + winners: [], + tenpai: [], + shown: seat !== null ? [{ seat, hand: sortIds(H(s).hands[seat]!) }] : [], + nagashi: [], + abortiveReason: reason, + abortiveSeat: seat, + deltas: new Array(n).fill(0), + poolTaken: 0, + }, + 'renchanHonba', + false, + ctx, + ); +} + +type NextKind = 'renchan' | 'rotate' | 'renchanHonba' | 'rotateHonba'; + +function finishHand( + s: MahjongState, + rec: Omit, + next: NextKind, + /** Dealer keeps the seat by winning or being tenpai (for 아가리야메). */ + dealerQualifies: boolean, + ctx: Ctx, +): void { + const h = H(s); + const n = nOf(s); + const o = s.options; + h.stage = 'ended'; + h.claim = null; + for (let i = 0; i < n; i++) s.points[i]! += rec.deltas[i]!; + const full: HandResultRecord = { + ...rec, + roundWind: s.roundWind, + handNumber: s.handNumber, + honba: s.honba, + pointsAfter: [...s.points], + doraIndicators: h.doraInd.slice(0, h.doraRevealed), + }; + s.lastHandResult = full; + s.handsPlayed++; + s.history.push({ label: handLabel(s.roundWind, s.handNumber, s.honba), kind: rec.kind, deltas: [...rec.deltas], pointsAfter: [...s.points] }); + pushEvent(ctx, { type: 'pointsChanged', deltas: [...rec.deltas] }); + + const renchan = next === 'renchan' || next === 'renchanHonba'; + const lastWind = o.length === 'tonpuu' ? 0 : 1; + const extension = s.roundWind > lastWind; + const allLast = s.roundWind >= lastWind && s.handNumber === n - 1; + const dealer = dealerOf(s); + let over: EndReason | null = null; + let nextHand: MahjongState['nextHand'] = null; + const honbaNext = next === 'rotate' ? 0 : s.honba + 1; + if (o.tobi && s.points.some((p) => p < 0)) over = 'tobi'; + else if (extension && s.points.some((p) => p >= o.returnPoints)) over = 'westEntryEnd'; + else if (renchan) { + const soleTop = s.points.every((p, i) => i === dealer || p < s.points[dealer]!); + if (allLast && o.agariYame && dealerQualifies && soleTop) over = 'agariYame'; + else nextHand = { roundWind: s.roundWind, handNumber: s.handNumber, honba: honbaNext }; + } else if (allLast) { + if (!extension && o.westEntry && s.points.every((p) => p < o.returnPoints)) nextHand = { roundWind: s.roundWind + 1, handNumber: 0, honba: honbaNext }; + else over = extension ? 'westEntryEnd' : 'normal'; + } else { + const hn = s.handNumber + 1; + nextHand = hn >= n ? { roundWind: s.roundWind + 1, handNumber: 0, honba: honbaNext } : { roundWind: s.roundWind, handNumber: hn, honba: honbaNext }; + } + s.gameOver = over; + s.nextHand = nextHand; + s.phase = 'handResult'; + s.resultAt = ctx.now; + s.readyForNext = Array.from({ length: n }, (_, i) => i).filter((i) => s.left[i]); + maybeNextHand(s, ctx); +} + +export function handLabel(roundWind: number, handNumber: number, honba: number): string { + return `${['동', '남', '서', '북'][roundWind]}${handNumber + 1}국${honba ? ` ${honba}본장` : ''}`; +} + +function maybeNextHand(s: MahjongState, ctx: Ctx): void { + if (s.phase !== 'handResult') return; + if (s.readyForNext.length < nOf(s)) return; + if (s.gameOver || !s.nextHand) { + finishGame(s, s.gameOver ?? 'normal', ctx); + return; + } + s.roundWind = s.nextHand.roundWind; + s.handNumber = s.nextHand.handNumber; + s.honba = s.nextHand.honba; + const max = s.options.timeBankSeconds * 1000; + s.timeBank = s.timeBank.map((b) => Math.min(max, b + BANK_REFILL_MS)); + startHand(s, ctx); +} + +function finishGame(s: MahjongState, reason: EndReason, ctx: Ctx): void { + const n = nOf(s); + const o = s.options; + const order = Array.from({ length: n }, (_, i) => i).sort((a, b) => s.points[b]! - s.points[a]! || a - b); + s.points[order[0]!]! += s.riichiPool; + s.riichiPool = 0; + const oka = ((o.returnPoints - o.startPoints) * n) / 1000; + const round1 = (x: number) => Math.round(x * 10) / 10; + const ranking = order.map((seat, i) => { + const raw = (s.points[seat]! - o.returnPoints) / 1000 + (o.uma[i] ?? 0) + (i === 0 ? oka : 0); + return { player: s.players[seat]!, seat, rank: i + 1, points: s.points[seat]!, score: round1(raw) }; + }); + // Scores add up to 0; 1st takes the rounding remainder (산마 본장 300 leaves half-hundreds). + ranking[0]!.score = round1(-ranking.slice(1).reduce((a, r) => a + r.score, 0)); + const best = s.best; + const top = ranking[0]!; + const lengthKo = o.length === 'tonpuu' ? '동풍전' : '반장전'; + const why = reason === 'tobi' ? ' · 토비로 끝' : reason === 'agariYame' ? ' · 아가리야메로 끝' : reason === 'westEntryEnd' ? ' · 연장전 끝' : ''; + const fmt = (x: number) => `${x > 0 ? '+' : ''}${x.toFixed(1)}`; + const final: MahjongFinal = { + ranking, + rounds: s.handsPlayed, + bestHand: best ? { player: s.players[best.seat]!, han: best.han, fu: best.fu, yaku: best.yaku, points: best.points, limit: best.limit } : null, + perPlayer: s.players.map((player, i) => ({ player, ...s.stats[i]! })), + endReason: reason, + summary: `${lengthKo} ${s.handsPlayed}국${why} · 1위 ${top.points.toLocaleString('en-US')}점(${fmt(top.score)})`, + }; + s.final = final; + s.phase = 'finished'; + s.hand = s.hand ? { ...s.hand, stage: 'ended' } : null; + pushEvent(ctx, { type: 'gameEnded', reason }); +} + +// ---------------------------------------------------------------- turn options + +function ankanKinds(s: MahjongState, seat: Seat): Kind[] { + const h = s.hand; + if (!h || h.stage !== 'afterDraw' || h.turn !== seat || h.wall.length === 0 || h.kanCount >= 4) return []; + const c = counts34(h.hands[seat]!.map(kindOf)); + const out: Kind[] = []; + for (let k = 0; k < 34; k++) { + if (c[k] !== 4) continue; + if (h.riichi[seat] && !riichiAnkanOk(h, seat, k)) continue; + out.push(k); + } + return out; +} + +/** 리치 후 안깡: 쯔모한 패로, 대기와 면자 해석이 바뀌지 않을 때만 (§5.4). */ +function riichiAnkanOk(h: HandState, seat: Seat, k: Kind): boolean { + const d = h.drawn[seat]; + if (d == null || kindOf(d) !== k) return false; + const before = h.hands[seat]!.filter((t) => t !== d); + const c13 = counts34(before.map(kindOf)); + if (c13[k] !== 3) return false; + const waits = h.waits[seat]!; + const meldCount = h.melds[seat]!.length; + for (const w of waits) { + c13[w]!++; + const decs = decompositions(c13, 4 - meldCount); + c13[w]!--; + if (decs.length === 0 || !decs.every((dec) => dec.sets.some((st) => st.t === 'koutsu' && st.k === k))) return false; + } + const after = [...c13]; + after[k] = 0; + const owned = [...after]; + for (const m of h.melds[seat]!) for (const t of m.tiles) owned[kindOf(t)]!++; + owned[k]! += 4; + const w2 = waitsOf(after, meldCount + 1, owned); + return w2.length === waits.length && w2.every((x) => waits.includes(x)); +} + +function kakanTiles(s: MahjongState, seat: Seat): TileId[] { + const h = s.hand; + if (!h || h.stage !== 'afterDraw' || h.turn !== seat || h.wall.length === 0 || h.kanCount >= 4 || h.riichi[seat]) return []; + const out: TileId[] = []; + for (const m of h.melds[seat]!) { + if (m.type !== 'pon') continue; + const k = kindOf(m.tiles[0]!); + const t = h.hands[seat]!.find((x) => kindOf(x) === k); + if (t !== undefined) out.push(t); + } + return out; +} + +function canKita(s: MahjongState, seat: Seat): boolean { + const h = s.hand; + if (!h || !sanmaOf(s) || h.stage !== 'afterDraw' || h.turn !== seat || h.wall.length === 0) return false; + if (h.riichi[seat]) { + const d = h.drawn[seat]; + return d != null && kindOf(d) === NORTH; + } + return h.hands[seat]!.some((t) => kindOf(t) === NORTH); +} + +function canKyuushu(s: MahjongState, seat: Seat): boolean { + const h = s.hand; + if (!h || !s.options.abortiveDraws || h.stage !== 'afterDraw' || h.turn !== seat) return false; + if (h.interrupted || h.discards[seat]!.length > 0 || h.lastDrawRinshan) return false; + const kinds = new Set(h.hands[seat]!.map(kindOf)); + return YAOCHU.filter((k) => kinds.has(k)).length >= 9; +} + +function riichiBlock(s: MahjongState, seat: Seat): string | null { + const h = H(s); + if (h.riichi[seat]) return '이미 리치했어요.'; + if (h.stage !== 'afterDraw') return '울고 난 뒤에는 리치할 수 없어요.'; + if (!isMenzen(h, seat)) return '울은 손으로는 리치할 수 없어요.'; + if (s.points[seat]! < 1000) return '점수가 1000점 미만이라 리치할 수 없어요.'; + if (h.wall.length < (sanmaOf(s) ? 3 : 4)) return '남은 패가 부족해 리치할 수 없어요.'; + return null; +} + +/** Discards that leave the hand tenpai (distinct tiles; one id per kind+red). */ +function tenpaiDiscards(s: MahjongState, seat: Seat): Map { + const h = H(s); + const out = new Map(); + const seen = new Set(); + for (const t of sortIds(h.hands[seat]!)) { + const key = `${kindOf(t)}${s.red.includes(t) ? 'r' : ''}`; + if (seen.has(key)) continue; + seen.add(key); + const rest = h.hands[seat]!.filter((x) => x !== t); + const w = computeWaits(h, seat, rest); + if (w.length) out.set(t, w); + } + return out; +} + +function riichiOk(s: MahjongState, seat: Seat, tile: TileId): boolean { + const h = H(s); + if (riichiBlock(s, seat)) return false; + return computeWaits( + h, + seat, + h.hands[seat]!.filter((x) => x !== tile), + ).length > 0; +} + +// ---------------------------------------------------------------- timers + +function turnLimitMs(s: MahjongState, seat: Seat): number { + return s.autoPilot[seat] ? AUTO_MS : s.options.turnSeconds * 1000 + s.timeBank[seat]!; +} + +function deadlineAt(s: MahjongState): number | null { + if (s.phase === 'finished') return null; + if (s.phase === 'handResult') return s.resultAt + RESULT_MS; + const h = H(s); + if (h.stage === 'claim') return h.claim!.decided ? h.claim!.minUntil : h.claim!.deadline; + if (h.stage === 'afterDraw' || h.stage === 'afterCall') return h.turnStartedAt + turnLimitMs(s, h.turn); + return null; +} + +function waitingSeats(s: MahjongState): Seat[] { + const n = nOf(s); + if (s.phase === 'finished') return []; + if (s.phase === 'handResult') return Array.from({ length: n }, (_, i) => i).filter((i) => !s.readyForNext.includes(i)); + const h = H(s); + if (h.stage === 'claim') return Array.from({ length: n }, (_, i) => i).filter((i) => i !== h.claim!.from); + if (h.stage === 'afterDraw' || h.stage === 'afterCall') return [h.turn]; + return []; +} + +/** + * Seats the deadline applies to (server-side only, never shown). In an open claim window these are the eligible + * seats that have not answered, so a disconnected one is passed at once (§8.5); once the outcome is settled only the + * minimum delay is left and the discarder's timer closes the window. + */ +function timedSeats(s: MahjongState): Seat[] { + if (s.phase === 'playing' && s.hand?.stage === 'claim') { + const c = s.hand.claim!; + if (c.decided) return [c.from]; + return c.eligible.flatMap((e, seat) => (e && !c.responses[seat] ? [seat] : [])); + } + return waitingSeats(s); +} + +function noteTimeout(s: MahjongState, seat: Seat): void { + if (s.left[seat]) return; + s.timeoutsInRow[seat]!++; + if (s.timeoutsInRow[seat]! >= 2) s.autoPilot[seat] = true; +} + +// ---------------------------------------------------------------- validation + +function respTiles(a: { tiles?: string[] }): TileId[] | null { + const out: TileId[] = []; + for (const c of a.tiles ?? []) { + const id = parseTileCode(c); + if (id === null) return null; + out.push(id); + } + return sortIds(out); +} + +const sameIds = (a: TileId[], b: TileId[]) => a.length === b.length && sortIds(a).every((x, i) => x === sortIds(b)[i]); + +function validateAction(s: MahjongState, actor: PlayerId, a: MahjongAction): Validation { + const seat = seatOf(s, actor); + if (seat < 0) return fail('이 게임의 플레이어가 아니에요.'); + if (s.phase === 'finished') return fail('게임이 이미 끝났어요.'); + if (a.type === 'leave') return s.left[seat] ? fail('이미 나간 플레이어예요.') : ok; + if (a.type === 'timeout') return waitingSeats(s).includes(seat) || timedSeats(s).includes(seat) ? ok : fail('시간 초과 대상이 아니에요.'); + if (s.left[seat]) return fail('방을 나간 플레이어예요.'); + if (a.type === 'setPref') { + if (a.beginner && !s.options.beginnerMode) return fail('이 방에서는 초보 모드를 쓸 수 없어요.'); + return ok; + } + if (a.type === 'ready') return s.phase === 'handResult' && !s.readyForNext.includes(seat) ? ok : fail('지금은 다음 국을 기다릴 때가 아니에요.'); + if (s.phase !== 'playing') return fail('지금은 할 수 없어요.'); + const h = H(s); + if (a.type === 'claim' || a.type === 'pass') { + if (h.stage !== 'claim') return fail('지금은 울거나 론할 수 없어요.'); + const c = h.claim!; + const e = c.eligible[seat]; + if (!e) return fail('이 패에는 응답할 수 없어요.'); + if (c.responses[seat]) return fail('이미 응답했어요.'); + if (a.type === 'pass') return ok; + if (a.call === 'ron') { + if (e.ron) return ok; + if (h.waits[seat]!.includes(kindOf(c.tile)) && isFuriten(h, seat)) return fail('후리텐이라 론할 수 없어요.'); + return fail('역이 없어 화료할 수 없어요.'); + } + const tiles = respTiles(a); + if (!tiles) return fail('패를 다시 골라 주세요.'); + const opts = e[a.call]; + if (!opts.length) return fail(a.call === 'chi' ? '지금은 치할 수 없어요.' : a.call === 'pon' ? '지금은 퐁할 수 없어요.' : '지금은 깡할 수 없어요.'); + return opts.some((o) => sameIds(o, tiles)) ? ok : fail('그 패 조합으로는 울 수 없어요.'); + } + if (h.stage === 'claim') return fail('다른 플레이어가 응답하는 중이에요.'); + if (h.turn !== seat || (h.stage !== 'afterDraw' && h.stage !== 'afterCall')) return fail('지금은 내 차례가 아니에요.'); + switch (a.type) { + case 'discard': { + const t = parseTileCode(a.tile); + if (t === null || !h.hands[seat]!.includes(t)) return fail('손에 없는 패예요.'); + if (h.stage === 'afterCall' && h.kuikae.includes(kindOf(t))) return fail('방금 울은 패와 같은 패(또는 스지)는 버릴 수 없어요.'); + if (h.riichi[seat] && t !== h.drawn[seat]) return fail('리치 후에는 쯔모한 패만 버릴 수 있어요.'); + if (a.riichi) { + const why = riichiBlock(s, seat); + if (why) return fail(why); + if (!riichiOk(s, seat, t)) return fail('그 패를 버리면 텐파이가 아니라 리치할 수 없어요.'); + } + return ok; + } + case 'tsumo': { + if (h.stage !== 'afterDraw' || h.drawn[seat] == null) return fail('지금은 쯔모 화료할 수 없어요.'); + return canTsumo(s, seat) ? ok : fail('역이 없어 화료할 수 없어요.'); + } + case 'ankan': { + const t = parseTileCode(a.tile); + if (t === null) return fail('패를 다시 골라 주세요.'); + if (h.stage !== 'afterDraw') return fail('지금은 깡할 수 없어요.'); + if (h.wall.length === 0) return fail('마지막 패로는 깡할 수 없어요.'); + if (h.kanCount >= 4) return fail('이번 국에서는 더 깡할 수 없어요.'); + if (h.hands[seat]!.filter((x) => kindOf(x) === kindOf(t)).length !== 4) return fail('같은 패 4장이 있어야 안깡할 수 있어요.'); + return ankanKinds(s, seat).includes(kindOf(t)) ? ok : fail('리치 후에는 대기가 바뀌는 깡을 할 수 없어요.'); + } + case 'kakan': { + const t = parseTileCode(a.tile); + if (t === null || !h.hands[seat]!.includes(t)) return fail('손에 없는 패예요.'); + if (h.stage !== 'afterDraw') return fail('지금은 깡할 수 없어요.'); + return kakanTiles(s, seat).some((x) => kindOf(x) === kindOf(t)) ? ok : fail('퐁한 패의 4번째 패만 가깡할 수 있어요.'); + } + case 'kita': + if (!sanmaOf(s)) return fail('북 뽑기는 3인 마작에서만 해요.'); + return canKita(s, seat) ? ok : fail('지금은 북을 뽑을 수 없어요.'); + case 'kyuushu': + return canKyuushu(s, seat) ? ok : fail('구종구패는 첫 쯔모 때 요구패가 9종 이상일 때만 할 수 있어요.'); + } + return fail('알 수 없는 행동이에요.'); +} + +// ---------------------------------------------------------------- view + +function meldView(m: Meld, red: TileId[]): MeldView { + return { type: m.type, tiles: m.tiles.map((t) => tileCode(t, red)), called: m.called === null ? null : tileCode(m.called, red), from: m.from }; +} + +/** Tiles of each kind I can see (my hand, every discard / meld / 북, dora indicators). */ +function visibleCounts(s: MahjongState, seat: Seat): number[] { + const h = H(s); + const c = counts34(h.hands[seat]!.map(kindOf)); + for (let i = 0; i < nOf(s); i++) { + for (const d of h.discards[i]!) if (d.calledBy === null) c[kindOf(d.tile)]!++; + for (const m of h.melds[i]!) for (const t of m.tiles) c[kindOf(t)]!++; + for (const t of h.kita[i]!) c[kindOf(t)]!++; + } + for (const t of h.doraInd.slice(0, h.doraRevealed)) c[kindOf(t)]!++; + return c; +} + +function ronHasYaku(s: MahjongState, seat: Seat, hand: TileId[], kind: Kind): boolean { + const h = H(s); + // Use any tile id of that kind as the winning tile (red-ness is dora only). + const winTile = kind * 4 + 3; + const saved = h.hands[seat]!; + h.hands[seat] = hand; + const ok = winScoreFor(s, seat, winTile, { tsumo: false }) !== null; + h.hands[seat] = saved; + return ok; +} + +function waitViews(s: MahjongState, seat: Seat, hand: TileId[], waits: Kind[], seen: number[]): WaitView[] { + return waits.map((k) => ({ kind: kindCode(k), left: Math.max(0, 4 - seen[k]!), yaku: ronHasYaku(s, seat, hand, k) })); +} + +function yakuHints(s: MahjongState, seat: Seat): string[] { + const h = H(s); + const hand = h.hands[seat]!.map(kindOf); + const melds = h.melds[seat]!; + const menzen = isMenzen(h, seat); + const all = [...hand, ...melds.flatMap((m) => m.tiles.map(kindOf))]; + const c = counts34(all); + const out: string[] = []; + const yakuhai = (k: Kind) => isDragon(k) || k === seatWindKind(s, seat) || k === roundWindKind(s); + const fixed = melds.some((m) => m.type !== 'chi' && yakuhai(kindOf(m.tiles[0]!))); + const yaochu = all.filter(isYaochu).length; + if (h.riichi[seat]) return ['리치 중이에요. 화료패가 오면 쯔모나 론을 눌러요.']; + if (fixed) out.push('역패를 이미 갖고 있어요. 화료만 하면 돼요.'); + for (let k = 27; k < 34; k++) if (yakuhai(k) && c[k]! === 2 && !melds.some((m) => kindOf(m.tiles[0]!) === k)) out.push(`${['동', '남', '서', '북', '백', '발', '중'][k - 27]} 2장: 1장 더 모으면 역패예요.`); + if (yaochu <= 3 && (menzen || s.options.kuitan)) out.push(yaochu === 0 ? '탕야오(2~8만 모으기) 모양이에요.' : `탕야오까지 요구패 ${yaochu}장을 버리면 돼요.`); + const suits = [0, 1, 2].map((su) => all.filter((k) => k < 27 && Math.floor(k / 9) === su).length); + const honors = all.filter((k) => k >= 27).length; + const topSuit = Math.max(...suits); + if (topSuit + honors >= all.length - 3 && topSuit >= 6) out.push(honors > 0 ? '한 종류 + 자패: 혼일색을 노려 보세요.' : '한 종류만: 청일색을 노려 보세요.'); + if (menzen) { + const pairs = c.filter((x) => x >= 2).length; + if (pairs >= 5) out.push(`대자 ${pairs}개: 칠대자도 가능해요.`); + out.push('멘젠이에요. 텐파이하면 리치로 역을 만들 수 있어요.'); + } else if (!fixed && !(yaochu <= 3 && s.options.kuitan) && !(topSuit + honors >= all.length - 3)) { + out.push('역이 없어요! 역패·탕야오·혼일색 중 하나를 만들어야 화료할 수 있어요.'); + } + return out; +} + +function myView(s: MahjongState, seat: Seat): MyView | null { + const h = s.hand; + const red = s.red; + const code = (t: TileId) => tileCode(t, red); + const base: MyView = { + seat, + hand: [], + drawn: null, + canDiscard: false, + discardable: [], + locked: [], + riichiDiscards: [], + canTsumo: false, + ankan: [], + kakan: [], + canKita: false, + canKyuushu: false, + claim: null, + riichi: false, + furiten: false, + waits: [], + tenpaiAfter: {}, + yakuHints: [], + timeBankMs: s.timeBank[seat]!, + autoPilot: s.autoPilot[seat]!, + prefs: { ...s.prefs[seat]! }, + }; + if (!h) return base; + const drawn = h.drawn[seat]; + base.hand = sortIds(h.hands[seat]!.filter((t) => t !== drawn)).map(code); + base.drawn = drawn == null ? null : code(drawn); + base.riichi = !!h.riichi[seat]; + if (s.phase !== 'playing') return base; + base.furiten = isFuriten(h, seat); + const beginner = s.options.beginnerMode && s.prefs[seat]!.beginner; + const myTurn = h.turn === seat && (h.stage === 'afterDraw' || h.stage === 'afterCall'); + const seen = beginner || myTurn ? visibleCounts(s, seat) : null; + if (myTurn) { + base.canDiscard = true; + const hand = h.hands[seat]!; + if (h.riichi[seat]) base.discardable = drawn == null ? [] : [code(drawn)]; + else { + base.discardable = sortIds(hand.filter((t) => !(h.stage === 'afterCall' && h.kuikae.includes(kindOf(t))))).map(code); + base.locked = h.stage === 'afterCall' ? sortIds(hand.filter((t) => h.kuikae.includes(kindOf(t)))).map(code) : []; + } + if (h.stage === 'afterDraw') { + base.canTsumo = canTsumo(s, seat); + base.ankan = ankanKinds(s, seat).map((k) => code(hand.find((t) => kindOf(t) === k)!)); + base.kakan = kakanTiles(s, seat).map(code); + base.canKita = canKita(s, seat); + base.canKyuushu = canKyuushu(s, seat); + } + const canRiichiNow = h.stage === 'afterDraw' && riichiBlock(s, seat) === null; + if (canRiichiNow || (beginner && !h.riichi[seat])) { + const td = tenpaiDiscards(s, seat); + for (const [t, w] of td) { + if (h.stage === 'afterCall' && h.kuikae.includes(kindOf(t))) continue; + if (canRiichiNow) { + for (const x of hand) if (kindOf(x) === kindOf(t) && red.includes(x) === red.includes(t)) base.riichiDiscards.push(code(x)); + } + if (beginner) { + const rest = hand.filter((x) => x !== t); + const wv = waitViews(s, seat, rest, w, seen!); + for (const x of hand) if (kindOf(x) === kindOf(t) && red.includes(x) === red.includes(t)) base.tenpaiAfter[code(x)] = wv; + } + } + } + } else if (h.hands[seat]!.length % 3 === 1 && h.waits[seat]!.length && (beginner || h.riichi[seat])) { + base.waits = waitViews(s, seat, h.hands[seat]!, h.waits[seat]!, seen ?? visibleCounts(s, seat)); + } + if (beginner) base.yakuHints = yakuHints(s, seat); + if (h.stage === 'claim') { + const c = h.claim!; + const e = c.eligible[seat]; + if (e) { + base.claim = { + tile: code(c.tile), + source: c.source, + ron: e.ron, + pon: e.pon.map((v) => v.map(code)), + kan: e.kan.map((v) => v.map(code)), + chi: e.chi.map((v) => v.map(code)), + responded: !!c.responses[seat], + deadline: c.deadline, + totalMs: s.options.claimSeconds * 1000, + }; + } + } + return base; +} + +function resultView(s: MahjongState): HandResultView | null { + const r = s.lastHandResult; + if (!r || s.phase === 'playing') return null; + const red = s.red; + const code = (t: TileId) => tileCode(t, red); + return { + ...r, + winners: r.winners.map((w) => ({ + ...w, + hand: w.hand.map(code), + melds: w.melds.map((m) => meldView(m, red)), + winTile: code(w.winTile), + uraIndicators: w.uraIndicators.map(code), + })), + shown: r.shown.map((x) => ({ seat: x.seat, hand: x.hand.map(code) })), + doraIndicators: r.doraIndicators.map(code), + }; +} + +function buildView(s: MahjongState, viewer: PlayerId | null): MahjongView { + const n = nOf(s); + const seat = viewer === null ? -1 : seatOf(s, viewer); + const h = s.hand; + const red = s.red; + const code = (t: TileId) => tileCode(t, red); + const live = s.phase === 'playing' && !!h; + let lastDiscard: MahjongView['lastDiscard'] = null; + if (h?.claim && h.claim.source === 'discard') lastDiscard = { seat: h.claim.from, index: h.discards[h.claim.from]!.length - 1 }; + const deadline = deadlineAt(s); + // The public clock never shows a claim window's countdown: its length would tell who can respond (§5.3.7). + // Eligible players get their own countdown in `my.claim`. + let total = 0; + if (s.phase === 'handResult') total = RESULT_MS; + else if (live && h.stage !== 'claim') total = turnLimitMs(s, h.turn); + return { + mode: s.mode, + n, + options: s.options, + me: seat >= 0 ? viewer : null, + seats: s.players.map((id, i) => ({ + id, + seat: i, + wind: (i - dealerOf(s) + n) % n, + points: s.points[i]!, + handCount: h ? h.hands[i]!.length : 0, + hasDrawn: !!h && live && (h.stage === 'afterDraw') && h.turn === i, + melds: h ? h.melds[i]!.map((m) => meldView(m, red)) : [], + kita: h ? h.kita[i]!.map(code) : [], + discards: h ? h.discards[i]!.map((d) => ({ tile: code(d.tile), tsumogiri: d.tsumogiri, riichi: d.riichi, called: d.calledBy !== null })) : [], + riichi: !!h?.riichi[i], + isDealer: i === dealerOf(s), + away: s.autoPilot[i]!, + left: s.left[i]!, + ready: s.phase === 'handResult' && s.readyForNext.includes(i), + })), + roundWind: s.roundWind, + handNumber: s.handNumber, + honba: s.honba, + riichiPool: s.riichiPool, + dealer: dealerOf(s), + phase: s.phase, + stage: h ? h.stage : null, + turn: live ? h.turn : null, + wallCount: h ? h.wall.length : 0, + doraIndicators: h ? h.doraInd.slice(0, h.doraRevealed).map(code) : [], + claimOn: live && h.claim ? { tile: code(h.claim.tile), from: h.claim.from, source: h.claim.source } : null, + lastDiscard, + lastCall: s.lastCall, + turnStartedAt: deadline !== null && total > 0 ? deadline - total : 0, + deadlineTotalMs: total, + my: seat >= 0 ? myView(s, seat) : null, + result: resultView(s), + nextHand: s.phase === 'handResult' ? s.nextHand : null, + gameOver: s.phase === 'playing' ? null : s.gameOver, + history: s.history, + final: s.final, + seq: s.seq, + handsPlayed: s.handsPlayed, + }; +} + +// ---------------------------------------------------------------- definition + +function cloneState(s: MahjongState): MahjongState { + return structuredClone(s); +} + +export const mahjong: GameDefinition = { + id: 'mahjong', + nameKo: '마작', + minPlayers: 3, + maxPlayers: 4, + stateVersion: 1, + defaultOptions: mahjongOptionsSchema.parse({}), + optionsSchema: mahjongOptionsSchema as unknown as GameDefinition['optionsSchema'], + actionSchema: mahjongActionSchema as unknown as GameDefinition['actionSchema'], + playersFor: () => ({ min: 3, max: 4 }), + + setup({ players, options, rng, now }) { + if (players.length < 3 || players.length > 4) throw new Error(`mahjong: ${players.length} players not allowed`); + const n = players.length; + const opts = resolveOptions(options, n); + const seats = rng.shuffle(players); + const fill = (f: () => T) => Array.from({ length: n }, f); + const s: MahjongState = { + options: opts, + mode: n === 3 ? 'sanma' : 'yonma', + players: seats, + red: redTiles(opts.akaDora, n === 3), + points: fill(() => opts.startPoints), + roundWind: 0, + handNumber: 0, + honba: 0, + riichiPool: 0, + hand: null, + phase: 'playing', + lastHandResult: null, + gameOver: null, + nextHand: null, + best: null, + resultAt: 0, + readyForNext: [], + timeBank: fill(() => opts.timeBankSeconds * 1000), + timeoutsInRow: fill(() => 0), + autoPilot: fill(() => false), + left: fill(() => false), + prefs: fill(() => ({ autoWin: false, noCalls: false, beginner: opts.beginnerMode })), + history: [], + stats: fill(() => ({ wins: 0, dealIns: 0, riichi: 0, calls: 0 })), + handsPlayed: 0, + seq: 0, + lastCall: null, + final: null, + }; + startHand(s, { rng, now, events: [] }); + return s; + }, + + validate: validateAction, + + apply(prev, actor, a, { rng, now }) { + const s = cloneState(prev); + const ctx: Ctx = { rng, now, events: [] }; + const seat = seatOf(s, actor); + if (a.type !== 'timeout' && a.type !== 'leave' && !s.left[seat]) { + s.timeoutsInRow[seat] = 0; + s.autoPilot[seat] = false; + } + const h = s.hand; + // Time bank use for own-turn decisions. + if (h && s.phase === 'playing' && h.turn === seat && (h.stage === 'afterDraw' || h.stage === 'afterCall') && a.type !== 'setPref' && a.type !== 'leave') { + if (a.type === 'timeout') s.timeBank[seat] = s.autoPilot[seat] ? s.timeBank[seat]! : 0; + else { + const over = now - h.turnStartedAt - s.options.turnSeconds * 1000; + if (over > 0) s.timeBank[seat] = Math.max(0, s.timeBank[seat]! - over); + } + } + switch (a.type) { + case 'setPref': { + const p = s.prefs[seat]!; + if (a.autoWin !== undefined) p.autoWin = a.autoWin; + if (a.noCalls !== undefined) p.noCalls = a.noCalls; + if (a.beginner !== undefined) p.beginner = a.beginner; + break; + } + case 'ready': + s.readyForNext.push(seat); + maybeNextHand(s, ctx); + break; + case 'leave': { + s.left[seat] = true; + s.autoPilot[seat] = true; + pushEvent(ctx, { type: 'left', seat }); + if (s.phase === 'handResult') { + if (!s.readyForNext.includes(seat)) s.readyForNext.push(seat); + maybeNextHand(s, ctx); + } else if (h && h.stage === 'claim' && h.claim!.eligible[seat] && !h.claim!.responses[seat]) { + h.claim!.responses[seat] = { type: 'pass' }; + tryResolve(s, ctx, false); + } + break; + } + case 'timeout': { + pushEvent(ctx, { type: 'timeout', seat }); + if (s.phase === 'handResult') { + if (!s.readyForNext.includes(seat)) s.readyForNext.push(seat); + maybeNextHand(s, ctx); + break; + } + const hh = H(s); + if (hh.stage === 'claim') { + const c = hh.claim!; + const due = c.decided ? c.minUntil : c.deadline; + // The discarder's timer only runs once the outcome is settled; it closes the window (also early, when the + // server fires it for a disconnected discarder — otherwise that timer would fire again and again). + if (now >= due || (c.decided && seat === c.from)) { + c.eligible.forEach((e, i) => { + if (e && !c.responses[i]) { + c.responses[i] = { type: 'pass' }; + // Seats whose window was closed early by a higher call did not time out. + if (!c.decided) noteTimeout(s, i); + } + }); + resolveWindow(s, ctx); + } else if (c.eligible[seat] && !c.responses[seat]) { + c.responses[seat] = { type: 'pass' }; + noteTimeout(s, seat); + tryResolve(s, ctx, false); + } + break; + } + if (hh.turn !== seat) break; + noteTimeout(s, seat); + if (hh.stage === 'afterDraw') { + if (s.prefs[seat]!.autoWin && canTsumo(s, seat)) doTsumo(s, seat, ctx); + else doDiscard(s, seat, hh.drawn[seat] ?? sortIds(hh.hands[seat]!).at(-1)!, false, ctx); + } else if (hh.stage === 'afterCall') { + const pick = sortIds(hh.hands[seat]!) + .reverse() + .find((t) => !hh.kuikae.includes(kindOf(t)))!; + doDiscard(s, seat, pick, false, ctx); + } + break; + } + case 'claim': + case 'pass': { + const c = H(s).claim!; + if (a.type === 'pass') c.responses[seat] = { type: 'pass' }; + else if (a.call === 'ron') c.responses[seat] = { type: 'ron' }; + else c.responses[seat] = { type: a.call, tiles: respTiles(a)! }; + tryResolve(s, ctx, false); + break; + } + case 'discard': + doDiscard(s, seat, parseTileCode(a.tile)!, !!a.riichi, ctx); + break; + case 'tsumo': + doTsumo(s, seat, ctx); + break; + case 'ankan': + doAnkan(s, seat, kindOf(parseTileCode(a.tile)!), ctx); + break; + case 'kakan': + doKakan(s, seat, parseTileCode(a.tile)!, ctx); + break; + case 'kita': + doKita(s, seat, ctx); + break; + case 'kyuushu': + endAbortive(s, 'kyuushu', seat, ctx); + break; + } + return { state: s, events: ctx.events }; + }, + + view: buildView, + + activePlayers(s) { + return waitingSeats(s).map((i) => s.players[i]!); + }, + + deadline: deadlineAt, + + timeoutPlayers(s) { + return timedSeats(s).map((i) => s.players[i]!); + }, + + onTimeout() { + return { type: 'timeout' }; + }, + + onLeave(s, player) { + const seat = seatOf(s, player); + if (seat < 0 || s.left[seat] || s.phase === 'finished') return null; + return { type: 'leave' }; + }, + + result(s): GameResult | null { + if (s.phase !== 'finished' || !s.final) return null; + return { + ranking: s.final.ranking.map((r) => [r.player]), + scores: Object.fromEntries(s.final.ranking.map((r) => [r.player, r.score])), + summary: s.final.summary, + reason: 'normal', + }; + }, + + legalActions(s, p) { + const seat = seatOf(s, p); + if (seat < 0 || s.left[seat] || s.phase === 'finished') return []; + const out: MahjongAction[] = []; + if (s.phase === 'handResult') return s.readyForNext.includes(seat) ? [] : [{ type: 'ready' }]; + const h = H(s); + const code = (t: TileId) => tileCode(t, s.red); + if (h.stage === 'claim') { + const c = h.claim!; + const e = c.eligible[seat]; + if (!e || c.responses[seat]) return []; + out.push({ type: 'pass' }); + if (e.ron) out.push({ type: 'claim', call: 'ron' }); + for (const call of ['pon', 'kan', 'chi'] as const) for (const v of e[call]) out.push({ type: 'claim', call, tiles: v.map(code) }); + return out; + } + if (h.turn !== seat) return []; + if (canTsumo(s, seat)) out.push({ type: 'tsumo' }); + for (const k of ankanKinds(s, seat)) out.push({ type: 'ankan', tile: code(h.hands[seat]!.find((t) => kindOf(t) === k)!) }); + for (const t of kakanTiles(s, seat)) out.push({ type: 'kakan', tile: code(t) }); + if (canKita(s, seat)) out.push({ type: 'kita' }); + if (canKyuushu(s, seat)) out.push({ type: 'kyuushu' }); + const seen = new Set(); + const d = h.drawn[seat]; + // In riichi only the drawn tile itself may be discarded (another copy of the same kind may not). + const pool = h.riichi[seat] ? (d == null ? [] : [d]) : sortIds(h.hands[seat]!); + for (const t of pool) { + const key = `${kindOf(t)}${s.red.includes(t)}`; + if (seen.has(key)) continue; + seen.add(key); + const a: MahjongAction = { type: 'discard', tile: code(t) }; + if (validateAction(s, p, a).ok) out.push(a); + if (h.stage === 'afterDraw' && !h.riichi[seat] && riichiBlock(s, seat) === null && riichiOk(s, seat, t)) out.push({ type: 'discard', tile: code(t), riichi: true }); + } + return out.filter((a) => validateAction(s, p, a).ok); + }, + + /** Own hand plus the walls and hidden indicators — tile codes exactly as they would appear in a view. */ + secretsOf(s, player) { + const h = s.hand; + const seat = seatOf(s, player); + if (!h || seat < 0) return []; + const code = (t: TileId) => tileCode(t, s.red); + const shown = new Set((s.phase !== 'playing' ? (s.lastHandResult?.shown ?? []) : []).map((x) => x.seat)); + const revealedUra = s.phase !== 'playing' && s.lastHandResult?.winners.some((w) => w.uraIndicators.length > 0) ? h.doraRevealed : 0; + const out: string[] = []; + if (!shown.has(seat)) out.push(...h.hands[seat]!.map(code)); + out.push(...h.wall.map(code), ...h.rinshan.map(code), ...h.doraInd.slice(h.doraRevealed).map(code), ...h.uraInd.slice(revealedUra).map(code)); + return out; + }, +}; + +// Internal helpers exposed for tests. +export const __test = { computeWaits, winScoreFor, canRon, tenpaiDiscards, riichiAnkanOk, kuikaeKinds, doraFromIndicator, startHand, openWindow, deadlineAt }; diff --git a/packages/games/src/mahjong/mahjong.test.ts b/packages/games/src/mahjong/mahjong.test.ts new file mode 100644 index 0000000..83967f0 --- /dev/null +++ b/packages/games/src/mahjong/mahjong.test.ts @@ -0,0 +1,1190 @@ +/** 마작 규칙 시나리오 (docs/games/mahjong.md §10). */ +import { describe, expect, test } from 'bun:test'; +import { SeededRng, seedN } from '@bg/engine'; +import { __test, allTiles, kindFromCode, kindOf, mahjong, tileCode, type HandState, type MahjongAction, type MahjongOptions, type MahjongState, type Meld, type TileId } from './index'; + +// ---------------------------------------------------------------- helpers + +const P4 = ['A', 'B', 'C', 'D']; +const P3 = ['A', 'B', 'C']; +let clock = 1_000_000; + +function make(n = 4, opts: Partial = {}, seed = 1): MahjongState { + const players = n === 4 ? P4 : P3; + const s = mahjong.setup({ players, options: mahjong.optionsSchema.parse(opts), rng: SeededRng.fromSeed(seedN(seed)), now: clock }); + s.players = [...players]; + return s; +} + +/** Picks tile ids for a notation like "123m0p55z" ('0' = red five). Plain fives avoid the red copy. */ +function take(notation: string, used: Set, sanma: boolean): TileId[] { + const out: TileId[] = []; + for (const m of notation.matchAll(/(\d+)([mpsz])/g)) { + for (const d of m[1]!) { + const kind = kindFromCode(`${d === '0' ? 5 : d}${m[2]}`)!; + if (sanma && kind >= 1 && kind <= 7) throw new Error('2m~8m are not used in 3-player'); + const copies = d === '0' ? [0] : [1, 2, 3, 0]; + const id = copies.map((c) => kind * 4 + c).find((x) => !used.has(x)); + if (id === undefined) throw new Error(`no copy left for ${d}${m[2]}`); + used.add(id); + out.push(id); + } + } + return out; +} + +/** + * '*' hands: 13 single tiles three apart in each suit (1·4·7) plus honors — never tenpai and never able to pon, chi + * or kan anything. Kinds whose copies are used up are skipped. + */ +function filler(used: Set, sanma: boolean): TileId[] { + const kinds: number[] = []; + for (const suit of [0, 1, 2]) for (const d of [0, 3, 6]) kinds.push(suit * 9 + d); + for (let z = 27; z < 34; z++) kinds.push(z); + const out: TileId[] = []; + for (const k of kinds) { + if (sanma && k >= 1 && k <= 7) continue; + const id = [1, 2, 3, 0].map((c) => k * 4 + c).find((x) => !used.has(x)); + if (id !== undefined) out.push(id); + if (out.length === 13) break; + } + if (out.length !== 13) throw new Error('no filler hand possible'); + for (const t of out) used.add(t); + return out; +} + +interface Rig { + /** 13-tile hands ('*' = inert filler). The turn seat additionally gets `drawn`. */ + hands: string[]; + turn?: number; + drawn?: string; + afterCall?: boolean; + /** Next tiles to be drawn, in order. */ + wall?: string; + wallLength?: number; + /** Dora indicators: the first is revealed, the rest come with kans. */ + dora?: string; + ura?: string; + rinshan?: string; + melds?: { seat: number; type: Meld['type']; tiles: string; from?: number }[]; + discards?: { seat: number; tiles: string }[]; + riichi?: number[]; + /** Still the first go-around (더블리치·천화·지화·구종구패·사풍연타). Default: not. */ + first?: boolean; +} + +function rig(s: MahjongState, r: Rig): MahjongState { + const sanma = s.mode === 'sanma'; + const n = s.players.length; + const used = new Set(); + const hands: TileId[][] = r.hands.map((h) => (h === '*' ? [] : take(h, used, sanma))); + const melds: Meld[][] = Array.from({ length: n }, () => []); + for (const m of r.melds ?? []) { + const tiles = take(m.tiles, used, sanma); + const from = m.type === 'ankan' ? null : (m.from ?? (m.seat + n - 1) % n); + melds[m.seat]!.push({ type: m.type, tiles, called: from === null ? null : tiles[0]!, from }); + } + const discards: HandState['discards'] = Array.from({ length: n }, () => []); + for (const d of r.discards ?? []) for (const t of take(d.tiles, used, sanma)) discards[d.seat]!.push({ tile: t, tsumogiri: false, riichi: false, calledBy: null }); + const turn = r.turn ?? 0; + const drawn = r.drawn ? take(r.drawn, used, sanma)[0]! : null; + const doraInd = take(r.dora ?? '9m', used, sanma); + const ura = take(r.ura ?? '', used, sanma); + const rinshan = take(r.rinshan ?? '', used, sanma); + const front = take(r.wall ?? '', used, sanma); + r.hands.forEach((h, i) => { + if (h === '*') hands[i] = filler(used, sanma); + }); + const rest = allTiles(sanma).filter((t) => !used.has(t)); + const pull = (k: number) => rest.splice(0, k); + rinshan.push(...pull(4 - rinshan.length)); + doraInd.push(...pull(5 - doraInd.length)); + ura.push(...pull(5 - ura.length)); + let wall = [...front, ...rest]; + if (r.wallLength !== undefined) wall = wall.slice(0, r.wallLength); + const empty = (f: () => T) => Array.from({ length: n }, f); + const kanBySeat = melds.map((ms) => ms.filter((m) => m.type === 'ankan' || m.type === 'daiminkan' || m.type === 'kakan').length); + const h: HandState = { + wall, + rinshan, + doraInd, + uraInd: ura, + doraRevealed: 1, + pendingKanDora: 0, + hands, + drawn: empty(() => null), + melds, + kita: empty(() => []), + discards, + riichi: empty(() => null), + sidewaysNext: empty(() => false), + tempFuriten: empty(() => false), + riichiFuriten: empty(() => false), + waits: empty(() => []), + turn, + stage: r.afterCall ? 'afterCall' : 'afterDraw', + interrupted: !r.first || (r.melds ?? []).length > 0, + kanCount: kanBySeat.reduce((a, b) => a + b, 0), + kanBySeat, + lastDrawRinshan: false, + suukanPending: false, + kuikae: [], + pao: empty(() => null), + claim: null, + turnStartedAt: clock, + }; + // Waits are those of the 13-tile hands (the turn player's before the draw). + for (let i = 0; i < n; i++) h.waits[i] = __test.computeWaits(h, i); + if (drawn !== null) hands[turn]!.push(drawn); + h.drawn[turn] = drawn; + for (const seat of r.riichi ?? []) h.riichi[seat] = { discardIndex: 0, double: false, ippatsu: false }; + s.hand = h; + s.phase = 'playing'; + return s; +} + +const code = (s: MahjongState, id: TileId) => tileCode(id, s.red); +/** Code of a tile of `kind` ("5m") in a seat's hand. */ +function codeIn(s: MahjongState, seat: number, kc: string): string { + const k = kindFromCode(kc)!; + const t = s.hand!.hands[seat]!.find((x) => kindOf(x) === k); + if (t === undefined) throw new Error(`seat ${seat} has no ${kc}`); + return code(s, t); +} + +function act(s: MahjongState, seat: number, a: MahjongAction, dt = 1000): MahjongState { + const v = mahjong.validate(s, s.players[seat]!, a); + if (!v.ok) throw new Error(`rejected for seat ${seat}: ${v.reason} ${JSON.stringify(a)}`); + clock += dt; + return mahjong.apply(s, s.players[seat]!, a, { rng: SeededRng.fromSeed([1, 2, 3, 4]), now: clock }).state; +} +function reject(s: MahjongState, seat: number, a: MahjongAction): string { + const v = mahjong.validate(s, s.players[seat]!, a); + if (v.ok) throw new Error(`expected rejection: ${JSON.stringify(a)}`); + return v.reason; +} +const discard = (s: MahjongState, seat: number, kc: string, riichi = false) => act(s, seat, { type: 'discard', tile: codeIn(s, seat, kc), ...(riichi ? { riichi } : {}) }); +/** Closes the open claim window: undecided seats pass, then the discarder's timer runs out the minimum delay. */ +function settle(s: MahjongState): MahjongState { + while (s.phase === 'playing' && s.hand?.stage === 'claim') { + const c = s.hand.claim!; + if (!c.decided) { + const seat = c.eligible.findIndex((e, i) => e && !c.responses[i]); + s = act(s, seat, { type: 'pass' }); + } else s = act(s, c.from, { type: 'timeout' }); + } + return s; +} +/** Turn player discards the tile just drawn, then the window closes. */ +function tsumogiri(s: MahjongState): MahjongState { + const t = s.hand!.turn; + return settle(act(s, t, { type: 'discard', tile: code(s, s.hand!.drawn[t]!) })); +} +const view = (s: MahjongState, seat: number | null) => mahjong.view(s, seat === null ? null : s.players[seat]!); +const claimOf = (s: MahjongState, seat: number) => view(s, seat).my!.claim; +const yakuNames = (s: MahjongState, i = 0) => s.lastHandResult!.winners[i]!.yaku.map((y) => y.name); +function readyAll(s: MahjongState): MahjongState { + for (let i = 0; i < s.players.length; i++) if (s.phase === 'handResult' && !s.readyForNext.includes(i)) s = act(s, i, { type: 'ready' }); + return s; +} + +/** Tenpai hands with yaku (탕야오·핑후 on every wait). */ +const T1 = '234m567p678s22p34s'; // waits 2s/5s +const T2 = '34m234p567p234s66s'; // waits 2m/5m +const T3 = '67m345p678p345s88s'; // waits 5m/8m +/** 34m-kanchan riichi shape: waits 4s only. */ +const R = '234m567p678s22p3s5s'; +/** Seat 1 can chi 5m (46m), seat 2 can pon 5m (55m); nobody is tenpai. */ +const CHI46 = '46m99p99s1155z367z'; +const PON55 = '55m88p89s2244z367z'; +const DEALER5M = '123p789p123s789s5m'; + +// ---------------------------------------------------------------- setup + +describe('셋업 §4', () => { + test('같은 시드면 같은 좌석·패산·배패; 4인 산패 70장(친 쯔모 후 69), 왕패 14장, 13장씩 + 친 14장', () => { + const a = mahjong.setup({ players: P4, options: mahjong.defaultOptions, rng: SeededRng.fromSeed(seedN(9)), now: 0 }); + const b = mahjong.setup({ players: P4, options: mahjong.defaultOptions, rng: SeededRng.fromSeed(seedN(9)), now: 0 }); + expect(JSON.stringify(a)).toBe(JSON.stringify(b)); + const h = a.hand!; + expect(h.wall.length + 1).toBe(70); + expect(h.rinshan.length + h.doraInd.length + h.uraInd.length).toBe(14); + expect(h.hands.map((x) => x.length)).toEqual([14, 13, 13, 13]); + expect(h.stage).toBe('afterDraw'); + expect(h.turn).toBe(0); + expect(a.points).toEqual([25000, 25000, 25000, 25000]); + expect([...a.players].sort()).toEqual(P4); + const c = mahjong.setup({ players: P4, options: mahjong.defaultOptions, rng: SeededRng.fromSeed(seedN(10)), now: 0 }); + expect(JSON.stringify(c.hand!.hands)).not.toBe(JSON.stringify(h.hands)); + expect(new Set([...h.wall, ...h.rinshan, ...h.doraInd, ...h.uraInd, ...h.hands.flat()]).size).toBe(136); + }); + test('산마: 108장, 2m~8m 없음, 산패 55장, 35000점 시작, 적도라 5p·5s, 우마 15', () => { + const s = mahjong.setup({ players: P3, options: mahjong.defaultOptions, rng: SeededRng.fromSeed(seedN(3)), now: 0 }); + const h = s.hand!; + const all = [...h.wall, ...h.rinshan, ...h.doraInd, ...h.uraInd, ...h.hands.flat()]; + expect(all.length).toBe(108); + expect(new Set(all).size).toBe(108); + expect(all.some((t) => kindOf(t) >= 1 && kindOf(t) <= 7)).toBe(false); + expect(h.wall.length + 1).toBe(55); + expect(s.points).toEqual([35000, 35000, 35000]); + expect(s.red).toEqual([52, 88]); + expect(s.options.uma).toEqual([15, 0, -15]); + expect(s.options.returnPoints).toBe(40000); + }); + test('산마 도라: 1m 표시 → 9m가 도라 (999m 각쯔 = 도라 3)', () => { + let s = rig(make(3), { hands: ['999m234p567p789s5p', '*', '*'], drawn: '5p', dora: '1m' }); + expect(view(s, 0).my!.canTsumo).toBe(true); + s = act(s, 0, { type: 'tsumo' }); + expect(yakuNames(s)).toContain('도라 3'); + }); + test('인원 3~4명, 옵션 메타는 모두 한국어 제목', () => { + expect(mahjong.playersFor!(mahjong.defaultOptions)).toEqual({ min: 3, max: 4 }); + const shape = (mahjong.optionsSchema as unknown as { shape: Record }).shape; + for (const [k, v] of Object.entries(shape)) expect(v.meta()?.title, k).toMatch(/[가-힣]/); + expect(() => mahjong.setup({ players: ['A', 'B'], options: mahjong.defaultOptions, rng: SeededRng.fromSeed(seedN(1)), now: 0 })).toThrow(); + }); +}); + +// ---------------------------------------------------------------- claim window + +/** 4인: 0(친)이 5m을 버린다. 1은 치(46m), 2는 퐁(55m), 3은 주어진 손. */ +const priorityRig = (seat3: string) => rig(make(4), { hands: [DEALER5M, CHI46, PON55, seat3], drawn: '1z' }); + +describe('응답 창 §5.3', () => { + test('치와 퐁이 동시에 → 퐁이 이긴다 (치 선언이 먼저 와도)', () => { + let s = priorityRig('*'); + s = discard(s, 0, '5m'); + expect(s.hand!.stage).toBe('claim'); + s = act(s, 1, { type: 'claim', call: 'chi', tiles: claimOf(s, 1)!.chi[0]! }); + expect(s.hand!.stage).toBe('claim'); + s = act(s, 2, { type: 'claim', call: 'pon', tiles: claimOf(s, 2)!.pon[0]! }); + expect(s.hand!.stage).toBe('afterCall'); + expect(s.hand!.turn).toBe(2); + expect(s.hand!.melds[2]![0]!.type).toBe('pon'); + expect(s.hand!.melds[1]).toEqual([]); + expect(s.hand!.discards[0]!.at(-1)!.calledBy).toBe(2); + expect(s.stats[2]!.calls).toBe(1); + }); + test('퐁 선언 뒤 론 가능자(3)가 미응답이면 확정하지 않고 기다린다 → 3 패스 후 퐁 + 동순 후리텐', () => { + let s = priorityRig(T2); + s = discard(s, 0, '5m'); + expect(claimOf(s, 3)!.ron).toBe(true); + s = act(s, 2, { type: 'claim', call: 'pon', tiles: claimOf(s, 2)!.pon[0]! }); + expect(s.hand!.stage).toBe('claim'); + s = act(s, 1, { type: 'pass' }); + expect(s.hand!.stage).toBe('claim'); + s = act(s, 3, { type: 'pass' }); + expect(s.hand!.stage).toBe('afterCall'); + expect(s.hand!.turn).toBe(2); + expect(s.hand!.tempFuriten[3]).toBe(true); + }); + test('론 선언이 있으면 다른 응답과 상관없이 론이 이긴다', () => { + let s = priorityRig(T2); + s = discard(s, 0, '5m'); + s = act(s, 2, { type: 'claim', call: 'pon', tiles: claimOf(s, 2)!.pon[0]! }); + s = act(s, 3, { type: 'claim', call: 'ron' }); + expect(s.phase).toBe('handResult'); + expect(s.lastHandResult!.winners.map((w) => w.seat)).toEqual([3]); + expect(s.hand!.melds[2]).toEqual([]); + }); + test('조기 확정: 퐁 선언 뒤 남은 미응답자가 치만 가능 → 바로 확정', () => { + let s = priorityRig('*'); + s = discard(s, 0, '5m'); + s = act(s, 2, { type: 'claim', call: 'pon', tiles: claimOf(s, 2)!.pon[0]! }); + expect(s.hand!.stage).toBe('afterCall'); + expect(s.hand!.turn).toBe(2); + // 1 never answered: closed early, so it is not a time-out. + expect(s.timeoutsInRow[1]).toBe(0); + }); + test('최소 지연: 응답이 400ms 안에 끝나도 그때까지 기다렸다가 확정, 응답 가능자가 없어도 같은 400ms', () => { + let s = priorityRig('*'); + s = discard(s, 0, '5m'); + const opened = s.hand!.claim!.openedAt; + s = act(s, 2, { type: 'claim', call: 'pon', tiles: claimOf(s, 2)!.pon[0]! }, 100); + expect(s.hand!.stage).toBe('claim'); + expect(s.hand!.claim!.decided).toBe(true); + expect(mahjong.deadline!(s)).toBe(opened + 400); + expect(mahjong.timeoutPlayers!(s)).toEqual(['A']); + s = settle(s); + expect(s.hand!.stage).toBe('afterCall'); + // Nobody can respond: still a 400ms window. + let t = rig(make(4), { hands: ['123p789p123s789s1z', CHI46, PON55, '*'], drawn: '9m' }); + t = discard(t, 0, '9m'); + expect(t.hand!.stage).toBe('claim'); + expect(t.hand!.claim!.eligible.every((e) => e === null)).toBe(true); + expect(mahjong.deadline!(t)).toBe(t.hand!.claim!.openedAt + 400); + // Everyone else is "waiting" publicly, so the window never shows who could respond. + expect(mahjong.activePlayers(t)).toEqual(['B', 'C', 'D']); + t = settle(t); + expect(t.hand!.turn).toBe(1); + expect(t.hand!.stage).toBe('afterDraw'); + }); + test('하저패(마지막 타패)에는 퐁·치·깡이 없고 론만, 해저 쯔모 뒤에는 깡 불가', () => { + let s = rig(make(4), { hands: ['123p789p1111s789s', CHI46, PON55, T2], drawn: '5m', wallLength: 0 }); + expect(reject(s, 0, { type: 'ankan', tile: codeIn(s, 0, '1s') })).toBe('마지막 패로는 깡할 수 없어요.'); + expect(view(s, 0).my!.ankan).toEqual([]); + s = discard(s, 0, '5m'); + expect(claimOf(s, 1)).toBeNull(); + expect(claimOf(s, 2)).toBeNull(); + expect(claimOf(s, 3)).toMatchObject({ ron: true, pon: [], chi: [], kan: [] }); + s = act(s, 3, { type: 'claim', call: 'ron' }); + expect(yakuNames(s, 0)).toContain('하저로어'); + }); + test('산마에는 치 선택지가 없다', () => { + let s = rig(make(3), { hands: ['123p789p123s789s1m', '46p99m112233z567z', '*'], drawn: '5p' }); + s = discard(s, 0, '5p'); + expect(s.hand!.claim!.eligible[1]).toBeNull(); + expect(claimOf(s, 1)).toBeNull(); + }); + test('치 조합이 여럿이면 선택지로 모두 나오고, 고른 조합으로 운다 (적5 포함 여부도 구별)', () => { + let s = rig(make(4), { hands: ['*', '23m056m11p99p1155z', '*', '*'], drawn: '4m' }); + s = discard(s, 0, '4m'); + const chi = claimOf(s, 1)!.chi; + // 23m, 35m(적5 / 일반 5), 56m(적5 / 일반 5) + expect(chi.length).toBe(5); + const red = chi.find((c) => c.some((x) => x.endsWith('r')) && c.some((x) => x.startsWith('6m')))!; + s = act(s, 1, { type: 'claim', call: 'chi', tiles: red }); + expect(s.hand!.melds[1]![0]!.tiles.map((t) => code(s, t)).sort()).toEqual([...red, code(s, s.hand!.melds[1]![0]!.called!)].sort()); + expect(s.hand!.kuikae).toEqual([kindFromCode('4m')!, kindFromCode('7m')!]); + }); +}); + +describe('더블론·삼가화', () => { + const prep = (opts: Partial = {}) => { + const s = rig(make(4, opts), { hands: [DEALER5M, T2, T3, '*'], drawn: '1z' }); + s.riichiPool = 2000; + s.honba = 1; + return discard(s, 0, '5m'); + }; + test('두 사람 론 → 둘 다 정산, 공탁·본장은 방총자 다음 순으로 가까운 사람(1)', () => { + let s = prep(); + s = act(s, 2, { type: 'claim', call: 'ron' }); + expect(s.phase).toBe('playing'); + s = act(s, 1, { type: 'claim', call: 'ron' }); + expect(s.phase).toBe('handResult'); + const r = s.lastHandResult!; + expect(r.winners.map((w) => w.seat)).toEqual([1, 2]); + expect(r.winners.map((w) => w.points)).toEqual([2000, 2000]); + expect(r.deltas).toEqual([-4300, 4300, 2000, 0]); + expect(r.poolTaken).toBe(2000); + expect(s.riichiPool).toBe(0); + expect(s.stats[0]!.dealIns).toBe(2); + }); + test('headBump: 가까운 1명만', () => { + let s = prep({ doubleRon: 'headBump' }); + s = act(s, 1, { type: 'claim', call: 'ron' }); + s = act(s, 2, { type: 'claim', call: 'ron' }); + expect(s.lastHandResult!.winners.map((w) => w.seat)).toEqual([1]); + expect(s.lastHandResult!.deltas).toEqual([-2300, 4300, 0, 0]); + }); + test('3명 론 + abort → 삼가화 유국 (친 연장, 본장 +1, 점수 이동 없음); allow면 3명 모두 화료', () => { + const go = (opts: Partial) => { + let s = rig(make(4, opts), { hands: [DEALER5M, T2, T3, '5m999p999s111s666z'], drawn: '1z' }); + s.riichiPool = 1000; + s = discard(s, 0, '5m'); + for (const seat of [1, 2, 3]) s = act(s, seat, { type: 'claim', call: 'ron' }); + return s; + }; + const s = go({}); + expect(s.lastHandResult!.kind).toBe('abortive'); + expect(s.lastHandResult!.abortiveReason).toBe('sanchaho'); + expect(s.lastHandResult!.deltas).toEqual([0, 0, 0, 0]); + expect(s.nextHand).toEqual({ roundWind: 0, handNumber: 0, honba: 1 }); + expect(s.riichiPool).toBe(1000); + const t = go({ tripleRon: 'allow' }); + expect(t.lastHandResult!.winners.map((w) => w.seat)).toEqual([1, 2, 3]); + }); +}); + +// ---------------------------------------------------------------- furiten + +describe('후리텐 §5.5', () => { + test('버림패 후리텐: 대기패를 버린 적이 있으면 론 선택지가 없고, 쯔모는 된다', () => { + let s = rig(make(4), { hands: [DEALER5M, '*', T2, '*'], drawn: '1z', discards: [{ seat: 2, tiles: '2m' }], wall: '9m5m' }); + expect(view(s, 2).my!.furiten).toBe(true); + s = discard(s, 0, '5m'); + expect(claimOf(s, 2)).toBeNull(); + s = settle(s); + s = tsumogiri(s); // 1 draws 9m and discards it + expect(s.hand!.turn).toBe(2); + expect(view(s, 2).my!.canTsumo).toBe(true); + s = act(s, 2, { type: 'tsumo' }); + expect(s.lastHandResult!.winners[0]!.seat).toBe(2); + }); + test('동순 후리텐: 론 패스 → 같은 순 다른 사람 타패로 론 불가 → 내 타패 후 해제', () => { + let s = rig(make(4), { hands: ['*', '*', T2, '*'], turn: 3, drawn: '5m', wall: '2m9p9s5m' }); + s = discard(s, 3, '5m'); + expect(claimOf(s, 2)!.ron).toBe(true); + s = act(s, 2, { type: 'pass' }); + s = settle(s); + expect(s.hand!.tempFuriten[2]).toBe(true); + expect(view(s, 2).my!.furiten).toBe(true); + expect(s.hand!.turn).toBe(0); + s = discard(s, 0, '2m'); + expect(claimOf(s, 2)).toBeNull(); + s = settle(s); + s = tsumogiri(s); // 1: 9p + expect(s.hand!.turn).toBe(2); + s = discard(s, 2, '9s'); + expect(s.hand!.tempFuriten[2]).toBe(false); + s = settle(s); + s = discard(s, 3, '5m'); + expect(claimOf(s, 2)!.ron).toBe(true); + }); + test('리치 후 후리텐: 화료패를 놓치면 그 국 끝까지 론 불가', () => { + let s = rig(make(4), { hands: ['*', '*', T2, '*'], turn: 3, drawn: '5m', wall: '9m9p9s5m', riichi: [2] }); + s = discard(s, 3, '5m'); + s = act(s, 2, { type: 'pass' }); + s = settle(s); + expect(s.hand!.riichiFuriten[2]).toBe(true); + s = tsumogiri(s); // 0: 9m + s = tsumogiri(s); // 1: 9p + // 2 is in riichi and cannot use 9s: it is discarded automatically. + expect(s.hand!.discards[2]!.length).toBe(1); + s = settle(s); + expect(s.hand!.turn).toBe(3); + s = discard(s, 3, '5m'); + expect(claimOf(s, 2)).toBeNull(); + expect(view(s, 2).my!.furiten).toBe(true); + expect(reject(s, 2, { type: 'claim', call: 'ron' })).toBe('이 패에는 응답할 수 없어요.'); + }); +}); + +// ---------------------------------------------------------------- yaku required + +describe('역 없는 화료 §5.6', () => { + test('울기 후 역 없는 손에서 쯔모 → "역이 없어 화료할 수 없어요."', () => { + const s = rig(make(4), { hands: ['567p678s55s44p', '*', '*', '*'], melds: [{ seat: 0, type: 'chi', tiles: '123m', from: 3 }], drawn: '4p' }); + expect(view(s, 0).my!.canTsumo).toBe(false); + expect(reject(s, 0, { type: 'tsumo' })).toBe('역이 없어 화료할 수 없어요.'); + }); + test('가타아가리: 역이 없는 대기패(1p)로는 론 불가 (역이 없어 못한 것도 동순 후리텐), 역이 생기는 4p로는 론', () => { + let s = rig(make(4), { hands: ['*', '567p678s55s23p', '*', '*'], melds: [{ seat: 1, type: 'chi', tiles: '234m', from: 0 }], drawn: '9m' }); + s = discard(s, 0, '1p'); + const c = claimOf(s, 1)!; + expect(c.ron).toBe(false); + expect(reject(s, 1, { type: 'claim', call: 'ron' })).toBe('역이 없어 화료할 수 없어요.'); + s = act(s, 1, { type: 'pass' }); + s = settle(s); + expect(s.hand!.tempFuriten[1]).toBe(true); + const t = rig(make(4), { hands: ['*', '567p678s55s23p', '*', '*'], melds: [{ seat: 1, type: 'chi', tiles: '234m', from: 0 }], drawn: '4p' }); + const u = discard(t, 0, '4p'); + expect(claimOf(u, 1)!.ron).toBe(true); + }); +}); + +// ---------------------------------------------------------------- riichi + +describe('리치 §5.4', () => { + test('999점이면 리치 불가', () => { + const s = rig(make(4), { hands: [R, '*', '*', '*'], drawn: '1z' }); + s.points[0] = 999; + expect(reject(s, 0, { type: 'discard', tile: codeIn(s, 0, '1z'), riichi: true })).toBe('점수가 1000점 미만이라 리치할 수 없어요.'); + expect(view(s, 0).my!.riichiDiscards).toEqual([]); + }); + test('남은 패 3장(4인)이면 리치 불가, 4장이면 가능 (산마는 3장이면 가능)', () => { + const s = rig(make(4), { hands: [R, '*', '*', '*'], drawn: '1z', wallLength: 3 }); + expect(reject(s, 0, { type: 'discard', tile: codeIn(s, 0, '1z'), riichi: true })).toBe('남은 패가 부족해 리치할 수 없어요.'); + const t = rig(make(4), { hands: [R, '*', '*', '*'], drawn: '1z', wallLength: 4 }); + expect(view(t, 0).my!.riichiDiscards).toEqual([codeIn(t, 0, '1z')]); + const u = rig(make(3), { hands: ['234p567p678s22p3s5s', '*', '*'], drawn: '1z', wallLength: 3 }); + expect(view(u, 0).my!.riichiDiscards).toEqual([codeIn(u, 0, '1z')]); + }); + test('텐파이가 아니게 되는 타패로는 리치 불가, 울은 손은 리치 불가', () => { + const s = rig(make(4), { hands: [R, '*', '*', '*'], drawn: '1z' }); + expect(reject(s, 0, { type: 'discard', tile: codeIn(s, 0, '2p'), riichi: true })).toBe('그 패를 버리면 텐파이가 아니라 리치할 수 없어요.'); + const t = rig(make(4), { hands: ['567p678s22p3s5s', '*', '*', '*'], melds: [{ seat: 0, type: 'chi', tiles: '234m' }], drawn: '1z' }); + expect(reject(t, 0, { type: 'discard', tile: codeIn(t, 0, '1z'), riichi: true })).toBe('울은 손으로는 리치할 수 없어요.'); + }); + test('리치 선언 패가 론 당하면 공탁 1000이 들어가지 않는다', () => { + let s = rig(make(4), { hands: [R, '*', T2, '*'], drawn: '5m', first: true }); + s = discard(s, 0, '5m', true); + expect(s.hand!.claim!.riichiPending).toEqual({ double: true }); + s = act(s, 2, { type: 'claim', call: 'ron' }); + expect(s.riichiPool).toBe(0); + expect(s.lastHandResult!.deltas).toEqual([-2000, 0, 2000, 0]); + expect(s.stats[0]!.riichi).toBe(0); + }); + test('통과하면 1000 공탁 + 더블리치, 다음 쯔모로 화료하면 일발', () => { + let s = rig(make(4), { hands: [R, '*', '*', '*'], drawn: '1p', wall: '9m9m9m4s', first: true, dora: '1m' }); + s = discard(s, 0, '1p', true); + expect(s.points[0]).toBe(25000); + s = settle(s); + expect(s.points[0]).toBe(24000); + expect(s.riichiPool).toBe(1000); + expect(s.hand!.riichi[0]).toMatchObject({ double: true, ippatsu: true }); + expect(view(s, 1).seats[0]!.riichi).toBe(true); + expect(view(s, 1).seats[0]!.discards[0]!.riichi).toBe(true); + for (let i = 0; i < 3; i++) s = tsumogiri(s); + expect(view(s, 0).my!.canTsumo).toBe(true); + s = act(s, 0, { type: 'tsumo' }); + expect(yakuNames(s)).toEqual(expect.arrayContaining(['더블리치', '일발', '멘젠쯔모'])); + expect(s.lastHandResult!.poolTaken).toBe(1000); + }); + test('리치 후에는 쯔모한 패만 버릴 수 있다', () => { + const s = rig(make(4), { hands: [R, '*', '*', '*'], drawn: '4s', riichi: [0] }); + // 4s completes the hand, so the riichi player is not auto-discarded. + expect(reject(s, 0, { type: 'discard', tile: codeIn(s, 0, '2p') })).toBe('리치 후에는 쯔모한 패만 버릴 수 있어요.'); + expect(view(s, 0).my!.discardable).toEqual([codeIn(s, 0, '4s')]); + }); + test('리치 후 안깡: 대기가 바뀌는 안깡(3334m에 3m)은 거부, 바뀌지 않는 안깡은 허용', () => { + const s = rig(make(4), { hands: ['3334m567p678s234p', '*', '*', '*'], drawn: '3m', riichi: [0] }); + expect(view(s, 0).my!.ankan).toEqual([]); + expect(reject(s, 0, { type: 'ankan', tile: codeIn(s, 0, '3m') })).toBe('리치 후에는 대기가 바뀌는 깡을 할 수 없어요.'); + const t = rig(make(4), { hands: ['111p34m567p678s22s', '*', '*', '*'], drawn: '1p', riichi: [0] }); + expect(view(t, 0).my!.ankan).toEqual([codeIn(t, 0, '1p')]); + let u = act(t, 0, { type: 'ankan', tile: codeIn(t, 0, '1p') }); + expect(u.hand!.melds[0]![0]!.type).toBe('ankan'); + expect(u.hand!.doraRevealed).toBe(2); + u = settle(u); + // Rinshan draw; in riichi with nothing else to do it is discarded automatically. + expect(u.hand!.discards[0]!.length).toBe(1); + expect(u.hand!.discards[0]![0]!.tsumogiri).toBe(true); + }); +}); + +// ---------------------------------------------------------------- kuikae + +describe('구이가에 §5.2', () => { + test('34m으로 2m 치 → 5m·2m 타패 거부, 다른 패는 가능', () => { + let s = rig(make(4), { hands: ['*', '34m25m99p567p234s7z', '*', '*'], drawn: '2m' }); + s = discard(s, 0, '2m'); + const chi = claimOf(s, 1)!.chi; + expect(chi.length).toBe(1); + s = act(s, 1, { type: 'claim', call: 'chi', tiles: chi[0]! }); + expect(s.hand!.stage).toBe('afterCall'); + expect(reject(s, 1, { type: 'discard', tile: codeIn(s, 1, '5m') })).toBe('방금 울은 패와 같은 패(또는 스지)는 버릴 수 없어요.'); + expect(reject(s, 1, { type: 'discard', tile: codeIn(s, 1, '2m') })).toBe('방금 울은 패와 같은 패(또는 스지)는 버릴 수 없어요.'); + expect(view(s, 1).my!.locked.length).toBe(2); + expect(view(s, 1).my!.canTsumo).toBe(false); + s = discard(s, 1, '9p'); + expect(s.hand!.stage).toBe('claim'); + }); + test('울고 나서 버릴 패가 구이가에 패뿐이면 그 치는 선택지에 없다', () => { + let s = rig(make(4), { + hands: ['*', '34m25m', '*', '*'], + melds: [ + { seat: 1, type: 'pon', tiles: '999p' }, + { seat: 1, type: 'pon', tiles: '111p' }, + { seat: 1, type: 'pon', tiles: '999s' }, + ], + drawn: '2m', + }); + s = discard(s, 0, '2m'); + expect(s.hand!.claim!.eligible[1]).toBeNull(); + }); +}); + +// ---------------------------------------------------------------- kan + +describe('깡 §5.2', () => { + test('대명깡 → 영상개화 쯔모: 새 도라 미공개, 영상개화 + 역패', () => { + let s = rig(make(4), { hands: ['*', '555z234m567p78s99p', '*', '*'], drawn: '5z', rinshan: '6s' }); + s = discard(s, 0, '5z'); + s = act(s, 1, { type: 'claim', call: 'kan', tiles: claimOf(s, 1)!.kan[0]! }); + expect(s.hand!.turn).toBe(1); + expect(s.hand!.lastDrawRinshan).toBe(true); + expect(s.hand!.pendingKanDora).toBe(1); + expect(s.hand!.kanCount).toBe(1); + s = act(s, 1, { type: 'tsumo' }); + expect(yakuNames(s)).toEqual(expect.arrayContaining(['영상개화', '역패: 백'])); + expect(s.lastHandResult!.doraIndicators.length).toBe(1); + }); + test('대명깡 후 타패 → 그 시점에 도라 공개, 그 타패로 론하면 새 도라 포함', () => { + let s = rig(make(4), { hands: ['*', '555z1199p1199s5m3z', T2, '*'], drawn: '5z', rinshan: '7z', dora: '9m2p' }); + s = discard(s, 0, '5z'); + s = act(s, 1, { type: 'claim', call: 'kan', tiles: claimOf(s, 1)!.kan[0]! }); + expect(view(s, 2).doraIndicators.length).toBe(1); + s = discard(s, 1, '5m'); + expect(s.hand!.doraRevealed).toBe(2); + expect(view(s, 2).doraIndicators.length).toBe(2); + s = act(s, 2, { type: 'claim', call: 'ron' }); + expect(yakuNames(s)).toContain('도라 1'); + expect(s.lastHandResult!.doraIndicators.length).toBe(2); + }); + test('가깡 → 창깡 론 가능, 창깡 1판 (가깡 도라는 공개되지 않는다)', () => { + let s = rig(make(4), { hands: ['1199p1199s37z', T2, '*', '*'], melds: [{ seat: 0, type: 'pon', tiles: '555m', from: 2 }], drawn: '5m' }); + expect(view(s, 0).my!.kakan).toEqual([codeIn(s, 0, '5m')]); + s = act(s, 0, { type: 'kakan', tile: codeIn(s, 0, '5m') }); + expect(s.hand!.stage).toBe('claim'); + expect(claimOf(s, 1)).toMatchObject({ ron: true, pon: [], chi: [], kan: [], source: 'kakan' }); + s = act(s, 1, { type: 'claim', call: 'ron' }); + expect(yakuNames(s)).toContain('창깡'); + expect(s.lastHandResult!.doraIndicators.length).toBe(1); + }); + test('가깡이 통과하면 영상패를 쯔모하고, 새 도라는 다음 타패 때 공개', () => { + let s = rig(make(4), { hands: ['1199p1199s37z', '*', '*', '*'], melds: [{ seat: 0, type: 'pon', tiles: '555m', from: 2 }], drawn: '5m' }); + s = settle(act(s, 0, { type: 'kakan', tile: codeIn(s, 0, '5m') })); + expect(s.hand!.melds[0]![0]!.type).toBe('kakan'); + expect(s.hand!.lastDrawRinshan).toBe(true); + expect(s.hand!.doraRevealed).toBe(1); + s = discard(s, 0, '3z'); + expect(s.hand!.doraRevealed).toBe(2); + }); + test('2명 합계 4깡 뒤 타패가 통과하면 사깡유국', () => { + let s = rig(make(4), { + hands: ['1111p234567z', '345m678m9s123s', '*', '*'], + melds: [ + { seat: 0, type: 'ankan', tiles: '1111z' }, + { seat: 0, type: 'ankan', tiles: '9999m' }, + { seat: 1, type: 'daiminkan', tiles: '9999p', from: 0 }, + ], + drawn: '5s', + dora: '1m', + }); + s = act(s, 0, { type: 'ankan', tile: codeIn(s, 0, '1p') }); + s = settle(s); + expect(s.hand!.kanCount).toBe(4); + expect(s.hand!.suukanPending).toBe(true); + expect(view(s, 0).my!.ankan).toEqual([]); + s = discard(s, 0, '5s'); + s = settle(s); + expect(s.lastHandResult!.abortiveReason).toBe('suukan'); + expect(s.nextHand).toEqual({ roundWind: 0, handNumber: 0, honba: 1 }); + }); + test('한 사람이 4깡이면 유국 없이 계속, 더는 아무도 깡 불가', () => { + let s = rig(make(4), { + hands: ['9999p', '*', '*', '*'], + melds: [ + { seat: 0, type: 'ankan', tiles: '5555z' }, + { seat: 0, type: 'ankan', tiles: '6666z' }, + { seat: 0, type: 'ankan', tiles: '7777z' }, + ], + drawn: '9s', + }); + s = act(s, 0, { type: 'ankan', tile: codeIn(s, 0, '9p') }); + s = settle(s); + expect(s.hand!.kanCount).toBe(4); + expect(s.hand!.suukanPending).toBe(false); + s = discard(s, 0, '9s'); + s = settle(s); + expect(s.phase).toBe('playing'); + expect(s.hand!.turn).toBe(1); + }); +}); + +// ---------------------------------------------------------------- draws + +describe('유국 §5.8', () => { + /** The last tile: seat 0 discards a 5s (not a terminal, so nobody gets 유국만관 from it). */ + function exhaust(hands: string[], opts: Partial = {}, n = 4) { + let s = rig(make(n, opts), { hands, drawn: '5s', wallLength: 0 }); + s = discard(s, 0, '5s'); + return settle(s); + } + test('텐파이 1명 → 노텐 3명이 1000씩; 친 노텐이면 친 이동, 본장 +1; 텐파이 손만 공개', () => { + const s = exhaust(['*', T2, '*', '*']); + expect(s.lastHandResult!.kind).toBe('exhaustive'); + expect(s.lastHandResult!.tenpai).toEqual([1]); + expect(s.lastHandResult!.deltas).toEqual([-1000, 3000, -1000, -1000]); + expect(s.nextHand).toEqual({ roundWind: 0, handNumber: 1, honba: 1 }); + expect(s.lastHandResult!.shown.map((x) => x.seat)).toEqual([1]); + }); + test('텐파이 2명 → 1500씩, 3명 → 노텐 1명이 1000씩 3명에게; 친 텐파이면 연장', () => { + const two = exhaust(['*', T2, T3, '*']); + expect(two.lastHandResult!.deltas).toEqual([-1500, 1500, 1500, -1500]); + const three = exhaust([T1, T2, T3, '*']); + expect(three.lastHandResult!.tenpai).toEqual([0, 1, 2]); + expect(three.lastHandResult!.deltas).toEqual([1000, 1000, 1000, -3000]); + expect(three.nextHand).toEqual({ roundWind: 0, handNumber: 0, honba: 1 }); + const none = exhaust(['*', '*', '*', '*']); + expect(none.lastHandResult!.deltas).toEqual([0, 0, 0, 0]); + const agariOnly = exhaust([T1, '*', '*', '*'], { renchanOn: 'agari' }); + expect(agariOnly.nextHand).toEqual({ roundWind: 0, handNumber: 1, honba: 1 }); + }); + test('대기패 4장을 내가 모두 가진 형태는 노텐', () => { + const s = exhaust(['*', '1111m234p567p789s', '*', '*']); + expect(s.lastHandResult!.tenpai).toEqual([]); + expect(s.lastHandResult!.deltas).toEqual([0, 0, 0, 0]); + }); + test('산마 텐파이료 2000: 텐파이 1명 → 노텐 둘이 1000씩, 2명 → 노텐이 1000씩 두 명에게', () => { + const s = exhaust(['*', '345p234p567p234s6s', '*'], {}, 3); + expect(s.lastHandResult!.tenpai).toEqual([1]); + expect(s.lastHandResult!.deltas).toEqual([-1000, 2000, -1000]); + const t = exhaust(['*', '345p234p567p234s6s', '123p456p789p11s99s'], {}, 3); + expect(t.lastHandResult!.tenpai).toEqual([1, 2]); + expect(t.lastHandResult!.deltas).toEqual([-2000, 1000, 1000]); + }); + test('유국만관: 요구패만 버리고 울리지 않음 → 만관 쯔모만큼, 텐파이료 없음', () => { + let s = rig(make(4), { hands: ['*', T2, '*', '*'], drawn: '5s', wallLength: 0, discards: [{ seat: 2, tiles: '19m1p' }] }); + s = discard(s, 0, '5s'); + s = settle(s); + expect(s.lastHandResult!.nagashi).toEqual([2]); + expect(s.lastHandResult!.deltas).toEqual([-4000, -2000, 8000, -2000]); + const off = settle(discard(rig(make(4, { nagashiMangan: false }), { hands: ['*', T2, '*', '*'], drawn: '5s', wallLength: 0, discards: [{ seat: 2, tiles: '19m1p' }] }), 0, '5s')); + expect(off.lastHandResult!.nagashi).toEqual([]); + expect(off.lastHandResult!.deltas).toEqual([-1000, 3000, -1000, -1000]); + }); +}); + +describe('도중유국 §5.8', () => { + test('구종구패: 친 연장, 본장 +1, 공탁 유지', () => { + let s = rig(make(4), { hands: ['19m19p19s123z4567p', '*', '*', '*'], drawn: '7z', first: true }); + s.riichiPool = 1000; + expect(view(s, 0).my!.canKyuushu).toBe(true); + s = act(s, 0, { type: 'kyuushu' }); + expect(s.lastHandResult!.abortiveReason).toBe('kyuushu'); + expect(s.nextHand).toEqual({ roundWind: 0, handNumber: 0, honba: 1 }); + s = readyAll(s); + expect(s.riichiPool).toBe(1000); + expect(s.honba).toBe(1); + expect(s.handNumber).toBe(0); + // 8 kinds only → not allowed. + const t = rig(make(4), { hands: ['19m19p19s12z34567p', '*', '*', '*'], drawn: '8p', first: true }); + expect(view(t, 0).my!.canKyuushu).toBe(false); + }); + test('사풍연타: 4명이 첫 타패를 모두 같은 풍패로 (하나라도 다르면 계속)', () => { + const run = (last: string) => { + let s = rig(make(4), { hands: ['*', '*', '*', '*'], drawn: '9p', wall: `5p5s${last}`, first: true }); + s = settle(discard(s, 0, '1z')); + for (const seat of [1, 2]) { + s = settle(discard(s, seat, '1z')); + } + return settle(discard(s, 3, last === '7m' ? '7m' : '1z')); + }; + expect(run('8p').lastHandResult!.abortiveReason).toBe('suufon'); + expect(run('7m').phase).toBe('playing'); + }); + test('사가리치: 4번째 리치 타패가 통과하면 유국 (공탁 4000 유지)', () => { + let s = rig(make(4), { hands: [R, '*', '*', '*'], drawn: '1z', riichi: [1, 2, 3] }); + s.riichiPool = 3000; + s = discard(s, 0, '1z', true); + s = settle(s); + expect(s.lastHandResult!.abortiveReason).toBe('suucha'); + expect(s.riichiPool).toBe(4000); + }); + test('도중유국을 끄면 구종구패를 못 한다', () => { + const s = rig(make(4, { abortiveDraws: false }), { hands: ['19m19p19s123z4567p', '*', '*', '*'], drawn: '7z', first: true }); + expect(view(s, 0).my!.canKyuushu).toBe(false); + }); +}); + +// ---------------------------------------------------------------- pao + +describe('책임지불 §6.5', () => { + const daisangen = (turn: number, drawn: string) => + rig(make(4), { + hands: ['*', '22m56p', '*', '*'], + melds: [ + { seat: 1, type: 'pon', tiles: '555z', from: 2 }, + { seat: 1, type: 'pon', tiles: '666z', from: 3 }, + { seat: 1, type: 'pon', tiles: '777z', from: 0 }, + ], + turn, + drawn, + }); + test('3번째 삼원 각쯔를 퐁하게 한 사람이 책임자가 된다', () => { + let s = rig(make(4), { + hands: ['*', '77z22m56p9s', '*', '*'], + melds: [ + { seat: 1, type: 'pon', tiles: '555z', from: 2 }, + { seat: 1, type: 'pon', tiles: '666z', from: 3 }, + ], + drawn: '7z', + }); + s = discard(s, 0, '7z'); + s = act(s, 1, { type: 'claim', call: 'pon', tiles: claimOf(s, 1)!.pon[0]! }); + expect(s.hand!.pao[1]).toEqual({ yakuman: 'daisangen', liable: 0 }); + }); + test('대삼원 쯔모 → 책임자(0)가 전액 + 본장', () => { + let s = daisangen(1, '7p'); + s.hand!.pao[1] = { yakuman: 'daisangen', liable: 0 }; + s.honba = 1; + s = act(s, 1, { type: 'tsumo' }); + expect(yakuNames(s)).toEqual(['대삼원']); + expect(s.lastHandResult!.deltas).toEqual([-32300, 32300, 0, 0]); + expect(s.lastHandResult!.winners[0]!.paoSeat).toBe(0); + }); + test('다른 사람(2)이 방총 → 방총자와 책임자가 반씩', () => { + let s = daisangen(2, '7p'); + s.hand!.pao[1] = { yakuman: 'daisangen', liable: 0 }; + s = discard(s, 2, '7p'); + s = act(s, 1, { type: 'claim', call: 'ron' }); + expect(s.lastHandResult!.deltas).toEqual([-16000, 32000, -16000, 0]); + }); + test('책임지불을 끄면 보통대로 낸다', () => { + let s = rig(make(4, { pao: false }), { + hands: ['*', '22m56p', '*', '*'], + melds: [ + { seat: 1, type: 'pon', tiles: '555z', from: 2 }, + { seat: 1, type: 'pon', tiles: '666z', from: 3 }, + { seat: 1, type: 'pon', tiles: '777z', from: 0 }, + ], + turn: 1, + drawn: '7p', + }); + s.hand!.pao[1] = { yakuman: 'daisangen', liable: 0 }; + s = act(s, 1, { type: 'tsumo' }); + expect(s.lastHandResult!.deltas).toEqual([-16000, 32000, -8000, -8000]); + }); +}); + +// ---------------------------------------------------------------- game end + +/** Ends the current hand with `seat`'s menzen pinfu tanyao tsumo (자 700/1300, 친 1300 올). */ +function tsumoHand(s: MahjongState, seat: number): MahjongState { + const hands = ['*', '*', '*', '*'].slice(0, s.players.length); + hands[seat] = T1; + s = rig(s, { hands, turn: seat, drawn: '5s' }); + return act(s, seat, { type: 'tsumo' }); +} + +describe('점수 이동', () => { + test('자 쯔모 700/1300 + 본장 2개(각자 +200)', () => { + let s = make(4); + s.honba = 2; + s = tsumoHand(s, 1); + expect(s.lastHandResult!.deltas).toEqual([-1500, 3300, -900, -900]); + expect(s.nextHand).toEqual({ roundWind: 0, handNumber: 1, honba: 0 }); + }); + test('친 쯔모 1300 올 → 친 연장, 본장 +1', () => { + let s = make(4); + s = tsumoHand(s, 0); + expect(s.lastHandResult!.deltas).toEqual([3900, -1300, -1300, -1300]); + expect(s.nextHand).toEqual({ roundWind: 0, handNumber: 0, honba: 1 }); + }); +}); + +describe('대국 종료 §5.10', () => { + test('남4 자 화료로 종료 → finished, 순위·우마·오카, 결과 요약', () => { + let s = make(4); + s.roundWind = 1; + s.handNumber = 3; + s.points = [40000, 25000, 20000, 15000]; + s = tsumoHand(s, 1); + expect(s.gameOver).toBe('normal'); + expect(mahjong.result(s)).toBeNull(); + s = readyAll(s); + expect(s.phase).toBe('finished'); + const r = mahjong.result(s)!; + expect(r.ranking).toEqual([['A'], ['B'], ['C'], ['D']]); + const total = Object.values(r.scores!).reduce((a, b) => a + b, 0); + expect(Math.abs(total)).toBeLessThan(1e-9); + expect(r.summary).toContain('반장전'); + expect(s.final!.bestHand!.yaku).toEqual(expect.arrayContaining(['핑후', '탕야오', '멘젠쯔모'])); + }); + test('남4 친 1위 화료 + agariYame → 종료; agariYame 끄면 연장', () => { + let s = make(4); + s.roundWind = 1; + s.handNumber = 3; + s.points = [19000, 25000, 25000, 31000]; + s = tsumoHand(s, 3); + expect(s.gameOver).toBe('agariYame'); + let t = make(4, { agariYame: false }); + t.roundWind = 1; + t.handNumber = 3; + t.points = [19000, 25000, 25000, 31000]; + t = tsumoHand(t, 3); + expect(t.gameOver).toBeNull(); + expect(t.nextHand).toEqual({ roundWind: 1, handNumber: 3, honba: 1 }); + }); + test('토비: -100점이면 즉시 종료, 정확히 0점은 계속', () => { + let s = make(4); + s.points = [25000, 25000, 25000, 600]; + s = tsumoHand(s, 1); + expect(s.points[3]).toBe(-100); + expect(s.gameOver).toBe('tobi'); + let t = make(4); + t.points = [25000, 25000, 25000, 700]; + t = tsumoHand(t, 1); + expect(t.points[3]).toBe(0); + expect(t.gameOver).toBeNull(); + let u = make(4, { tobi: false }); + u.points = [25000, 25000, 25000, 600]; + u = tsumoHand(u, 1); + expect(u.gameOver).toBeNull(); + }); + test('남4 후 전원 30000 미만 → 서입, 서1에서 30000 넘으면 바로 종료', () => { + let s = make(4); + s.roundWind = 1; + s.handNumber = 3; + s.points = [27000, 26000, 24000, 23000]; + s = tsumoHand(s, 1); + expect(s.points.every((p) => p < 30000)).toBe(true); + expect(s.nextHand).toEqual({ roundWind: 2, handNumber: 0, honba: 0 }); + s = readyAll(s); + expect(s.roundWind).toBe(2); + expect(view(s, 0).roundWind).toBe(2); + s.points = [29000, 26000, 23000, 22000]; + s = tsumoHand(s, 0); + expect(s.points[0]).toBeGreaterThanOrEqual(30000); + expect(s.gameOver).toBe('westEntryEnd'); + let t = make(4, { westEntry: false }); + t.roundWind = 1; + t.handNumber = 3; + t.points = [27000, 26000, 24000, 23000]; + t = tsumoHand(t, 1); + expect(t.gameOver).toBe('normal'); + }); + test('동풍전은 동4에서 끝난다', () => { + let s = make(4, { length: 'tonpuu' }); + s.handNumber = 3; + s.points = [40000, 25000, 20000, 15000]; + s = tsumoHand(s, 1); + expect(s.gameOver).toBe('normal'); + }); + test('순위 동점은 기가(좌석 0)에 가까운 좌석이 위, 남은 공탁은 1위에게, 우마·오카 합 0', () => { + let t = make(4, { westEntry: false }); + t.riichiPool = 1000; + t.roundWind = 1; + t.handNumber = 3; + t.points = [25000, 25000, 24000, 25000]; + t = rig(t, { hands: ['*', '*', '*', '*'], drawn: '5s', wallLength: 0 }); + t = settle(discard(t, 0, '5s')); + t = readyAll(t); + expect(t.final!.ranking.map((r) => r.seat)).toEqual([0, 1, 3, 2]); + expect(t.final!.ranking[0]!.points).toBe(26000); + expect(t.final!.ranking.reduce((a, r) => a + r.score, 0)).toBeCloseTo(0, 6); + expect(t.riichiPool).toBe(0); + }); + test('§6.7 예: 42000/31000/18000/9000 → +52.0 / +11.0 / -22.0 / -41.0', () => { + let s = make(4); + s.roundWind = 1; + s.handNumber = 3; + s = rig(s, { hands: ['*', '*', '*', '*'], drawn: '5s', wallLength: 0 }); + s.points = [42000, 31000, 18000, 9000]; + s = settle(discard(s, 0, '5s')); + s = readyAll(s); + expect(s.final!.ranking.map((r) => r.score)).toEqual([52, 11, -22, -41]); + expect(s.final!.endReason).toBe('normal'); + }); + test('산마 결과: 우마 15 + 오카 15, 합 0', () => { + let s = make(3); + s.roundWind = 1; + s.handNumber = 2; + s = rig(s, { hands: ['*', '*', '*'], drawn: '5s', wallLength: 0 }); + s.points = [50000, 30000, 25000]; + s = settle(discard(s, 0, '5s')); + s = readyAll(s); + // (50000-40000)/1000 + 15 + 15 = 40, (30000-40000)/1000 + 0 = -10, (25000-40000)/1000 - 15 = -30 + expect(s.final!.ranking.map((r) => r.score)).toEqual([40, -10, -30]); + }); +}); + +// ---------------------------------------------------------------- sanma + +describe('산마 §5.7', () => { + test('북 뽑기 → 응답 창 → 영상패 쯔모, 북 도라 +1, 영상개화', () => { + let s = rig(make(3), { hands: ['234p567p678s22s34p', '*', '*'], drawn: '4z', rinshan: '5p' }); + const wall = s.hand!.wall.length; + expect(view(s, 0).my!.canKita).toBe(true); + s = act(s, 0, { type: 'kita' }); + expect(s.hand!.stage).toBe('claim'); + s = settle(s); + expect(s.hand!.kita[0]!.length).toBe(1); + expect(s.hand!.lastDrawRinshan).toBe(true); + expect(s.hand!.wall.length).toBe(wall - 1); + expect(s.hand!.rinshan.length).toBe(4); + expect(view(s, 1).seats[0]!.kita.length).toBe(1); + s = act(s, 0, { type: 'tsumo' }); + expect(yakuNames(s)).toEqual(expect.arrayContaining(['영상개화', '북 1'])); + }); + test('북 뽑기 패로 론 가능 (창깡 역은 없음)', () => { + let s = rig(make(3), { hands: ['*', '234p567p678s222s4z', '*'], drawn: '4z', riichi: [1] }); + s = act(s, 0, { type: 'kita' }); + expect(claimOf(s, 1)!.ron).toBe(true); + s = act(s, 1, { type: 'claim', call: 'ron' }); + expect(yakuNames(s)).toContain('리치'); + expect(yakuNames(s)).not.toContain('창깡'); + }); + test('쯔모손: 자 만관 쯔모 → 친 4000 + 자 2000 = 6000; 북가절반이면 5000 + 3000', () => { + const go = (opts: Partial) => act(rig(make(3, opts), { hands: ['*', '234p067p678s22p34s', '*'], turn: 1, drawn: '5s', dora: '2p' }), 1, { type: 'tsumo' }); + const s = go({}); + expect(s.lastHandResult!.winners[0]!.han).toBe(5); + expect(s.lastHandResult!.deltas).toEqual([-4000, 6000, -2000]); + const t = go({ sanmaTsumo: 'northBisection' }); + expect(t.lastHandResult!.deltas).toEqual([-5000, 8000, -3000]); + }); + test('산마 본장: 론이면 방총자가 1개당 200, 쯔모면 둘이 반씩', () => { + let s = rig(make(3), { hands: ['*', '345p234p567p234s6s', '*'], drawn: '6s' }); + s.honba = 1; + s = discard(s, 0, '6s'); + s = act(s, 1, { type: 'claim', call: 'ron' }); + // 탕야오 1판 40부(단기) 1300 + 본장 200 + expect(s.lastHandResult!.deltas).toEqual([-1500, 1500, 0]); + let t = rig(make(3), { hands: ['*', '345p234p567p234s6s', '*'], turn: 1, drawn: '6s' }); + t.honba = 1; + t = act(t, 1, { type: 'tsumo' }); + // 멘젠쯔모·탕야오 2판 30부: 친 1000 + 자 500 (쯔모손), 본장 200을 둘이 100씩 + expect(t.lastHandResult!.deltas).toEqual([-1100, 1700, -600]); + }); +}); + +// ---------------------------------------------------------------- timers + +describe('시간 초과 §8.5', () => { + test('차례 시간 초과 → 쯔모기리, 예비 시간 0; 응답 창 시간 초과 → 패스 + 동순 후리텐', () => { + let s = rig(make(4), { hands: ['*', T2, '*', '*'], drawn: '5m' }); + expect(mahjong.deadline!(s)).toBe(s.hand!.turnStartedAt + 15_000 + 60_000); + s = act(s, 0, { type: 'timeout' }, 80_000); + expect(s.hand!.discards[0]!.at(-1)!.tsumogiri).toBe(true); + expect(s.timeBank[0]).toBe(0); + expect(s.hand!.stage).toBe('claim'); + expect(claimOf(s, 1)!.ron).toBe(true); + // Only the seats that may answer are timed; the discarder's timer does not run yet. + expect(mahjong.timeoutPlayers!(s)).toEqual(['B']); + expect(reject(s, 0, { type: 'timeout' })).toBe('시간 초과 대상이 아니에요.'); + s = act(s, 1, { type: 'timeout' }, 9000); + expect(s.hand!.tempFuriten[1]).toBe(true); + expect(s.timeoutsInRow[1]).toBe(1); + expect(s.hand!.turn).toBe(1); + }); + test('예비 시간: 기본 시간을 넘긴 만큼 깎이고, 다음 국에 5초 충전', () => { + let s = rig(make(4), { hands: ['*', '*', '*', '*'], drawn: '5s' }); + s = act(s, 0, { type: 'discard', tile: codeIn(s, 0, '5s') }, 25_000); + expect(s.timeBank[0]).toBe(50_000); + }); + test('2회 연속 시간 초과 → autoPilot, 다음 차례 마감 2초; 행동 1회로 해제', () => { + let s = rig(make(4), { hands: ['*', '*', '*', '*'], drawn: '5s' }); + s.timeoutsInRow[0] = 1; + s = act(s, 0, { type: 'timeout' }, 80_000); + expect(s.autoPilot[0]).toBe(true); + expect(view(s, 1).seats[0]!.away).toBe(true); + s = settle(s); + for (let i = 0; i < 3; i++) s = tsumogiri(s); + expect(s.hand!.turn).toBe(0); + expect(mahjong.deadline!(s)).toBe(s.hand!.turnStartedAt + 2000); + s = act(s, 0, { type: 'discard', tile: code(s, s.hand!.drawn[0]!) }); + expect(s.autoPilot[0]).toBe(false); + }); + test('자리 비움(autoPilot) 사람이 론 가능해도 다른 사람의 퐁은 기다리지 않는다', () => { + let s = priorityRig(T2); + s.autoPilot[3] = true; + s = discard(s, 0, '5m'); + expect(s.hand!.claim!.responses[3]).toEqual({ type: 'pass' }); + s = act(s, 2, { type: 'claim', call: 'pon', tiles: claimOf(s, 2)!.pon[0]! }); + expect(s.hand!.stage).toBe('afterCall'); + }); + test('연결 끊긴 응답 가능자는 서버 타이머가 바로 패스 처리 (창 마감 전에도)', () => { + let s = priorityRig(T2); + s = discard(s, 0, '5m'); + s = act(s, 2, { type: 'claim', call: 'pon', tiles: claimOf(s, 2)!.pon[0]! }, 100); + expect(mahjong.timeoutPlayers!(s).sort()).toEqual(['B', 'D']); + s = act(s, 3, { type: 'timeout' }, 100); + s = act(s, 1, { type: 'timeout' }, 100); + expect(s.hand!.claim!.decided).toBe(true); + // The discarder's timer closes the window; if the discarder is disconnected it may fire early — still closes. + expect(mahjong.timeoutPlayers!(s)).toEqual(['A']); + s = act(s, 0, { type: 'timeout' }, 10); + expect(s.hand!.stage).toBe('afterCall'); + expect(s.hand!.turn).toBe(2); + }); + test('onTimeout: afterCall은 구이가에가 아닌 맨 오른쪽 패를 버린다', () => { + let s = rig(make(4), { hands: ['*', '34m25m99p567p234s7z', '*', '*'], drawn: '2m' }); + s = discard(s, 0, '2m'); + s = act(s, 1, { type: 'claim', call: 'chi', tiles: claimOf(s, 1)!.chi[0]! }); + const before = s.hand!.hands[1]!.length; + for (const p of mahjong.timeoutPlayers!(s)) expect(mahjong.validate(s, p, mahjong.onTimeout(s, p)).ok).toBe(true); + s = act(s, 1, { type: 'timeout' }, 80_000); + expect(s.hand!.hands[1]!.length).toBe(before - 1); + expect(code(s, s.hand!.discards[1]!.at(-1)!.tile).slice(0, 2)).toBe('7z'); + }); + test('결과 화면: 15초 뒤 시간 초과 = 확인, 나간 사람은 기다리지 않는다', () => { + let s = tsumoHand(make(4), 1); + expect(mahjong.deadline!(s)).toBe(s.resultAt + 15_000); + s = act(s, 3, { type: 'leave' }); + expect(s.readyForNext).toContain(3); + for (const seat of [0, 1, 2]) s = act(s, seat, { type: 'timeout' }, 16_000); + expect(s.phase).toBe('playing'); + expect(s.handNumber).toBe(1); + expect(s.autoPilot[3]).toBe(true); + }); +}); + +// ---------------------------------------------------------------- information + +describe('정보 누출 §8.3', () => { + test('다른 사람·관전자 view에 손패·쯔모패·패산·미공개 도라/우라·eligible·응답이 없다', () => { + let s = priorityRig(T2); + s = discard(s, 0, '5m'); + s = act(s, 2, { type: 'claim', call: 'pon', tiles: claimOf(s, 2)!.pon[0]! }); + expect(s.hand!.stage).toBe('claim'); + for (const viewer of [null, 0, 1, 2, 3] as const) { + const v = view(s, viewer); + const json = JSON.stringify(v); + for (let other = 0; other < 4; other++) { + if (other === viewer) continue; + for (const secret of mahjong.secretsOf!(s, s.players[other]!)) expect(json.includes(JSON.stringify(secret))).toBe(false); + } + for (const key of ['eligible', 'responses', 'winning', 'decided', 'minUntil', 'wall', 'uraInd']) expect(json.includes(`"${key}"`)).toBe(false); + if (viewer !== 3) expect(json.includes('"ron":true')).toBe(false); + // No public countdown for the claim window (its length would tell who may answer). + expect(v.deadlineTotalMs).toBe(0); + expect(v.lastCall).toBeNull(); + expect(v.seats[2]!.melds).toEqual([]); + } + expect(view(s, 3).my!.claim!.totalMs).toBe(8000); + expect(mahjong.activePlayers(s)).toEqual(['B', 'C', 'D']); + }); + test('국 종료 후: 화료자 손패와 우라도라(리치 화료)만 공개, 다른 손패는 비공개', () => { + let s = rig(make(4), { hands: [R, '*', '*', '*'], drawn: '4s', riichi: [0], ura: '1p' }); + s = act(s, 0, { type: 'tsumo' }); + const v = view(s, null); + expect(v.result!.winners[0]!.uraIndicators.length).toBe(1); + expect(v.result!.shown.map((x) => x.seat)).toEqual([0]); + const json = JSON.stringify(v); + for (const t of s.hand!.hands[1]!) expect(json.includes(JSON.stringify(code(s, t)))).toBe(false); + for (const p of P4) for (const secret of mahjong.secretsOf!(s, p)) if (p !== 'A') expect(json.includes(JSON.stringify(secret))).toBe(false); + }); + test('리치 화료가 아니면 우라도라를 공개하지 않는다', () => { + const s = tsumoHand(make(4), 1); + expect(view(s, null).result!.winners[0]!.uraIndicators).toEqual([]); + const json = JSON.stringify(view(s, null)); + for (const t of s.hand!.uraInd) expect(json.includes(JSON.stringify(code(s, t)))).toBe(false); + }); +}); + +describe('재현성', () => { + test('같은 시드 + 같은 행동 → 같은 상태', () => { + const run = () => { + let s = mahjong.setup({ players: P4, options: mahjong.defaultOptions, rng: SeededRng.fromSeed(seedN(77)), now: 0 }); + const rng = SeededRng.fromSeed(seedN(78)); + let now = 0; + for (let i = 0; i < 400 && !mahjong.result(s); i++) { + const who = mahjong.timeoutPlayers!(s)[0]!; + const legal = mahjong.legalActions!(s, who); + const a = legal.length ? legal[i % legal.length]! : mahjong.onTimeout(s, who); + now += 1000; + s = mahjong.apply(s, who, a, { rng, now }).state; + } + return JSON.stringify(s); + }; + expect(run()).toBe(run()); + }); +}); + +describe('설정·초보 모드 §7.1', () => { + test('setPref: 자동 화료 → 화료패를 쯔모하면 바로 쯔모', () => { + let s = rig(make(4), { hands: ['*', T1, '*', '*'], drawn: '9m', wall: '5s' }); + s = act(s, 1, { type: 'setPref', autoWin: true }); + s = discard(s, 0, '9m'); + s = settle(s); + expect(s.phase).toBe('handResult'); + expect(s.lastHandResult!.winners[0]!.seat).toBe(1); + }); + test('setPref: 울기 안 함 → 퐁·치 선택지가 없고 론만 남는다', () => { + let s = priorityRig('*'); + s = act(s, 2, { type: 'setPref', noCalls: true }); + s = discard(s, 0, '5m'); + expect(claimOf(s, 2)).toBeNull(); + expect(claimOf(s, 1)!.chi.length).toBe(1); + }); + test('초보 모드: 텐파이 타패 하이라이트와 대기·남은 장수, 끄면 사라진다', () => { + const s = rig(make(4), { hands: [R, '*', '*', '*'], drawn: '1z' }); + const my = view(s, 0).my!; + const after = my.tenpaiAfter[codeIn(s, 0, '1z')]!; + expect(after.map((w) => w.kind)).toEqual(['4s']); + // Other hands are not visible to me: all four 4s still count. + expect(after[0]!.left).toBe(4); + expect(my.yakuHints.length).toBeGreaterThan(0); + const off = act(s, 0, { type: 'setPref', beginner: false }); + expect(Object.keys(view(off, 0).my!.tenpaiAfter)).toEqual([]); + expect(view(off, 0).my!.yakuHints).toEqual([]); + }); + test('초보 모드를 막은 방에서는 켤 수 없다', () => { + const s = rig(make(4, { beginnerMode: false }), { hands: [R, '*', '*', '*'], drawn: '1z' }); + expect(reject(s, 0, { type: 'setPref', beginner: true })).toBe('이 방에서는 초보 모드를 쓸 수 없어요.'); + expect(view(s, 0).my!.tenpaiAfter).toEqual({}); + }); +}); + diff --git a/packages/games/src/mahjong/playout-3.test.ts b/packages/games/src/mahjong/playout-3.test.ts new file mode 100644 index 0000000..83026e6 --- /dev/null +++ b/packages/games/src/mahjong/playout-3.test.ts @@ -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); + } + }); +}); diff --git a/packages/games/src/mahjong/playout-4h.test.ts b/packages/games/src/mahjong/playout-4h.test.ts new file mode 100644 index 0000000..541f504 --- /dev/null +++ b/packages/games/src/mahjong/playout-4h.test.ts @@ -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); + }); +}); diff --git a/packages/games/src/mahjong/playout-4t.test.ts b/packages/games/src/mahjong/playout-4t.test.ts new file mode 100644 index 0000000..96b27cc --- /dev/null +++ b/packages/games/src/mahjong/playout-4t.test.ts @@ -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); + }); +}); diff --git a/packages/games/src/mahjong/playout.ts b/packages/games/src/mahjong/playout.ts new file mode 100644 index 0000000..08f3ec1 --- /dev/null +++ b/packages/games/src/mahjong/playout.ts @@ -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; +} + +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 { + const r = SeededRng.fromSeed(seedN(seed + 31_337)); + const pick = (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; + /** 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})`); +} diff --git a/packages/games/src/mahjong/scoring.test.ts b/packages/games/src/mahjong/scoring.test.ts new file mode 100644 index 0000000..cbe2d9d --- /dev/null +++ b/packages/games/src/mahjong/scoring.test.ts @@ -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; +} +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); + }); +}); diff --git a/packages/games/src/mahjong/scoring.ts b/packages/games/src/mahjong/scoring.ts new file mode 100644 index 0000000..7ee09f9 --- /dev/null +++ b/packages/games/src/mahjong/scoring.ts @@ -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 = { + 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 = { + 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(); + 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(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(); + 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; +} diff --git a/packages/games/src/mahjong/tiles.ts b/packages/games/src/mahjong/tiles.ts new file mode 100644 index 0000000..7f29f24 --- /dev/null +++ b/packages/games/src/mahjong/tiles.ts @@ -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 "[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 = 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): number[] { + const c = new Array(34).fill(0); + for (const k of kinds) c[k]!++; + return c; +} diff --git a/packages/games/src/mahjong/types.ts b/packages/games/src/mahjong/types.ts new file mode 100644 index 0000000..4929344 --- /dev/null +++ b/packages/games/src/mahjong/types.ts @@ -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 = (...vals: T[]) => + z.union(vals.map((v) => z.literal(v)) as unknown as [z.ZodLiteral, z.ZodLiteral, ...z.ZodLiteral[]]); + +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; + +/** Options with per-mode defaults filled in (§2: 3인은 3인 기본값). */ +export interface ResolvedOptions extends Omit { + 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; +/** 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; + yakuHints: string[]; + timeBankMs: number; + autoPilot: boolean; + prefs: { autoWin: boolean; noCalls: boolean; beginner: boolean }; +} +export interface WinnerView extends Omit { + hand: string[]; + melds: MeldView[]; + winTile: string; + uraIndicators: string[]; +} +export interface HandResultView extends Omit { + 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; +} diff --git a/packages/shared/src/catalog.ts b/packages/shared/src/catalog.ts index b3bd701..ba9a42f 100644 --- a/packages/shared/src/catalog.ts +++ b/packages/shared/src/catalog.ts @@ -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);