Files
joke-app/packages/games/src/janggi/index.ts
EJClaw 19c163f4d2 시간 제한 없음은 정말 무제한, 대기실 설정은 바꾸면 바로 적용, 모르는 사람 기다리기(빠른 시작·공개 방) 제거
- 오목에서 [시간 제한 없음]을 골라도 30초가 흐르던 문제: 대기실 설정이 [설정 저장]을 눌러야
  서버에 반영되어, 누르지 않고 [게임 시작]하면 기본값(한 수 30초)으로 시작했다. 이제 설정은
  바꾸는 즉시 서버에 보낸다(저장 버튼 없음, 휴대폰 시트는 [닫기]만). 빠르게 여러 번 눌러도
  먼저 온 방 정보가 새 값을 되돌리지 않게 lib/option-sync.ts로 맞춘다.
- 시간 제한 없음(오목·체스·장기·오델로·바둑·쿼리도): 무응답 한도(idleLimitSec, 5~10분 뒤 시간패)와
  그 카운트다운을 없앴다. 체스는 첫 수 제한도 피셔일 때만. 연결이 끊긴 사람의 접속 유예는 그대로.
- 홈의 [지금 열린 방] 탭, 게임 소개의 [아무나와 빠른 시작]·기다리는 방 목록, 대기실의 방 공개
  설정을 뺐다(방은 모두 친구만). 서버 API는 남겨 두고 화면에서만 부르지 않는다.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 20:42:46 +09:00

782 lines
30 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/** 장기 (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<Side, string> = { 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<typeof janggiOptionsSchema>;
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<typeof janggiActionSchema> | { 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<Side, number>;
moveCount: number;
summaryKo: string;
}
export interface JanggiState {
options: JanggiOptions;
players: Record<Side, PlayerId>;
phase: 'setup' | 'playing' | 'finished';
/** `startedAt` = when the current chooser's setup clock started. */
setup: { choice: Record<Side, Arrangement | null>; 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<string, number>;
bikjang: { calledBy: Side; ply: number } | null;
consecutivePasses: number;
inCheck: Side | null;
clock: { mainMs: Record<Side, number>; periodsLeft: Record<Side, number>; turnStartedAt: number };
undo: { usedBy: Record<Side, number>; pending: { by: Side; plies: 1 | 2 } | null };
draw: { pendingBy: Side | null; lastOfferPly: Record<Side, number | null> };
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<Side, PlayerId>;
mySide: Side | null;
turn: Side;
setup: {
order: JanggiOptions['setupOrder'];
/** Public (or my own) choices; hidden choices are null. */
choice: Record<Side, Arrangement | null>;
ready: Record<Side, boolean>;
/** 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<Side, PieceKind[]>;
/** Live score incl. 덤. */
scores: Record<Side, number>;
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<JanggiOptions, 'scoring' | 'bikjang' | 'repetition' | 'allowPass' | 'moveLimit' | 'hints' | 'timeControl' | 'setupTimeSec'>;
clock: JanggiState['clock'];
undo: { allowed: boolean; remaining: Record<Side, number | null>; 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<Side, number> {
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<string, string> = { 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<JanggiState, JanggiAction, JanggiView, JanggiOptions> = {
id: 'janggi',
nameKo: '장기',
minPlayers: 2,
maxPlayers: 2,
stateVersion: 1,
defaultOptions: janggiOptionsSchema.parse({}),
optionsSchema: janggiOptionsSchema,
adminOnlyOptions: ['threatHints'],
actionSchema: janggiActionSchema as z.ZodType<JanggiAction>,
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<Side, PieceKind[]> = { 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<Record<JanggiReason, GameResult['reason']>> = { 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] : [];
},
};