- 규칙 모듈 packages/games/src/mahjong (tiles·agari·scoring·types·index): 4인 리치 마작(동풍전/반장전)과 3인 산마, 옵션 전부 한국어 meta, 응답 창(론 > 퐁·깡 > 치, 조기 확정, 최소 400ms, 시간 초과 = 패스), 리치·일발·더블리치, 후리텐(버림패·동순·리치), 구이가에, 깡 도라 시점, 사깡·사풍·사가리치·구종구패·삼가화, 유국만관, 책임지불, 북 뽑기, 고점법 점수 계산(역·부·만관~더블 역만, 본장·공탁, 산마 쯔모손/북가절반), 토비·아가리야메·서입 서든데스, 우마·오카 결과, 초보 모드 힌트, autoPilot·나가기 처리 - 응답 창 타이머는 아직 응답하지 않은 응답 가능자에게만 걸어 연결 끊긴 사람은 바로 패스, 확정 뒤에는 방총자 타이머로 닫음(끊긴 방총자 때문에 타이머가 헛도는 일 방지). 공개 시계에는 응답 창 남은 시간을 싣지 않음 - 테스트: 점수 계산기 161건(1~4판 × 20~110부 표 전 칸, 만관 이상, 역·부 예시, 산마 지불), 규칙 시나리오 77건(§10 체크리스트), 무작위 대국 1,000판(4인 반장전 240 · 동풍전 400 · 산마 360, 옵션 무작위, 패·점수 보존, onTimeout 합법, 재현성) + 엔진 randomPlayout과 매 단계 정보 누출 검사 - 화면 apps/web/src/games/mahjong: 직접 그린 SVG 패, 4방향(3인은 3방향) 버림패 테이블, 탭 두 번 버리기, 리치·쯔모·깡·북·구종구패, 론·퐁·치(조합 선택)·깡·패스 응답, 국 결과(역·판·부·점수 변화)와 최종 순위, 초보 모드(텐파이 타패·대기·남은 장수·역 힌트·후리텐 배지), PC 2단 배치·휴대폰 한 줄 손패, 키보드·스크린리더 - 등록: GAMES, 카탈로그(available), 웹 registry, '곧 나와요'에서 마작 제거 - E2E: PC 방장 + 휴대폰 3명 4인 대국으로 한 국을 끝까지(타패·패스·유국 결과 확인·다음 국 시작) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1647 lines
63 KiB
TypeScript
1647 lines
63 KiB
TypeScript
/**
|
|
* 마작 — 리치 마작 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<string, number[]> = {
|
|
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 = <T>(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<ClaimResponse, { tiles: TileId[] }> } | 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<ClaimResponse, { tiles: TileId[] }>, 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<number>(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<number>(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<number>(n).fill(0),
|
|
poolTaken: 0,
|
|
},
|
|
'renchanHonba',
|
|
false,
|
|
ctx,
|
|
);
|
|
}
|
|
|
|
type NextKind = 'renchan' | 'rotate' | 'renchanHonba' | 'rotateHonba';
|
|
|
|
function finishHand(
|
|
s: MahjongState,
|
|
rec: Omit<HandResultRecord, 'roundWind' | 'handNumber' | 'honba' | 'pointsAfter' | 'doraIndicators'>,
|
|
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<TileId, Kind[]> {
|
|
const h = H(s);
|
|
const out = new Map<TileId, Kind[]>();
|
|
const seen = new Set<string>();
|
|
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<MahjongState, MahjongAction, MahjongView, MahjongOptions> = {
|
|
id: 'mahjong',
|
|
nameKo: '마작',
|
|
minPlayers: 3,
|
|
maxPlayers: 4,
|
|
stateVersion: 1,
|
|
defaultOptions: mahjongOptionsSchema.parse({}),
|
|
optionsSchema: mahjongOptionsSchema as unknown as GameDefinition<MahjongState, MahjongAction, MahjongView, MahjongOptions>['optionsSchema'],
|
|
actionSchema: mahjongActionSchema as unknown as GameDefinition<MahjongState, MahjongAction, MahjongView, MahjongOptions>['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 = <T>(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<string>();
|
|
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 };
|