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:
781
packages/games/src/janggi/index.ts
Normal file
781
packages/games/src/janggi/index.ts
Normal 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] : [];
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user