- 오목에서 [시간 제한 없음]을 골라도 30초가 흐르던 문제: 대기실 설정이 [설정 저장]을 눌러야 서버에 반영되어, 누르지 않고 [게임 시작]하면 기본값(한 수 30초)으로 시작했다. 이제 설정은 바꾸는 즉시 서버에 보낸다(저장 버튼 없음, 휴대폰 시트는 [닫기]만). 빠르게 여러 번 눌러도 먼저 온 방 정보가 새 값을 되돌리지 않게 lib/option-sync.ts로 맞춘다. - 시간 제한 없음(오목·체스·장기·오델로·바둑·쿼리도): 무응답 한도(idleLimitSec, 5~10분 뒤 시간패)와 그 카운트다운을 없앴다. 체스는 첫 수 제한도 피셔일 때만. 연결이 끊긴 사람의 접속 유예는 그대로. - 홈의 [지금 열린 방] 탭, 게임 소개의 [아무나와 빠른 시작]·기다리는 방 목록, 대기실의 방 공개 설정을 뺐다(방은 모두 친구만). 서버 API는 남겨 두고 화면에서만 부르지 않는다. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
782 lines
30 KiB
TypeScript
782 lines
30 KiB
TypeScript
/** 장기 (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] : [];
|
||
},
|
||
};
|