diff --git a/apps/web/src/games/chess/index.tsx b/apps/web/src/games/chess/index.tsx new file mode 100644 index 0000000..3fa282a --- /dev/null +++ b/apps/web/src/games/chess/index.tsx @@ -0,0 +1,856 @@ +/** 체스 화면 (docs/games/chess.md §9). Legal moves come from the server view; the client has no rule code. */ +import { useEffect, useMemo, useRef, useState } from 'react'; +import type { ChessOptions, ChessView, LegalMove } from '@bg/games/chess'; +import type { PublicUser } from '@bg/shared/lite'; +import { Avatar, ConfirmSheet, Sheet } from '../../components/ui'; +import { TurnTimer, useServerNow } from '../../components/TurnTimer'; +import { haptic, sounds } from '../../lib/feedback'; +import type { BoardProps, GameUI, OptionsProps } from '../types'; + +type Color = 'white' | 'black'; +type PieceType = 'k' | 'q' | 'r' | 'b' | 'n' | 'p'; +type Promo = 'q' | 'r' | 'b' | 'n'; +const COLOR_KO: Record = { white: '백', black: '흑' }; +const PIECE_KO: Record = { k: '킹', q: '퀸', r: '룩', b: '비숍', n: '나이트', p: '폰' }; +const FILES = 'abcdefgh'; +const other = (c: Color): Color => (c === 'white' ? 'black' : 'white'); + +// ---------------------------------------------------------------- squares + +/** Board array index (a8 = 0 … h1 = 63) for a square name. */ +const idxOf = (sq: string) => (8 - Number(sq[1])) * 8 + FILES.indexOf(sq[0]!); +const sqOf = (file: number, rank: number) => `${FILES[file]}${rank + 1}`; +/** Display cell (0..7 from the top-left) for a square, given which colour sits at the bottom. */ +function cellOf(sq: string, bottom: Color): { x: number; y: number } { + const f = FILES.indexOf(sq[0]!); + const r = Number(sq[1]) - 1; + return bottom === 'white' ? { x: f, y: 7 - r } : { x: 7 - f, y: r }; +} +function squareAt(x: number, y: number, bottom: Color): string { + return bottom === 'white' ? sqOf(x, 7 - y) : sqOf(7 - x, y); +} +const pieceAt = (v: ChessView, sq: string) => v.board[idxOf(sq)] ?? null; +const colorOfPiece = (p: string): Color => (p === p.toUpperCase() ? 'white' : 'black'); + +// ---------------------------------------------------------------- pieces (hand-drawn SVG, 100×100 box) + +const BASE = 'M20 91 H80 Q84 91 84 87 V84 Q84 79 79 79 H21 Q16 79 16 84 V87 Q16 91 20 91 Z'; +const ROYAL_BODY = 'M30 79 L36 57 H64 L70 79 Z'; +const SHAPES: Record = { + p: { + fill: [ + 'M33 79 C35 68 39 62 43 58 H57 C61 62 65 68 67 79 Z', + 'M38 59 H62 Q65 59 65 56 Q65 52 61 52 H39 Q35 52 35 56 Q35 59 38 59 Z', + ], + balls: [[50, 38, 14]], + }, + r: { + fill: ['M30 79 L34 47 H66 L70 79 Z', 'M28 47 L27 19 H37 V26 H45 V19 H55 V26 H63 V19 H73 L72 47 Z'], + detail: ['M33 39 H67', 'M33 66 H67'], + }, + b: { + fill: [ + 'M35 79 C36 72 40 68 42 64 H58 C60 68 64 72 65 79 Z', + 'M37 65 H63 Q66 65 66 62 Q66 58 62 58 H38 Q34 58 34 62 Q34 65 37 65 Z', + 'M50 17 C62 26 68 38 65 49 Q63 56 58 58 H42 Q37 56 35 49 C32 38 38 26 50 17 Z', + ], + balls: [[50, 13, 5.5]], + detail: ['M57 29 L47 42'], + }, + n: { + fill: [ + 'M33 79 C32 66 37 58 46 51 C41 51 36 53 31 57 C27 60 21 58 20 52 C19 47 23 43 28 39 L43 25 C45 20 48 16 52 12 L56 19 C71 22 80 40 77 79 Z', + ], + detail: ['M61 26 C69 36 72 52 70 70'], + balls: [ + [44, 32, 2.8], + [26, 51, 1.6], + ], + }, + q: { + fill: [ROYAL_BODY, 'M32 57 L19 30 L33 44 L35 24 L44 41 L50 19 L56 41 L65 24 L67 44 L81 30 L68 57 Z'], + balls: [ + [19, 29, 4.5], + [35, 23, 4.5], + [50, 17, 4.5], + [65, 23, 4.5], + [81, 29, 4.5], + ], + detail: ['M34 63 H66'], + }, + k: { + fill: [ + ROYAL_BODY, + 'M32 57 C23 48 25 33 36 33 C43 33 47 38 50 44 C53 38 57 33 64 33 C75 33 77 48 68 57 Z', + 'M47 8 H53 V16 H60 V22 H53 V36 H47 V22 H40 V16 H47 Z', + ], + detail: ['M34 63 H66', 'M50 44 V57'], + }, +}; + +function PieceShape({ type, color }: { type: PieceType; color: Color }) { + const shape = SHAPES[type]; + const fill = color === 'white' ? '#fbfaf5' : '#2c2a28'; + const stroke = color === 'white' ? '#1d1b19' : '#0c0b0a'; + const detail = color === 'white' ? '#1d1b19' : '#ece7dc'; + return ( + + + {shape.fill.map((d, i) => ( + + ))} + {shape.balls?.map(([cx, cy, r], i) => + type === 'n' ? ( + + ) : ( + + ), + )} + {shape.detail?.map((d, i) => ( + + ))} + + ); +} + +/** A piece as a standalone inline icon (player bars, picker, rules). */ +function PieceIcon({ type, color, size = 22 }: { type: PieceType; color: Color; size?: number }) { + return ( + + + + ); +} + +function letterPiece(p: string): { type: PieceType; color: Color } { + return { type: p.toLowerCase() as PieceType, color: colorOfPiece(p) }; +} + +// ---------------------------------------------------------------- board + +const LIGHT = '#efd9b4'; +const DARK = '#b38660'; + +function ChessBoard({ view: v, act, pending }: BoardProps) { + const myTurn = v.phase === 'playing' && v.myColor !== null && v.myColor === v.turn; + const [flip, setFlip] = useState(false); + const bottom: Color = v.myColor ?? (flip ? 'black' : 'white'); + const [selected, setSelected] = useState(null); + const [hover, setHover] = useState(null); + const [cursor, setCursor] = useState<{ x: number; y: number }>({ x: 4, y: 6 }); + const [kbd, setKbd] = useState(false); + const [promo, setPromo] = useState<{ from: string; to: string } | null>(null); + const [dragPos, setDragPos] = useState<{ x: number; y: number } | null>(null); + const drag = useRef<{ from: string; wasSelected: boolean; sx: number; sy: number; moved: boolean } | null>(null); + const [announce, setAnnounce] = useState(''); + const svgRef = useRef(null); + const prevCount = useRef(v.moves.length); + const movedRef = useRef(null); + const dropped = useRef(false); + + const byFrom = useMemo(() => { + const m = new Map(); + for (const mv of v.legalMoves ?? []) m.set(mv.from, [...(m.get(mv.from) ?? []), mv]); + return m; + }, [v.legalMoves]); + const targets = selected ? (byFrom.get(selected) ?? []) : []; + const showDots = v.hints !== 'off'; + + useEffect(() => { + if (v.moves.length !== prevCount.current) { + const last = v.moves[v.moves.length - 1]; + if (v.moves.length > prevCount.current && last) { + // Slide the moved piece in (~150ms) unless I just dropped it there myself. + const el = movedRef.current; + if (el && v.lastMove && !dropped.current && !matchMedia('(prefers-reduced-motion: reduce)').matches) { + const a = cellOf(v.lastMove.from, bottom); + const b = cellOf(v.lastMove.to, bottom); + el.animate([{ transform: `translate(${a.x - b.x}px, ${a.y - b.y}px)` }, { transform: 'translate(0px, 0px)' }], { duration: 150, easing: 'ease-out' }); + } + sounds.stone(); + setAnnounce(`${COLOR_KO[last.color]}: ${last.san}${v.checkSquare ? ', 체크!' : ''}`); + if (myTurn) { + haptic(40); + if (document.visibilityState === 'hidden') sounds.myTurn(); + } + } else setAnnounce('수를 물렀어요.'); + } + prevCount.current = v.moves.length; + dropped.current = false; + setSelected(null); + setPromo(null); + }, [v.moves.length]); // eslint-disable-line react-hooks/exhaustive-deps + + const send = (from: string, to: string, promotion?: Promo) => { + act({ type: 'move', from, to, ...(promotion ? { promotion } : {}) }); + setSelected(null); + setPromo(null); + setHover(null); + }; + + const attempt = (from: string, to: string) => { + const mv = (byFrom.get(from) ?? []).find((m) => m.to === to); + if (!mv || pending) return; + if (mv.promotion && !v.autoQueen) setPromo({ from, to }); + else send(from, to); + }; + + const ownMovable = (sq: string) => myTurn && byFrom.has(sq); + + const click = (sq: string, wasSelected: boolean) => { + if (!myTurn) return; + if (selected && targets.some((m) => m.to === sq)) return attempt(selected, sq); + const p = pieceAt(v, sq); + if (p && colorOfPiece(p) === v.myColor) { + if (!byFrom.has(sq)) { + setSelected(null); + setAnnounce(`${PIECE_KO[p.toLowerCase() as PieceType]}은 지금 움직일 수 없어요.`); + haptic([30, 30, 30]); + return; + } + setSelected(wasSelected ? null : sq); + return; + } + if (selected) { + setAnnounce('그 칸으로는 갈 수 없어요.'); + haptic([30, 30, 30]); + } + setSelected(null); + }; + + const toSvg = (e: React.PointerEvent) => { + const svg = svgRef.current; + if (!svg) return null; + const pt = svg.createSVGPoint(); + pt.x = e.clientX; + pt.y = e.clientY; + return pt.matrixTransform(svg.getScreenCTM()!.inverse()); + }; + const cellAt = (e: React.PointerEvent): string | null => { + const p = toSvg(e); + if (!p) return null; + const x = Math.floor(p.x); + const y = Math.floor(p.y); + return x >= 0 && y >= 0 && x < 8 && y < 8 ? squareAt(x, y, bottom) : null; + }; + + const onKey = (e: React.KeyboardEvent) => { + setKbd(true); + const d: Record = { ArrowLeft: [-1, 0], ArrowRight: [1, 0], ArrowUp: [0, -1], ArrowDown: [0, 1] }; + const step = d[e.key]; + if (step) { + e.preventDefault(); + setCursor((c) => ({ x: Math.min(7, Math.max(0, c.x + step[0])), y: Math.min(7, Math.max(0, c.y + step[1])) })); + } else if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + const sq = squareAt(cursor.x, cursor.y, bottom); + click(sq, selected === sq); + } else if (e.key === 'Escape') setSelected(null); + }; + + const cell = (sq: string) => cellOf(sq, bottom); + const lastFrom = v.lastMove ? cell(v.lastMove.from) : null; + const lastTo = v.lastMove ? cell(v.lastMove.to) : null; + const check = v.checkSquare ? cell(v.checkSquare) : null; + const hoverTarget = hover && selected && targets.some((m) => m.to === hover) ? cell(hover) : null; + const dragging = drag.current?.moved && dragPos ? drag.current.from : null; + const cursorSq = squareAt(cursor.x, cursor.y, bottom); + + return ( +
+ { + setKbd(false); + const sq = cellAt(e); + if (!sq || !ownMovable(sq) || pending) return; + drag.current = { from: sq, wasSelected: selected === sq, sx: e.clientX, sy: e.clientY, moved: false }; + setSelected(sq); + svgRef.current?.setPointerCapture(e.pointerId); + }} + onPointerMove={(e) => { + const sq = cellAt(e); + if (sq !== hover) setHover(sq); + const d = drag.current; + if (!d) return; + if (!d.moved && Math.hypot(e.clientX - d.sx, e.clientY - d.sy) > 6) d.moved = true; + if (d.moved) setDragPos(toSvg(e)); + }} + onPointerLeave={() => setHover(null)} + onPointerUp={(e) => { + const sq = cellAt(e); + const d = drag.current; + drag.current = null; + setDragPos(null); + if (d?.moved) { + if (sq && sq !== d.from) { + dropped.current = true; + attempt(d.from, sq); + } + return; + } + if (sq) click(sq, d ? d.wasSelected : false); + }} + onPointerCancel={() => { + drag.current = null; + setDragPos(null); + }} + > + + + + + + + + {Array.from({ length: 64 }, (_, i) => { + const x = i % 8; + const y = Math.floor(i / 8); + return ; + })} + {lastFrom && } + {lastTo && } + {selected && } + {v.threatened?.map((sq) => { + const c = cell(sq); + return ; + })} + {check && } + {/* Coordinates: files on the bottom row, ranks on the left column. */} + {Array.from({ length: 8 }, (_, i) => ( + + + {squareAt(i, 7, bottom)[0]} + + + {squareAt(0, i, bottom)[1]} + + + ))} + {hoverTarget && } + {v.board.map((p, i) => { + if (!p) return null; + const sq = sqOf(i % 8, 7 - Math.floor(i / 8)); + if (sq === dragging) return null; + const c = cell(sq); + const { type, color } = letterPiece(p); + return ( + + + + + + + + ); + })} + {showDots && + targets.map((m) => { + const c = cell(m.to); + const capture = !!pieceAt(v, m.to) || (pieceAt(v, m.from)?.toLowerCase() === 'p' && m.from[0] !== m.to[0]); + return capture ? ( + + ) : ( + + ); + })} + {check && ( + + + + 체크! + + + )} + {dragging && dragPos && ( + + + + )} + {kbd && } + +

+ {announce} +

