/** * 마작 — 리치 마작 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 };