M2: 체스·장기 추가

- 체스(chess.js 1.4.0, BSD-2): 캐슬링·앙파상·승진·체크메이트·스테일메이트·3회 반복·50/75수·기물 부족,
  피셔 시계(상대가 메이트 불가면 시간패 대신 무승부), 무르기·무승부·기권, 직접 그린 SVG 기물,
  클릭·드래그 이동, 승진 선택, 판 돌리기 (테스트 40개, perft 포함)
- 장기: 궁성 대각·포다리·멱·장군 합법성·빅장(KJF/KJA/끔)·반복 수 처리·한수쉼·점수 판정(한 덤 1.5),
  상차림 단계(순서/동시 비공개), 초읽기, 팔각 기물(한자+한글), 한 쪽이면 판 뒤집기 (테스트 46개)
- 게임 공통 E2E에 체스·장기 추가(PC 방장 + 휴대폰 친구, 상차림→첫 수)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
EJClaw
2026-10-04 05:24:11 +09:00
parent c5c30d4ada
commit 62a6caa6a7
11 changed files with 4822 additions and 0 deletions

View File

@@ -0,0 +1,781 @@
/** 장기 (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: '시간 제한' }),
idleLimitSec: z.number().int().min(60).max(3600).default(600).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', 'movesAndThreats'])
.default('moves')
.meta({ title: '도움 표시', labels: { off: '끄기', moves: '갈 수 있는 곳', movesAndThreats: '갈 수 있는 곳 + 잡힐 위험' } }),
});
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' | 'idleLimitSec' | '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,
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.hints === 'movesAndThreats') {
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,
idleLimitSec: o.idleLimitSec,
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 start + s.options.idleLimitSec * 1000;
},
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] : [];
},
};