/** 장기 (docs/games/janggi.md). */ import { z } from 'zod'; import { fail, ok, type GameDefinition, type GameEvent, type GameResult, type PlayerId } from '@bg/engine'; import { ARRANGEMENTS, HAN_BONUS, explainIllegal, hasLegalMove, idx, inCheck, initialBoard, kingsFacing, legalMoves, makeMove, material, other, placeArrangement, positionKey, pseudoMoves, attacks, sqOf, type Arrangement, type Board, type LegalMove, type Piece, type PieceKind, type Side, type Sq, } from './rules'; export type { Arrangement, Piece, PieceKind, Side, Sq } from './rules'; export { ARRANGEMENTS, PIECE_VALUE, HAN_BONUS } from './rules'; const SIDE_KO: Record = { cho: '초', han: '한' }; export const janggiOptionsSchema = z.object({ scoring: z.boolean().default(true).meta({ title: '점수제(판정이 필요하면 기물 점수로 승패)' }), bikjang: z .enum(['off', 'kjf', 'kja30']) .default('kjf') .meta({ title: '빅장', labels: { off: '없음', kjf: '연맹식(기물이 하나 잡힌 뒤부터)', kja30: '협회식(양쪽 30점 미만일 때)' } }), repetition: z .enum(['forbid', 'lose', 'scoreEnd']) .default('forbid') .meta({ title: '같은 국면 4번째', labels: { forbid: '그 수를 둘 수 없음', lose: '만든 쪽 실격패', scoreEnd: '점수로 판정 종료' } }), allowPass: z.boolean().default(true).meta({ title: '한수쉼 허용' }), setupOrder: z .enum(['hanFirst', 'simultaneous']) .default('hanFirst') .meta({ title: '상차림 순서', labels: { hanFirst: '한이 먼저 공개(연맹식)', simultaneous: '동시에 비공개로' } }), sideAssignment: z .enum(['random', 'hostCho', 'hostHan', 'alternate']) .default('random') .meta({ title: '초·한 정하기', labels: { random: '무작위', hostCho: '방장이 초', hostHan: '방장이 한', alternate: '판마다 번갈아' } }), moveLimit: z.number().int().min(0).max(1000).default(0).meta({ title: '수 제한(양쪽 합산, 0이면 없음)' }), timeControl: z .object({ kind: z.enum(['none', 'byoyomi']).default('byoyomi').meta({ title: '시간 방식', labels: { none: '제한 없음', byoyomi: '기본 시간 + 초읽기' } }), mainSec: z.number().int().min(0).max(7200).default(600).meta({ title: '기본 시간(초)' }), periodSec: z.number().int().min(5).max(300).default(30).meta({ title: '초읽기 1회(초)' }), periods: z.number().int().min(1).max(10).default(3).meta({ title: '초읽기 횟수' }), }) .default({ kind: 'byoyomi', mainSec: 600, periodSec: 30, periods: 3 }) .meta({ title: '시간 제한' }), setupTimeSec: z.number().int().min(10).max(600).default(60).meta({ title: '상차림 고르는 시간(초)' }), undo: z .enum(['off', '1', '3', 'unlimited']) .default('3') .meta({ title: '무르기(1인당)', labels: { off: '안 씀', 1: '1번', 3: '3번', unlimited: '무제한' } }), drawOffer: z.boolean().default(true).meta({ title: '무승부 제안 허용' }), drawReofferMoves: z.number().int().min(0).max(50).default(5).meta({ title: '무승부 다시 제안까지 둬야 하는 수' }), hints: z .enum(['off', 'moves']) .default('moves') .meta({ title: '도움 표시', labels: { off: '끄기', moves: '갈 수 있는 곳' } }), threatHints: z.boolean().default(false).meta({ title: '위협 가이드(관리자 전용): 잡힐 위험이 있는 내 기물 표시' }), }); export type JanggiOptions = z.infer; const sqSchema = z.object({ f: z.number().int().min(0).max(8), r: z.number().int().min(0).max(9) }); export const janggiActionSchema = z.discriminatedUnion('type', [ z.object({ type: z.literal('chooseSetup'), arrangement: z.enum(['MSSM', 'SMMS', 'SMSM', 'MSMS']) }), z.object({ type: z.literal('move'), from: sqSchema, to: sqSchema }), z.object({ type: z.literal('pass') }), z.object({ type: z.literal('requestUndo') }), z.object({ type: z.literal('respondUndo'), accept: z.boolean() }), z.object({ type: z.literal('offerDraw') }), z.object({ type: z.literal('respondDraw'), accept: z.boolean() }), z.object({ type: z.literal('cancelOffer') }), z.object({ type: z.literal('resign') }), ]); /** Client-sendable actions plus the system-only timeout action (not in the schema). */ export type JanggiAction = z.infer | { type: 'timeout' }; export type JanggiMove = | { kind: 'move'; side: Side; piece: PieceKind; from: Sq; to: Sq; captured: PieceKind | null } | { kind: 'pass'; side: Side }; export type JanggiReason = | 'checkmate' | 'resign' | 'timeLoss' | 'repetitionLoss' | 'scoreBikjang' | 'scorePasses' | 'scoreMoveLimit' | 'scoreRepetition' | 'drawBikjang' | 'drawPasses' | 'drawRepetition' | 'drawMoveLimit' | 'drawAgreed' | 'aborted'; export interface JanggiResult { winner: Side | null; reason: JanggiReason; /** Always recorded; 한 includes the 1.5 덤. */ scores: Record; moveCount: number; summaryKo: string; } export interface JanggiState { options: JanggiOptions; players: Record; phase: 'setup' | 'playing' | 'finished'; /** `startedAt` = when the current chooser's setup clock started. */ setup: { choice: Record; startedAt: number }; board: Board; /** Board when play began (replayed for undo). */ startBoard: Board | null; turn: Side; moves: JanggiMove[]; /** 0-based ply index of the first capture. */ firstCapturePly: number | null; positionCounts: Record; bikjang: { calledBy: Side; ply: number } | null; consecutivePasses: number; inCheck: Side | null; clock: { mainMs: Record; periodsLeft: Record; turnStartedAt: number }; undo: { usedBy: Record; pending: { by: Side; plies: 1 | 2 } | null }; draw: { pendingBy: Side | null; lastOfferPly: Record }; result: JanggiResult | null; } export interface JanggiLegalMove { from: Sq; to: Sq; /** 포다리 for 포 moves. */ via?: Sq; } export interface JanggiView { phase: JanggiState['phase']; board: (Piece | null)[]; players: Record; mySide: Side | null; turn: Side; setup: { order: JanggiOptions['setupOrder']; /** Public (or my own) choices; hidden choices are null. */ choice: Record; ready: Record; /** Sides that still have to choose right now. */ waitingFor: Side[]; }; moves: JanggiMove[]; moveCount: number; lastMove: { from: Sq; to: Sq } | null; /** Opponent pieces each side has captured. */ captured: Record; /** Live score incl. 덤. */ scores: Record; inCheck: Side | null; bikjang: { calledBy: Side } | null; /** A legal move/pass of the side to move would create the 4th repetition. */ repeatWarning: boolean; /** Only for the player to move. */ legalMoves: JanggiLegalMove[] | null; canPass: boolean; threatened: Sq[]; rules: Pick; clock: JanggiState['clock']; undo: { allowed: boolean; remaining: Record; pending: { by: Side; plies: number } | null }; draw: { allowed: boolean; pendingBy: Side | null }; result: JanggiResult | null; } // ---------------------------------------------------------------- helpers const toIdx = (q: Sq): number => idx(q.f, q.r); const undoLimit = (o: JanggiOptions): number => (o.undo === 'off' ? 0 : o.undo === 'unlimited' ? Infinity : Number(o.undo)); const sideOf = (s: JanggiState, p: PlayerId): Side | null => (s.players.cho === p ? 'cho' : s.players.han === p ? 'han' : null); const fmt = (n: number): string => String(n); export function scoresOf(b: Board): Record { return { cho: material(b, 'cho'), han: material(b, 'han') + HAN_BONUS }; } /** Sides that must pick a 상차림 now. */ function setupWaiting(s: JanggiState): Side[] { if (s.phase !== 'setup') return []; const { choice } = s.setup; if (s.options.setupOrder === 'hanFirst') return choice.han === null ? ['han'] : choice.cho === null ? ['cho'] : []; return (['cho', 'han'] as const).filter((x) => choice[x] === null); } interface MoveOptions { moves: LegalMove[]; pass: boolean; /** Some option of the mover would create a 4th repetition. */ warn: boolean; } /** Legal options for the side to move, including the repetition rule (docs §5.5). */ function moveOptions(s: JanggiState): MoveOptions { const side = s.turn; const safe = legalMoves(s.board, side); const check = s.inCheck === side; const passBase = !check && (s.options.allowPass || safe.length === 0); const hot = Object.values(s.positionCounts).some((c) => c >= 3); if (!hot) return { moves: safe, pass: passBase, warn: false }; const opp = other(side); const repeats = (key: string) => (s.positionCounts[key] ?? 0) >= 3; const moveRepeats = safe.map((m) => repeats(positionKey(makeMove(s.board, m.from, m.to), opp))); const passRepeats = passBase && repeats(positionKey(s.board, opp)); const warn = passRepeats || moveRepeats.some(Boolean); if (s.options.repetition !== 'forbid') return { moves: safe, pass: passBase, warn }; const nonRep = safe.filter((_, i) => !moveRepeats[i]); const passOk = passBase && !passRepeats; if (nonRep.length > 0 || passOk) return { moves: nonRep, pass: passOk, warn }; // Exception: every option repeats a 4th time → allowed, and advance() ends the game by score. return { moves: safe, pass: passBase, warn }; } const SCORE_WHY: Record = { Bikjang: '빅장', Passes: '연속 한수쉼', MoveLimit: '수 제한', Repetition: '반복수' }; function finish(s: JanggiState, winner: Side | null, reason: JanggiReason): void { s.phase = 'finished'; s.undo.pending = null; s.draw.pendingBy = null; s.bikjang = null; const scores = scoresOf(s.board); const n = s.moves.length; let summaryKo: string; if (reason.startsWith('score')) { const w = winner!; summaryKo = `${SIDE_KO[w]} 판정승 ${fmt(scores[w])} : ${fmt(scores[other(w)])} (${SCORE_WHY[reason.slice(5)]})`; } else if (reason.startsWith('draw') && reason !== 'drawAgreed') { summaryKo = `무승부 (${SCORE_WHY[reason.slice(4)]}, ${n}수)`; } else { switch (reason) { case 'checkmate': summaryKo = `${SIDE_KO[winner!]} 외통승 (${n}수)`; break; case 'resign': summaryKo = `${SIDE_KO[other(winner!)]} 기권으로 ${SIDE_KO[winner!]} 승 (${n}수)`; break; case 'timeLoss': summaryKo = `${SIDE_KO[other(winner!)]} 시간패로 ${SIDE_KO[winner!]} 승 (${n}수)`; break; case 'repetitionLoss': summaryKo = `${SIDE_KO[other(winner!)]} 반복수 실격패로 ${SIDE_KO[winner!]} 승 (${n}수)`; break; case 'drawAgreed': summaryKo = `합의 무승부 (${n}수)`; break; default: summaryKo = '상차림 중 기권해서 대국이 취소됐어요'; } } s.result = { winner, reason, scores, moveCount: n, summaryKo }; } /** Score judgement (scoring=true) or draw (scoring=false). */ function judge(s: JanggiState, why: 'Bikjang' | 'Passes' | 'MoveLimit' | 'Repetition'): void { if (!s.options.scoring) { finish(s, null, `draw${why}` as JanggiReason); return; } const sc = scoresOf(s.board); finish(s, sc.cho > sc.han ? 'cho' : 'han', `score${why}` as JanggiReason); } function bikjangCondition(s: JanggiState, ply: number): boolean { switch (s.options.bikjang) { case 'off': return false; case 'kjf': return s.firstCapturePly !== null && s.firstCapturePly < ply; case 'kja30': return material(s.board, 'cho') < 30 && material(s.board, 'han') < 30; } } type Ply = { kind: 'move'; from: Sq; to: Sq } | { kind: 'pass' }; /** Steps 2–7 of docs §8.2 (board, 빅장, 외통, repetition, passes, move limit, 장군 events). No clocks. */ function advance(s: JanggiState, side: Side, ply: Ply, events: GameEvent[]): void { const wasFacing = kingsFacing(s.board); const wasInCheck = s.inCheck === side; const n = s.moves.length; if (ply.kind === 'move') { const from = toIdx(ply.from); const to = toIdx(ply.to); const piece = s.board[from]!; const captured = s.board[to]?.kind ?? null; s.board = makeMove(s.board, from, to); s.moves.push({ kind: 'move', side, piece: piece.kind, from: ply.from, to: ply.to, captured }); if (captured && s.firstCapturePly === null) s.firstCapturePly = n; events.push({ type: 'moved', side, piece: piece.kind, from: ply.from, to: ply.to, captured }); } else { s.moves.push({ kind: 'pass', side }); events.push({ type: 'passed', side }); } s.undo.pending = null; s.draw.pendingBy = null; const opp = other(side); s.turn = opp; s.inCheck = inCheck(s.board, opp) ? opp : null; const facing = kingsFacing(s.board); // 3. Answer to a pending 빅장. if (s.bikjang && s.bikjang.calledBy !== side) { if (facing) { events.push({ type: 'bik' }); judge(s, 'Bikjang'); return; } s.bikjang = null; events.push({ type: 'bikjangBlocked', side }); } // 4. 외통. if (s.inCheck === opp && !hasLegalMove(s.board, opp)) { events.push({ type: 'janggun', side }); finish(s, side, 'checkmate'); events.push({ type: 'checkmate', side }); return; } // 5. New 빅장. if (facing && !wasFacing && bikjangCondition(s, n)) { s.bikjang = { calledBy: side, ply: n }; events.push({ type: 'bikjang', side }); } // 6. Repetition, passes, move limit. const key = positionKey(s.board, s.turn); const count = (s.positionCounts[key] ?? 0) + 1; s.positionCounts[key] = count; if (count >= 4) { if (s.options.repetition === 'lose') { finish(s, opp, 'repetitionLoss'); return; } // scoreEnd, or the forbid exception (only reachable when every option repeated). judge(s, 'Repetition'); return; } s.consecutivePasses = ply.kind === 'pass' ? s.consecutivePasses + 1 : 0; if (s.consecutivePasses >= 2) { judge(s, 'Passes'); return; } if (s.options.moveLimit > 0 && s.moves.length >= s.options.moveLimit) { judge(s, 'MoveLimit'); return; } // 7. 장군 / 멍군. if (s.inCheck === opp) events.push({ type: 'janggun', side }); else if (wasInCheck) events.push({ type: 'meonggun', side }); } /** Rebuild derived fields from startBoard + moves (undo). */ function replay(s: JanggiState, moves: JanggiMove[]): void { s.board = s.startBoard!.slice(); s.moves = []; s.turn = 'cho'; s.firstCapturePly = null; s.bikjang = null; s.consecutivePasses = 0; s.inCheck = null; s.positionCounts = { [positionKey(s.board, 'cho')]: 1 }; const sink: GameEvent[] = []; for (const m of moves) advance(s, m.side, m.kind === 'move' ? { kind: 'move', from: m.from, to: m.to } : { kind: 'pass' }, sink); } /** Byoyomi clock (same as 바둑). Returns false when the mover has run out of time. */ function spendClock(s: JanggiState, side: Side, now: number): boolean { const tc = s.options.timeControl; if (tc.kind !== 'byoyomi') return true; let elapsed = Math.max(0, now - s.clock.turnStartedAt); const main = s.clock.mainMs[side]; if (elapsed <= main) { s.clock.mainMs[side] = main - elapsed; return true; } elapsed -= main; s.clock.mainMs[side] = 0; const used = Math.floor(elapsed / (tc.periodSec * 1000)); s.clock.periodsLeft[side] = Math.max(0, s.clock.periodsLeft[side] - used); return s.clock.periodsLeft[side] > 0; } function chooseSetup(s: JanggiState, side: Side, arr: Arrangement, now: number, events: GameEvent[], auto: boolean): void { s.setup.choice[side] = arr; const hanFirst = s.options.setupOrder === 'hanFirst'; if (hanFirst) { placeArrangement(s.board, side, arr); s.setup.startedAt = now; events.push({ type: 'setupChosen', side, arrangement: arr, auto }); } else { events.push({ type: 'setupChosen', side, auto }); } const { cho, han } = s.setup.choice; if (cho && han) { if (!hanFirst) { placeArrangement(s.board, 'cho', cho); placeArrangement(s.board, 'han', han); } s.phase = 'playing'; s.turn = 'cho'; s.startBoard = s.board.slice(); s.positionCounts = { [positionKey(s.board, 'cho')]: 1 }; s.clock.turnStartedAt = now; events.push({ type: 'setupDone', cho, han }); } } function pendingResponder(s: JanggiState): Side | null { if (s.undo.pending) return other(s.undo.pending.by); if (s.draw.pendingBy) return other(s.draw.pendingBy); return null; } const legalToView = (m: LegalMove): JanggiLegalMove => m.via === undefined ? { from: sqOf(m.from), to: sqOf(m.to) } : { from: sqOf(m.from), to: sqOf(m.to), via: sqOf(m.via) }; // ---------------------------------------------------------------- definition export const janggi: GameDefinition = { id: 'janggi', nameKo: '장기', minPlayers: 2, maxPlayers: 2, stateVersion: 1, defaultOptions: janggiOptionsSchema.parse({}), optionsSchema: janggiOptionsSchema, adminOnlyOptions: ['threatHints'], actionSchema: janggiActionSchema as z.ZodType, setup({ players, options, rng, now, gameNo }) { const [host, guest] = players as [PlayerId, PlayerId]; let hostCho: boolean; switch (options.sideAssignment) { case 'hostCho': hostCho = true; break; case 'hostHan': hostCho = false; break; case 'alternate': hostCho = (gameNo ?? 1) % 2 === 1; break; default: hostCho = rng.int(2) === 0; } const tc = options.timeControl; return { options, players: hostCho ? { cho: host, han: guest } : { cho: guest, han: host }, phase: 'setup', setup: { choice: { cho: null, han: null }, startedAt: now }, board: initialBoard(), startBoard: null, turn: 'cho', moves: [], firstCapturePly: null, positionCounts: {}, bikjang: null, consecutivePasses: 0, inCheck: null, clock: { mainMs: { cho: tc.mainSec * 1000, han: tc.mainSec * 1000 }, periodsLeft: { cho: tc.periods, han: tc.periods }, turnStartedAt: now, }, undo: { usedBy: { cho: 0, han: 0 }, pending: null }, draw: { pendingBy: null, lastOfferPly: { cho: null, han: null } }, result: null, }; }, validate(s, actor, a) { const me = sideOf(s, actor); if (!me) return fail('이 게임의 플레이어가 아니에요.'); if (s.phase === 'finished') return fail('게임이 이미 끝났어요.'); if (a.type === 'resign') return ok; if (s.phase === 'setup') { if (a.type !== 'chooseSetup' && a.type !== 'timeout') return fail('상차림을 먼저 골라 주세요.'); if (s.setup.choice[me]) return fail('이미 상차림을 골랐어요.'); if (!setupWaiting(s).includes(me)) return fail('한이 먼저 상차림을 골라요. 잠시 기다려 주세요.'); return ok; } switch (a.type) { case 'chooseSetup': return fail('상차림은 이미 끝났어요.'); case 'move': { if (s.turn !== me) return fail('지금은 내 차례가 아니에요.'); const from = toIdx(a.from); const to = toIdx(a.to); const p = s.board[from]; if (!p) return fail('그 자리에는 기물이 없어요.'); if (p.side !== me) return fail('내 기물만 움직일 수 있어요.'); if (s.board[to]?.side === me) return fail('내 기물이 있는 곳으로는 갈 수 없어요.'); if (!pseudoMoves(s.board, from).some((m) => m.to === to)) return fail(explainIllegal(s.board, from, to)); if (inCheck(makeMove(s.board, from, to), me)) { return fail(s.inCheck === me ? '장군을 피해야 해요.' : '그 수를 두면 내 궁이 잡혀요(장군).'); } if (!moveOptions(s).moves.some((m) => m.from === from && m.to === to)) return fail('같은 수를 너무 많이 반복해서 둘 수 없어요.'); return ok; } case 'pass': { if (s.turn !== me) return fail('지금은 내 차례가 아니에요.'); if (s.inCheck === me) return fail('장군을 받은 상태에서는 쉴 수 없어요.'); const mo = moveOptions(s); if (mo.pass) return ok; if (!s.options.allowPass && hasLegalMove(s.board, me)) return fail('이 방은 한수쉼을 쓰지 않아요.'); return fail('같은 수를 너무 많이 반복해서 쉴 수 없어요.'); } case 'requestUndo': if (s.options.undo === 'off') return fail('이 방은 무르기를 쓰지 않아요.'); if (s.undo.pending || s.draw.pendingBy) return fail('이미 대기 중인 요청이 있어요.'); if (s.undo.usedBy[me] >= undoLimit(s.options)) return fail('무르기 횟수를 다 썼어요.'); if (!s.moves.some((m) => m.side === me)) return fail('아직 무를 수가 없어요.'); return ok; case 'respondUndo': if (!s.undo.pending || s.undo.pending.by === me) return fail('응답할 무르기 요청이 없어요.'); return ok; case 'offerDraw': { if (!s.options.drawOffer) return fail('이 방은 무승부 제안을 쓰지 않아요.'); if (s.undo.pending || s.draw.pendingBy) return fail('이미 대기 중인 요청이 있어요.'); const last = s.draw.lastOfferPly[me]; const gap = s.options.drawReofferMoves; if (last !== null && s.moves.slice(last).filter((m) => m.side === me).length < gap) { return fail(`무승부 제안은 ${gap}수를 더 둔 뒤에 다시 할 수 있어요.`); } return ok; } case 'respondDraw': if (!s.draw.pendingBy || s.draw.pendingBy === me) return fail('응답할 무승부 제안이 없어요.'); return ok; case 'cancelOffer': if (s.undo.pending?.by !== me && s.draw.pendingBy !== me) return fail('취소할 요청이 없어요.'); return ok; case 'timeout': if (s.turn !== me) return fail('지금은 내 차례가 아니에요.'); return ok; } }, apply(prev, actor, a, { rng, now }) { const s = structuredClone(prev); const me = sideOf(s, actor)!; const events: GameEvent[] = []; if (s.phase === 'setup') { if (a.type === 'resign') { finish(s, null, 'aborted'); events.push({ type: 'aborted', side: me }); } else if (a.type === 'chooseSetup') { chooseSetup(s, me, a.arrangement, now, events, false); } else if (a.type === 'timeout') { chooseSetup(s, me, ARRANGEMENTS[rng.int(ARRANGEMENTS.length)]!, now, events, true); } else { throw new Error(`janggi: ${a.type} during setup`); } return { state: s, events }; } switch (a.type) { case 'move': case 'pass': { if (!spendClock(s, me, now)) { finish(s, other(me), 'timeLoss'); events.push({ type: 'timeLoss', side: me }); break; } s.clock.turnStartedAt = now; advance(s, me, a.type === 'move' ? { kind: 'move', from: a.from, to: a.to } : { kind: 'pass' }, events); break; } case 'timeout': if (s.options.timeControl.kind === 'byoyomi') { s.clock.mainMs[me] = 0; s.clock.periodsLeft[me] = 0; } finish(s, other(me), 'timeLoss'); events.push({ type: 'timeLoss', side: me }); break; case 'requestUndo': { const last = s.moves[s.moves.length - 1]!; s.undo.pending = { by: me, plies: last.side === me ? 1 : 2 }; events.push({ type: 'undoRequested', side: me }); break; } case 'respondUndo': { const { by, plies } = s.undo.pending!; s.undo.pending = null; if (a.accept) { const keep = s.moves.slice(0, Math.max(0, s.moves.length - plies)); replay(s, keep); s.undo.usedBy[by]++; s.clock.turnStartedAt = now; events.push({ type: 'undoAccepted', side: by, plies }); } else { events.push({ type: 'undoDeclined', side: by }); } break; } case 'offerDraw': s.draw.pendingBy = me; s.draw.lastOfferPly[me] = s.moves.length; events.push({ type: 'drawOffered', side: me }); break; case 'respondDraw': s.draw.pendingBy = null; if (a.accept) { finish(s, null, 'drawAgreed'); events.push({ type: 'drawAccepted' }); } else events.push({ type: 'drawDeclined' }); break; case 'cancelOffer': if (s.undo.pending?.by === me) s.undo.pending = null; if (s.draw.pendingBy === me) s.draw.pendingBy = null; events.push({ type: 'offerCancelled', side: me }); break; case 'resign': finish(s, other(me), 'resign'); events.push({ type: 'resigned', side: me }); break; case 'chooseSetup': throw new Error('janggi: chooseSetup after setup'); } return { state: s, events }; }, view(s, viewer) { const mySide = viewer ? sideOf(s, viewer) : null; const hidden = s.phase === 'setup' && s.options.setupOrder === 'simultaneous'; const choice = { cho: hidden && mySide !== 'cho' ? null : s.setup.choice.cho, han: hidden && mySide !== 'han' ? null : s.setup.choice.han, }; const playing = s.phase === 'playing'; const mo = playing ? moveOptions(s) : null; const myTurn = playing && mySide === s.turn; const captured: Record = { cho: [], han: [] }; for (const m of s.moves) if (m.kind === 'move' && m.captured) captured[m.side].push(m.captured); let threatened: Sq[] = []; if (myTurn && s.options.threatHints) { const opp = other(s.turn); threatened = []; s.board.forEach((p, i) => { if (p && p.side === s.turn && p.kind !== 'king' && attacks(s.board, opp, i)) threatened.push(sqOf(i)); }); } const last = [...s.moves].reverse().find((m) => m.kind === 'move'); const limit = undoLimit(s.options); const o = s.options; return { phase: s.phase, board: s.board, players: s.players, mySide, turn: s.turn, setup: { order: o.setupOrder, choice, ready: { cho: s.setup.choice.cho !== null, han: s.setup.choice.han !== null }, waitingFor: setupWaiting(s), }, moves: s.moves, moveCount: s.moves.length, lastMove: last?.kind === 'move' ? { from: last.from, to: last.to } : null, captured, scores: scoresOf(s.board), inCheck: s.inCheck, bikjang: s.bikjang ? { calledBy: s.bikjang.calledBy } : null, repeatWarning: mo?.warn ?? false, legalMoves: myTurn && mo ? mo.moves.map(legalToView) : null, canPass: myTurn && !!mo?.pass, threatened, rules: { scoring: o.scoring, bikjang: o.bikjang, repetition: o.repetition, allowPass: o.allowPass, moveLimit: o.moveLimit, hints: o.hints, timeControl: o.timeControl, setupTimeSec: o.setupTimeSec, }, clock: s.clock, undo: { allowed: o.undo !== 'off', remaining: { cho: Number.isFinite(limit) ? limit - s.undo.usedBy.cho : null, han: Number.isFinite(limit) ? limit - s.undo.usedBy.han : null, }, pending: s.undo.pending, }, draw: { allowed: o.drawOffer, pendingBy: s.draw.pendingBy }, result: s.result, }; }, activePlayers(s) { if (s.phase === 'setup') return setupWaiting(s).map((x) => s.players[x]); if (s.phase !== 'playing') return []; const out = [s.players[s.turn]]; const responder = pendingResponder(s); if (responder && responder !== s.turn) out.push(s.players[responder]); return out; }, deadline(s) { if (s.phase === 'setup') return s.setup.startedAt + s.options.setupTimeSec * 1000; if (s.phase !== 'playing') return null; const tc = s.options.timeControl; const start = s.clock.turnStartedAt; if (tc.kind === 'byoyomi') return start + s.clock.mainMs[s.turn] + s.clock.periodsLeft[s.turn] * tc.periodSec * 1000; return null; // 시간 제한 없음: 차례 마감 없음 }, timeoutPlayers(s) { if (s.phase === 'setup') return setupWaiting(s).map((x) => s.players[x]); return s.phase === 'playing' ? [s.players[s.turn]] : []; }, onTimeout() { return { type: 'timeout' }; }, onLeave() { return { type: 'resign' }; }, result(s): GameResult | null { if (!s.result) return null; const { winner, reason, summaryKo, scores } = s.result; if (reason === 'aborted') return { ranking: [], summary: summaryKo, reason: 'abandoned' }; const ranking = winner ? [[s.players[winner]], [s.players[other(winner)]]] : [[s.players.cho, s.players.han]]; const map: Partial> = { resign: 'resign', timeLoss: 'timeout', drawAgreed: 'draw-agreed' }; return { ranking, scores: { [s.players.cho]: scores.cho, [s.players.han]: scores.han }, summary: summaryKo, reason: map[reason] ?? 'normal', }; }, legalActions(s, p) { const me = sideOf(s, p); if (!me || s.phase === 'finished') return []; if (s.phase === 'setup') { return setupWaiting(s).includes(me) ? ARRANGEMENTS.map((arrangement) => ({ type: 'chooseSetup' as const, arrangement })) : []; } const responder = pendingResponder(s); if (responder === me) return s.undo.pending ? [{ type: 'respondUndo', accept: false }] : [{ type: 'respondDraw', accept: false }]; if (s.turn !== me) return []; const mo = moveOptions(s); const out: JanggiAction[] = mo.moves.map((m) => ({ type: 'move' as const, from: sqOf(m.from), to: sqOf(m.to) })); if (mo.pass) out.push({ type: 'pass' }); return out; }, secretsOf(s, p) { const me = sideOf(s, p); if (!me || s.phase !== 'setup' || s.options.setupOrder !== 'simultaneous') return []; const c = s.setup.choice[me]; return c ? [c] : []; }, };