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] : [];
},
};

View File

@@ -0,0 +1,563 @@
import { describe, expect, test } from 'bun:test';
import { SeededRng, randomPlayout, seedN } from '@bg/engine';
import { janggi, type JanggiAction, type JanggiOptions, type JanggiState } from './index';
import { idx, inCheck, initialBoard, kingsFacing, makeMove, positionKey, type Board, type PieceKind, type Side } from './rules';
const rng = () => SeededRng.fromSeed([1, 2, 3, 4]);
type P = [Side, PieceKind, number, number];
function opts(o: Partial<JanggiOptions> = {}): JanggiOptions {
return janggi.optionsSchema.parse({ sideAssignment: 'hostCho', ...o });
}
/** Fresh game in setup phase. A = 초, B = 한. */
function fresh(o: Partial<JanggiOptions> = {}): JanggiState {
return janggi.setup({ players: ['A', 'B'], options: opts(o), rng: rng(), now: 0 });
}
/** Game in play with a custom board. */
function custom(pieces: P[], o: Partial<JanggiOptions> = {}, turn: Side = 'cho'): JanggiState {
const s = fresh(o);
const b: Board = new Array(90).fill(null);
for (const [side, kind, f, r] of pieces) b[idx(f, r)] = { side, kind };
s.board = b;
s.startBoard = b.slice();
s.phase = 'playing';
s.turn = turn;
s.inCheck = inCheck(b, turn) ? turn : null;
s.positionCounts = { [positionKey(b, turn)]: 1 };
return s;
}
/** Standard opening (both 상차림 chosen), 초 to move. */
function standard(o: Partial<JanggiOptions> = {}, cho: string = 'MSSM', han: string = 'MSSM'): JanggiState {
let s = fresh(o);
s = act(s, 'B', { type: 'chooseSetup', arrangement: han as 'MSSM' });
return act(s, 'A', { type: 'chooseSetup', arrangement: cho as 'MSSM' });
}
const pl = (s: JanggiState, side: Side = s.turn) => s.players[side];
function act(s: JanggiState, who: string, a: JanggiAction, now = 1000): JanggiState {
const v = janggi.validate(s, who, a);
if (!v.ok) throw new Error(v.reason);
return janggi.apply(s, who, a, { rng: rng(), now }).state;
}
const mv = (f1: number, r1: number, f2: number, r2: number): JanggiAction => ({ type: 'move', from: { f: f1, r: r1 }, to: { f: f2, r: r2 } });
function check(s: JanggiState, f1: number, r1: number, f2: number, r2: number) {
return janggi.validate(s, pl(s), mv(f1, r1, f2, r2));
}
function play(s: JanggiState, f1: number, r1: number, f2: number, r2: number, now = 1000) {
return act(s, pl(s), mv(f1, r1, f2, r2), now);
}
/** Destinations of the piece at (f,r) from the mover's legal list. */
function targets(s: JanggiState, f: number, r: number): string[] {
return (janggi.legalActions!(s, pl(s)) as JanggiAction[])
.filter((a): a is Extract<JanggiAction, { type: 'move' }> => a.type === 'move' && a.from.f === f && a.from.r === r)
.map((a) => `${a.to.f},${a.to.r}`)
.sort();
}
const kingsOnly: P[] = [
['cho', 'king', 3, 0],
['han', 'king', 5, 9],
];
describe('setup (§4)', () => {
test('SMSM maps to the correct squares for both seats', () => {
const s = standard({}, 'SMSM', 'SMSM');
const k = (f: number, r: number) => s.board[idx(f, r)]?.kind;
expect([k(1, 0), k(2, 0), k(6, 0), k(7, 0)]).toEqual(['sang', 'ma', 'sang', 'ma']);
expect([k(7, 9), k(6, 9), k(2, 9), k(1, 9)]).toEqual(['sang', 'ma', 'sang', 'ma']);
expect(s.board[idx(7, 9)]?.side).toBe('han');
});
test.each([
['MSSM', ['ma', 'sang', 'sang', 'ma']],
['SMMS', ['sang', 'ma', 'ma', 'sang']],
['SMSM', ['sang', 'ma', 'sang', 'ma']],
['MSMS', ['ma', 'sang', 'ma', 'sang']],
])('%s left→right from own seat', (arr, kinds) => {
const s = standard({}, arr, arr);
expect([[1, 0], [2, 0], [6, 0], [7, 0]].map(([f, r]) => s.board[idx(f!, r!)]?.kind as string | undefined)).toEqual(kinds);
expect([[7, 9], [6, 9], [2, 9], [1, 9]].map(([f, r]) => s.board[idx(f!, r!)]?.kind as string | undefined)).toEqual(kinds);
});
test('fixed pieces, 초 moves first, initial position counted once', () => {
const s = standard();
expect(s.phase).toBe('playing');
expect(s.turn).toBe('cho');
expect(s.board[idx(4, 1)]).toEqual({ side: 'cho', kind: 'king' });
expect(s.board[idx(4, 8)]).toEqual({ side: 'han', kind: 'king' });
expect(s.board[idx(1, 7)]).toEqual({ side: 'han', kind: 'po' });
expect(s.board.filter(Boolean)).toHaveLength(32);
expect(Object.values(s.positionCounts)).toEqual([1]);
expect(janggi.view(s, 'A').scores).toEqual({ cho: 72, han: 73.5 });
});
test('hanFirst: 초 must wait, 한 choice is public immediately', () => {
const s = fresh();
expect(janggi.activePlayers(s)).toEqual(['B']);
expect(janggi.validate(s, 'A', { type: 'chooseSetup', arrangement: 'MSSM' }).ok).toBe(false);
const s2 = act(s, 'B', { type: 'chooseSetup', arrangement: 'SMMS' }, 5000);
expect(janggi.view(s2, 'A').setup.choice.han).toBe('SMMS');
expect(janggi.view(s2, null).board[idx(7, 9)]?.kind).toBe('sang');
expect(janggi.activePlayers(s2)).toEqual(['A']);
expect(janggi.deadline!(s2)).toBe(5000 + 60_000);
expect(janggi.validate(s2, 'B', { type: 'chooseSetup', arrangement: 'MSSM' }).ok).toBe(false);
});
test('simultaneous: no leak of the hidden choice to the opponent or spectators', () => {
const s = act(fresh({ setupOrder: 'simultaneous' }), 'A', { type: 'chooseSetup', arrangement: 'MSMS' });
expect(janggi.activePlayers(s)).toEqual(['B']);
for (const viewer of ['B', null]) {
const v = janggi.view(s, viewer);
expect(v.setup.choice.cho).toBeNull();
expect(v.setup.ready.cho).toBe(true);
expect(JSON.stringify(v)).not.toContain('MSMS');
}
expect(janggi.view(s, 'A').setup.choice.cho).toBe('MSMS');
expect(janggi.secretsOf!(s, 'A')).toEqual(['MSMS']);
const s2 = act(s, 'B', { type: 'chooseSetup', arrangement: 'SMMS' });
expect(s2.phase).toBe('playing');
expect(janggi.view(s2, null).setup.choice).toEqual({ cho: 'MSMS', han: 'SMMS' });
});
test('setup timeout picks with the seeded RNG, deterministically', () => {
const run = () => {
let s = fresh();
for (let i = 0; i < 2; i++) {
const p = janggi.timeoutPlayers!(s)[0]!;
const a = janggi.onTimeout(s, p);
expect(janggi.validate(s, p, a).ok).toBe(true);
s = janggi.apply(s, p, a, { rng: SeededRng.fromSeed([9, 8, 7, 6]), now: 60_000 * (i + 1) }).state;
}
return s;
};
const a = run();
expect(a.phase).toBe('playing');
expect(a.setup.choice).toEqual(run().setup.choice);
expect(janggi.validate(fresh(), 'A', { type: 'timeout' }).ok).toBe(false);
});
test('resign during setup aborts without a result ranking', () => {
const s = act(fresh(), 'A', { type: 'resign' });
expect(janggi.result(s)).toMatchObject({ ranking: [], reason: 'abandoned' });
});
});
describe('piece movement (§5.2)', () => {
test('마 멱: a blocked first step removes both targets in that direction', () => {
const base: P[] = [...kingsOnly, ['cho', 'ma', 4, 4]];
expect(targets(custom(base), 4, 4)).toHaveLength(8);
const s = custom([...base, ['cho', 'jol', 4, 5]]);
expect(targets(s, 4, 4)).not.toContain('3,6');
expect(targets(s, 4, 4)).not.toContain('5,6');
expect(targets(s, 4, 4)).toHaveLength(6);
expect(check(s, 4, 4, 5, 6)).toEqual({ ok: false, reason: '마의 길(멱)이 막혀 있어요.' });
});
test('상 멱: first and second steps both matter', () => {
const base: P[] = [...kingsOnly, ['cho', 'sang', 4, 4]];
expect(targets(custom(base), 4, 4).sort()).toEqual(['1,2', '1,6', '2,1', '2,7', '6,1', '6,7', '7,2', '7,6'].sort());
const first = custom([...base, ['han', 'jol', 4, 5]]);
expect(targets(first, 4, 4)).not.toContain('2,7');
expect(targets(first, 4, 4)).not.toContain('6,7');
const second = custom([...base, ['han', 'jol', 5, 6]]);
expect(targets(second, 4, 4)).toContain('2,7');
expect(targets(second, 4, 4)).not.toContain('6,7');
expect(check(second, 4, 4, 6, 7)).toEqual({ ok: false, reason: '상의 길(멱)이 막혀 있어요.' });
});
test('포: needs a screen, cannot jump or capture 포, may land on any square beyond', () => {
const lone = custom([...kingsOnly, ['cho', 'po', 0, 4]]);
expect(targets(lone, 0, 4)).toEqual([]);
expect(check(lone, 0, 4, 0, 6).ok).toBe(false);
const s = custom([...kingsOnly, ['cho', 'po', 0, 4], ['cho', 'jol', 0, 5], ['han', 'cha', 0, 8]]);
expect(targets(s, 0, 4)).toEqual(['0,6', '0,7', '0,8']);
const overPo = custom([...kingsOnly, ['cho', 'po', 0, 4], ['han', 'po', 0, 5]]);
expect(targets(overPo, 0, 4)).toEqual([]);
expect(check(overPo, 0, 4, 0, 7)).toEqual({ ok: false, reason: '포는 포를 넘을 수 없어요.' });
const takePo = custom([...kingsOnly, ['cho', 'po', 0, 4], ['cho', 'jol', 0, 5], ['han', 'po', 0, 7]]);
expect(targets(takePo, 0, 4)).toEqual(['0,6']);
expect(check(takePo, 0, 4, 0, 7)).toEqual({ ok: false, reason: '포는 포를 잡을 수 없어요.' });
});
test('포 over the palace diagonal (corner → centre screen → corner)', () => {
const s = custom([['cho', 'king', 4, 1], ['han', 'king', 3, 9], ['cho', 'po', 3, 7], ['han', 'sa', 4, 8]], {}, 'cho');
expect(targets(s, 3, 7)).toContain('5,9');
const empty = custom([['cho', 'king', 4, 1], ['han', 'king', 3, 9], ['cho', 'po', 3, 7]]);
expect(targets(empty, 3, 7)).not.toContain('5,9');
const poScreen = custom([['cho', 'king', 4, 1], ['han', 'king', 3, 9], ['cho', 'po', 3, 7], ['han', 'po', 4, 8]]);
expect(targets(poScreen, 3, 7)).not.toContain('5,9');
});
test('차 palace diagonal: corner to corner through an empty centre only', () => {
const s = custom([['cho', 'king', 4, 1], ['han', 'king', 3, 8], ['cho', 'cha', 3, 7]]);
expect(targets(s, 3, 7)).toContain('4,8');
expect(targets(s, 3, 7)).toContain('5,9');
const blocked = custom([['cho', 'king', 4, 1], ['han', 'king', 3, 8], ['cho', 'cha', 3, 7], ['han', 'sa', 4, 8]]);
expect(targets(blocked, 3, 7)).toContain('4,8');
expect(targets(blocked, 3, 7)).not.toContain('5,9');
// No diagonal outside the palace or from an edge midpoint.
const mid = custom([['cho', 'king', 3, 0], ['han', 'king', 3, 9], ['cho', 'cha', 4, 7]]);
expect(targets(mid, 4, 7)).not.toContain('5,8');
});
test('궁·사: no diagonal from edge midpoints, never leave the palace', () => {
const s = custom([['cho', 'king', 4, 0], ['cho', 'sa', 3, 1], ['han', 'king', 3, 9]]);
expect(targets(s, 4, 0)).toEqual(['3,0', '4,1', '5,0']);
expect(targets(s, 3, 1)).toEqual(['3,0', '3,2', '4,1']);
const corner = custom([['cho', 'king', 3, 2], ['han', 'king', 5, 9]]);
expect(targets(corner, 3, 2)).toEqual(['3,1', '4,1', '4,2']);
expect(check(corner, 3, 2, 3, 3)).toEqual({ ok: false, reason: '궁은 궁성 밖으로 나갈 수 없어요.' });
const centre = custom([['cho', 'king', 4, 1], ['han', 'king', 3, 9]]);
expect(targets(centre, 4, 1)).toHaveLength(8);
});
test('졸: no backwards, forward diagonal in the enemy palace, not in its own', () => {
const s = custom([...kingsOnly, ['cho', 'jol', 2, 5]]);
expect(targets(s, 2, 5)).toEqual(['1,5', '2,6', '3,5']);
expect(check(s, 2, 5, 2, 4)).toEqual({ ok: false, reason: '졸은 뒤로 갈 수 없어요.' });
const pal = custom([['cho', 'king', 4, 1], ['han', 'king', 5, 9], ['cho', 'jol', 3, 7]]);
expect(targets(pal, 3, 7)).toContain('4,8');
const centre = custom([['cho', 'king', 4, 1], ['han', 'king', 5, 8], ['cho', 'jol', 4, 8]]);
expect(targets(centre, 4, 8)).toEqual(['3,8', '3,9', '4,9', '5,8', '5,9']);
const own = custom([['cho', 'king', 3, 0], ['han', 'king', 5, 9], ['han', 'jol', 3, 7]], {}, 'han');
expect(targets(own, 3, 7)).toEqual(['2,7', '3,6', '4,7']);
// 한 병 in 초's palace goes diagonally toward r=0.
const enemy = custom([['cho', 'king', 5, 0], ['han', 'king', 5, 9], ['han', 'jol', 3, 2]], {}, 'han');
expect(targets(enemy, 3, 2)).toContain('4,1');
// Last rank: sideways only.
const last = custom([['cho', 'king', 4, 1], ['han', 'king', 5, 8], ['cho', 'jol', 0, 9]]);
expect(targets(last, 0, 9)).toEqual(['1,9']);
});
});
describe('장군 (§5.3)', () => {
test('moving into check is rejected; 포 pins (one screen left) are respected', () => {
const s = custom([['cho', 'king', 4, 1], ['han', 'king', 3, 9], ['han', 'cha', 5, 6]]);
expect(check(s, 4, 1, 5, 1)).toEqual({ ok: false, reason: '그 수를 두면 내 궁이 잡혀요(장군).' });
const pin = custom([['cho', 'king', 4, 1], ['cho', 'sa', 4, 2], ['cho', 'jol', 4, 3], ['han', 'king', 3, 9], ['han', 'po', 4, 6]]);
expect(inCheck(pin.board, 'cho')).toBe(false);
expect(check(pin, 4, 3, 3, 3).ok).toBe(false);
expect(check(pin, 4, 3, 4, 4).ok).toBe(true);
// Stepping into a 포 line creates the screen → also illegal.
const screen = custom([['cho', 'king', 4, 1], ['cho', 'jol', 3, 4], ['han', 'king', 3, 9], ['han', 'po', 4, 6]]);
expect(check(screen, 3, 4, 4, 4).ok).toBe(false);
});
test('pass is refused in check; check must be answered', () => {
const s = custom([['cho', 'king', 3, 0], ['han', 'king', 4, 9], ['cho', 'cha', 0, 9]], {}, 'han');
expect(s.inCheck).toBe('han');
expect(janggi.validate(s, 'B', { type: 'pass' })).toEqual({ ok: false, reason: '장군을 받은 상태에서는 쉴 수 없어요.' });
expect(check(s, 4, 9, 5, 9)).toEqual({ ok: false, reason: '장군을 피해야 해요.' });
expect(targets(s, 4, 9)).toEqual(['4,8']);
});
test('외통 ends the game', () => {
const s = custom([['cho', 'king', 3, 0], ['cho', 'cha', 8, 8], ['cho', 'cha', 0, 0], ['han', 'king', 4, 9]]);
const r = janggi.apply(s, 'A', mv(0, 0, 0, 9), { rng: rng(), now: 1000 });
expect(r.state.result).toMatchObject({ winner: 'cho', reason: 'checkmate', summaryKo: '초 외통승 (1수)' });
expect(r.events.map((e) => e.type)).toContain('checkmate');
expect(janggi.result(r.state)?.ranking).toEqual([['A'], ['B']]);
});
test('janggun / meonggun events', () => {
const s = custom([['cho', 'king', 3, 0], ['cho', 'cha', 0, 0], ['han', 'king', 4, 9], ['han', 'sa', 5, 8]]);
const r1 = janggi.apply(s, 'A', mv(0, 0, 0, 9), { rng: rng(), now: 1 });
expect(r1.events.map((e) => e.type)).toContain('janggun');
const r2 = janggi.apply(r1.state, 'B', mv(4, 9, 4, 8), { rng: rng(), now: 2 });
expect(r2.events.map((e) => e.type)).toContain('meonggun');
});
});
describe('빅장 (§5.4)', () => {
// Kings face on file 4 once the 초 졸 on (4,4) steps aside.
const facing: P[] = [
['cho', 'king', 4, 1],
['cho', 'jol', 4, 4],
['cho', 'cha', 0, 0],
['cho', 'sa', 3, 0],
['han', 'king', 4, 8],
['han', 'jol', 0, 6],
['han', 'cha', 8, 5],
['han', 'sa', 3, 9],
];
test('kjf: facing before any capture does nothing', () => {
const s = play(custom(facing), 4, 4, 3, 4);
expect(kingsFacing(s.board)).toBe(true);
expect(s.bikjang).toBeNull();
});
test('kjf: the first capture itself cannot call 빅장', () => {
const s = play(custom([...facing.filter((p) => p[1] !== 'jol' || p[0] !== 'cho'), ['cho', 'ma', 4, 4], ['han', 'jol', 6, 5]]), 4, 4, 6, 5);
expect(s.firstCapturePly).toBe(0);
expect(kingsFacing(s.board)).toBe(true);
expect(s.bikjang).toBeNull();
});
const called = () => {
let s = play(custom(facing), 0, 0, 0, 6); // capture
s = play(s, 3, 9, 3, 8);
return play(s, 4, 4, 3, 4); // 빅장
};
test('kjf: after a capture, facing calls 빅장; blocking continues the game', () => {
const s = called();
expect(s.bikjang).toEqual({ calledBy: 'cho', ply: 2 });
expect(janggi.view(s, 'B').bikjang).toEqual({ calledBy: 'cho' });
const blocked = play(s, 8, 5, 4, 5);
expect(blocked.phase).toBe('playing');
expect(blocked.bikjang).toBeNull();
});
test('kjf: not answering → 빅, score judgement (덤 means no tie)', () => {
const s = act(called(), 'B', { type: 'pass' });
// 초: 차13 + 사3 + 졸2 = 18; 한: 차13 + 사3 + 1.5 = 17.5
expect(s.result).toMatchObject({ winner: 'cho', reason: 'scoreBikjang', scores: { cho: 18, han: 17.5 }, summaryKo: '초 판정승 18 : 17.5 (빅장)' });
const noScore = act(
(() => {
let t = play(custom(facing, { scoring: false }), 0, 0, 0, 6);
t = play(t, 3, 9, 3, 8);
return play(t, 4, 4, 3, 4);
})(),
'B',
{ type: 'pass' },
);
expect(noScore.result).toMatchObject({ winner: null, reason: 'drawBikjang' });
expect(janggi.result(noScore)?.ranking).toEqual([['A', 'B']]);
});
test('kja30: needs both sides under 30 points', () => {
const rich = play(custom([...facing, ['cho', 'cha', 8, 0], ['cho', 'po', 1, 2]], { bikjang: 'kja30' }), 4, 4, 3, 4);
expect(rich.bikjang).toBeNull();
const poor = play(custom(facing, { bikjang: 'kja30' }), 4, 4, 3, 4);
expect(poor.bikjang?.calledBy).toBe('cho');
});
test('off: facing is harmless', () => {
let s = play(custom(facing, { bikjang: 'off' }), 0, 0, 0, 6);
s = play(s, 3, 9, 3, 8);
s = play(s, 4, 4, 3, 4);
expect(s.bikjang).toBeNull();
expect(act(s, 'B', { type: 'pass' }).phase).toBe('playing');
});
});
describe('repetition (§5.5) and passes (§5.6)', () => {
const shuffle: P[] = [...kingsOnly, ['cho', 'cha', 0, 0], ['han', 'cha', 8, 9]];
/** 초 차 0,0↔0,1 and 한 차 8,9↔8,8; the 12th ply would repeat the start a 4th time. */
function cycle(s: JanggiState, plies: number): JanggiState {
const seq: [number, number, number, number][] = [
[0, 0, 0, 1],
[8, 9, 8, 8],
[0, 1, 0, 0],
[8, 8, 8, 9],
];
for (let i = 0; i < plies; i++) s = play(s, ...seq[i % 4]!);
return s;
}
test('forbid: the move creating the 4th repetition is refused, others allowed', () => {
const s = cycle(custom(shuffle), 11);
expect(janggi.view(s, 'B').repeatWarning).toBe(true);
expect(check(s, 8, 8, 8, 9)).toEqual({ ok: false, reason: '같은 수를 너무 많이 반복해서 둘 수 없어요.' });
expect(targets(s, 8, 8)).not.toContain('8,9');
expect(check(s, 8, 8, 8, 7).ok).toBe(true);
});
test('forbid exception: in check with only a repeating move → allowed, then score judgement', () => {
const s = custom([['cho', 'king', 3, 0], ['cho', 'cha', 0, 9], ['han', 'king', 4, 9]], {}, 'han');
s.positionCounts[positionKey(makeMove(s.board, idx(4, 9), idx(4, 8)), 'cho')] = 3;
expect(check(s, 4, 9, 4, 8).ok).toBe(true);
const end = play(s, 4, 9, 4, 8);
expect(end.result).toMatchObject({ reason: 'scoreRepetition', winner: 'cho' });
});
test('lose: the side creating the 4th repetition loses', () => {
const s = cycle(custom(shuffle, { repetition: 'lose' }), 12);
expect(s.result).toMatchObject({ winner: 'cho', reason: 'repetitionLoss' });
});
test('scoreEnd: 4th repetition ends by score', () => {
const s = cycle(custom(shuffle, { repetition: 'scoreEnd' }), 12);
expect(s.result?.reason).toBe('scoreRepetition');
});
test('both pass in a row → score judgement, or draw without scoring', () => {
let s = act(standard(), 'A', { type: 'pass' });
s = act(s, 'B', { type: 'pass' });
expect(s.result).toMatchObject({ winner: 'han', reason: 'scorePasses', summaryKo: '한 판정승 73.5 : 72 (연속 한수쉼)' });
let d = act(standard({ scoring: false }), 'A', { type: 'pass' });
d = act(d, 'B', { type: 'pass' });
expect(d.result).toMatchObject({ winner: null, reason: 'drawPasses' });
});
test('allowPass=false refuses pass unless there is no legal move', () => {
const s = standard({ allowPass: false });
expect(janggi.validate(s, 'A', { type: 'pass' })).toEqual({ ok: false, reason: '이 방은 한수쉼을 쓰지 않아요.' });
// 초 궁 boxed in by defended 한 pieces, no other 초 piece, not in check → pass allowed.
const stuck = custom(
[
['cho', 'king', 3, 0],
['han', 'ma', 4, 0],
['han', 'ma', 3, 1],
['han', 'ma', 4, 1],
['han', 'cha', 3, 5],
['han', 'cha', 4, 5],
['han', 'jol', 5, 0],
['han', 'king', 5, 9],
],
{ allowPass: false },
);
expect(stuck.inCheck).toBeNull();
expect(janggi.view(stuck, 'A').legalMoves).toEqual([]);
expect(janggi.validate(stuck, 'A', { type: 'pass' }).ok).toBe(true);
});
test('moveLimit reached → score judgement', () => {
let s = standard({ moveLimit: 2 });
s = play(s, 0, 3, 1, 3);
s = play(s, 0, 6, 1, 6);
expect(s.result?.reason).toBe('scoreMoveLimit');
});
});
describe('clocks (§8.5)', () => {
test('byoyomi: main time, then periods, then time loss', () => {
const tc = { kind: 'byoyomi' as const, mainSec: 60, periodSec: 30, periods: 3 };
let s = standard({ timeControl: tc });
s.clock.turnStartedAt = 0;
expect(janggi.deadline!(s)).toBe(60_000 + 90_000);
s = play(s, 0, 3, 1, 3, 50_000); // 초 used 50s
expect(s.clock.mainMs.cho).toBe(10_000);
s = play(s, 0, 6, 1, 6, 60_000);
s = play(s, 1, 3, 0, 3, 60_000 + 35_000); // 10s main + 25s of a period → keeps 3
expect(s.clock).toMatchObject({ mainMs: { cho: 0 }, periodsLeft: { cho: 3 } });
s = play(s, 1, 6, 0, 6, 100_000);
s = play(s, 0, 3, 1, 3, 100_000 + 31_000); // over one period
expect(s.clock.periodsLeft.cho).toBe(2);
s = play(s, 0, 6, 1, 6, 140_000);
expect(janggi.deadline!(s)).toBe(140_000 + 60_000);
s = play(s, 1, 3, 0, 3, 140_000 + 61_000); // used both remaining periods
expect(s.result).toMatchObject({ winner: 'han', reason: 'timeLoss' });
expect(s.board[idx(1, 3)]?.kind).toBe('jol'); // the late move was not played
});
test('timeout action → time loss for the side to move; none uses idle limit', () => {
const s = standard();
expect(janggi.timeoutPlayers!(s)).toEqual(['A']);
const t = act(s, 'A', janggi.onTimeout(s, 'A'));
expect(janggi.result(t)).toMatchObject({ ranking: [['B'], ['A']], reason: 'timeout' });
const n = standard({ timeControl: { kind: 'none', mainSec: 600, periodSec: 30, periods: 3 }, idleLimitSec: 300 });
expect(janggi.deadline!(n)).toBe(n.clock.turnStartedAt + 300_000);
});
});
describe('requests, undo, views', () => {
test('undo restores firstCapturePly, 빅장 and repetition counts', () => {
const facing: P[] = [
['cho', 'king', 4, 1],
['cho', 'jol', 4, 4],
['cho', 'cha', 0, 0],
['han', 'king', 4, 8],
['han', 'jol', 0, 6],
['han', 'cha', 8, 5],
['han', 'sa', 3, 9],
];
const s0 = custom(facing);
const s1 = play(s0, 0, 0, 0, 6);
const s2 = play(s1, 3, 9, 3, 8);
const s3 = play(s2, 4, 4, 3, 4);
expect(s3.bikjang).not.toBeNull();
const s4 = play(s3, 8, 5, 4, 5);
expect(s4.bikjang).toBeNull();
// 한 takes back its block → 빅장 pending again.
let u = act(s4, 'B', { type: 'requestUndo' });
expect(u.undo.pending).toEqual({ by: 'han', plies: 1 });
u = act(u, 'A', { type: 'respondUndo', accept: true });
expect(u.bikjang).toEqual(s3.bikjang);
expect(u.positionCounts).toEqual(s3.positionCounts);
expect(u.board).toEqual(s3.board);
expect(u.turn).toBe('han');
// 초 takes back two plies (its 빅장 + 한's reply), then its capture.
u = act(u, 'A', { type: 'requestUndo' });
expect(u.undo.pending?.plies).toBe(1);
u = act(u, 'B', { type: 'respondUndo', accept: true });
expect(u.positionCounts).toEqual(s2.positionCounts);
u = act(u, 'A', { type: 'requestUndo' });
expect(u.undo.pending?.plies).toBe(2);
u = act(u, 'B', { type: 'respondUndo', accept: true });
expect(u.firstCapturePly).toBeNull();
expect(u.board).toEqual(s0.board);
expect(u.positionCounts).toEqual(s0.positionCounts);
expect(u.undo.usedBy).toEqual({ cho: 2, han: 1 });
expect(janggi.validate(u, 'A', { type: 'requestUndo' })).toEqual({ ok: false, reason: '아직 무를 수가 없어요.' });
});
test('draw offer: decline, re-offer gap, accept', () => {
let s = act(standard({ drawReofferMoves: 1 }), 'A', { type: 'offerDraw' });
expect(janggi.activePlayers(s)).toEqual(['A', 'B']);
s = act(s, 'B', { type: 'respondDraw', accept: false });
expect(janggi.validate(s, 'A', { type: 'offerDraw' }).ok).toBe(false);
s = play(play(s, 0, 3, 1, 3), 0, 6, 1, 6);
s = act(s, 'A', { type: 'offerDraw' });
s = act(s, 'B', { type: 'respondDraw', accept: true });
expect(janggi.result(s)).toMatchObject({ ranking: [['A', 'B']], reason: 'draw-agreed' });
});
test('resign during play', () => {
const s = act(standard(), 'B', { type: 'resign' });
expect(s.result).toMatchObject({ winner: 'cho', reason: 'resign' });
});
test('view: no rng, legal moves only for the player to move, threats when enabled', () => {
const s = standard({ hints: 'movesAndThreats' });
expect(JSON.stringify(janggi.view(s, 'A'))).not.toContain('rng');
expect(janggi.view(s, null).legalMoves).toBeNull();
expect(janggi.view(s, 'B').legalMoves).toBeNull();
const v = janggi.view(s, 'A');
expect(v.legalMoves!.length).toBeGreaterThan(20);
expect(v.canPass).toBe(true);
const t = custom([...kingsOnly, ['cho', 'ma', 4, 4], ['han', 'cha', 4, 7]], { hints: 'movesAndThreats' });
expect(janggi.view(t, 'A').threatened).toEqual([{ f: 4, r: 4 }]);
});
test('포 legal moves carry their screen for the UI', () => {
const v = janggi.view(custom([...kingsOnly, ['cho', 'po', 0, 4], ['cho', 'jol', 0, 5]]), 'A');
expect(v.legalMoves!.find((m) => m.from.f === 0 && m.from.r === 4)?.via).toEqual({ f: 0, r: 5 });
});
test('initial board helper is symmetric', () => {
const b = initialBoard();
for (let r = 0; r < 10; r++)
for (let f = 0; f < 9; f++) expect(b[idx(f, r)]?.kind).toBe(b[idx(f, 9 - r)]?.kind);
});
});
describe('random playouts', () => {
const kings = (st: unknown) => {
const s = st as JanggiState;
if (s.phase !== 'setup' && s.board.filter((p) => p?.kind === 'king').length !== 2) throw new Error('king missing');
};
test.each([
['default + limit 200', { moveLimit: 200 }],
['no scoring, kja30, simultaneous, limit 150', { scoring: false, bikjang: 'kja30', setupOrder: 'simultaneous', moveLimit: 150 }],
['repetition lose, no pass, limit 250', { repetition: 'lose', allowPass: false, moveLimit: 250 }],
] as const)('%s: finish, deterministic, JSON-safe', (_n, o) => {
for (let i = 0; i < 6; i++) {
const options = opts(o as Partial<JanggiOptions>);
const a = randomPlayout(janggi, { players: ['A', 'B'], options, seed: seedN(i), maxApplyMs: 50, invariant: kings, maxSteps: 400 });
const b = randomPlayout(janggi, { players: ['A', 'B'], options, seed: seedN(i), maxApplyMs: 50, maxSteps: 400 });
expect(a.finished).toBe(true);
expect(JSON.stringify(a.finalState)).toBe(JSON.stringify(b.finalState));
expect(janggi.result(a.finalState as JanggiState)).not.toBeNull();
}
});
test('defaults without a move limit stay valid for 300 steps', () => {
for (let i = 10; i < 13; i++) {
randomPlayout(janggi, { players: ['A', 'B'], options: janggi.defaultOptions, seed: seedN(i), maxApplyMs: 50, invariant: kings, maxSteps: 300 });
}
});
});