+ {!v.myColor && ( +
+ +
+ )} + {promo && v.myColor && ( + setPromo(null)} label="승진할 기물 고르기"> +

어떤 기물로 바꿀까요?

+
+ {(['q', 'r', 'b', 'n'] as const).map((t) => ( + + ))} +
+ +
+ )} +
+ ); +} + +// ---------------------------------------------------------------- side panel + +const REASON_HELP: Partial['reason'], string>> = { + checkmate: '킹이 공격받고 있는데 피할 곳도, 막을 방법도 없어요.', + stalemate: '체크는 아니지만 둘 수 있는 수가 하나도 없어서 비겼어요.', + insufficientMaterial: '남은 기물로는 어느 쪽도 체크메이트할 수 없어서 비겼어요.', + threefold: '같은 국면이 세 번 나와서 비겼어요.', + fivefold: '같은 국면이 다섯 번 나와서 비겼어요.', + fiftyMove: '50수 동안 폰 이동도 잡기도 없어서 비겼어요.', + seventyFiveMove: '75수 동안 폰 이동도 잡기도 없어서 비겼어요.', + timeoutVsInsufficient: '시간이 다 됐지만 상대도 체크메이트할 기물이 없어서 비겼어요.', + aborted: '첫 수를 두지 않아 대국이 취소됐어요. 승패는 기록되지 않아요.', +}; + +function ChessSide({ view: v, me, players, act, pending, deadline }: BoardProps) { + const [confirm, setConfirm] = useState(null); + const mine = v.myColor; + const incoming = + v.phase === 'playing' && mine + ? v.undo.pending && v.undo.pending.by !== mine + ? 'undo' + : v.draw.pendingBy && v.draw.pendingBy !== mine + ? 'draw' + : null + : null; + const outgoing = !!mine && (v.undo.pending?.by === mine || v.draw.pendingBy === mine); + const undoLeft = mine ? v.undo.remaining[mine] : null; + const top: Color = mine ? other(mine) : 'black'; + const bottom: Color = mine ?? 'white'; + const beforeFirst = !!mine && !v.started[mine]; + const canClaim = v.drawMode === 'fide' && !!v.claimable && !!mine && v.turn === mine; + return ( + <> + + + {v.phase === 'playing' && mine && ( +
+ {v.undo.allowed && ( + + )} + {v.draw.allowed && ( + + )} + +
+ )} + {canClaim && ( + + )} + {outgoing && ( +
+ 상대의 답을 기다리는 중… + +
+ )} + {v.result && REASON_HELP[v.result.reason] && ( +
+ {v.result.summary} +
{REASON_HELP[v.result.reason]}
+
+ )} + + {incoming && ( + {}} label="상대의 요청"> +

{incoming === 'undo' ? `상대가 ${v.undo.pending!.plies}수 무르기를 요청했어요` : '상대가 무승부를 제안했어요'}

+

{incoming === 'undo' ? '수락하면 기물이 되돌아가요. 시계는 되돌아가지 않아요.' : '수락하면 이 판은 무승부로 끝나요. 거절하거나 내가 수를 두면 제안이 사라져요.'}

+
+ + +
+
+ )} + {confirm === 'resign' && + (beforeFirst ? ( + act({ type: 'resign' })} onClose={() => setConfirm(null)} /> + ) : ( + act({ type: 'resign' })} onClose={() => setConfirm(null)} /> + ))} + {confirm === 'draw' && act({ type: 'offerDraw' })} onClose={() => setConfirm(null)} />} + {confirm === 'claim' && ( + act({ type: 'claimDraw' })} onClose={() => setConfirm(null)} /> + )} + + ); +} + +function MoveList({ view: v }: { view: ChessView }) { + const ref = useRef(null); + useEffect(() => { + ref.current?.scrollTo({ top: ref.current.scrollHeight }); + }, [v.moves.length]); + if (v.moves.length === 0) return null; + // Rows of [white, black]; a game started by black would show "…" first (not possible in standard chess). + const rows: { no: number; w?: string; b?: string }[] = []; + for (const m of v.moves) { + if (m.color === 'white' || rows.length === 0) rows.push({ no: rows.length + 1 }); + rows[rows.length - 1]![m.color === 'white' ? 'w' : 'b'] = m.san; + } + const lastIdx = v.moves.length - 1; + return ( +
+ 기보 ({Math.ceil(v.moves.length / 2)}수) +
+ {rows.map((r, i) => ( + + ))} +
+
+ ); +} + +function MoveRow({ no, w, b, lastW, lastB }: { no: number; w?: string; b?: string; lastW: boolean; lastB: boolean }) { + const cell = (san: string | undefined, last: boolean) => ( + + {san ?? ''} + + ); + return ( + <> + {no}. + {cell(w, lastW)} + {cell(b, lastB)} + + ); +} + +function fmtClock(ms: number): string { + const clamped = Math.max(0, ms); + if (clamped < 20_000) return `${Math.floor(clamped / 1000)}.${Math.floor((clamped % 1000) / 100)}`; + const s = Math.ceil(clamped / 1000); + const h = Math.floor(s / 3600); + const m = Math.floor((s % 3600) / 60); + return h ? `${h}:${String(m).padStart(2, '0')}:${String(s % 60).padStart(2, '0')}` : `${m}:${String(s % 60).padStart(2, '0')}`; +} + +function PlayerBar({ view: v, color, players, deadline, me }: { view: ChessView; color: Color; players: Record; deadline: number | null; me: string | null }) { + const id = v.players[color]; + const u = players[id] ?? { id, nickname: '알 수 없음', avatar: null, kind: 'guest' as const }; + const active = v.phase === 'playing' && v.turn === color; + const now = useServerNow(100); + const tc = v.timeControl; + let clock: React.ReactNode = null; + if (active && !v.started[color]) { + clock = ( + + 첫 수 + + + ); + } else if (tc.kind === 'fischer') { + const ms = v.remainingMs[color] - (active && v.started[color] ? now - v.turnStartedAt : 0); + const low = ms <= 10_000; + clock = ( + + {fmtClock(ms)} + + ); + } else if (active) { + clock = ; + } + const caps = [...v.captured[color]].sort((a, b) => 'qrbnp'.indexOf(a) - 'qrbnp'.indexOf(b)); + const adv = color === 'white' ? v.materialDiff : -v.materialDiff; + return ( +
+
+ + +
+
+ {u.nickname} + {id === me && (나)} +
+
+ {COLOR_KO[color]} + {active ? ' · 두는 중' : ''} +
+
+ {clock} +
+ {(caps.length > 0 || adv > 0) && ( +
PIECE_KO[c as PieceType]).join(', ')}`}> + {caps.map((c, i) => ( + + ))} + {adv > 0 && +{adv}} +
+ )} +
+ ); +} + +function statusText(v: ChessView, _me: string | null, players: Record): string { + if (v.phase === 'finished') return v.result?.summary ?? '게임이 끝났어요'; + const mine = v.myColor; + if (mine && v.undo.pending && v.undo.pending.by !== mine) return '상대가 무르기를 요청했어요'; + if (mine && v.draw.pendingBy && v.draw.pendingBy !== mine) return '상대가 무승부를 제안했어요'; + if (mine === v.turn) { + if (v.checkSquare) return '체크! 킹을 지켜야 해요'; + if (!v.started[mine]) return `내 차례예요 (${COLOR_KO[mine]}). 첫 수를 ${v.firstMoveSec}초 안에 둬 주세요`; + if (v.claimable) return '내 차례예요. 무승부를 주장할 수 있어요'; + return `내 차례예요 (${COLOR_KO[mine]}). 움직일 기물을 눌러 주세요`; + } + const nick = players[v.players[v.turn]]?.nickname ?? COLOR_KO[v.turn]; + const check = v.checkSquare ? ' (체크)' : ''; + return mine ? `${nick}님이 생각 중이에요…${check}` : `${nick}님(${COLOR_KO[v.turn]}) 차례예요${check}`; +} + +// ---------------------------------------------------------------- options + +function Seg({ value, options, onChange }: { value: T; options: [T, string][]; onChange: (v: T) => void }) { + return ( +
+ {options.map(([k, l]) => ( + + ))} +
+ ); +} + +const TIME_PRESETS: [string, string, ChessOptions['timeControl']][] = [ + ['3+2', '3+2 블리츠', { kind: 'fischer', baseMin: 3, incSec: 2 }], + ['5+0', '5+0', { kind: 'fischer', baseMin: 5, incSec: 0 }], + ['5+3', '5+3', { kind: 'fischer', baseMin: 5, incSec: 3 }], + ['10+0', '10+0 래피드', { kind: 'fischer', baseMin: 10, incSec: 0 }], + ['10+5', '10+5', { kind: 'fischer', baseMin: 10, incSec: 5 }], + ['15+10', '15+10', { kind: 'fischer', baseMin: 15, incSec: 10 }], + ['30+0', '30+0 클래식', { kind: 'fischer', baseMin: 30, incSec: 0 }], + ['none', '시간 제한 없음', { kind: 'none', baseMin: 10, incSec: 5 }], +]; +const timeKey = (tc: ChessOptions['timeControl']) => (tc.kind === 'none' ? 'none' : `${tc.baseMin}+${tc.incSec}`); + +function Options({ value: o, onChange }: OptionsProps) { + const [more, setMore] = useState(false); + const set = (patch: Partial) => onChange({ ...o, ...patch }); + const key = timeKey(o.timeControl); + const isPreset = TIME_PRESETS.some((p) => p[0] === key); + const num = (val: number, min: number, max: number) => Math.min(max, Math.max(min, Math.round(val) || min)); + return ( +
+
+ 시간 (기본 분 + 한 수마다 추가 초) + [k, l] as [string, string]), ...(isPreset ? [] : [[key, `${timeLabel(o.timeControl)} (직접 설정)`] as [string, string]])]} + onChange={(k) => { + const preset = TIME_PRESETS.find((p) => p[0] === k); + if (preset) set({ timeControl: preset[2] }); + }} + /> +
+
+ 백(먼저 두는 쪽) + set({ colorAssignment })} + /> +
+
+ 초보 도움말 + set({ hints })} /> +
+ + {more && ( + <> + {o.timeControl.kind === 'fischer' && ( +
+ 시간 직접 정하기 +
+ + +
+
+ )} + {o.timeControl.kind === 'none' && ( +
+ 응답이 없으면 시간패 + set({ idleLimitSec })} /> +
+ )} +
+ 첫 수 제한 (넘기면 대국 취소) + set({ firstMoveSec })} /> +
+
+ 3회 반복 · 50수 무승부 + set({ drawMode })} /> +
+
+ 무르기 (상대 동의) + set({ undo })} /> +
+
+ 그 밖에 + set({ autoQueen: k === 'on' })} /> + set({ drawOffer: k === 'on' })} /> +
+ + )} +
+ ); +} + +function timeLabel(tc: ChessOptions['timeControl']): string { + if (tc.kind === 'none') return '시간 제한 없음'; + return `${tc.baseMin}분 + ${tc.incSec}초`; +} + +function optionsSummary(o: ChessOptions): string { + const undo = o.undo === 'off' ? '무르기 없음' : o.undo === 'unlimited' ? '무르기 무제한' : `무르기 ${o.undo}번`; + return ['표준', timeLabel(o.timeControl), undo].join(' · '); +} + +// ---------------------------------------------------------------- rules + +const PIECE_RULES: [PieceType, string][] = [ + ['k', '가로·세로·대각선으로 한 칸. 공격받는 칸으로는 갈 수 없어요. 잡히면 안 되는 가장 중요한 기물이에요.'], + ['q', '가로·세로·대각선으로 몇 칸이든. 가장 강한 기물이에요(9점).'], + ['r', '가로·세로로 몇 칸이든(5점).'], + ['b', '대각선으로 몇 칸이든. 처음 칸과 같은 색 칸만 다녀요(3점).'], + ['n', "'ㄱ'자(두 칸 + 옆 한 칸)로 움직이고, 다른 기물을 뛰어넘을 수 있어요(3점)."], + ['p', '앞으로 한 칸(처음 자리에서는 두 칸까지). 잡을 때만 대각선 앞으로 한 칸 가요(1점).'], +]; + +function Rules({ options }: { options?: ChessOptions }) { + const fide = options?.drawMode === 'fide'; + return ( +
+

+ 백과 흑이 번갈아 한 수씩 둬요. 백이 먼저 둬요. 상대 킹을 공격해서(체크) 어디로도 피할 수 없게 만들면(체크메이트) 이겨요. + 내 킹이 공격받게 되는 수는 둘 수 없어요. +

+
+ {PIECE_RULES.map(([t, text]) => ( +
+ +
+ {PIECE_KO[t]} {text} +
+
+ ))} +
+

+ 캐슬링: 킹과 룩이 한 번도 움직이지 않았고 사이가 비어 있으면, 킹을 룩 쪽으로 두 칸 옮기고 룩을 킹 너머로 넘겨요. 체크 중이거나 킹이 지나가는 칸·도착 칸이 + 공격받으면 안 돼요. 킹을 누르면 캐슬링할 칸도 표시돼요. +

+

+ 앙파상: 상대 폰이 처음 자리에서 두 칸 나와 내 폰 바로 옆에 섰다면, 바로 다음 수에 한해 한 칸만 나온 것처럼 대각선으로 잡을 수 있어요. +

+

+ 승진: 폰이 끝 줄에 닿으면 퀸·룩·비숍·나이트 중 하나로 바꿔요. 보통은 퀸을 골라요. +

+

+ 비기는 경우: 체크가 아닌데 둘 수가 없을 때(스테일메이트), 남은 기물로 체크메이트가 불가능할 때(예: 킹 대 킹), 같은 국면이 3번 나오거나 50수 동안 폰 이동과 잡기가 + 없을 때{fide ? '(이 방은 차례인 사람이 [무승부 주장]을 눌러야 해요. 5번 반복·75수는 자동)' : '(이 방은 자동으로 비겨요)'}, 그리고 서로 합의했을 때예요. +

+

+ 시간: 수를 둘 때마다 정해진 초가 더해져요. 시간이 다 되면 져요. 단 상대에게 체크메이트할 기물이 없으면 비겨요. 각자 첫 수는 시계가 줄지 않지만 정해진 시간 안에 두지 + 않으면 대국이 취소돼요. +

+

조작: 기물을 누르면 갈 수 있는 칸에 점이 찍혀요(잡을 수 있는 칸은 동그라미). 그 칸을 누르거나 기물을 끌어다 놓으면 둬요.

+
+ ); +} + +const ui: GameUI = { + Board: ChessBoard, + Side: ChessSide, + statusText, + isMyTurn: (v, me) => v.phase === 'playing' && !!v.myColor && v.players[v.turn] === me, + Options, + optionsSummary, + Rules, +}; + +export default ui; diff --git a/apps/web/src/games/janggi/index.tsx b/apps/web/src/games/janggi/index.tsx new file mode 100644 index 0000000..533c248 --- /dev/null +++ b/apps/web/src/games/janggi/index.tsx @@ -0,0 +1,988 @@ +/** 장기 화면 (docs/games/janggi.md §9). */ +import { useEffect, useMemo, useRef, useState } from 'react'; +import type { Arrangement, JanggiMove, JanggiOptions, JanggiView, Piece, PieceKind, Side, Sq } from '@bg/games/janggi'; +import type { PublicUser } from '@bg/shared/lite'; +import { Avatar, ConfirmSheet, Sheet } from '../../components/ui'; +import { TurnTimer, useServerNow } from '../../components/TurnTimer'; +import { haptic, sounds } from '../../lib/feedback'; +import type { BoardProps, GameUI, OptionsProps } from '../types'; + +const SIDE_KO: Record = { cho: '초', han: '한' }; +const INK: Record = { cho: '#1d4ed8', han: '#c0262d' }; +const other = (s: Side): Side => (s === 'cho' ? 'han' : 'cho'); +const HANJA: Record> = { + king: { cho: '楚', han: '漢' }, + cha: { cho: '車', han: '車' }, + po: { cho: '包', han: '包' }, + ma: { cho: '馬', han: '馬' }, + sang: { cho: '象', han: '象' }, + sa: { cho: '士', han: '士' }, + jol: { cho: '卒', han: '兵' }, +}; +const HANGUL: Record> = { + king: { cho: '궁', han: '궁' }, + cha: { cho: '차', han: '차' }, + po: { cho: '포', han: '포' }, + ma: { cho: '마', han: '마' }, + sang: { cho: '상', han: '상' }, + sa: { cho: '사', han: '사' }, + jol: { cho: '졸', han: '병' }, +}; +const VALUE: Record = { king: 0, cha: 13, po: 7, ma: 5, sang: 3, sa: 3, jol: 2 }; +const ARRS: Arrangement[] = ['MSSM', 'SMMS', 'SMSM', 'MSMS']; +const ARR_NAME: Record = { + MSSM: ['안상차림', '원앙마'], + SMMS: ['바깥상차림', '양귀마'], + SMSM: ['왼상차림', '귀마'], + MSMS: ['오른상차림', '귀마'], +}; +/** Screen x of my four back-row slots (left→right from my seat; same for both sides once flipped). */ +const SLOT_X = [1, 2, 6, 7]; + +const sqKey = (q: Sq) => q.r * 9 + q.f; +const fromKey = (i: number): Sq => ({ f: i % 9, r: Math.floor(i / 9) }); +/** Korean-style square code: row 1–9,0 from 한's back rank, then file 1–9 (초 seat). */ +const sqCode = (q: Sq) => `${(10 - q.r) % 10}${q.f + 1}`; +const pieceName = (p: { side: Side; kind: PieceKind }) => HANGUL[p.kind][p.side]; +const fmtScore = (n: number) => String(n); + +function pieceRadius(kind: PieceKind): number { + return kind === 'king' ? 0.47 : kind === 'sa' || kind === 'jol' ? 0.33 : 0.41; +} + +function octagon(cx: number, cy: number, r: number): string { + const pts: string[] = []; + for (let i = 0; i < 8; i++) { + const a = Math.PI / 8 + (i * Math.PI) / 4; + pts.push(`${(cx + r * Math.cos(a)).toFixed(3)},${(cy + r * Math.sin(a)).toFixed(3)}`); + } + return pts.join(' '); +} + +function PieceGlyph({ piece, cx, cy, ghost, hangul, scale = 1 }: { piece: Piece; cx: number; cy: number; ghost?: boolean; hangul?: boolean; scale?: number }) { + const r = pieceRadius(piece.kind) * scale; + const ink = INK[piece.side]; + const fs = r * (piece.kind === 'king' ? 1.05 : 1.08); + return ( + + + + + + {HANJA[piece.kind][piece.side]} + + {hangul && ( + + {HANGUL[piece.kind][piece.side]} + + )} + + ); +} + +function useHangulLabels(): [boolean, (v: boolean) => void] { + const [on, setOn] = useState(() => { + try { + return localStorage.getItem('janggi.hangul') !== '0'; + } catch { + return true; + } + }); + return [ + on, + (v: boolean) => { + setOn(v); + try { + localStorage.setItem('janggi.hangul', v ? '1' : '0'); + } catch {} + }, + ]; +} + +/** Small corner ticks at the 졸/포 starting points, like a printed board. */ +const MARKS: [number, number][] = [ + [1, 2], [7, 2], [0, 3], [2, 3], [4, 3], [6, 3], [8, 3], + [1, 7], [7, 7], [0, 6], [2, 6], [4, 6], [6, 6], [8, 6], +]; + +function BoardLines() { + const d = 0.08; + const l = 0.16; + return ( + + {Array.from({ length: 10 }, (_, y) => ( + + ))} + {Array.from({ length: 9 }, (_, x) => ( + + ))} + + {[0, 7].map((y) => ( + + + + + ))} + {MARKS.map(([x, y]) => ( + + {[-1, 1].map((sx) => + x + sx < 0 || x + sx > 8 + ? null + : [-1, 1].map((sy) => ( + + )), + )} + + ))} + + ); +} + +type Bubble = { text: string; color: string; id: number }; + +function JanggiBoard({ view: v, act, pending, tapConfirm }: BoardProps) { + const flip = v.mySide === 'han'; + const toScreen = (q: Sq) => (flip ? { x: 8 - q.f, y: q.r } : { x: q.f, y: 9 - q.r }); + const fromScreen = (x: number, y: number): Sq => (flip ? { f: 8 - x, r: y } : { f: x, r: 9 - y }); + const myTurn = v.phase === 'playing' && v.mySide === v.turn; + const [hangul, setHangul] = useHangulLabels(); + const [selected, setSelected] = useState(null); + const [hover, setHover] = useState(null); + const [preview, setPreview] = useState(null); + const [cursor, setCursor] = useState<{ x: number; y: number }>({ x: 4, y: 8 }); + const [kbd, setKbd] = useState(false); + const [announce, setAnnounce] = useState(''); + const [bubble, setBubble] = useState(null); + const svgRef = useRef(null); + const prev = useRef({ count: v.moveCount, inCheck: v.inCheck, bikjang: v.bikjang, phase: v.phase }); + + const legal = useMemo(() => { + const m = new Map(); + for (const x of v.legalMoves ?? []) { + const k = sqKey(x.from); + if (!m.has(k)) m.set(k, []); + m.get(k)!.push({ to: sqKey(x.to), via: x.via ? sqKey(x.via) : null }); + } + return m; + }, [v.legalMoves]); + const showHints = v.rules.hints !== 'off'; + + // Sounds, bubbles and announcements when the position changes. + useEffect(() => { + const p = prev.current; + const show = (text: string, color: string) => setBubble({ text, color, id: Date.now() }); + if (v.moveCount > p.count) { + const m = v.moves[v.moves.length - 1]!; + if (m.kind === 'move') { + sounds.stone(); + setAnnounce(`${SIDE_KO[m.side]} ${HANGUL[m.piece][m.side]}이 ${sqCode(m.from)}에서 ${sqCode(m.to)}로 갔어요${m.captured ? ` (${HANGUL[m.captured][other(m.side)]} 잡음)` : ''}.`); + } else { + setAnnounce(`${SIDE_KO[m.side]}이 한수쉼했어요.`); + } + if (v.inCheck && v.inCheck !== p.inCheck) { + show('장군!', '#b91c1c'); + setAnnounce((a) => `${a} 장군!`); + } else if (p.inCheck && !v.inCheck && v.phase === 'playing') { + show('멍군!', '#1d4ed8'); + } + if (v.bikjang && !p.bikjang) show('빅장!', '#7c3aed'); + if (myTurn) { + haptic(40); + if (document.visibilityState === 'hidden') sounds.myTurn(); + } + } + if (p.phase === 'setup' && v.phase === 'playing') setAnnounce('상차림이 끝났어요. 초부터 둬요.'); + prev.current = { count: v.moveCount, inCheck: v.inCheck, bikjang: v.bikjang, phase: v.phase }; + setSelected(null); + setPreview(null); + }, [v.moveCount, v.phase]); // eslint-disable-line react-hooks/exhaustive-deps + + useEffect(() => { + if (!bubble) return; + const t = setTimeout(() => setBubble(null), 1600); + return () => clearTimeout(t); + }, [bubble]); + + const doMove = (from: number, to: number) => { + act({ type: 'move', from: fromKey(from), to: fromKey(to) }); + setSelected(null); + setPreview(null); + setHover(null); + }; + + const tap = (i: number, direct: boolean) => { + const sq = fromKey(i); + setCursor(toScreen(sq)); + if (!myTurn || pending) return; + const piece = v.board[i]; + if (piece && piece.side === v.mySide) { + setPreview(null); + if (selected === i) { + setSelected(null); + return; + } + setSelected(i); + const n = legal.get(i)?.length ?? 0; + setAnnounce(`${pieceName(piece)} ${sqCode(sq)} 선택. ${n ? `갈 수 있는 곳 ${n}곳` : '지금은 움직일 수 없어요'}`); + if (!n) haptic([30, 30, 30]); + return; + } + if (selected === null) return; + const ok = legal.get(selected)?.some((m) => m.to === i); + if (!ok && showHints) { + // Targets are drawn, so a click elsewhere just deselects. + setSelected(null); + setPreview(null); + return; + } + // Without hints, an illegal target goes to the server, which explains why (멱, 포다리, 장군 …). + if (!ok || direct || !tapConfirm) { + doMove(selected, i); + return; + } + if (preview === i) doMove(selected, i); + else setPreview(i); + }; + + const pointToIndex = (e: React.PointerEvent): number | null => { + const svg = svgRef.current; + if (!svg) return null; + const pt = svg.createSVGPoint(); + pt.x = e.clientX; + pt.y = e.clientY; + const loc = pt.matrixTransform(svg.getScreenCTM()!.inverse()); + const x = Math.round(loc.x); + const y = Math.round(loc.y); + if (x < 0 || y < 0 || x > 8 || y > 9) return null; + return sqKey(fromScreen(x, y)); + }; + + const onKey = (e: React.KeyboardEvent) => { + setKbd(true); + const d: Record = { ArrowLeft: [-1, 0], ArrowRight: [1, 0], ArrowUp: [0, -1], ArrowDown: [0, 1] }; + if (d[e.key]) { + e.preventDefault(); + setCursor((c) => ({ x: Math.min(8, Math.max(0, c.x + d[e.key]![0])), y: Math.min(9, Math.max(0, c.y + d[e.key]![1])) })); + } else if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + tap(sqKey(fromScreen(cursor.x, cursor.y)), true); + } else if (e.key === 'Escape') { + setSelected(null); + setPreview(null); + } + }; + + // Setup: card hover previews my 마·상 on the board. + const [setupHover, setSetupHover] = useState(null); + const mustChoose = v.phase === 'setup' && !!v.mySide && v.setup.waitingFor.includes(v.mySide); + const myChoice = v.mySide ? v.setup.choice[v.mySide] : null; + const setupGhost = v.phase === 'setup' && v.mySide ? (setupHover ?? (v.setup.order === 'simultaneous' ? myChoice : null)) : null; + + const focusFrom = selected ?? (hover !== null && v.board[hover]?.side === v.mySide && myTurn ? hover : null); + const targets = focusFrom !== null && showHints ? (legal.get(focusFrom) ?? []) : []; + const faint = selected === null; + const ghostTo = selected !== null ? (preview ?? (hover !== null && legal.get(selected)?.some((m) => m.to === hover) ? hover : null)) : null; + const vias = new Set(); + for (const t of targets) if (t.via !== null && (ghostTo === null || t.to === ghostTo)) vias.add(t.via); + const kingInCheck = v.inCheck ? v.board.findIndex((p) => p?.kind === 'king' && p.side === v.inCheck) : -1; + const last = v.lastMove; + const pad = 0.95; + const setupMaxW = v.phase === 'setup' ? 'min(100%, calc((100vh - 400px) * 0.9))' : 'min(100%, calc((100vh - 250px) * 0.9))'; + + return ( +
+
+ { + if (e.pointerType !== 'mouse' || !myTurn) return; + const i = pointToIndex(e); + if (i !== hover) setHover(i); + }} + onPointerLeave={() => setHover(null)} + onPointerUp={(e) => { + setKbd(false); + const i = pointToIndex(e); + if (i !== null) tap(i, e.pointerType === 'mouse'); + }} + tabIndex={0} + role="grid" + aria-label={`장기판. ${flip ? '한' : '초'} 쪽이 아래예요. 방향키로 움직이고 Enter로 기물을 고르거나 둡니다. 현재 위치 ${sqCode(fromScreen(cursor.x, cursor.y))}`} + onKeyDown={onKey} + > + + {Array.from({ length: 9 }, (_, x) => ( + + {fromScreen(x, 0).f + 1} + + ))} + {Array.from({ length: 10 }, (_, y) => ( + + {(10 - fromScreen(0, y).r) % 10} + + ))} + {last && + [last.from, last.to].map((q, k) => { + const s = toScreen(q); + return ; + })} + {kingInCheck >= 0 && (() => { + const s = toScreen(fromKey(kingInCheck)); + return ; + })()} + {v.board.map((p, i) => { + if (!p) return null; + const s = toScreen(fromKey(i)); + return ; + })} + {setupGhost && + v.mySide && + SLOT_X.map((x, k) => { + const kind: PieceKind = setupGhost[k] === 'M' ? 'ma' : 'sang'; + return ; + })} + {myTurn && + v.threatened.map((q) => { + const s = toScreen(q); + return ; + })} + {[...vias].map((i) => { + const s = toScreen(fromKey(i)); + return ; + })} + {selected !== null && (() => { + const s = toScreen(fromKey(selected)); + return ; + })()} + {targets.map((t) => { + const s = toScreen(fromKey(t.to)); + return v.board[t.to] ? ( + + ) : ( + + ); + })} + {ghostTo !== null && selected !== null && v.board[selected] && (() => { + const s = toScreen(fromKey(ghostTo)); + return ; + })()} + {kbd && (myTurn || mustChoose) && ( + + )} + + {bubble && ( +
+ {bubble.text} +
+ )} +
+

+ {announce} +

+ {v.phase === 'playing' && v.inCheck && ( +
+ {v.inCheck === v.mySide ? '장군이에요! 궁을 지켜야 해요.' : `${SIDE_KO[v.inCheck]} 장군!`} +
+ )} + {v.phase === 'playing' && v.bikjang && ( +
+ {v.bikjang.calledBy === v.mySide + ? '빅장을 불렀어요. 상대가 막지 못하면 점수로 판정해요.' + : v.mySide + ? '빅장이에요! 궁 사이를 막거나 궁을 옆으로 옮기지 않으면 점수로 판정해요.' + : `${SIDE_KO[v.bikjang.calledBy]} 빅장!`} +
+ )} + {myTurn && v.repeatWarning && ( +
+ {v.rules.repetition === 'forbid' + ? '같은 국면이 3번 나왔어요. 한 번 더 반복하는 수는 둘 수 없어요.' + : v.rules.repetition === 'lose' + ? '같은 국면이 3번 나왔어요. 한 번 더 반복하면 실격패예요!' + : '같은 국면이 3번 나왔어요. 한 번 더 반복하면 점수로 판정해요.'} +
+ )} + {preview !== null && selected !== null && myTurn && ( +
+ + +
+ )} + {v.phase === 'setup' && ( + + )} + +
+ ); +} + +function ArrangementMini({ arr, side }: { arr: Arrangement; side: Side }) { + return ( + + + {[0, 1, 2.4, 3.4].map((x, k) => ( + + ))} + + ); +} + +function SetupPicker({ + view: v, + act, + pending, + tapConfirm, + mustChoose, + onHover, +}: { + view: JanggiView; + act: (a: unknown) => void; + pending: boolean; + tapConfirm: boolean; + mustChoose: boolean; + onHover: (a: Arrangement | null) => void; +}) { + const [picked, setPicked] = useState(null); + const me = v.mySide; + const choose = (a: Arrangement) => { + act({ type: 'chooseSetup', arrangement: a }); + setPicked(null); + onHover(null); + }; + if (!me || !mustChoose) { + const mine = me ? v.setup.choice[me] : null; + return ( +
+ {mine ? `${ARR_NAME[mine][0]}(${ARR_NAME[mine][1]})을 골랐어요. ` : ''} + {v.setup.waitingFor.length ? `${v.setup.waitingFor.map((s) => SIDE_KO[s]).join('·')}이 상차림을 고르는 중이에요…` : ''} +
+ ); + } + const hanChose = v.setup.order === 'hanFirst' && me === 'cho' ? v.setup.choice.han : null; + return ( +
+ + 상차림을 골라 주세요 (마·상 놓는 자리, 내 쪽에서 본 왼쪽→오른쪽) + + {hanChose &&

한은 {ARR_NAME[hanChose][0]}({ARR_NAME[hanChose][1]})을 골랐어요.

} +
+ {ARRS.map((a) => ( + + ))} +
+ {picked && ( + + )} +
+ ); +} + +// ---------------------------------------------------------------- side panel + +function JanggiSide({ view: v, me, players, act, pending, deadline }: BoardProps) { + const [confirm, setConfirm] = useState(null); + const incoming = + v.phase === 'playing' && v.mySide + ? v.undo.pending && v.undo.pending.by !== v.mySide + ? 'undo' + : v.draw.pendingBy && v.draw.pendingBy !== v.mySide + ? 'draw' + : null + : null; + const outgoing = v.mySide && (v.undo.pending?.by === v.mySide || v.draw.pendingBy === v.mySide); + const undoLeft = v.mySide ? v.undo.remaining[v.mySide] : null; + const top: Side = v.mySide ? other(v.mySide) : 'han'; + const bottom: Side = v.mySide ?? 'cho'; + const myTurn = v.phase === 'playing' && v.mySide === v.turn; + const lastWasPass = v.moves.length > 0 && v.moves[v.moves.length - 1]!.kind === 'pass'; + return ( + <> + + + + {v.phase === 'setup' && v.mySide && ( + + )} + {v.phase === 'playing' && v.mySide && ( +
+ {(v.rules.allowPass || v.canPass) && ( + + )} + {v.undo.allowed && ( + + )} + {v.draw.allowed && ( + + )} + +
+ )} + {outgoing && v.phase === 'playing' && ( +
+ 상대의 답을 기다리는 중… + +
+ )} + + {incoming && ( + {}} label="상대의 요청"> +

{incoming === 'undo' ? `상대가 ${v.undo.pending!.plies}수 무르기를 요청했어요` : '상대가 무승부를 제안했어요'}

+

{incoming === 'undo' ? '수락하면 기물이 되돌아가요.' : '수락하면 이 판은 무승부로 끝나요.'}

+
+ + +
+
+ )} + {confirm === 'resign' && ( + act({ type: 'resign' })} onClose={() => setConfirm(null)} /> + )} + {confirm === 'abort' && ( + act({ type: 'resign' })} onClose={() => setConfirm(null)} /> + )} + {confirm === 'draw' && act({ type: 'offerDraw' })} onClose={() => setConfirm(null)} />} + {confirm === 'pass' && ( + act({ type: 'pass' })} + onClose={() => setConfirm(null)} + /> + )} + + ); +} + +function ScoreBar({ view: v }: { view: JanggiView }) { + const { cho, han } = v.scores; + const pct = (cho / (cho + han)) * 100; + return ( +
+
+ 초 {fmtScore(cho)} + {v.rules.scoring ? '기물 점수 (한 덤 1.5)' : '기물 점수 (참고)'} + {fmtScore(han)} 한 +
+
+
+
+
+
+ ); +} + +function mmss(ms: number): string { + const s = Math.max(0, Math.ceil(ms / 1000)); + return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`; +} + +function Clock({ view: v, side, deadline, mine }: { view: JanggiView; side: Side; deadline: number | null; mine: boolean }) { + const now = useServerNow(250); + const tc = v.rules.timeControl; + if (v.phase === 'setup') { + return v.setup.waitingFor.includes(side) ? : null; + } + const active = v.phase === 'playing' && v.turn === side; + if (tc.kind === 'none') return active ? : null; + const periodMs = tc.periodSec * 1000; + const elapsed = active ? Math.max(0, now - v.clock.turnStartedAt) : 0; + const main = v.clock.mainMs[side] - elapsed; + let big: string; + let periods = v.clock.periodsLeft[side]; + let danger = false; + if (main > 0) { + big = mmss(main); + } else { + const over = -main; + const used = Math.floor(over / periodMs); + periods = Math.max(0, periods - used); + const left = periods > 0 ? periodMs - (over % periodMs) : 0; + big = active ? String(Math.ceil(left / 1000)) : `${tc.periodSec}`; + danger = active && left <= 10_000; + } + return ( +
+
{big}
+
+ {main > 0 ? `초읽기 ${tc.periodSec}초 ` : '초읽기 '} + {Array.from({ length: Math.max(periods, 0) }, () => '●').join('')} +
+
+ ); +} + +function PlayerBar({ view: v, side, players, deadline, me }: { view: JanggiView; side: Side; players: Record; deadline: number | null; me: string | null }) { + const id = v.players[side]; + const u = players[id] ?? { id, nickname: '알 수 없음', avatar: null, kind: 'guest' as const }; + const active = (v.phase === 'playing' && v.turn === side) || (v.phase === 'setup' && v.setup.waitingFor.includes(side)); + const caps = [...v.captured[side]].sort((a, b) => VALUE[b] - VALUE[a]); + return ( +
+
+ + + + +
+
+ {u.nickname} + {id === me && (나)} +
+
+ {SIDE_KO[side]} {side === 'cho' ? '· 선수' : '· 후수 덤 1.5'} + {v.phase === 'playing' && v.turn === side ? ' · 두는 중' : ''} + {v.inCheck === side ? ' · 장군!' : ''} +
+
+ +
+ {caps.length > 0 && ( +
HANGUL[k][other(side)]).join(', ')}`}> + + 잡은 기물 + + {caps.map((k, i) => ( + + + + ))} +
+ )} +
+ ); +} + +function moveText(m: JanggiMove): string { + if (m.kind === 'pass') return '한수쉼'; + return `${HANGUL[m.piece][m.side]} ${sqCode(m.from)}→${sqCode(m.to)}${m.captured ? ` ×${HANGUL[m.captured][other(m.side)]}` : ''}`; +} + +function MoveList({ view: v }: { view: JanggiView }) { + const ref = useRef(null); + useEffect(() => { + ref.current?.scrollTo({ top: ref.current.scrollHeight }); + }, [v.moveCount]); + if (v.moveCount === 0) return null; + return ( +
+ 기보 ({v.moveCount}수) +
    + {v.moves.map((m, i) => ( +
  1. + {i + 1}. {moveText(m)} +
  2. + ))} +
+
+ ); +} + +function statusText(v: JanggiView, me: string | null, players: Record): string { + if (v.phase === 'finished') return v.result?.summaryKo ?? '대국이 끝났어요'; + const nick = (s: Side) => players[v.players[s]]?.nickname ?? SIDE_KO[s]; + if (v.phase === 'setup') { + if (v.mySide && v.setup.waitingFor.includes(v.mySide)) { + const han = v.setup.choice.han; + if (v.mySide === 'cho' && v.setup.order === 'hanFirst' && han) return `한이 ${ARR_NAME[han][0]}을 골랐어요. 내 상차림을 골라 주세요`; + return `상차림을 골라 주세요 (나는 ${SIDE_KO[v.mySide]})`; + } + const w = v.setup.waitingFor; + return w.length ? `${w.map(nick).join('·')}님이 상차림을 고르는 중이에요…` : '곧 대국이 시작돼요'; + } + if (v.mySide && v.undo.pending && v.undo.pending.by !== v.mySide) return '상대가 무르기를 요청했어요'; + if (v.mySide && v.draw.pendingBy && v.draw.pendingBy !== v.mySide) return '상대가 무승부를 제안했어요'; + if (v.mySide === v.turn) { + if (v.inCheck === v.mySide) return '장군이에요! 궁을 지켜 주세요'; + if (v.bikjang && v.bikjang.calledBy !== v.mySide) return '빅장이에요! 막지 않으면 점수로 판정해요'; + return `내 차례예요 (${SIDE_KO[v.turn]}). 움직일 기물을 눌러 주세요`; + } + return v.mySide ? `${nick(v.turn)}님이 생각 중이에요…` : `${nick(v.turn)}님(${SIDE_KO[v.turn]}) 차례예요`; +} + +function isMyTurn(v: JanggiView, me: string | null): boolean { + if (!me || !v.mySide || v.players[v.mySide] !== me) return false; + if (v.phase === 'setup') return v.setup.waitingFor.includes(v.mySide); + return v.phase === 'playing' && v.turn === v.mySide; +} + +// ---------------------------------------------------------------- options + +function Seg({ value, options, onChange }: { value: T; options: [T, string][]; onChange: (v: T) => void }) { + return ( +
+ {options.map(([k, l]) => ( + + ))} +
+ ); +} + +function Field({ title, children }: { title: string; children: React.ReactNode }) { + return ( +
+ {title} + {children} +
+ ); +} + +const BIKJANG_SHORT: Record = { off: '빅장 없음', kjf: '빅장(연맹식)', kja30: '빅장(30점 미만)' }; + +function timeLabel(tc: JanggiOptions['timeControl']): string { + if (tc.kind === 'none') return '시간 제한 없음'; + const m = Math.floor(tc.mainSec / 60); + const s = tc.mainSec % 60; + return `${m ? `${m}분` : ''}${s ? ` ${s}초` : ''}${tc.mainSec === 0 ? '0분' : ''} + 초읽기 ${tc.periodSec}초×${tc.periods}`; +} + +function Options({ value: o, onChange }: OptionsProps) { + const [more, setMore] = useState(false); + const set = (patch: Partial) => onChange({ ...o, ...patch }); + const setTc = (patch: Partial) => set({ timeControl: { ...o.timeControl, ...patch } }); + const timeKey = o.timeControl.kind === 'none' ? 'none' : `m${o.timeControl.mainSec}`; + const presets: [string, string][] = [ + ['m300', '5분'], + ['m600', '10분'], + ['m1200', '20분'], + ['none', '시간 제한 없음'], + ]; + if (!presets.some((p) => p[0] === timeKey)) presets.unshift([timeKey, `${timeLabel(o.timeControl)}(관리자 설정)`]); + return ( +
+ + set({ scoring: k === 'on' })} /> + + + set({ bikjang })} /> + + + { + if (k === 'none') setTc({ kind: 'none' }); + else setTc({ kind: 'byoyomi', mainSec: Number(k.slice(1)) }); + }} + /> + + + set({ sideAssignment })} + /> + + + {more && ( + <> + {o.timeControl.kind === 'byoyomi' && ( + <> + + setTc({ periodSec })} /> + + + setTc({ periods })} /> + + + )} + + set({ setupOrder })} /> + + + set({ repetition })} /> + + + set({ moveLimit })} /> + + + set({ undo })} /> + + + set({ hints })} /> + + + set({ allowPass: k === 'on' })} /> + set({ drawOffer: k === 'on' })} /> + + + )} +
+ ); +} + +function optionsSummary(o: JanggiOptions): string { + return [o.scoring ? '점수제' : '비점수제', BIKJANG_SHORT[o.bikjang], timeLabel(o.timeControl)].join(' · '); +} + +function Rules({ options }: { options?: JanggiOptions }) { + const o = options; + const bik = o?.bikjang ?? 'kjf'; + const rep = o?.repetition ?? 'forbid'; + const scoring = o?.scoring ?? true; + return ( +
+

+ 초(파랑)와 한(빨강)이 16개씩 기물을 움직여 상대 궁을 잡을 수밖에 없게(외통) 만들면 이겨요. 초가 먼저 둬요. 기물은 칸이 아니라 선이 + 만나는 점에 놓여요. +

+

+ 상차림: 시작 전에 뒷줄 마·상 네 개의 자리를 골라요(안상차림·바깥상차림·왼상차림·오른상차림).{' '} + {o?.setupOrder === 'simultaneous' ? '두 사람이 동시에 몰래 고르고, 둘 다 고르면 공개돼요.' : '한이 먼저 골라 공개하고, 초가 보고 골라요.'} +

+
+ 기물 움직임 +
    +
  • + 궁(楚·漢)·사(士): 궁성(X자가 그려진 3×3) 안에서 선을 따라 한 칸. 대각선은 X 선이 있는 곳에서만 가요. +
  • +
  • + 차(車): 가로·세로로 막히지 않으면 몇 칸이든. 궁성 안에서는 X 선을 따라서도 가요. +
  • +
  • + 포(包): 반드시 기물 하나(포다리)를 넘어서 가요. 포다리 없이는 못 움직이고, 포는 포를 넘거나 잡을 수 없어요. +
  • +
  • + 마(馬): 한 칸 곧게 간 뒤 대각선으로 한 칸. 처음 곧게 가는 칸(멱)에 기물이 있으면 못 가요. +
  • +
  • + 상(象): 한 칸 곧게 간 뒤 대각선으로 두 칸. 지나가는 두 칸 중 하나라도 막히면 못 가요. +
  • +
  • + 졸(卒)·병(兵): 앞이나 옆으로 한 칸. 뒤로는 못 가요. 상대 궁성 안에서는 앞쪽 대각선으로도 가요. +
  • +
+
+

+ 장군: 다음 수에 궁을 잡을 수 있는 상태예요. 장군을 받으면 반드시 피해야 하고(멍군), 내 궁이 잡히게 되는 수는 둘 수 없어요. 피할 수 없으면{' '} + 외통으로 져요. +

+ {bik !== 'off' && ( +

+ 빅장: 두 궁이 같은 세로줄에서 사이에 아무것도 없이 마주보게 만들면 빅장이에요 + {bik === 'kjf' ? '(기물이 하나라도 잡힌 다음부터)' : '(양쪽 기물 점수가 모두 30점 미만일 때만)'}. 빅장을 받은 쪽이 다음 수로 막지 못하면{' '} + {scoring ? '대국이 끝나고 점수로 승패를 가려요' : '무승부로 끝나요'}. +

+ )} +

+ 한수쉼: {o?.allowPass === false ? '이 방에서는 쓰지 않아요(움직일 수 있는 기물이 없을 때만 쉬어요).' : '기물을 움직이지 않고 차례를 넘길 수 있어요. 장군을 받았을 때는 안 돼요.'} 두 사람이 연달아 + 쉬면 {scoring ? '점수로 판정해요' : '무승부예요'}. +

+

+ 반복수: 같은 국면은 3번까지 괜찮아요. 4번째는{' '} + {rep === 'forbid' ? '만들 수 없어요(그 수를 둘 수 없어요).' : rep === 'lose' ? '만든 쪽이 실격패해요.' : '나오는 순간 점수로 판정해요.'} +

+

+ 점수: 차 13, 포 7, 마 5, 상 3, 사 3, 졸·병 2점. 후수인 한은 덤 1.5점을 더 받아 시작 점수는 초 72 : 한 73.5예요. 점수 판정은 남은 기물 점수가 높은 쪽이 이기고, + 덤 때문에 동점은 없어요. +

+ {o?.timeControl.kind !== 'none' && ( +

+ 시간: 기본 시간을 다 쓰면 초읽기에 들어가요. 초읽기 시간 안에 두면 횟수가 줄지 않고, 넘기면 한 번씩 줄어요. 다 쓰면 시간패예요. +

+ )} +

(사)대한장기연맹 규정을 바탕으로 하고, 온라인에 맞게 일부 방식을 정했어요.

+
+ ); +} + +const ui: GameUI = { + Board: JanggiBoard, + Side: JanggiSide, + statusText, + isMyTurn, + Options, + optionsSummary, + Rules, +}; + +export default ui; diff --git a/apps/web/src/games/registry.ts b/apps/web/src/games/registry.ts index c353f92..c9f22ac 100644 --- a/apps/web/src/games/registry.ts +++ b/apps/web/src/games/registry.ts @@ -4,6 +4,8 @@ import type { GameUI } from './types'; export const GAME_UI: Record Promise<{ default: GameUI }>> = { omok: () => import('./omok'), othello: () => import('./othello'), + chess: () => import('./chess'), + janggi: () => import('./janggi'), }; const cache = new Map(); diff --git a/e2e/games.e2e.ts b/e2e/games.e2e.ts index cb4ba95..bbef4cd 100644 --- a/e2e/games.e2e.ts +++ b/e2e/games.e2e.ts @@ -27,6 +27,34 @@ const PLANS: Record = { // cursor starts at d4 (3,3): black d3 = up; then white c3 = up, left moves: [['ArrowUp', 'Enter'], ['ArrowUp', 'ArrowLeft', 'Enter']], }, + chess: { + // cursor starts on the e-file pawn of whoever is at the bottom: white e2-e4, black d7-d5 (board flipped) + moves: [['Enter', 'ArrowUp', 'ArrowUp', 'Enter'], ['Enter', 'ArrowUp', 'ArrowUp', 'Enter']], + }, + janggi: { + // Both sides pick 안상차림 (order may be han-first), then each pushes the centre 졸 one step. + setup: async (host, guest) => { + for (let round = 0; round < 20; round++) { + let picked = false; + for (const p of [host, guest]) { + const btn = p.getByRole('button', { name: /^안상차림/ }); + if ((await btn.count()) && (await btn.isEnabled())) { + await btn.click(); + picked = true; + } + } + if (await host.evaluate(() => !document.body.innerText.includes('상차림을 골라'))) { + if (await guest.evaluate(() => !document.body.innerText.includes('상차림을 골라'))) return; + } + if (!picked) await host.waitForTimeout(200); + } + throw new Error('상차림 단계가 끝나지 않음'); + }, + moves: [ + ['ArrowUp', 'ArrowUp', 'Enter', 'ArrowUp', 'Enter'], + ['ArrowUp', 'ArrowUp', 'Enter', 'ArrowUp', 'Enter'], + ], + }, }; const games = process.argv.slice(2).length ? process.argv.slice(2) : Object.keys(PLANS); diff --git a/packages/games/src/chess/chess.test.ts b/packages/games/src/chess/chess.test.ts new file mode 100644 index 0000000..ad2931c --- /dev/null +++ b/packages/games/src/chess/chess.test.ts @@ -0,0 +1,378 @@ +import { describe, expect, test } from 'bun:test'; +import { SeededRng, randomPlayout, seedN } from '@bg/engine'; +import { chess, type ChessAction, type ChessOptions, type ChessState } from './index'; +import { START_FEN, adapterPerft, cannotMate, fullFen, insufficientMaterial, keyOf, load, positionKey, threatenedSquares } from './rules'; + +const rng = () => SeededRng.fromSeed([1, 2, 3, 4]); + +/** White = 'A', black = 'B'. Both clocks already running unless `fresh`. */ +function make(opts: Partial = {}, fen = START_FEN, fresh = false): ChessState { + const options = chess.optionsSchema.parse({ colorAssignment: 'hostWhite', ...opts }); + const s = chess.setup({ players: ['A', 'B'], options, rng: rng(), now: 0 }); + s.startFen = fullFen(load(fen)); + s.fen = s.startFen; + s.repetition = { [keyOf(load(fen))]: 1 }; + if (!fresh) s.clock.started = { white: true, black: true }; + return s; +} + +const turnPlayer = (s: ChessState) => (s.fen.split(' ')[1] === 'w' ? 'A' : 'B'); + +function act(s: ChessState, actor: string, a: ChessAction, now = 1000): ChessState { + const v = chess.validate(s, actor, a); + if (!v.ok) throw new Error(`${JSON.stringify(a)}: ${v.reason}`); + return chess.apply(s, actor, a, { rng: rng(), now }).state; +} + +/** Play UCI moves ("e2e4", "a7a8q") for whoever is to move. */ +function play(s: ChessState, ...ucis: string[]): ChessState { + for (const u of ucis) { + const a: ChessAction = { type: 'move', from: u.slice(0, 2), to: u.slice(2, 4), ...(u[4] ? { promotion: u[4] as 'q' } : {}) }; + s = act(s, turnPlayer(s), a); + } + return s; +} + +const tryMove = (s: ChessState, uci: string) => + chess.validate(s, turnPlayer(s), { type: 'move', from: uci.slice(0, 2), to: uci.slice(2, 4), ...(uci[4] ? { promotion: uci[4] as 'q' } : {}) }); +const canCastle = (s: ChessState, side: 'king' | 'queen') => chess.validate(s, turnPlayer(s), { type: 'castle', side }).ok; + +describe('perft (§8.0)', () => { + const KIWI = 'r3k2r/p1ppqpb1/bn2pnp1/3PN3/1p2P3/2N2Q1p/PPPBBPPP/R3K2R w KQkq - 0 1'; + test('initial position depth 1-4 (chess.js generator on adapter-loaded position)', () => { + expect([1, 2, 3, 4].map((d) => load(START_FEN).perft(d))).toEqual([20, 400, 8902, 197281]); + }); + test('Kiwipete depth 1-3', () => { + expect([1, 2, 3].map((d) => load(KIWI).perft(d))).toEqual([48, 2039, 97862]); + }); + test('adapter move list (promotion collapse, castling) reproduces perft', () => { + expect([1, 2, 3].map((d) => adapterPerft(START_FEN, d))).toEqual([20, 400, 8902]); + expect([1, 2].map((d) => adapterPerft(KIWI, d))).toEqual([48, 2039]); + // Promotion-heavy position (chessprogramming.org "position 4"): 6 / 264. + expect([1, 2].map((d) => adapterPerft('r3k2r/Pppp1ppp/1b3nbN/nP6/BBP1P3/q4N2/Pp1P2PP/R2Q1RK1 w kq - 0 1', d))).toEqual([6, 264]); + }); +}); + +describe('castling (§5.2)', () => { + test('both sides allowed in the base position; castle action and king two-step both work', () => { + const s = make({}, 'r3k2r/8/8/8/8/8/8/R3K2R w KQkq - 0 1'); + expect(canCastle(s, 'king')).toBe(true); + expect(canCastle(s, 'queen')).toBe(true); + const a = act(s, 'A', { type: 'castle', side: 'king' }); + expect(a.fen.split(' ')[0]).toBe('r3k2r/8/8/8/8/8/8/R4RK1'); + expect(a.moves[0]!.san).toBe('O-O'); + const b = play(s, 'e1c1'); + expect(b.fen.split(' ')[0]).toBe('r3k2r/8/8/8/8/8/8/2KR3R'); + }); + test('forbidden while in check, through or into an attacked square', () => { + const inCheck = make({}, 'k3r3/8/8/8/8/8/8/R3K2R w KQ - 0 1'); + expect(canCastle(inCheck, 'king')).toBe(false); + expect(canCastle(inCheck, 'queen')).toBe(false); + expect(tryMove(inCheck, 'e1g1')).toEqual({ ok: false, reason: '지금은 캐슬링할 수 없어요.' }); + const through = make({}, 'k4r2/8/8/8/8/8/8/R3K2R w KQ - 0 1'); + expect(canCastle(through, 'king')).toBe(false); + expect(canCastle(through, 'queen')).toBe(true); + const into = make({}, 'k5r1/8/8/8/8/8/8/R3K2R w KQ - 0 1'); + expect(canCastle(into, 'king')).toBe(false); + const d1 = make({}, 'k2r4/8/8/8/8/8/8/R3K2R w KQ - 0 1'); + expect(canCastle(d1, 'queen')).toBe(false); + }); + test('queenside is allowed when only b1 is attacked', () => { + expect(canCastle(make({}, 'kr6/8/8/8/8/8/8/R3K2R w KQ - 0 1'), 'queen')).toBe(true); + }); + test('rook or king that has moved loses the right', () => { + const base = make({}, 'r3k2r/8/8/8/8/8/8/R3K2R w KQkq - 0 1'); + const rook = play(base, 'h1h2', 'a8a7', 'h2h1', 'a7a8'); + expect(canCastle(rook, 'king')).toBe(false); + expect(canCastle(rook, 'queen')).toBe(true); + const king = play(base, 'e1e2', 'a8a7', 'e2e1', 'a7a8'); + expect(canCastle(king, 'king')).toBe(false); + expect(canCastle(king, 'queen')).toBe(false); + }); +}); + +describe('en passant (§5.2)', () => { + test('only immediately after the double step', () => { + const s = play(make({}, '4k3/3p4/8/4P3/8/8/8/4K3 b - - 0 1'), 'd7d5'); + expect(tryMove(s, 'e5d6').ok).toBe(true); + const done = play(s, 'e5d6'); + expect(done.fen.split(' ')[0]).toBe('4k3/8/3P4/8/8/8/8/4K3'); + expect(done.moves[1]!.captured).toBe('p'); + const late = play(s, 'e1e2', 'e8e7'); + expect(tryMove(late, 'e5d6')).toEqual({ ok: false, reason: '그 기물은 그렇게 움직일 수 없어요.' }); + }); + test('illegal when it exposes the own king along the rank', () => { + const s = play(make({}, '7k/3p4/8/K3P2r/8/8/8/8 b - - 0 1'), 'd7d5'); + expect(tryMove(s, 'e5d6')).toEqual({ ok: false, reason: '킹이 공격받게 되어 둘 수 없어요.' }); + }); +}); + +describe('promotion (§5.2)', () => { + const P = '7k/P7/8/8/8/8/8/K7 w - - 0 1'; + test('all four pieces, missing piece rejected, capture-promotion', () => { + for (const p of ['q', 'r', 'b', 'n']) { + const s = play(make({}, P), `a7a8${p}`); + expect(s.fen.split(' ')[0]!.startsWith(p.toUpperCase())).toBe(true); + } + expect(tryMove(make({}, P), 'a7a8')).toEqual({ ok: false, reason: '승진할 기물을 골라 주세요.' }); + const cap = play(make({}, '1r5k/P7/8/8/8/8/8/K7 w - - 0 1'), 'a7b8n'); + expect(cap.moves[0]!.san).toBe('axb8=N'); + }); + test('autoQueen: promotion may be omitted and becomes a queen', () => { + const s = play(make({ autoQueen: true }, P), 'a7a8'); + expect(s.fen.startsWith('Q6k')).toBe(true); + }); +}); + +describe('game end (§5.3, §6)', () => { + test("scholar's mate: checkmate in 4, PGN and summary", () => { + const s = play(make(), 'e2e4', 'e7e5', 'f1c4', 'b8c6', 'd1h5', 'g8f6', 'h5f7'); + expect(s.result).toMatchObject({ winner: 'white', reason: 'checkmate', moveCount: 4, summary: '백 승 (체크메이트, 4수)' }); + expect(s.result!.pgn).toContain('1. e4 e5 2. Bc4 Nc6 3. Qh5 Nf6 4. Qxf7# 1-0'); + expect(chess.result(s)).toEqual({ ranking: [['A'], ['B']], summary: '백 승 (체크메이트, 4수)', reason: 'normal' }); + }); + test('queen stalemate', () => { + const s = play(make({}, 'k7/8/2K5/8/8/8/8/1Q6 w - - 0 1'), 'b1b6'); + expect(s.result).toMatchObject({ winner: null, reason: 'stalemate' }); + expect(chess.result(s)!.ranking).toEqual([['A', 'B']]); + }); + test('rejections: wrong turn, wrong piece, impossible move, not a player', () => { + const s = make(); + expect(chess.validate(s, 'B', { type: 'move', from: 'e7', to: 'e5' })).toEqual({ ok: false, reason: '지금은 상대 차례예요.' }); + expect(tryMove(s, 'e7e5')).toEqual({ ok: false, reason: '내 기물을 골라 주세요.' }); + expect(tryMove(s, 'e2e5')).toEqual({ ok: false, reason: '그 기물은 그렇게 움직일 수 없어요.' }); + expect(chess.validate(s, 'Z', { type: 'resign' }).ok).toBe(false); + const check = make({}, '4k3/8/8/8/8/8/3P4/r3K3 w - - 0 1'); + expect(tryMove(check, 'd2d3')).toEqual({ ok: false, reason: '체크를 먼저 막아야 해요.' }); + }); +}); + +describe('repetition (§6 3)', () => { + test('auto: third occurrence draws; unusable e.p. square is ignored', () => { + const s = make(); + const after = play(s, 'e2e4', 'g8f6', 'g1f3', 'f6g8', 'f3g1', 'g8f6', 'g1f3', 'f6g8'); + expect(after.phase).toBe('playing'); + const end = play(after, 'f3g1'); + expect(end.result?.reason).toBe('threefold'); + expect(positionKey(load('rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq e3 0 1').fen())).toBe( + 'rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq -', + ); + }); + test('different castling rights make a different position', () => { + const s = make({}, '4k3/8/8/8/8/8/8/4K2R w K - 0 1'); + const cycle = ['h1h2', 'e8d8', 'h2h1', 'd8e8']; + const twice = play(s, ...cycle, ...cycle); + // The start placement has now been seen 3×, but the first time with castling rights → no draw yet. + expect(twice.phase).toBe('playing'); + expect(twice.repetition[keyOf(load('4k3/8/8/8/8/8/8/4K2R w K - 0 1'))]).toBe(1); + expect(twice.repetition[keyOf(load('4k3/8/8/8/8/8/8/4K2R w - - 0 1'))]).toBe(2); + expect(play(twice, 'h1h2').result?.reason).toBe('threefold'); + }); + test('fide: threefold is claimable by the side to move, fivefold is automatic', () => { + const cycle = ['g1f3', 'g8f6', 'f3g1', 'f6g8']; + let s = play(make({ drawMode: 'fide' }), ...cycle, ...cycle); + expect(s.phase).toBe('playing'); + expect(s.claimable).toBe('threefold'); + expect(chess.validate(s, 'B', { type: 'claimDraw' }).ok).toBe(false); + const claimed = act(s, 'A', { type: 'claimDraw' }); + expect(claimed.result?.reason).toBe('threefold'); + s = play(s, ...cycle, ...cycle); + expect(s.result?.reason).toBe('fivefold'); + expect(chess.validate(make(), 'A', { type: 'claimDraw' }).ok).toBe(false); + }); +}); + +describe('50/75 moves (§6 4)', () => { + test('auto: 100th quiet ply draws, but checkmate on that ply wins', () => { + expect(play(make({}, 'k7/8/8/8/8/8/8/KR6 w - - 99 80'), 'b1b2').result?.reason).toBe('fiftyMove'); + expect(play(make({}, '7k/8/6K1/8/8/8/8/R7 w - - 99 80'), 'a1a8').result?.reason).toBe('checkmate'); + }); + test('fide: claimable at 100, automatic at 150', () => { + const s = play(make({ drawMode: 'fide' }, 'k7/8/8/8/8/8/8/KR6 w - - 99 80'), 'b1b2'); + expect(s.phase).toBe('playing'); + expect(s.claimable).toBe('fiftyMove'); + expect(act(s, 'B', { type: 'claimDraw' }).result?.reason).toBe('fiftyMove'); + expect(play(make({ drawMode: 'fide' }, 'k7/8/8/8/8/8/8/KR6 w - - 149 80'), 'b1b2').result?.reason).toBe('seventyFiveMove'); + }); +}); + +describe('insufficient material (§6 2)', () => { + test.each([ + ['K vs K', '8/8/8/4k3/8/8/8/4K3 w - - 0 1', true], + ['KB vs K', '8/8/8/4k3/8/8/8/4KB2 w - - 0 1', true], + ['KN vs K', '8/8/8/4k3/8/8/8/4KN2 w - - 0 1', true], + ['KB vs KB same colour', '8/8/8/4k3/8/3b4/8/4KB2 w - - 0 1', true], + ['KB vs KB opposite colour', '8/8/8/4k3/8/2b5/8/4KB2 w - - 0 1', false], + ['KNN vs K', '8/8/8/4k3/8/8/8/3NKN2 w - - 0 1', false], + ['KP vs K', '8/8/8/4k3/8/8/4P3/4K3 w - - 0 1', false], + ])('%s', (_n, fen, dead) => { + expect(insufficientMaterial(load(fen))).toBe(dead); + }); + test('capturing the last piece ends the game drawn', () => { + const s = play(make({}, 'k7/8/8/8/8/8/1q6/K7 w - - 0 1'), 'a1b2'); + expect(s.result?.reason).toBe('insufficientMaterial'); + }); +}); + +describe('clocks and time (§8.5)', () => { + const tc = { kind: 'fischer' as const, baseMin: 1, incSec: 5 }; + test('first move is free, increment added after a move, a late move loses instead of being played', () => { + let s = make({ timeControl: tc }, START_FEN, true); + expect(chess.deadline!(s)).toBe(30_000); + s = play1(s, 'e2e4', 10_000); // white first move: no charge + expect(s.clock.remainingMs.white).toBe(60_000); + expect(chess.deadline!(s)).toBe(40_000); // black's first-move limit + s = play1(s, 'e7e5', 20_000); + expect(chess.deadline!(s)).toBe(80_000); // white's main clock now runs + s = play1(s, 'g1f3', 30_000); + expect(s.clock.remainingMs.white).toBe(55_000); + s = play1(s, 'b8c6', 100_000); // black used 70s of 60s + expect(s.moves).toHaveLength(3); + expect(s.result).toMatchObject({ winner: 'white', reason: 'timeLoss' }); + expect(chess.result(s)!.reason).toBe('timeout'); + }); + test('first-move limit expiry aborts with an empty ranking (white or black)', () => { + const s = make({}, START_FEN, true); + const t = act(s, 'A', chess.onTimeout(s, 'A')); + expect(t.result?.reason).toBe('aborted'); + expect(chess.result(t)!.ranking).toEqual([]); + const b = play1(make({}, START_FEN, true), 'e2e4', 1000); + expect(act(b, 'B', { type: 'timeout' }).result?.reason).toBe('aborted'); + const late = play1(make({ firstMoveSec: 15 }, START_FEN, true), 'e2e4', 16_000); + expect(late.result?.reason).toBe('aborted'); + }); + test('time-out exception: opponent with a bare king draws, K+N vs K+Q still wins', () => { + const bare = act(make({ timeControl: tc }, '4k3/8/8/8/8/8/8/Q3K3 w - - 0 1'), 'A', { type: 'timeout' }); + expect(bare.result).toMatchObject({ winner: null, reason: 'timeoutVsInsufficient' }); + const knight = act(make({ timeControl: tc }, '4k3/8/5n2/8/8/8/8/Q3K3 w - - 0 1'), 'A', { type: 'timeout' }); + expect(knight.result).toMatchObject({ winner: 'black', reason: 'timeLoss' }); + expect(cannotMate(load('4k3/8/5n2/8/8/8/8/4K3 w - - 0 1'), 'b')).toBe(true); + expect(cannotMate(load('4k3/8/2b5/8/8/8/8/4KB2 w - - 0 1'), 'b')).toBe(true); + expect(cannotMate(load('4k3/8/3b4/8/8/8/8/4KB2 w - - 0 1'), 'b')).toBe(false); + }); + test('disconnect grace expiry: the runner fires onTimeout for the player to move → time loss', () => { + const s = make({ timeControl: tc }); + expect(chess.timeoutPlayers!(s)).toEqual(['A']); + const a = chess.onTimeout(s, 'A'); + expect(chess.validate(s, 'A', a).ok).toBe(true); + expect(chess.validate(s, 'B', a).ok).toBe(false); + expect(act(s, 'A', a).result).toMatchObject({ winner: 'black', reason: 'timeLoss' }); + }); + test('no time control: idle limit deadline', () => { + const s = make({ timeControl: { kind: 'none', baseMin: 10, incSec: 0 }, idleLimitSec: 300 }); + expect(chess.deadline!(s)).toBe(300_000); + }); +}); + +function play1(s: ChessState, uci: string, now: number): ChessState { + return act(s, turnPlayer(s), { type: 'move', from: uci.slice(0, 2), to: uci.slice(2, 4) }, now); +} + +describe('requests', () => { + test('undo 1 ply / 2 plies restores position and repetition; decline changes nothing', () => { + let s = play(make({ undo: '3' }), 'g1f3', 'g8f6', 'f3g1', 'f6g8'); + expect(s.repetition[positionKey(START_FEN)]).toBe(2); + s = act(s, 'A', { type: 'requestUndo' }); + expect(s.undo.pending).toEqual({ by: 'white', plies: 2 }); + expect(chess.activePlayers(s)).toEqual(['A', 'B']); + const declined = act(s, 'B', { type: 'respondUndo', accept: false }); + expect(declined.fen).toBe(s.fen); + expect(declined.moves).toHaveLength(4); + expect(declined.undo.usedBy.white).toBe(0); + s = act(s, 'B', { type: 'respondUndo', accept: true }); + expect(s.moves).toHaveLength(2); + expect(s.fen).toBe(play(make(), 'g1f3', 'g8f6').fen); + expect(s.repetition[positionKey(START_FEN)]).toBe(1); + expect(Object.values(s.repetition).reduce((a, b) => a + b, 0)).toBe(3); + s = play(s, 'f3g1'); + s = act(s, 'A', { type: 'requestUndo' }); + expect(s.undo.pending?.plies).toBe(1); + s = act(s, 'B', { type: 'respondUndo', accept: true }); + expect(s.moves).toHaveLength(2); + expect(turnPlayer(s)).toBe('A'); + }); + test('undo limit and pending request cleared by a move', () => { + let s = play(make({ undo: '1' }), 'e2e4'); + s = act(act(s, 'A', { type: 'requestUndo' }), 'B', { type: 'respondUndo', accept: true }); + s = play(s, 'd2d4'); + expect(chess.validate(s, 'A', { type: 'requestUndo' })).toEqual({ ok: false, reason: '무르기 횟수를 다 썼어요.' }); + s = play(s, 'd7d5'); + s = act(s, 'B', { type: 'requestUndo' }); + s = play(s, 'c2c4'); + expect(s.undo.pending).toBeNull(); + expect(chess.validate(make({ undo: 'off' }), 'A', { type: 'requestUndo' }).ok).toBe(false); + }); + test('draw offer: survives the offerer moving, auto-declined when the opponent moves, re-offer gap, accept', () => { + let s = act(make(), 'A', { type: 'offerDraw' }); + s = play(s, 'e2e4'); + expect(s.draw.pendingBy).toBe('white'); + s = play(s, 'e7e5'); + expect(s.draw.pendingBy).toBeNull(); + expect(chess.validate(s, 'A', { type: 'offerDraw' }).ok).toBe(false); + s = play(s, 'g1f3', 'b8c6', 'f1c4', 'g8f6', 'b1c3'); + expect(chess.validate(s, 'A', { type: 'offerDraw' }).ok).toBe(true); + s = act(s, 'B', { type: 'offerDraw' }); + s = act(s, 'A', { type: 'respondDraw', accept: true }); + expect(chess.result(s)).toMatchObject({ ranking: [['A', 'B']], reason: 'draw-agreed' }); + }); + test('resign: loss after your first move, abort before it', () => { + const s = play(make({}, START_FEN, true), 'e2e4'); + expect(chess.result(act(s, 'A', { type: 'resign' }))!.ranking).toEqual([['B'], ['A']]); + expect(act(s, 'B', { type: 'resign' }).result?.reason).toBe('aborted'); + }); +}); + +describe('views and playouts', () => { + test('legalMoves / threatened only for the player to move; no rng anywhere', () => { + const s = make({ hints: 'movesAndThreats' }, '4k3/8/8/3q4/8/8/3N4/K7 w - - 0 1'); + const mine = chess.view(s, 'A'); + expect(mine.legalMoves?.length).toBeGreaterThan(0); + expect(mine.threatened).toEqual(['d2']); + for (const v of [chess.view(s, 'B'), chess.view(s, null)]) { + expect(v.legalMoves).toBeNull(); + expect(v.threatened).toBeNull(); + } + expect(chess.view(make(), 'A').threatened).toBeNull(); + for (const v of [mine, chess.view(s, 'B'), chess.view(s, null)]) expect(JSON.stringify(v)).not.toMatch(/rng/i); + expect(JSON.stringify(s)).not.toMatch(/rng/i); + expect(threatenedSquares(load('4k3/8/8/3q4/8/8/3N4/K2Q4 w - - 0 1'))).toEqual([]); + }); + test('view shape: board, check square, captured pieces and material, promotion flag', () => { + const s = play(make(), 'e2e4', 'd7d5', 'e4d5', 'd8d5', 'b1c3', 'd5e5'); + const v = chess.view(s, null); + expect(v.board[0]).toBe('r'); + expect(v.board[63]).toBe('R'); + expect(v.captured).toEqual({ white: ['p'], black: ['p'] }); + expect(v.materialDiff).toBe(0); + expect(v.lastMove).toEqual({ from: 'd5', to: 'e5' }); + expect(v.checkSquare).toBe('e1'); + const p = chess.view(make({}, '7k/P7/8/8/8/8/8/K7 w - - 0 1'), 'A'); + expect(p.legalMoves).toContainEqual({ from: 'a7', to: 'a8', promotion: true }); + expect(p.legalMoves!.filter((m) => m.from === 'a7')).toHaveLength(1); + }); + test('colour assignment', () => { + const p = (o: Partial, gameNo?: number) => + chess.setup({ players: ['H', 'G'], options: chess.optionsSchema.parse(o), rng: rng(), now: 0, gameNo }).players.white; + expect(p({ colorAssignment: 'hostWhite' })).toBe('H'); + expect(p({ colorAssignment: 'hostBlack' })).toBe('G'); + expect([1, 2].map((n) => p({ colorAssignment: 'alternate' }, n))).toEqual(['H', 'G']); + }); + test('random playouts finish, are deterministic and JSON round-trip', () => { + const runs: [Partial, number][] = [ + [{}, 4], + [{ drawMode: 'fide', undo: '3' }, 3], + ]; + for (const [o, n] of runs) { + for (let i = 0; i < n; i++) { + const options = chess.optionsSchema.parse(o); + const a = randomPlayout(chess, { players: ['A', 'B'], options, seed: seedN(i), maxApplyMs: 50 }); + expect(a.finished).toBe(true); + expect(chess.result(a.finalState as ChessState)).not.toBeNull(); + if (i === 0) { + const b = randomPlayout(chess, { players: ['A', 'B'], options, seed: seedN(i), maxApplyMs: 50 }); + expect(JSON.stringify(b.finalState)).toBe(JSON.stringify(a.finalState)); + } + } + } + }, 30_000); +}); diff --git a/packages/games/src/chess/index.ts b/packages/games/src/chess/index.ts new file mode 100644 index 0000000..3c79265 --- /dev/null +++ b/packages/games/src/chess/index.ts @@ -0,0 +1,643 @@ +/** + * 체스 (docs/games/chess.md). Standard chess only: chess.js 1.4.0 has no Chess960 castling support, so the + * `variant` option is left out until a 960-capable generator is chosen (§8.0). + */ +import { z } from 'zod'; +import type { Chess, Square } from 'chess.js'; +import { fail, ok, type GameDefinition, type GameEvent, type GameResult, type PlayerId } from '@bg/engine'; +import { + START_FEN, + boardArray, + buildPgn, + cannotMate, + halfmoveClock, + insufficientMaterial, + isSquare, + kingSquare, + legalMoves, + load, + materialDiff, + other, + fullFen, + keyOf, + positionKey, + pseudoReachable, + threatenedSquares, + toSide, + type Color, + type LegalMove, + type Promo, +} from './rules'; + +export type { Color, LegalMove, Promo } from './rules'; +const COLOR_KO: Record = { white: '백', black: '흑' }; + +export const chessOptionsSchema = z.object({ + timeControl: z + .object({ + kind: z + .enum(['fischer', 'none']) + .default('fischer') + .meta({ title: '시간 방식', labels: { fischer: '기본 시간 + 수당 추가', none: '시간 제한 없음' } }), + baseMin: z.number().int().min(1).max(90).default(10).meta({ title: '기본 시간(분)' }), + incSec: z.number().int().min(0).max(60).default(5).meta({ title: '한 수마다 추가(초)' }), + }) + .default({ kind: 'fischer', baseMin: 10, incSec: 5 }) + .meta({ title: '시간 제한' }), + drawMode: z + .enum(['auto', 'fide']) + .default('auto') + .meta({ + title: '반복·50수 무승부', + labels: { auto: '자동 무승부(3회 반복·50수)', fide: 'FIDE(주장해야 무승부, 5회 반복·75수는 자동)' }, + }), + colorAssignment: z + .enum(['random', 'hostWhite', 'hostBlack', 'alternate']) + .default('random') + .meta({ title: '백흑 정하기', labels: { random: '무작위', hostWhite: '방장이 백', hostBlack: '방장이 흑', alternate: '판마다 번갈아' } }), + autoQueen: z.boolean().default(false).meta({ title: '승진하면 퀸으로 자동 선택' }), + undo: z + .enum(['off', '1', '3', 'unlimited']) + .default('1') + .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(3).meta({ title: '무승부 다시 제안까지 둬야 하는 수' }), + firstMoveSec: z + .union([z.literal(15), z.literal(30), z.literal(60)]) + .default(30) + .meta({ title: '첫 수 제한(넘기면 대국 취소)', labels: { 15: '15초', 30: '30초', 60: '60초' } }), + idleLimitSec: z + .union([z.literal(300), z.literal(600)]) + .default(600) + .meta({ title: '시간 제한 없음일 때 무응답 한도', labels: { 300: '5분', 600: '10분' } }), + hints: z + .enum(['off', 'moves', 'movesAndThreats']) + .default('moves') + .meta({ title: '초보 도움말', labels: { off: '끄기', moves: '갈 수 있는 칸 표시', movesAndThreats: '갈 수 있는 칸 + 위험한 기물 경고' } }), +}); +export type ChessOptions = z.infer; + +const sq = z.string().regex(/^[a-h][1-8]$/); +export const chessActionSchema = z.discriminatedUnion('type', [ + z.object({ type: z.literal('move'), from: sq, to: sq, promotion: z.enum(['q', 'r', 'b', 'n']).optional() }), + z.object({ type: z.literal('castle'), side: z.enum(['king', 'queen']) }), + z.object({ type: z.literal('claimDraw') }), + 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 ChessAction = z.infer | { type: 'timeout' }; + +export type ChessReason = + | 'checkmate' + | 'resign' + | 'timeLoss' + | 'stalemate' + | 'insufficientMaterial' + | 'threefold' + | 'fivefold' + | 'fiftyMove' + | 'seventyFiveMove' + | 'drawAgreed' + | 'timeoutVsInsufficient' + | 'aborted'; + +export interface ChessResult { + winner: Color | null; + reason: ChessReason; + /** Full moves played. */ + moveCount: number; + pgn: string; + summary: string; +} + +export interface ChessMoveRecord { + uci: string; + san: string; + color: Color; + at: number; + fenAfter: string; + /** Normalised repetition key of fenAfter. */ + key: string; + captured?: string; +} + +export interface ChessState { + options: ChessOptions; + players: Record; + startFen: string; + /** Current position (single source of truth). */ + fen: string; + moves: ChessMoveRecord[]; + /** positionKey → occurrences, including the start position. */ + repetition: Record; + phase: 'playing' | 'finished'; + clock: { remainingMs: Record; turnStartedAt: number; started: Record }; + undo: { usedBy: Record; pending: { by: Color; plies: 1 | 2 } | null }; + draw: { pendingBy: Color | null; lastOfferPly: Record }; + /** drawMode=fide: what the side to move may claim right now. */ + claimable: 'threefold' | 'fiftyMove' | null; + result: ChessResult | null; +} + +export interface ChessView { + fen: string; + /** 64 FEN letters (uppercase = white), a8 first, then b8 … h1. */ + board: (string | null)[]; + players: Record; + myColor: Color | null; + turn: Color; + phase: ChessState['phase']; + moves: { san: string; uci: string; color: Color }[]; + lastMove: { from: string; to: string } | null; + /** King square of the side to move when it is in check. */ + checkSquare: string | null; + timeControl: ChessOptions['timeControl']; + firstMoveSec: number; + idleLimitSec: number; + remainingMs: Record; + turnStartedAt: number; + started: Record; + /** Pieces captured *by* each colour (lowercase letters). */ + captured: Record; + /** White material minus black material. */ + materialDiff: number; + undo: { allowed: boolean; remaining: Record; pending: { by: Color; plies: number } | null }; + draw: { allowed: boolean; pendingBy: Color | null }; + drawMode: ChessOptions['drawMode']; + claimable: ChessState['claimable']; + autoQueen: boolean; + hints: ChessOptions['hints']; + /** Only for the player to move. */ + legalMoves: LegalMove[] | null; + /** Only for the player to move with hints=movesAndThreats. */ + threatened: string[] | null; + result: ChessResult | null; +} + +const undoLimit = (o: ChessOptions): number => (o.undo === 'off' ? 0 : o.undo === 'unlimited' ? Infinity : Number(o.undo)); +const colorOf = (s: ChessState, p: PlayerId): Color | null => + s.players.white === p ? 'white' : s.players.black === p ? 'black' : null; +const turnOf = (s: ChessState): Color => (s.fen.split(' ')[1] === 'b' ? 'black' : 'white'); + +const REASON_KO: Record = { + checkmate: '체크메이트', + resign: '기권', + timeLoss: '시간패', + stalemate: '스테일메이트', + insufficientMaterial: '기물 부족', + threefold: '3회 반복', + fivefold: '5회 반복', + fiftyMove: '50수 규칙', + seventyFiveMove: '75수 규칙', + drawAgreed: '합의', + timeoutVsInsufficient: '시간 초과, 상대 기물 부족', + aborted: '대국 취소', +}; + +function finish(s: ChessState, winner: Color | null, reason: ChessReason, events: GameEvent[], who?: Color): void { + s.phase = 'finished'; + s.undo.pending = null; + s.draw.pendingBy = null; + s.claimable = null; + const moveCount = Math.ceil(s.moves.length / 2); + let summary: string; + if (reason === 'aborted') summary = `대국 취소 (${COLOR_KO[who ?? turnOf(s)]}이 첫 수를 두지 않았어요)`; + else if (winner) summary = `${COLOR_KO[winner]} 승 (${REASON_KO[reason]}, ${moveCount}수)`; + else summary = `무승부 (${REASON_KO[reason]}, ${moveCount}수)`; + const tag = reason === 'aborted' ? '*' : winner === 'white' ? '1-0' : winner === 'black' ? '0-1' : '1/2-1/2'; + const pgn = buildPgn( + s.startFen, + s.moves.map((m) => m.san), + { White: s.players.white, Black: s.players.black, Termination: REASON_KO[reason] }, + tag, + ); + s.result = { winner, reason, moveCount, pgn, summary }; + events.push({ type: 'gameEnded', winner, reason }); +} + +/** Rebuild repetition counts from the start position and the stored positions after each move. */ +function rebuildRepetition(s: ChessState): void { + s.repetition = {}; + for (const k of [keyOf(load(s.startFen)), ...s.moves.map((m) => m.key)]) s.repetition[k] = (s.repetition[k] ?? 0) + 1; +} + +/** §8.2 end order: checkmate > stalemate > insufficient > (75/5-fold) > (auto: 50/3-fold) → claimable. */ +function judge(s: ChessState, c: Chess, events: GameEvent[]): void { + const mover = other(turnOf(s)); + if (c.isCheckmate()) { + events.push({ type: 'checkmate', color: mover }); + return finish(s, mover, 'checkmate', events); + } + if (c.isStalemate()) { + events.push({ type: 'stalemate' }); + return finish(s, null, 'stalemate', events); + } + if (insufficientMaterial(c)) return finish(s, null, 'insufficientMaterial', events); + const key = keyOf(c); + const reps = s.repetition[key] ?? 0; + const half = halfmoveClock(s.fen); + if (s.options.drawMode === 'fide') { + if (half >= 150) return finish(s, null, 'seventyFiveMove', events); + if (reps >= 5) return finish(s, null, 'fivefold', events); + } else { + if (half >= 100) return finish(s, null, 'fiftyMove', events); + if (reps >= 3) return finish(s, null, 'threefold', events); + } + refreshClaimable(s, key); + if (s.claimable) events.push({ type: 'drawClaimable', reason: s.claimable }); +} + +function refreshClaimable(s: ChessState, key: string): void { + if (s.phase !== 'playing' || s.options.drawMode !== 'fide') { + s.claimable = null; + return; + } + const reps = s.repetition[key] ?? 0; + s.claimable = reps >= 3 ? 'threefold' : halfmoveClock(s.fen) >= 100 ? 'fiftyMove' : null; +} + +/** Time ran out for `loser`: loss, unless the opponent cannot possibly mate (FIDE 6.9). */ +function flag(s: ChessState, loser: Color, events: GameEvent[]): void { + if (s.options.timeControl.kind === 'fischer') s.clock.remainingMs[loser] = 0; + events.push({ type: 'timeLoss', color: loser }); + const winner = other(loser); + if (cannotMate(load(s.fen), toSide(winner))) finish(s, null, 'timeoutVsInsufficient', events); + else finish(s, winner, 'timeLoss', events); +} + +function resolveMove(s: ChessState, a: ChessAction): { from: Square; to: Square; promotion?: Promo } | null { + if (a.type === 'move') return { from: a.from as Square, to: a.to as Square, promotion: a.promotion }; + if (a.type !== 'castle') return null; + const m = legalMoves(load(s.fen)).find((x) => x.castle === a.side); + return m ? { from: m.from as Square, to: m.to as Square } : null; +} + +function pendingResponder(s: ChessState): Color | null { + if (s.undo.pending) return other(s.undo.pending.by); + if (s.draw.pendingBy) return other(s.draw.pendingBy); + return null; +} + +export const chess: GameDefinition = { + id: 'chess', + nameKo: '체스', + minPlayers: 2, + maxPlayers: 2, + stateVersion: 1, + defaultOptions: chessOptionsSchema.parse({}), + optionsSchema: chessOptionsSchema, + actionSchema: chessActionSchema as z.ZodType, + + setup({ players, options, rng, now, gameNo }) { + const [host, guest] = players as [PlayerId, PlayerId]; + let hostWhite: boolean; + switch (options.colorAssignment) { + case 'hostWhite': + hostWhite = true; + break; + case 'hostBlack': + hostWhite = false; + break; + case 'alternate': + hostWhite = (gameNo ?? 1) % 2 === 1; + break; + default: + hostWhite = rng.int(2) === 0; + } + const base = options.timeControl.baseMin * 60_000; + return { + options, + players: hostWhite ? { white: host, black: guest } : { white: guest, black: host }, + startFen: START_FEN, + fen: START_FEN, + moves: [], + repetition: { [positionKey(START_FEN)]: 1 }, + phase: 'playing', + clock: { remainingMs: { white: base, black: base }, turnStartedAt: now, started: { white: false, black: false } }, + undo: { usedBy: { white: 0, black: 0 }, pending: null }, + draw: { pendingBy: null, lastOfferPly: { white: null, black: null } }, + claimable: null, + result: null, + }; + }, + + validate(s, actor, a) { + const me = colorOf(s, actor); + if (!me) return fail('이 게임의 플레이어가 아니에요.'); + if (s.phase !== 'playing') return fail('게임이 이미 끝났어요.'); + const turn = turnOf(s); + switch (a.type) { + case 'move': + case 'castle': { + if (turn !== me) return fail('지금은 상대 차례예요.'); + const c = load(s.fen); + if (a.type === 'castle') { + return legalMoves(c).some((m) => m.castle === a.side) ? ok : fail('지금은 그쪽으로 캐슬링할 수 없어요.'); + } + if (!isSquare(a.from) || !isSquare(a.to)) return fail('판 밖이에요.'); + const piece = c.get(a.from); + if (!piece || piece.color !== toSide(me)) return fail('내 기물을 골라 주세요.'); + const m = legalMoves(c, a.from).find((x) => x.to === a.to); + if (!m) { + if (piece.type === 'k' && Math.abs(a.from.charCodeAt(0) - a.to.charCodeAt(0)) === 2 && a.from[1] === a.to[1]) { + return fail('지금은 캐슬링할 수 없어요.'); + } + if (pseudoReachable(c, a.from, a.to)) { + return fail(c.inCheck() ? '체크를 먼저 막아야 해요.' : '킹이 공격받게 되어 둘 수 없어요.'); + } + return fail('그 기물은 그렇게 움직일 수 없어요.'); + } + if (m.promotion && !a.promotion && !s.options.autoQueen) return fail('승진할 기물을 골라 주세요.'); + return ok; + } + case 'claimDraw': + if (s.options.drawMode !== 'fide') return fail('이 방은 반복·50수 무승부가 자동이에요.'); + if (turn !== me) return fail('무승부 주장은 내 차례에만 할 수 있어요.'); + if (!s.claimable) return fail('지금은 무승부를 주장할 수 없어요.'); + return ok; + 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.color === 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.color === 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 'resign': + return ok; + case 'timeout': + if (turn !== me) return fail('지금은 상대 차례예요.'); + return ok; + } + }, + + apply(prev, actor, a, { now }) { + const s = structuredClone(prev); + const me = colorOf(s, actor)!; + const events: GameEvent[] = []; + switch (a.type) { + case 'move': + case 'castle': { + // 1) Clock. A colour's first move only has the first-move limit; its main clock is not charged. + const elapsed = now - s.clock.turnStartedAt; + if (!s.clock.started[me]) { + if (elapsed > s.options.firstMoveSec * 1000) { + finish(s, null, 'aborted', events, me); + break; + } + s.clock.started[me] = true; + } else if (s.options.timeControl.kind === 'fischer') { + s.clock.remainingMs[me] -= elapsed; + if (s.clock.remainingMs[me] <= 0) { + flag(s, me, events); + break; + } + s.clock.remainingMs[me] += s.options.timeControl.incSec * 1000; + } + // 2) Move. + const mv = resolveMove(s, a)!; + const c = load(s.fen); + const isPromo = c.get(mv.from)?.type === 'p' && (mv.to[1] === '8' || mv.to[1] === '1'); + const done = c.move({ from: mv.from, to: mv.to, promotion: isPromo ? (mv.promotion ?? 'q') : undefined }); + s.fen = fullFen(c); + const key = keyOf(c); + s.moves.push({ + uci: done.lan, + san: done.san, + color: me, + at: now, + fenAfter: s.fen, + key, + ...(done.captured ? { captured: done.captured } : {}), + }); + s.repetition[key] = (s.repetition[key] ?? 0) + 1; + s.clock.turnStartedAt = now; + s.undo.pending = null; + if (s.draw.pendingBy && s.draw.pendingBy !== me) { + s.draw.pendingBy = null; + events.push({ type: 'drawDeclined', auto: true }); + } + const check = c.inCheck(); + events.push({ type: 'moved', color: me, san: done.san, from: done.from, to: done.to, captured: done.captured ?? null, check }); + if (done.isPromotion()) events.push({ type: 'promoted', color: me, piece: done.promotion }); + if (done.isKingsideCastle() || done.isQueensideCastle()) { + events.push({ type: 'castled', color: me, side: done.isKingsideCastle() ? 'king' : 'queen' }); + } + if (done.isEnPassant()) events.push({ type: 'enPassant', color: me }); + if (check) events.push({ type: 'check', color: other(me) }); + // 3) End of game / claimable. + judge(s, c, events); + break; + } + case 'timeout': + if (!s.clock.started[me]) finish(s, null, 'aborted', events, me); + else flag(s, me, events); + break; + case 'claimDraw': + events.push({ type: 'drawClaimed', color: me, reason: s.claimable }); + finish(s, null, s.claimable!, events); + break; + case 'requestUndo': { + const last = s.moves[s.moves.length - 1]!; + s.undo.pending = { by: me, plies: last.color === me ? 1 : 2 }; + events.push({ type: 'undoRequested', color: me, plies: s.undo.pending.plies }); + break; + } + case 'respondUndo': { + const { by, plies } = s.undo.pending!; + s.undo.pending = null; + if (!a.accept) { + events.push({ type: 'undoDeclined', color: by }); + break; + } + // The clock is not rewound, but the side to move is charged for the time it spent until now. + const onTurn = turnOf(s); + if (s.clock.started[onTurn] && s.options.timeControl.kind === 'fischer') { + s.clock.remainingMs[onTurn] = Math.max(0, s.clock.remainingMs[onTurn] - (now - s.clock.turnStartedAt)); + } + s.moves.splice(s.moves.length - plies, plies); + s.fen = s.moves.length ? s.moves[s.moves.length - 1]!.fenAfter : s.startFen; + rebuildRepetition(s); + s.undo.usedBy[by]++; + s.clock.turnStartedAt = now; + refreshClaimable(s, s.moves.length ? s.moves[s.moves.length - 1]!.key : keyOf(load(s.startFen))); + events.push({ type: 'undoAccepted', color: by, plies }); + break; + } + case 'offerDraw': + s.draw.pendingBy = me; + s.draw.lastOfferPly[me] = s.moves.length; + events.push({ type: 'drawOffered', color: me }); + break; + case 'respondDraw': + s.draw.pendingBy = null; + if (a.accept) { + events.push({ type: 'drawAccepted' }); + finish(s, null, 'drawAgreed', events); + } 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', color: me }); + break; + case 'resign': + // Before your own first move a resignation just cancels the game (no result recorded). + if (!s.clock.started[me]) finish(s, null, 'aborted', events, me); + else { + events.push({ type: 'resigned', color: me }); + finish(s, other(me), 'resign', events); + } + break; + } + return { state: s, events }; + }, + + view(s, viewer) { + const myColor = viewer ? colorOf(s, viewer) : null; + const turn = turnOf(s); + const c = load(s.fen); + const mover = s.phase === 'playing' && myColor !== null && myColor === turn; + const limit = undoLimit(s.options); + const last = s.moves[s.moves.length - 1]; + const captured: Record = { white: [], black: [] }; + for (const m of s.moves) if (m.captured) captured[m.color].push(m.captured); + return { + fen: s.fen, + board: boardArray(s.fen), + players: s.players, + myColor, + turn, + phase: s.phase, + moves: s.moves.map((m) => ({ san: m.san, uci: m.uci, color: m.color })), + lastMove: last ? { from: last.uci.slice(0, 2), to: last.uci.slice(2, 4) } : null, + checkSquare: c.inCheck() ? kingSquare(c, c.turn()) : null, + timeControl: s.options.timeControl, + firstMoveSec: s.options.firstMoveSec, + idleLimitSec: s.options.idleLimitSec, + remainingMs: s.clock.remainingMs, + turnStartedAt: s.clock.turnStartedAt, + started: s.clock.started, + captured, + materialDiff: materialDiff(c), + undo: { + allowed: s.options.undo !== 'off', + remaining: { + white: Number.isFinite(limit) ? limit - s.undo.usedBy.white : null, + black: Number.isFinite(limit) ? limit - s.undo.usedBy.black : null, + }, + pending: s.undo.pending, + }, + draw: { allowed: s.options.drawOffer, pendingBy: s.draw.pendingBy }, + drawMode: s.options.drawMode, + claimable: s.claimable, + autoQueen: s.options.autoQueen, + hints: s.options.hints, + legalMoves: mover ? legalMoves(c) : null, + threatened: mover && s.options.hints === 'movesAndThreats' ? threatenedSquares(c) : null, + result: s.result, + }; + }, + + activePlayers(s) { + if (s.phase !== 'playing') return []; + const turn = turnOf(s); + const out = [s.players[turn]]; + const responder = pendingResponder(s); + if (responder && responder !== turn) out.push(s.players[responder]); + return out; + }, + + deadline(s) { + if (s.phase !== 'playing') return null; + const turn = turnOf(s); + const start = s.clock.turnStartedAt; + if (!s.clock.started[turn]) return start + s.options.firstMoveSec * 1000; + if (s.options.timeControl.kind === 'fischer') return start + s.clock.remainingMs[turn]; + return start + s.options.idleLimitSec * 1000; + }, + + timeoutPlayers(s) { + return s.phase === 'playing' ? [s.players[turnOf(s)]] : []; + }, + + onTimeout() { + return { type: 'timeout' }; + }, + + onLeave() { + return { type: 'resign' }; + }, + + result(s): GameResult | null { + if (!s.result) return null; + const { winner, reason, summary } = s.result; + let ranking: PlayerId[][]; + if (reason === 'aborted') ranking = []; + else if (winner) ranking = [[s.players[winner]], [s.players[other(winner)]]]; + else ranking = [[s.players.white, s.players.black]]; + const map: Record = { + checkmate: 'normal', + stalemate: 'normal', + insufficientMaterial: 'normal', + threefold: 'normal', + fivefold: 'normal', + fiftyMove: 'normal', + seventyFiveMove: 'normal', + resign: 'resign', + timeLoss: 'timeout', + timeoutVsInsufficient: 'timeout', + drawAgreed: 'draw-agreed', + aborted: 'abandoned', + }; + return { ranking, summary, reason: map[reason] }; + }, + + legalActions(s, p) { + const me = colorOf(s, p); + if (!me || s.phase !== 'playing') return []; + const responder = pendingResponder(s); + if (responder === me) { + return s.undo.pending + ? [ + { type: 'respondUndo', accept: true }, + { type: 'respondUndo', accept: false }, + ] + : [{ type: 'respondDraw', accept: false }]; + } + if (turnOf(s) !== me) return []; + const out: ChessAction[] = []; + for (const m of legalMoves(load(s.fen))) { + if (m.promotion) for (const promotion of ['q', 'r', 'b', 'n'] as const) out.push({ type: 'move', from: m.from, to: m.to, promotion }); + else out.push({ type: 'move', from: m.from, to: m.to }); + } + if (s.claimable) out.push({ type: 'claimDraw' }); + for (const extra of [{ type: 'requestUndo' }, { type: 'offerDraw' }] as const) { + if (chess.validate(s, p, extra).ok) out.push(extra); + } + return out; + }, +}; + diff --git a/packages/games/src/chess/rules.ts b/packages/games/src/chess/rules.ts new file mode 100644 index 0000000..2eb0fc1 --- /dev/null +++ b/packages/games/src/chess/rules.ts @@ -0,0 +1,232 @@ +/** + * chess.js adapter (docs/games/chess.md §8.0). Move generation/validation comes from chess.js (BSD-2-Clause, + * Copyright (c) 2025 Jeff Hlywa); position keys, draw rules, insufficient material and the FIDE 6.9 + * time-out exception are decided here with our own rules. + */ +import { Chess, DEFAULT_POSITION, type Move, type PieceSymbol, type Square } from 'chess.js'; + +export type Color = 'white' | 'black'; +export type Promo = 'q' | 'r' | 'b' | 'n'; +export const START_FEN = DEFAULT_POSITION; +export const toSide = (c: Color): 'w' | 'b' => (c === 'white' ? 'w' : 'b'); +export const fromSide = (s: 'w' | 'b'): Color => (s === 'w' ? 'white' : 'black'); +export const other = (c: Color): Color => (c === 'white' ? 'black' : 'white'); + +export const PIECE_VALUE: Record = { p: 1, n: 3, b: 3, r: 5, q: 9, k: 0 }; + +/** One entry per (from, to); promotions are a single entry with `promotion: true` (client shows a picker). */ +export interface LegalMove { + from: string; + to: string; + promotion?: true; + castle?: 'king' | 'queen'; +} + +export const isSquare = (s: string): s is Square => /^[a-h][1-8]$/.test(s); + +export function load(fen: string): Chess { + return new Chess(fen); +} + +/** + * Repetition key: placement, side to move, castling rights, en-passant square. chess.js's fen() already prints + * the e.p. square only when a legal e.p. capture exists, which is exactly the FIDE 9.2 normalisation. + */ +export function positionKey(fen: string): string { + return fen.split(' ').slice(0, 4).join(' '); +} +export const keyOf = (c: Chess): string => positionKey(c.fen()); +/** Stored FEN keeps the e.p. square whenever a double step just happened (for accurate rejection messages). */ +export const fullFen = (c: Chess): string => c.fen({ forceEnpassantSquare: true }); + +export const halfmoveClock = (fen: string): number => Number(fen.split(' ')[4] ?? 0); +export const turnOf = (fen: string): Color => (fen.split(' ')[1] === 'b' ? 'black' : 'white'); + +function toLegal(m: Move): LegalMove { + const out: LegalMove = { from: m.from, to: m.to }; + if (m.isPromotion()) out.promotion = true; + if (m.isKingsideCastle()) out.castle = 'king'; + if (m.isQueensideCastle()) out.castle = 'queen'; + return out; +} + +/** Legal moves for the side to move (optionally from one square), promotions collapsed. */ +export function legalMoves(c: Chess, square?: Square): LegalMove[] { + const raw = square ? c.moves({ square, verbose: true }) : c.moves({ verbose: true }); + const out: LegalMove[] = []; + const seen = new Set(); + for (const m of raw) { + const k = m.from + m.to; + if (seen.has(k)) continue; + seen.add(k); + out.push(toLegal(m)); + } + return out; +} + +/** Count leaf nodes through the adapter (legalMoves → expand promotions → chess.move). */ +export function adapterPerft(fen: string, depth: number): number { + const c = load(fen); + const walk = (d: number): number => { + const moves = legalMoves(c); + let n = 0; + for (const m of moves) { + for (const promotion of m.promotion ? (['q', 'r', 'b', 'n'] as const) : [undefined]) { + if (d === 1) { + n++; + continue; + } + c.move({ from: m.from, to: m.to, promotion }); + n += walk(d - 1); + c.undo(); + } + } + return n; + }; + return depth === 0 ? 1 : walk(depth); +} + +/** + * Can `from → to` be reached by the piece's movement pattern, ignoring checks? Used only to pick the rejection + * message ("킹이 공격받게 되어…" vs "그렇게 움직일 수 없어요"). Castling is handled separately. + */ +export function pseudoReachable(c: Chess, from: Square, to: Square): boolean { + const p = c.get(from); + if (!p || from === to) return false; + const target = c.get(to); + if (target && target.color === p.color) return false; + const fx = from.charCodeAt(0) - 97; + const fy = Number(from[1]) - 1; + const tx = to.charCodeAt(0) - 97; + const ty = Number(to[1]) - 1; + const dx = tx - fx; + const dy = ty - fy; + const adx = Math.abs(dx); + const ady = Math.abs(dy); + const sq = (x: number, y: number) => `${String.fromCharCode(97 + x)}${y + 1}` as Square; + const clear = () => { + const sx = Math.sign(dx); + const sy = Math.sign(dy); + for (let x = fx + sx, y = fy + sy; x !== tx || y !== ty; x += sx, y += sy) if (c.get(sq(x, y))) return false; + return true; + }; + switch (p.type) { + case 'n': + return (adx === 1 && ady === 2) || (adx === 2 && ady === 1); + case 'k': + return Math.max(adx, ady) === 1; + case 'r': + return (dx === 0 || dy === 0) && clear(); + case 'b': + return adx === ady && clear(); + case 'q': + return (dx === 0 || dy === 0 || adx === ady) && clear(); + case 'p': { + const dir = p.color === 'w' ? 1 : -1; + if (dx === 0) { + if (target) return false; + if (dy === dir) return true; + return dy === 2 * dir && fy === (p.color === 'w' ? 1 : 6) && !c.get(sq(fx, fy + dir)); + } + if (adx !== 1 || dy !== dir) return false; + return !!target || c.fen({ forceEnpassantSquare: true }).split(' ')[3] === to; + } + } + return false; +} + +interface PieceAt { + square: Square; + color: 'w' | 'b'; + type: PieceSymbol; +} + +export function pieces(c: Chess): PieceAt[] { + const out: PieceAt[] = []; + for (const row of c.board()) for (const p of row) if (p) out.push(p); + return out; +} + +/** + * Automatic dead-position draws (§6 무승부 2): K vs K, KB vs K, KN vs K, and kings plus bishops only where every + * bishop stands on the same square colour. KNN vs K is *not* included. + */ +export function insufficientMaterial(c: Chess): boolean { + const minors = pieces(c).filter((p) => p.type !== 'k'); + if (minors.length === 0) return true; + if (minors.length === 1 && (minors[0]!.type === 'b' || minors[0]!.type === 'n')) return true; + if (minors.every((p) => p.type === 'b')) { + const colors = new Set(minors.map((p) => c.squareColor(p.square))); + return colors.size === 1; + } + return false; +} + +/** + * FIDE 6.9 `cannotMate(side)` (§6 무승부 6): true only for the conservative, certain cases. Everything else is + * treated as "mate is possible" (so the flagged player loses). + */ +export function cannotMate(c: Chess, side: 'w' | 'b'): boolean { + const all = pieces(c).filter((p) => p.type !== 'k'); + const mine = all.filter((p) => p.color === side); + const theirs = all.filter((p) => p.color !== side); + if (mine.length === 0) return true; + if (mine.length === 1 && theirs.length === 0 && (mine[0]!.type === 'n' || mine[0]!.type === 'b')) return true; + if (all.every((p) => p.type === 'b')) { + return new Set(all.map((p) => c.squareColor(p.square))).size === 1; + } + return false; +} + +/** The side to move's non-king pieces that are attacked and not defended (hints=movesAndThreats). */ +export function threatenedSquares(c: Chess): string[] { + const us = c.turn(); + const them = us === 'w' ? 'b' : 'w'; + return pieces(c) + .filter((p) => p.color === us && p.type !== 'k' && c.isAttacked(p.square, them) && !c.isAttacked(p.square, us)) + .map((p) => p.square); +} + +export function kingSquare(c: Chess, side: 'w' | 'b'): Square | null { + return c.findPiece({ type: 'k', color: side })[0] ?? null; +} + +/** White material minus black material (Q9 R5 B3 N3 P1). */ +export function materialDiff(c: Chess): number { + let d = 0; + for (const p of pieces(c)) d += (p.color === 'w' ? 1 : -1) * PIECE_VALUE[p.type]; + return d; +} + +/** 64 FEN piece letters (uppercase = white), index = (8 - rank) * 8 + file, i.e. a8 first. */ +export function boardArray(fen: string): (string | null)[] { + const out: (string | null)[] = []; + for (const ch of fen.split(' ')[0]!) { + if (ch === '/') continue; + if (/\d/.test(ch)) for (let i = 0; i < Number(ch); i++) out.push(null); + else out.push(ch); + } + return out; +} + +/** PGN text from stored SAN moves (no replay needed). */ +export function buildPgn(startFen: string, sans: string[], tags: Record, result: string): string { + const head: Record = { Event: '같이 놀자 보드게임', Variant: 'Standard', ...tags, Result: result }; + if (startFen !== START_FEN) { + head.SetUp = '1'; + head.FEN = startFen; + } + const lines = Object.entries(head).map(([k, v]) => `[${k} "${v.replace(/["\\]/g, '')}"]`); + const [, side, , , , full] = startFen.split(' '); + let no = Number(full ?? 1); + let black = side === 'b'; + const parts: string[] = []; + sans.forEach((san, i) => { + if (!black) parts.push(`${no}. ${san}`); + else parts.push(i === 0 ? `${no}... ${san}` : san); + if (black) no++; + black = !black; + }); + parts.push(result); + return `${lines.join('\n')}\n\n${parts.join(' ')}`; +} diff --git a/packages/games/src/index.ts b/packages/games/src/index.ts index da90d25..433fed3 100644 --- a/packages/games/src/index.ts +++ b/packages/games/src/index.ts @@ -1,8 +1,12 @@ import type { AnyGameDefinition } from '@bg/engine'; import { omok } from './omok'; import { othello } from './othello'; +import { chess } from './chess'; +import { janggi } from './janggi'; export const GAMES: Record = { [omok.id]: omok, [othello.id]: othello, + [chess.id]: chess, + [janggi.id]: janggi, }; diff --git a/packages/games/src/janggi/index.ts b/packages/games/src/janggi/index.ts new file mode 100644 index 0000000..5ee3e9b --- /dev/null +++ b/packages/games/src/janggi/index.ts @@ -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 = { 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; + +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 | { 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; + moveCount: number; + summaryKo: string; +} + +export interface JanggiState { + options: JanggiOptions; + players: Record; + phase: 'setup' | 'playing' | 'finished'; + /** `startedAt` = when the current chooser's setup clock started. */ + setup: { choice: Record; 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; + bikjang: { calledBy: Side; ply: number } | null; + consecutivePasses: number; + inCheck: Side | null; + clock: { mainMs: Record; periodsLeft: Record; turnStartedAt: number }; + undo: { usedBy: Record; pending: { by: Side; plies: 1 | 2 } | null }; + draw: { pendingBy: Side | null; lastOfferPly: Record }; + 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; + mySide: Side | null; + turn: Side; + setup: { + order: JanggiOptions['setupOrder']; + /** Public (or my own) choices; hidden choices are null. */ + choice: Record; + ready: Record; + /** 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; + /** Live score incl. 덤. */ + scores: Record; + 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; + clock: JanggiState['clock']; + undo: { allowed: boolean; remaining: Record; 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 { + 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 = { 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 = { + id: 'janggi', + nameKo: '장기', + minPlayers: 2, + maxPlayers: 2, + stateVersion: 1, + defaultOptions: janggiOptionsSchema.parse({}), + optionsSchema: janggiOptionsSchema, + actionSchema: janggiActionSchema as z.ZodType, + + 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 = { 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> = { 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] : []; + }, +}; diff --git a/packages/games/src/janggi/janggi.test.ts b/packages/games/src/janggi/janggi.test.ts new file mode 100644 index 0000000..a288f6a --- /dev/null +++ b/packages/games/src/janggi/janggi.test.ts @@ -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 { + return janggi.optionsSchema.parse({ sideAssignment: 'hostCho', ...o }); +} + +/** Fresh game in setup phase. A = 초, B = 한. */ +function fresh(o: Partial = {}): 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 = {}, 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 = {}, 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 => 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); + 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 }); + } + }); +}); diff --git a/packages/games/src/janggi/rules.ts b/packages/games/src/janggi/rules.ts new file mode 100644 index 0000000..032c251 --- /dev/null +++ b/packages/games/src/janggi/rules.ts @@ -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 = { 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 = { + 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(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 = { 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; + } +}