View File

@@ -0,0 +1,347 @@
/** 장기 행마·장군 판정 (docs/games/janggi.md §5). Pure board logic, no state machine. */
export type Side = 'cho' | 'han';
export type PieceKind = 'king' | 'cha' | 'po' | 'ma' | 'sang' | 'sa' | 'jol';
export interface Piece {
side: Side;
kind: PieceKind;
}
export interface Sq {
f: number;
r: number;
}
/** index = r * 9 + f. */
export type Board = (Piece | null)[];
export type Arrangement = 'MSSM' | 'SMMS' | 'SMSM' | 'MSMS';
export const FILES = 9;
export const RANKS = 10;
export const ARRANGEMENTS: readonly Arrangement[] = ['MSSM', 'SMMS', 'SMSM', 'MSMS'];
export const PIECE_VALUE: Record<PieceKind, number> = { king: 0, cha: 13, po: 7, ma: 5, sang: 3, sa: 3, jol: 2 };
/** 후수(한) 덤. Half-point so score judgements never tie. */
export const HAN_BONUS = 1.5;
export const other = (s: Side): Side => (s === 'cho' ? 'han' : 'cho');
export const idx = (f: number, r: number): number => r * FILES + f;
export const sqOf = (i: number): Sq => ({ f: i % FILES, r: Math.floor(i / FILES) });
export const onBoard = (f: number, r: number): boolean => f >= 0 && f < FILES && r >= 0 && r < RANKS;
export const inPalace = (f: number, r: number): boolean => f >= 3 && f <= 5 && (r <= 2 || r >= 7);
const isCenter = (f: number, r: number): boolean => f === 4 && (r === 1 || r === 8);
/** Palace corners and centres: the points that lie on the drawn X. */
const isDiagPoint = (f: number, r: number): boolean => isCenter(f, r) || ((f === 3 || f === 5) && (r === 0 || r === 2 || r === 7 || r === 9));
/** One diagonal step along a drawn palace line (corner ↔ centre). */
const diagLinked = (f: number, r: number, nf: number, nr: number): boolean =>
onBoard(nf, nr) && isDiagPoint(f, r) && isDiagPoint(nf, nr) && (isCenter(f, r) || isCenter(nf, nr));
const ORTHO: readonly [number, number][] = [
[1, 0],
[-1, 0],
[0, 1],
[0, -1],
];
const DIAG: readonly [number, number][] = [
[1, 1],
[1, -1],
[-1, 1],
[-1, -1],
];
/** Back-row slots for 마·상, left→right from that player's own seat. */
export const SETUP_SLOTS: Record<Side, Sq[]> = {
cho: [
{ f: 1, r: 0 },
{ f: 2, r: 0 },
{ f: 6, r: 0 },
{ f: 7, r: 0 },
],
han: [
{ f: 7, r: 9 },
{ f: 6, r: 9 },
{ f: 2, r: 9 },
{ f: 1, r: 9 },
],
};
/** Fixed pieces (everything except 마·상). */
export function initialBoard(): Board {
const b: Board = new Array<Piece | null>(FILES * RANKS).fill(null);
const put = (side: Side, kind: PieceKind, f: number, r: number) => {
b[idx(f, side === 'cho' ? r : RANKS - 1 - r)] = { side, kind };
};
for (const side of ['cho', 'han'] as const) {
put(side, 'king', 4, 1);
put(side, 'sa', 3, 0);
put(side, 'sa', 5, 0);
put(side, 'cha', 0, 0);
put(side, 'cha', 8, 0);
put(side, 'po', 1, 2);
put(side, 'po', 7, 2);
for (const f of [0, 2, 4, 6, 8]) put(side, 'jol', f, 3);
}
return b;
}
export function placeArrangement(b: Board, side: Side, arr: Arrangement): void {
SETUP_SLOTS[side].forEach((sq, i) => {
b[idx(sq.f, sq.r)] = { side, kind: arr[i] === 'M' ? 'ma' : 'sang' };
});
}
export interface PseudoMove {
to: number;
/** 포다리 (screen) for 포 moves. */
via?: number;
}
function slide(b: Board, f: number, r: number, df: number, dr: number, diag: boolean, fn: (i: number) => boolean): void {
let cf = f;
let cr = r;
for (;;) {
const nf = cf + df;
const nr = cr + dr;
if (diag ? !diagLinked(cf, cr, nf, nr) : !onBoard(nf, nr)) return;
if (!fn(idx(nf, nr))) return;
cf = nf;
cr = nr;
}
}
/** All rays a 차/포 may travel from (f,r): orthogonal lines plus palace diagonals. */
function rays(f: number, r: number): { df: number; dr: number; diag: boolean }[] {
const out = ORTHO.map(([df, dr]) => ({ df, dr, diag: false }));
if (isDiagPoint(f, r)) for (const [df, dr] of DIAG) if (diagLinked(f, r, f + df, r + dr)) out.push({ df, dr, diag: true });
return out;
}
/** Moves ignoring check (own-king safety). Captures of the enemy 궁 are included (attack detection). */
export function pseudoMoves(b: Board, from: number): PseudoMove[] {
const p = b[from];
if (!p) return [];
const { f, r } = sqOf(from);
const out: PseudoMove[] = [];
const free = (i: number) => b[i]?.side !== p.side;
const add = (nf: number, nr: number) => {
if (onBoard(nf, nr) && free(idx(nf, nr))) out.push({ to: idx(nf, nr) });
};
switch (p.kind) {
case 'king':
case 'sa':
for (const [df, dr] of ORTHO) if (inPalace(f + df, r + dr) && inPalace(f, r)) add(f + df, r + dr);
for (const [df, dr] of DIAG) if (diagLinked(f, r, f + df, r + dr)) add(f + df, r + dr);
break;
case 'cha':
for (const ray of rays(f, r)) {
slide(b, f, r, ray.df, ray.dr, ray.diag, (i) => {
if (!b[i]) {
out.push({ to: i });
return true;
}
if (b[i]!.side !== p.side) out.push({ to: i });
return false;
});
}
break;
case 'po':
for (const ray of rays(f, r)) {
let screen = -1;
slide(b, f, r, ray.df, ray.dr, ray.diag, (i) => {
const q = b[i];
if (screen < 0) {
if (!q) return true;
if (q.kind === 'po') return false;
screen = i;
return true;
}
if (!q) {
out.push({ to: i, via: screen });
return true;
}
if (q.side !== p.side && q.kind !== 'po') out.push({ to: i, via: screen });
return false;
});
}
break;
case 'ma':
for (const [df, dr] of ORTHO) {
if (!onBoard(f + df, r + dr) || b[idx(f + df, r + dr)]) continue;
const [sf, sr] = [dr, df]; // perpendicular
add(f + 2 * df + sf, r + 2 * dr + sr);
add(f + 2 * df - sf, r + 2 * dr - sr);
}
break;
case 'sang':
for (const [df, dr] of ORTHO) {
if (!onBoard(f + df, r + dr) || b[idx(f + df, r + dr)]) continue;
const [sf, sr] = [dr, df];
for (const s of [1, -1]) {
const mf = f + 2 * df + s * sf;
const mr = r + 2 * dr + s * sr;
if (!onBoard(mf, mr) || b[idx(mf, mr)]) continue;
add(f + 3 * df + 2 * s * sf, r + 3 * dr + 2 * s * sr);
}
}
break;
case 'jol': {
const fwd = p.side === 'cho' ? 1 : -1;
add(f, r + fwd);
add(f - 1, r);
add(f + 1, r);
const enemyPalace = p.side === 'cho' ? r >= 7 : r <= 2;
if (enemyPalace) for (const df of [-1, 1]) if (diagLinked(f, r, f + df, r + fwd)) add(f + df, r + fwd);
break;
}
}
return out;
}
export function findKing(b: Board, side: Side): number {
for (let i = 0; i < b.length; i++) {
const p = b[i];
if (p && p.kind === 'king' && p.side === side) return i;
}
return -1;
}
/** Is square `target` attacked by any piece of `by`? (Facing 궁 is not an attack.) */
export function attacks(b: Board, by: Side, target: number): boolean {
for (let i = 0; i < b.length; i++) {
const p = b[i];
if (!p || p.side !== by) continue;
if (p.kind === 'king' || p.kind === 'sa') {
// Cheap reject: palace pieces only reach adjacent palace points.
const a = sqOf(i);
const t = sqOf(target);
if (Math.abs(a.f - t.f) > 1 || Math.abs(a.r - t.r) > 1) continue;
}
for (const m of pseudoMoves(b, i)) if (m.to === target) return true;
}
return false;
}
export function inCheck(b: Board, side: Side): boolean {
const k = findKing(b, side);
return k >= 0 && attacks(b, other(side), k);
}
/** Two 궁 on the same file with nothing between them. */
export function kingsFacing(b: Board): boolean {
const c = findKing(b, 'cho');
const h = findKing(b, 'han');
if (c < 0 || h < 0) return false;
const a = sqOf(c);
const z = sqOf(h);
if (a.f !== z.f) return false;
const [lo, hi] = a.r < z.r ? [a.r, z.r] : [z.r, a.r];
for (let r = lo + 1; r < hi; r++) if (b[idx(a.f, r)]) return false;
return true;
}
export interface LegalMove {
from: number;
to: number;
via?: number;
}
export function makeMove(b: Board, from: number, to: number): Board {
const n = b.slice();
n[to] = n[from]!;
n[from] = null;
return n;
}
/** Moves that do not leave the mover's own 궁 attacked. */
export function legalMoves(b: Board, side: Side): LegalMove[] {
const out: LegalMove[] = [];
for (let i = 0; i < b.length; i++) {
if (b[i]?.side !== side) continue;
for (const m of pseudoMoves(b, i)) {
if (!inCheck(makeMove(b, i, m.to), side)) out.push(m.via === undefined ? { from: i, to: m.to } : { from: i, to: m.to, via: m.via });
}
}
return out;
}
export function hasLegalMove(b: Board, side: Side): boolean {
for (let i = 0; i < b.length; i++) {
if (b[i]?.side !== side) continue;
for (const m of pseudoMoves(b, i)) if (!inCheck(makeMove(b, i, m.to), side)) return true;
}
return false;
}
/** Material on board (덤 excluded). */
export function material(b: Board, side: Side): number {
let n = 0;
for (const p of b) if (p && p.side === side) n += PIECE_VALUE[p.kind];
return n;
}
const CODE: Record<PieceKind, string> = { king: 'k', cha: 'c', po: 'p', ma: 'm', sang: 's', sa: 'a', jol: 'j' };
/** Position key = placement + side to move (docs §5.5). */
export function positionKey(b: Board, turn: Side): string {
let s = turn === 'cho' ? 'C' : 'H';
for (const p of b) s += p ? (p.side === 'cho' ? CODE[p.kind].toUpperCase() : CODE[p.kind]) : '.';
return s;
}
/** Best-effort Korean explanation for why a piece cannot reach `to` (ignores check). */
export function explainIllegal(b: Board, from: number, to: number): string {
const p = b[from]!;
const a = sqOf(from);
const t = sqOf(to);
const df = t.f - a.f;
const dr = t.r - a.r;
const adf = Math.abs(df);
const adr = Math.abs(dr);
switch (p.kind) {
case 'king':
case 'sa':
if (!inPalace(t.f, t.r)) return `${p.kind === 'king' ? '궁' : '사'}은 궁성 밖으로 나갈 수 없어요.`;
if (adf === 1 && adr === 1) return '궁성 대각선이 그려진 곳에서만 대각선으로 갈 수 있어요.';
return `${p.kind === 'king' ? '궁' : '사'}은 궁성 안에서 한 칸씩만 움직여요.`;
case 'ma':
if ((adf === 1 && adr === 2) || (adf === 2 && adr === 1)) return '마의 길(멱)이 막혀 있어요.';
return '마는 한 칸 곧게 간 뒤 대각선으로 한 칸 가요.';
case 'sang':
if ((adf === 2 && adr === 3) || (adf === 3 && adr === 2)) return '상의 길(멱)이 막혀 있어요.';
return '상은 한 칸 곧게 간 뒤 대각선으로 두 칸 가요.';
case 'jol': {
const fwd = p.side === 'cho' ? 1 : -1;
if (dr === -fwd) return `${p.side === 'cho' ? '졸' : '병'}은 뒤로 갈 수 없어요.`;
return `${p.side === 'cho' ? '졸' : '병'}은 앞이나 옆으로 한 칸만 가요.`;
}
case 'po': {
const between = lineBetween(from, to);
if (!between) return '포는 곧은 선(궁성에서는 대각선)을 따라 움직여요.';
const pieces = between.map((i) => b[i]).filter((q): q is Piece => !!q);
if (pieces.some((q) => q.kind === 'po')) return '포는 포를 넘을 수 없어요.';
if (b[to]?.kind === 'po') return '포는 포를 잡을 수 없어요.';
if (pieces.length === 0) return '포는 기물 하나(포다리)를 넘어야 움직일 수 있어요.';
return '포는 기물을 하나만 넘을 수 있어요.';
}
case 'cha':
return lineBetween(from, to) ? '차는 다른 기물을 넘을 수 없어요.' : '차는 곧은 선(궁성에서는 대각선)을 따라 움직여요.';
}
}
/** Squares strictly between two points on one 차/포 line, or null if not on a line. */
function lineBetween(from: number, to: number): number[] | null {
const a = sqOf(from);
const t = sqOf(to);
const df = Math.sign(t.f - a.f);
const dr = Math.sign(t.r - a.r);
const diag = df !== 0 && dr !== 0;
if (diag ? Math.abs(t.f - a.f) !== Math.abs(t.r - a.r) : df === 0 && dr === 0) return null;
const out: number[] = [];
let f = a.f;
let r = a.r;
for (;;) {
const nf = f + df;
const nr = r + dr;
if (diag ? !diagLinked(f, r, nf, nr) : !onBoard(nf, nr)) return null;
if (nf === t.f && nr === t.r) return out;
out.push(idx(nf, nr));
f = nf;
r = nr;
}
}